Compare commits
31 Commits
bc74a1089b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b431802d50 | ||
|
|
5559e9951a | ||
|
|
9c7a383ee2 | ||
|
|
496f9d432d | ||
|
|
9a7e0d6818 | ||
|
|
6afd0c0392 | ||
|
|
c741e520ea | ||
|
|
ed3a3f0571 | ||
|
|
20dde11ff0 | ||
|
|
4857438e6f | ||
|
|
252c2447a8 | ||
|
|
a7c732e139 | ||
|
|
302b55b346 | ||
|
|
e8eafaf9b9 | ||
|
|
5aa1be27bb | ||
|
|
5c9ab6bb11 | ||
|
|
800eb976ab | ||
|
|
c07938c94e | ||
|
|
615e07ad32 | ||
|
|
c07264fd69 | ||
|
|
9c77475ce5 | ||
|
|
eaa0a5010d | ||
|
|
a6e07e46d9 | ||
|
|
166f97c629 | ||
|
|
98d278947b | ||
|
|
c7536d9097 | ||
|
|
b4e51eb8eb | ||
|
|
51b73ed82b | ||
|
|
eb5cc01d56 | ||
|
|
4c3df482be | ||
|
|
377754c26e |
@@ -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
|
||||||
|
|||||||
@@ -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;"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
151
backend/db.js
151
backend/db.js
@@ -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);`);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const {
|
|||||||
getProductDetailsAnalytics,
|
getProductDetailsAnalytics,
|
||||||
getRfmAnalytics
|
getRfmAnalytics
|
||||||
} = require('../services/analyticsService');
|
} = require('../services/analyticsService');
|
||||||
|
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -58,6 +59,25 @@ router.get('/analytics/products/:productId/details', verifyToken, async (req, re
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/analytics/products/:productId/composition', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const composition = await getProductComposition(req.params.productId);
|
||||||
|
res.json({ composition });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching product composition:', error);
|
||||||
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/analytics/product-compositions', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ compositions: await listProductCompositions() });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting product compositions:', error);
|
||||||
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/analytics/clients', verifyToken, async (req, res) => {
|
router.get('/analytics/clients', verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));
|
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));
|
||||||
|
|||||||
@@ -1,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) {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
15
backend/routes/databaseDiagnosticRoutes.js
Normal file
15
backend/routes/databaseDiagnosticRoutes.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { verifySuperAdmin } = require('../auth');
|
||||||
|
const { buildDatabaseDiagnostic } = require('../services/databaseDiagnosticService');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/admin/database-diagnostic', verifySuperAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await buildDatabaseDiagnostic(req.user));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const productionOrderRoutes = require('./routes/productionOrderRoutes');
|
|||||||
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
|
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
|
||||||
const catalogRoutes = require('./routes/catalogRoutes');
|
const catalogRoutes = require('./routes/catalogRoutes');
|
||||||
const supplyRoutes = require('./routes/supplyRoutes');
|
const supplyRoutes = require('./routes/supplyRoutes');
|
||||||
|
const databaseDiagnosticRoutes = require('./routes/databaseDiagnosticRoutes');
|
||||||
|
|
||||||
const createApp = () => {
|
const createApp = () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -27,6 +28,7 @@ const createApp = () => {
|
|||||||
app.use('/api', cuttingSettingsRoutes);
|
app.use('/api', cuttingSettingsRoutes);
|
||||||
app.use('/api', catalogRoutes);
|
app.use('/api', catalogRoutes);
|
||||||
app.use('/api', supplyRoutes);
|
app.use('/api', supplyRoutes);
|
||||||
|
app.use('/api', databaseDiagnosticRoutes);
|
||||||
app.use('/api', analyticsRoutes);
|
app.use('/api', analyticsRoutes);
|
||||||
app.use('/api', userRoutes);
|
app.use('/api', userRoutes);
|
||||||
app.use('/api/internal', internalRoutes);
|
app.use('/api/internal', internalRoutes);
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
177
backend/services/databaseDiagnosticService.js
Normal file
177
backend/services/databaseDiagnosticService.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
const { pool } = require('../db');
|
||||||
|
|
||||||
|
const SAMPLE_LIMIT = 50;
|
||||||
|
|
||||||
|
const sampleSpecs = {
|
||||||
|
catalog_categories: {
|
||||||
|
columns: ['id', 'name', 'description', 'created_at', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
catalog_products: {
|
||||||
|
columns: ['id', 'type', 'sku', 'name', 'category_id', 'composition', 'gramature', 'material_yield', 'width_cm', 'color', 'subcategory', 'sizes', 'created_at', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
consumption_references: {
|
||||||
|
columns: ['id', 'product_id', 'material_product_id', 'color', 'general_yield', 'size_yields', 'size_areas', 'gramature', 'efficiency_percent', 'rib_g_per_piece', 'material_cost_per_kg', 'consumption_quantity', 'consumption_unit', 'source', 'last_production_order_id', 'created_at', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
cutting_family_rules: {
|
||||||
|
columns: ['family_key', 'units_per_roll', 'updated_at'],
|
||||||
|
orderBy: ['family_key']
|
||||||
|
},
|
||||||
|
cutting_product_overrides: {
|
||||||
|
columns: ['product_id', 'family_key', 'color', 'size', 'product_type', 'planning_notes', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'product_id']
|
||||||
|
},
|
||||||
|
orders: {
|
||||||
|
columns: ['id', 'pedido_id', 'data_pedido', 'data_pedido_date', 'valor_pedido', 'produto_id', 'produto_descricao', 'quantidade', 'valor_unitario', 'id_vendedor', 'nome_vendedor', 'marketplace', 'canal_venda', 'numero_ecommerce', 'created_at'],
|
||||||
|
orderBy: ['data_pedido_date', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
production_order_markers: {
|
||||||
|
columns: ['id', 'production_order_id', 'label', 'color', 'created_at'],
|
||||||
|
orderBy: ['created_at', 'id']
|
||||||
|
},
|
||||||
|
production_order_components: {
|
||||||
|
columns: ['id', 'production_order_id', 'component_tiny_id', 'component_sku', 'component_name', 'quantity_per_unit', 'total_quantity', 'unit', 'created_at', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
production_order_steps: {
|
||||||
|
columns: ['id', 'production_order_id', 'step_number', 'name', 'start_date', 'end_date', 'status', 'color', 'created_at', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
production_orders: {
|
||||||
|
columns: ['id', 'tiny_id', 'number', 'status', 'order_reference', 'issue_date', 'expected_date', 'product_sku', 'product_description', 'quantity', 'unit', 'integration_status', 'notes', 'supplier', 'lot_code', 'roll_quantity', 'fabric_kg', 'rib_kg', 'yield_pieces_per_kg', 'created_at', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
stock: {
|
||||||
|
columns: ['produto_id', 'nome', 'saldo', 'delta_estoque', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'produto_id']
|
||||||
|
},
|
||||||
|
stock_campaign_queue: {
|
||||||
|
columns: ['id', 'base_product_name', 'produto_id', 'nome', 'saldo', 'delta_estoque', 'status', 'attempts', 'last_error', 'created_at', 'updated_at', 'sent_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
supply_fabric_plans: {
|
||||||
|
columns: ['id', 'material', 'color', 'quantity_kg', 'supplier', 'priority', 'status', 'created_at', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
supply_movements: {
|
||||||
|
columns: ['id', 'receipt_id', 'lot_id', 'type', 'category', 'product', 'quantity', 'unit', 'reason', 'created_at'],
|
||||||
|
orderBy: ['created_at', 'id']
|
||||||
|
},
|
||||||
|
supply_receipts: {
|
||||||
|
columns: ['id', 'category', 'product', 'quantity', 'unit', 'supplier', 'invoice', 'notes', 'status', 'created_at', 'updated_at', 'approved_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
},
|
||||||
|
supply_stock_lots: {
|
||||||
|
columns: ['id', 'receipt_id', 'category', 'product', 'quantity', 'unit', 'supplier', 'invoice', 'status', 'created_at', 'updated_at'],
|
||||||
|
orderBy: ['updated_at', 'created_at', 'id']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const quoteIdentifier = (identifier) => {
|
||||||
|
if (!/^[a-z_][a-z0-9_]*$/.test(identifier)) {
|
||||||
|
throw new Error(`Unsafe database identifier: ${identifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `"${identifier}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listPublicTables = async () => {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
ORDER BY table_name
|
||||||
|
`);
|
||||||
|
|
||||||
|
return result.rows.map(row => row.table_name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const listPublicColumns = async () => {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT table_name, column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
ORDER BY table_name, ordinal_position
|
||||||
|
`);
|
||||||
|
|
||||||
|
return result.rows.reduce((columnsByTable, row) => {
|
||||||
|
if (!columnsByTable[row.table_name]) columnsByTable[row.table_name] = [];
|
||||||
|
columnsByTable[row.table_name].push(row.column_name);
|
||||||
|
return columnsByTable;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const countTableRows = async (tableName) => {
|
||||||
|
const result = await pool.query(`SELECT COUNT(*)::int AS count FROM ${quoteIdentifier(tableName)}`);
|
||||||
|
return result.rows[0]?.count || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildOrderClause = (spec, availableColumns) => {
|
||||||
|
const orderColumns = spec.orderBy.filter(column => availableColumns.includes(column));
|
||||||
|
if (!orderColumns.length) return '';
|
||||||
|
|
||||||
|
const clauses = orderColumns.map(column => {
|
||||||
|
const direction = column === 'family_key' || column === 'product_id' || column === 'produto_id' ? 'ASC' : 'DESC';
|
||||||
|
return `${quoteIdentifier(column)} ${direction}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return ` ORDER BY ${clauses.join(', ')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sampleTableRows = async (tableName, availableColumns) => {
|
||||||
|
const spec = sampleSpecs[tableName];
|
||||||
|
if (!spec) return null;
|
||||||
|
|
||||||
|
const selectedColumns = spec.columns.filter(column => availableColumns.includes(column));
|
||||||
|
if (!selectedColumns.length) return null;
|
||||||
|
|
||||||
|
const selectClause = selectedColumns.map(quoteIdentifier).join(', ');
|
||||||
|
const orderClause = buildOrderClause(spec, availableColumns);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT ${selectClause} FROM ${quoteIdentifier(tableName)}${orderClause} LIMIT $1`,
|
||||||
|
[SAMPLE_LIMIT]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDatabaseDiagnostic = async (user) => {
|
||||||
|
const [tables, columnsByTable] = await Promise.all([
|
||||||
|
listPublicTables(),
|
||||||
|
listPublicColumns()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const counts = {};
|
||||||
|
const samples = {};
|
||||||
|
|
||||||
|
for (const tableName of tables) {
|
||||||
|
counts[tableName] = await countTableRows(tableName);
|
||||||
|
|
||||||
|
const sampleRows = await sampleTableRows(tableName, columnsByTable[tableName] || []);
|
||||||
|
if (sampleRows) samples[tableName] = sampleRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
generatedBy: {
|
||||||
|
role: user?.role || null,
|
||||||
|
userId: user?.userId || null
|
||||||
|
},
|
||||||
|
sampleLimit: SAMPLE_LIMIT,
|
||||||
|
privacy: {
|
||||||
|
countsIncludeAllPublicTables: true,
|
||||||
|
samplesExcludeTables: ['app_users', 'client_identity_tokens'],
|
||||||
|
ordersSampleExcludesCustomerNameAndPhone: true,
|
||||||
|
productionOrdersSampleExcludesTinyPayload: true
|
||||||
|
},
|
||||||
|
counts,
|
||||||
|
samples
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildDatabaseDiagnostic
|
||||||
|
};
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
111
backend/test/campaignService.test.js
Normal file
111
backend/test/campaignService.test.js
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
275
backend/test/productCompositionService.test.js
Normal file
275
backend/test/productCompositionService.test.js
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const { pool } = require('../db');
|
||||||
|
const productionOrderRouter = require('../routes/productionOrderRoutes');
|
||||||
|
const analyticsRouter = require('../routes/analyticsRoutes');
|
||||||
|
|
||||||
|
const getRouteHandler = (router, method, path) => {
|
||||||
|
const route = router.stack.find(layer => layer.route?.path === path && layer.route.methods[method]);
|
||||||
|
if (!route) throw new Error(`Route not found: ${method.toUpperCase()} ${path}`);
|
||||||
|
return route.route.stack.at(-1).handle;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync');
|
||||||
|
const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition');
|
||||||
|
const productCompositionsHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/product-compositions');
|
||||||
|
|
||||||
|
const invokeHandler = async (handler, req) => {
|
||||||
|
let statusCode = 200;
|
||||||
|
let body;
|
||||||
|
const res = {
|
||||||
|
status(code) {
|
||||||
|
statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(payload) {
|
||||||
|
body = payload;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await handler(req, res);
|
||||||
|
return { statusCode, body };
|
||||||
|
};
|
||||||
|
|
||||||
|
const compositionPayload = (components = [
|
||||||
|
{
|
||||||
|
componentTinyId: '919498232',
|
||||||
|
componentName: 'ETIQUETA DE TAMANHO GG',
|
||||||
|
componentSku: 'ETIQ.T.GG',
|
||||||
|
quantityPerUnit: '1',
|
||||||
|
totalQuantity: '1',
|
||||||
|
unit: 'UN'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
componentTinyId: '976059144',
|
||||||
|
componentName: '6118 MALHA CAMISETA 30OE COR CAFE',
|
||||||
|
componentSku: 'MC.30.CAF',
|
||||||
|
quantityPerUnit: '0.1851',
|
||||||
|
totalQuantity: '0.1851',
|
||||||
|
unit: 'KG'
|
||||||
|
}
|
||||||
|
]) => ({
|
||||||
|
order: {
|
||||||
|
tinyId: 'STRUCTURE-V3-976058813',
|
||||||
|
number: 'STRUCTURE-V3-BLCS.CAF.GG',
|
||||||
|
status: 'completed',
|
||||||
|
productSku: 'BLCS.CAF.GG',
|
||||||
|
productDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG',
|
||||||
|
quantity: '1',
|
||||||
|
unit: 'UN',
|
||||||
|
notes: 'Composição sincronizada da API pública Olist V3.'
|
||||||
|
},
|
||||||
|
components,
|
||||||
|
steps: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const withFakeDatabase = async (run) => {
|
||||||
|
const originalConnect = pool.connect;
|
||||||
|
const originalQuery = pool.query;
|
||||||
|
const state = {
|
||||||
|
nextCompositionId: 1,
|
||||||
|
nextOrderId: 1,
|
||||||
|
compositions: [],
|
||||||
|
compositionComponents: [],
|
||||||
|
productionOrders: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = async (sql, params = []) => {
|
||||||
|
const normalizedSql = String(sql).replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
if (/^(BEGIN|COMMIT|ROLLBACK);?$/.test(normalizedSql)) return { rows: [] };
|
||||||
|
|
||||||
|
if (normalizedSql.startsWith('SELECT id FROM product_compositions WHERE source')) {
|
||||||
|
const composition = state.compositions.find(item => item.source === params[0] && item.finished_product_identity === params[1]);
|
||||||
|
return { rows: composition ? [{ id: composition.id }] : [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSql.startsWith('INSERT INTO product_compositions')) {
|
||||||
|
const composition = {
|
||||||
|
id: state.nextCompositionId++,
|
||||||
|
source: params[0],
|
||||||
|
external_source_id: params[1],
|
||||||
|
finished_product_identity: params[2],
|
||||||
|
finished_product_sku: params[3],
|
||||||
|
finished_product_description: params[4],
|
||||||
|
finished_product_unit: params[5],
|
||||||
|
finished_tiny_product_id: params[6],
|
||||||
|
source_metadata: JSON.parse(params[7]),
|
||||||
|
last_synced_at: '2026-07-29T12:00:00.000Z'
|
||||||
|
};
|
||||||
|
state.compositions.push(composition);
|
||||||
|
return { rows: [{ id: composition.id }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSql.startsWith('UPDATE product_compositions')) {
|
||||||
|
const composition = state.compositions.find(item => item.id === params[7]);
|
||||||
|
composition.external_source_id = params[0];
|
||||||
|
composition.finished_product_sku = params[2];
|
||||||
|
composition.finished_product_description = params[3];
|
||||||
|
composition.finished_product_unit = params[4];
|
||||||
|
composition.finished_tiny_product_id = params[5];
|
||||||
|
composition.source_metadata = JSON.parse(params[6]);
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSql.startsWith('DELETE FROM product_composition_components')) {
|
||||||
|
state.compositionComponents = state.compositionComponents.filter(item => item.product_composition_id !== params[0]);
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSql.startsWith('INSERT INTO product_composition_components')) {
|
||||||
|
state.compositionComponents.push({
|
||||||
|
id: state.compositionComponents.length + 1,
|
||||||
|
product_composition_id: params[0],
|
||||||
|
component_identity: params[1],
|
||||||
|
component_tiny_id: params[2],
|
||||||
|
component_sku: params[3],
|
||||||
|
component_name: params[4],
|
||||||
|
quantity_per_unit: params[5],
|
||||||
|
unit: params[6]
|
||||||
|
});
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSql.includes('FROM product_compositions composition')) {
|
||||||
|
const compositions = state.compositions.filter(item => (
|
||||||
|
item.source === params[0]
|
||||||
|
&& (params[1] === '' || item.finished_tiny_product_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase())
|
||||||
|
));
|
||||||
|
return {
|
||||||
|
rows: compositions.map(composition => ({
|
||||||
|
...composition,
|
||||||
|
components: state.compositionComponents
|
||||||
|
.filter(item => item.product_composition_id === composition.id)
|
||||||
|
.map(item => ({ ...item, component_product_id: null }))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSql.startsWith('SELECT id FROM production_orders')) {
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSql.startsWith('INSERT INTO production_orders')) {
|
||||||
|
const order = {
|
||||||
|
id: state.nextOrderId++,
|
||||||
|
tiny_id: params[0], number: params[1], status: params[2], order_reference: params[3],
|
||||||
|
issue_date: params[4], expected_date: params[5], product_sku: params[6],
|
||||||
|
product_description: params[7], quantity: params[8], unit: params[9],
|
||||||
|
integration_status: params[10], notes: params[11], supplier: params[12], lot_code: params[13],
|
||||||
|
roll_quantity: params[14], fabric_kg: params[15], rib_kg: params[16],
|
||||||
|
yield_pieces_per_kg: params[17], created_at: null, updated_at: null
|
||||||
|
};
|
||||||
|
state.productionOrders.push(order);
|
||||||
|
return { rows: [{ id: order.id }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSql.startsWith('DELETE FROM production_order_components') || normalizedSql.startsWith('DELETE FROM production_order_steps')) {
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSql.includes('FROM production_orders po') && normalizedSql.includes('WHERE po.id = $1')) {
|
||||||
|
const order = state.productionOrders.find(item => item.id === params[0]);
|
||||||
|
return { rows: order ? [{ ...order, markers: [], components: [], steps: [] }] : [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected SQL in test double: ${normalizedSql.slice(0, 100)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
pool.query = query;
|
||||||
|
pool.connect = async () => ({ query, release() {} });
|
||||||
|
try {
|
||||||
|
await run(state);
|
||||||
|
} finally {
|
||||||
|
pool.connect = originalConnect;
|
||||||
|
pool.query = originalQuery;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test('Tiny/Olist V3 first import creates a composition without creating an OP', async () => {
|
||||||
|
await withFakeDatabase(async (state) => {
|
||||||
|
const response = await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||||
|
const result = response.body;
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 201);
|
||||||
|
assert.equal(result.componentCount, 2);
|
||||||
|
assert.equal(state.compositions.length, 1);
|
||||||
|
assert.equal(state.compositionComponents.length, 2);
|
||||||
|
assert.equal(state.productionOrders.length, 0);
|
||||||
|
assert.equal(result.composition.finishedTinyProductId, '976058813');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Tiny/Olist V3 re-sync replaces composition components without duplicates', async () => {
|
||||||
|
await withFakeDatabase(async (state) => {
|
||||||
|
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||||
|
await invokeHandler(tinySyncHandler, { body: compositionPayload([
|
||||||
|
{
|
||||||
|
componentTinyId: '976059144', componentName: 'MALHA ATUALIZADA', componentSku: 'MC.30.CAF',
|
||||||
|
quantityPerUnit: '0.2', totalQuantity: '0.2', unit: 'KG'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
componentTinyId: '976059144', componentName: 'MALHA ATUALIZADA', componentSku: 'MC.30.CAF',
|
||||||
|
quantityPerUnit: '0.2', totalQuantity: '0.2', unit: 'KG'
|
||||||
|
}
|
||||||
|
]) });
|
||||||
|
|
||||||
|
assert.equal(state.compositions.length, 1);
|
||||||
|
assert.equal(state.compositionComponents.length, 1);
|
||||||
|
assert.equal(state.compositionComponents[0].quantity_per_unit, 0.2);
|
||||||
|
assert.equal(state.productionOrders.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('product composition detail lookup returns stored components', async () => {
|
||||||
|
await withFakeDatabase(async () => {
|
||||||
|
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||||
|
const response = await invokeHandler(productCompositionHandler, { params: { productId: '976058813' } });
|
||||||
|
const composition = response.body.composition;
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.equal(composition.finishedProductSku, 'BLCS.CAF.GG');
|
||||||
|
assert.equal(composition.components.length, 2);
|
||||||
|
assert.deepEqual(composition.components[0], {
|
||||||
|
id: 1,
|
||||||
|
componentTinyId: '919498232',
|
||||||
|
componentSku: 'ETIQ.T.GG',
|
||||||
|
componentName: 'ETIQUETA DE TAMANHO GG',
|
||||||
|
quantityPerUnit: 1,
|
||||||
|
unit: 'UN',
|
||||||
|
productId: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('product composition export endpoint returns every stored composition', async () => {
|
||||||
|
await withFakeDatabase(async () => {
|
||||||
|
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||||
|
const response = await invokeHandler(productCompositionsHandler, { params: {} });
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.equal(response.body.compositions.length, 1);
|
||||||
|
assert.equal(response.body.compositions[0].components.length, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary Tiny production-order payloads continue to create production orders', async () => {
|
||||||
|
await withFakeDatabase(async (state) => {
|
||||||
|
const response = await invokeHandler(tinySyncHandler, { body: {
|
||||||
|
order: {
|
||||||
|
tinyId: '12345', number: 'OP-12345', status: 'in_progress', productSku: 'BLCS.CAF.GG',
|
||||||
|
productDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG', quantity: '10', unit: 'UN'
|
||||||
|
},
|
||||||
|
components: [],
|
||||||
|
steps: []
|
||||||
|
} });
|
||||||
|
const result = response.body;
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 201);
|
||||||
|
assert.equal(state.compositions.length, 0);
|
||||||
|
assert.equal(state.productionOrders.length, 1);
|
||||||
|
assert.equal(result.order.tinyId, '12345');
|
||||||
|
assert.equal(result.order.number, 'OP-12345');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,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
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,6 +176,23 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
|
|||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const downloadDatabaseDiagnostic = async (): Promise<void> => {
|
||||||
|
const response = await authFetch('/admin/database-diagnostic', { cache: 'no-store' });
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error || 'Não foi possível exportar o diagnóstico do banco.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `graphs-db-diagnostic-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchProductionOrders = async (
|
export const fetchProductionOrders = async (
|
||||||
dateRange: DateRange,
|
dateRange: DateRange,
|
||||||
filters?: { search?: string },
|
filters?: { search?: string },
|
||||||
@@ -507,6 +539,42 @@ export const fetchProductDetailsAnalytics = async (productId: string, dateRange:
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchProductComposition = async (productId: string): Promise<ProductComposition | null> => {
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/composition`);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = await response.json() as { composition?: ProductComposition | null };
|
||||||
|
return data.composition || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch product composition failed', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportProductCompositions = async (): Promise<number> => {
|
||||||
|
const response = await authFetch('/analytics/product-compositions');
|
||||||
|
const data = await response.json().catch(() => null) as { compositions?: ProductComposition[]; error?: string } | null;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error || 'Não foi possível exportar as composições.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const compositions = data?.compositions || [];
|
||||||
|
const blob = new Blob([JSON.stringify({
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
compositions
|
||||||
|
}, null, 2)], { type: 'application/json;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `composicoes_produtos_${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
return compositions.length;
|
||||||
|
};
|
||||||
|
|
||||||
const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial<ClientMetadataFilters>) => {
|
const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial<ClientMetadataFilters>) => {
|
||||||
if (!filters) return;
|
if (!filters) return;
|
||||||
if (filters.marketplace) params.set('marketplace', filters.marketplace);
|
if (filters.marketplace) params.set('marketplace', filters.marketplace);
|
||||||
|
|||||||
@@ -1,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>
|
||||||
|
|||||||
@@ -218,16 +218,25 @@ const PlanningIssues = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Dados Pendentes</h1>
|
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Dados Pendentes</h1>
|
||||||
<p className="font-medium text-zinc-500 dark:text-dark-muted">
|
<p className="font-medium text-zinc-500 dark:text-dark-muted">
|
||||||
Fila de SKUs com informações faltando para corte e planejamento.
|
Fila principal para revisar tipo, cor, tamanho, família e rendimento dos SKUs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DateRangePicker
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center 2xl:justify-end">
|
||||||
dateRange={dateRange}
|
<Link
|
||||||
onChange={(range) => {
|
to="/registrations"
|
||||||
setDateRange(range);
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
|
||||||
setCurrentPage(1);
|
>
|
||||||
}}
|
<PackageSearch className="h-4 w-4 text-brand-primary" />
|
||||||
/>
|
Cadastros
|
||||||
|
</Link>
|
||||||
|
<DateRangePicker
|
||||||
|
dateRange={dateRange}
|
||||||
|
onChange={(range) => {
|
||||||
|
setDateRange(range);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RefreshStatus isRefreshing={isRefreshing} />
|
<RefreshStatus isRefreshing={isRefreshing} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2 } from 'lucide-react';
|
import { ClipboardList, Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
deleteCatalogCategory,
|
deleteCatalogCategory,
|
||||||
deleteCatalogProduct,
|
deleteCatalogProduct,
|
||||||
@@ -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);
|
||||||
@@ -340,7 +350,7 @@ const Registrations = () => {
|
|||||||
const tabItems: Array<{ key: RegistrationTab; label: string; icon: typeof Package; count: number }> = [
|
const tabItems: Array<{ key: RegistrationTab; label: string; icon: typeof Package; count: number }> = [
|
||||||
{ key: 'products', label: 'Produtos', icon: Package, count: catalog.products.length },
|
{ key: 'products', label: 'Produtos', icon: Package, count: catalog.products.length },
|
||||||
{ key: 'categories', label: 'Categorias', icon: Tags, count: catalog.categories.length },
|
{ key: 'categories', label: 'Categorias', icon: Tags, count: catalog.categories.length },
|
||||||
{ key: 'references', label: 'Referencia de Consumo', icon: Ruler, count: catalog.consumptionReferences.length }
|
{ key: 'references', label: 'Referência de Consumo', icon: Ruler, count: catalog.consumptionReferences.length }
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -349,17 +359,26 @@ const Registrations = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Cadastros</h1>
|
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Cadastros</h1>
|
||||||
<p className="font-medium text-zinc-500 dark:text-dark-muted">
|
<p className="font-medium text-zinc-500 dark:text-dark-muted">
|
||||||
Base de produtos, categorias e referencias de consumo usadas pelo corte.
|
Fonte de verdade para produtos, matérias-primas e referências de consumo.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex flex-wrap gap-2">
|
||||||
type="button"
|
<Link
|
||||||
onClick={() => void loadCatalog()}
|
to="/planning-issues"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4 text-brand-primary" />
|
<ClipboardList className="h-4 w-4 text-brand-primary" />
|
||||||
Atualizar
|
Dados Pendentes
|
||||||
</button>
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadCatalog()}
|
||||||
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 text-brand-primary" />
|
||||||
|
Atualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
@@ -654,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">
|
||||||
@@ -671,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)} pç/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)} pç/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" />
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ import DateRangePicker from '../components/DateRangePicker';
|
|||||||
import PaginationControls from '../components/PaginationControls';
|
import PaginationControls from '../components/PaginationControls';
|
||||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||||
import { classifyCutFamily } from '../analytics/cutting';
|
import { classifyCutFamily } from '../analytics/cutting';
|
||||||
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchCuttingSettings, fetchProductAnalytics, fetchStock, fetchSupplySummary } from '../dataService';
|
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchStock, fetchSupplySummary, isSuperAdmin } from '../dataService';
|
||||||
import { parseProductName } from '../productParsing';
|
import { parseProductName } from '../productParsing';
|
||||||
import { resolveProductType, type ProductTypeKey } from '../productClassification';
|
import { resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||||
import { getPlanningStock } from '../planningStock';
|
import { getPlanningStock } from '../planningStock';
|
||||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types';
|
import type { CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types';
|
||||||
|
|
||||||
type InventoryTab = 'dashboard' | 'tiny_stock' | 'balance' | 'receipts' | 'inventory' | 'movements';
|
type InventoryTab = 'dashboard' | 'stock' | 'balance' | 'receipts' | 'inventory' | 'movements';
|
||||||
type ReceiptView = 'new' | 'pending' | 'history';
|
type ReceiptView = 'new' | 'pending' | 'history';
|
||||||
|
|
||||||
const pageClassName = 'flex w-full flex-col gap-6';
|
const pageClassName = 'flex w-full flex-col gap-6';
|
||||||
@@ -100,7 +100,7 @@ const exportCsv = (filename: string, rows: Array<Record<string, string | number
|
|||||||
|
|
||||||
const inventoryTabs: Array<{ id: InventoryTab; name: string; icon: typeof BarChart3 }> = [
|
const inventoryTabs: Array<{ id: InventoryTab; name: string; icon: typeof BarChart3 }> = [
|
||||||
{ id: 'dashboard', name: 'Dashboard', icon: Warehouse },
|
{ id: 'dashboard', name: 'Dashboard', icon: Warehouse },
|
||||||
{ id: 'tiny_stock', name: 'Estoque Tiny', icon: PackageSearch },
|
{ id: 'stock', name: 'Estoque', icon: PackageSearch },
|
||||||
{ id: 'balance', name: 'Saldo', icon: BarChart3 },
|
{ id: 'balance', name: 'Saldo', icon: BarChart3 },
|
||||||
{ id: 'receipts', name: 'Recebimentos', icon: Truck },
|
{ id: 'receipts', name: 'Recebimentos', icon: Truck },
|
||||||
{ id: 'inventory', name: 'Inventário', icon: ClipboardCheck },
|
{ id: 'inventory', name: 'Inventário', icon: ClipboardCheck },
|
||||||
@@ -192,9 +192,44 @@ const ModuleCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SuppliesHub = () => (
|
const SuppliesHub = () => {
|
||||||
|
const [isExportingDiagnostic, setIsExportingDiagnostic] = useState(false);
|
||||||
|
const [diagnosticError, setDiagnosticError] = useState('');
|
||||||
|
const canExportDiagnostic = isSuperAdmin();
|
||||||
|
|
||||||
|
const handleExportDiagnostic = async () => {
|
||||||
|
setIsExportingDiagnostic(true);
|
||||||
|
setDiagnosticError('');
|
||||||
|
try {
|
||||||
|
await downloadDatabaseDiagnostic();
|
||||||
|
} catch (error) {
|
||||||
|
setDiagnosticError(error instanceof Error ? error.message : 'Não foi possível exportar o diagnóstico do banco.');
|
||||||
|
} finally {
|
||||||
|
setIsExportingDiagnostic(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
<div className={pageClassName}>
|
<div className={pageClassName}>
|
||||||
<Header title="Suprimentos" subtitle="Corte, estoque, malha e compras em um fluxo operacional." />
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<Header title="Suprimentos" subtitle="Corte, estoque, malha e compras em um fluxo operacional." />
|
||||||
|
{canExportDiagnostic && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportDiagnostic}
|
||||||
|
disabled={isExportingDiagnostic}
|
||||||
|
className={`${buttonClassName} w-fit disabled:cursor-not-allowed disabled:opacity-60`}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{isExportingDiagnostic ? 'Exportando...' : 'Exportar diagnóstico DB'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{diagnosticError && (
|
||||||
|
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm font-bold text-red-300">
|
||||||
|
{diagnosticError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<ModuleCard title="Planejamento Operacional" description="Prioridades de reposição, compra e revisão calculadas por demanda, estoque e cobertura." icon={PackageSearch} to="/supplies/current-planning" />
|
<ModuleCard title="Planejamento Operacional" description="Prioridades de reposição, compra e revisão calculadas por demanda, estoque e cobertura." icon={PackageSearch} to="/supplies/current-planning" />
|
||||||
<ModuleCard title="Planejamento de Corte" description="Ruptura por cor e tamanho, montagem do corte e prioridade por cobertura." icon={Scissors} to="/cutting" />
|
<ModuleCard title="Planejamento de Corte" description="Ruptura por cor e tamanho, montagem do corte e prioridade por cobertura." icon={Scissors} to="/cutting" />
|
||||||
@@ -204,7 +239,8 @@ const SuppliesHub = () => (
|
|||||||
<ModuleCard title="Necessidade de Compra" description="Itens abaixo do mínimo e necessidade projetada para compra." icon={BarChart3} to="/supplies/purchase-needs" />
|
<ModuleCard title="Necessidade de Compra" description="Itens abaixo do mínimo e necessidade projetada para compra." icon={BarChart3} to="/supplies/purchase-needs" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getRangeDays = (range: DateRange) => {
|
const getRangeDays = (range: DateRange) => {
|
||||||
const start = new Date(range.start);
|
const start = new Date(range.start);
|
||||||
@@ -959,7 +995,7 @@ const ReceiptsTab = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TinyStockTab = ({
|
const StockTab = ({
|
||||||
stock,
|
stock,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
@@ -968,6 +1004,8 @@ const TinyStockTab = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [stockFilter, setStockFilter] = useState('all');
|
const [stockFilter, setStockFilter] = useState('all');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||||
const normalizedSearch = normalizeSearch(search);
|
const normalizedSearch = normalizeSearch(search);
|
||||||
const totalBalance = stock.reduce((total, item) => total + Number(item.saldo || 0), 0);
|
const totalBalance = stock.reduce((total, item) => total + Number(item.saldo || 0), 0);
|
||||||
const updatedItems = stock.filter(item => Number(item.delta_estoque || 0) !== 0).length;
|
const updatedItems = stock.filter(item => Number(item.delta_estoque || 0) !== 0).length;
|
||||||
@@ -987,12 +1025,16 @@ const TinyStockTab = ({
|
|||||||
(stockFilter === 'changed' && delta !== 0);
|
(stockFilter === 'changed' && delta !== 0);
|
||||||
return matchesSearch && matchesFilter;
|
return matchesSearch && matchesFilter;
|
||||||
}).sort((a, b) => Number(b.saldo || 0) - Number(a.saldo || 0));
|
}).sort((a, b) => Number(b.saldo || 0) - Number(a.saldo || 0));
|
||||||
|
const totalPages = Math.ceil(visibleStock.length / itemsPerPage);
|
||||||
|
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||||
|
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||||
|
const paginatedStock = visibleStock.slice(startIndex, startIndex + itemsPerPage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||||
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">SKUs Tiny</p>
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">SKUs</p>
|
||||||
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(stock.length, 0)}</p>
|
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(stock.length, 0)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
||||||
@@ -1012,13 +1054,13 @@ const TinyStockTab = ({
|
|||||||
<div className={`${panelClassName} p-4`}>
|
<div className={`${panelClassName} p-4`}>
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-dark-text">Estoque vindo do Tiny</h2>
|
<h2 className="text-base font-bold text-dark-text">Estoque</h2>
|
||||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Saldo de produtos sincronizado pelo fluxo Tiny para Graphs.</p>
|
<p className="mt-1 text-sm font-semibold text-dark-muted">Saldo de produtos sincronizado pelo fluxo de estoque para Graphs.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => exportCsv('estoque-tiny.csv', visibleStock.map(item => ({
|
onClick={() => exportCsv('estoque.csv', visibleStock.map(item => ({
|
||||||
produto_id: item.produto_id,
|
produto_id: item.produto_id,
|
||||||
nome: item.nome,
|
nome: item.nome,
|
||||||
saldo: item.saldo,
|
saldo: item.saldo,
|
||||||
@@ -1040,12 +1082,22 @@ const TinyStockTab = ({
|
|||||||
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
|
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
|
||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
className={`${inputClassName} pl-9`}
|
className={`${inputClassName} pl-9`}
|
||||||
placeholder="Buscar por SKU ou produto..."
|
placeholder="Buscar por SKU ou produto..."
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<select value={stockFilter} onChange={(event) => setStockFilter(event.target.value)} className={inputClassName}>
|
<select
|
||||||
|
value={stockFilter}
|
||||||
|
onChange={(event) => {
|
||||||
|
setStockFilter(event.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className={inputClassName}
|
||||||
|
>
|
||||||
<option value="all">Todos os saldos</option>
|
<option value="all">Todos os saldos</option>
|
||||||
<option value="positive">Com saldo</option>
|
<option value="positive">Com saldo</option>
|
||||||
<option value="empty">Sem saldo</option>
|
<option value="empty">Sem saldo</option>
|
||||||
@@ -1056,14 +1108,14 @@ const TinyStockTab = ({
|
|||||||
{visibleStock.length ? (
|
{visibleStock.length ? (
|
||||||
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
||||||
<div className="hidden grid-cols-[140px_1fr_120px_120px_160px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted md:grid">
|
<div className="hidden grid-cols-[140px_1fr_120px_120px_160px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted md:grid">
|
||||||
<span>SKU Tiny</span>
|
<span>SKU</span>
|
||||||
<span>Produto</span>
|
<span>Produto</span>
|
||||||
<span className="text-right">Saldo</span>
|
<span className="text-right">Saldo</span>
|
||||||
<span className="text-right">Delta</span>
|
<span className="text-right">Delta</span>
|
||||||
<span className="text-right">Atualizado</span>
|
<span className="text-right">Atualizado</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-dark-border">
|
<div className="divide-y divide-dark-border">
|
||||||
{visibleStock.map(item => {
|
{paginatedStock.map(item => {
|
||||||
const delta = Number(item.delta_estoque || 0);
|
const delta = Number(item.delta_estoque || 0);
|
||||||
return (
|
return (
|
||||||
<div key={item.produto_id} className="grid grid-cols-1 gap-2 bg-dark-card px-4 py-3 md:grid-cols-[140px_1fr_120px_120px_160px] md:items-center">
|
<div key={item.produto_id} className="grid grid-cols-1 gap-2 bg-dark-card px-4 py-3 md:grid-cols-[140px_1fr_120px_120px_160px] md:items-center">
|
||||||
@@ -1078,13 +1130,30 @@ const TinyStockTab = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<PaginationControls
|
||||||
|
totalItems={visibleStock.length}
|
||||||
|
currentPage={safeCurrentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
pageSize={itemsPerPage}
|
||||||
|
pageSizeOptions={[20, 50, 100]}
|
||||||
|
itemLabel="SKUs"
|
||||||
|
pageSizeLabel="por página"
|
||||||
|
startIndex={startIndex}
|
||||||
|
endIndex={Math.min(startIndex + itemsPerPage, visibleStock.length)}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onPageSizeChange={(pageSize) => {
|
||||||
|
setItemsPerPage(pageSize);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="border-t border-dark-border px-4 py-3"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={emptyStateClassName}>
|
<div className={emptyStateClassName}>
|
||||||
<PackageSearch className="h-8 w-8 text-brand-primary" />
|
<PackageSearch className="h-8 w-8 text-brand-primary" />
|
||||||
<h3 className="text-base font-bold text-dark-text">{stock.length ? 'Nenhum SKU encontrado' : 'Nenhum estoque Tiny sincronizado'}</h3>
|
<h3 className="text-base font-bold text-dark-text">{stock.length ? 'Nenhum SKU encontrado' : 'Nenhum estoque sincronizado'}</h3>
|
||||||
<p className="text-sm font-semibold text-dark-muted">
|
<p className="text-sm font-semibold text-dark-muted">
|
||||||
{stock.length ? 'Ajuste a busca ou o filtro.' : 'Quando o fluxo Tiny postar em /api/stock, os saldos aparecem aqui.'}
|
{stock.length ? 'Ajuste a busca ou o filtro.' : 'Quando o fluxo de estoque postar em /api/stock, os saldos aparecem aqui.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1368,19 +1437,19 @@ const MovementsTab = ({
|
|||||||
const InventoryScreen = () => {
|
const InventoryScreen = () => {
|
||||||
const [activeTab, setActiveTab] = useState<InventoryTab>('dashboard');
|
const [activeTab, setActiveTab] = useState<InventoryTab>('dashboard');
|
||||||
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
||||||
const [tinyStock, setTinyStock] = useState<StockData[]>([]);
|
const [stockSnapshot, setStockSnapshot] = useState<StockData[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isBusy, setIsBusy] = useState(false);
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
const loadInventoryData = async () => {
|
const loadInventoryData = async () => {
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
const [nextSummary, nextTinyStock] = await Promise.all([
|
const [nextSummary, nextStockSnapshot] = await Promise.all([
|
||||||
fetchSupplySummary(),
|
fetchSupplySummary(),
|
||||||
fetchStock(),
|
fetchStock(),
|
||||||
]);
|
]);
|
||||||
setSummary(nextSummary);
|
setSummary(nextSummary);
|
||||||
setTinyStock(nextTinyStock);
|
setStockSnapshot(nextStockSnapshot);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSummary = async () => {
|
const loadSummary = async () => {
|
||||||
@@ -1389,9 +1458,9 @@ const InventoryScreen = () => {
|
|||||||
setSummary(nextSummary);
|
setSummary(nextSummary);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadTinyStock = async () => {
|
const loadStockSnapshot = async () => {
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
setTinyStock(await fetchStock());
|
setStockSnapshot(await fetchStock());
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1400,13 +1469,13 @@ const InventoryScreen = () => {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [nextSummary, nextTinyStock] = await Promise.all([
|
const [nextSummary, nextStockSnapshot] = await Promise.all([
|
||||||
fetchSupplySummary(),
|
fetchSupplySummary(),
|
||||||
fetchStock(),
|
fetchStock(),
|
||||||
]);
|
]);
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setSummary(nextSummary);
|
setSummary(nextSummary);
|
||||||
setTinyStock(nextTinyStock);
|
setStockSnapshot(nextStockSnapshot);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (isMounted) setIsLoading(false);
|
if (isMounted) setIsLoading(false);
|
||||||
@@ -1461,7 +1530,7 @@ const InventoryScreen = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{activeTab === 'dashboard' && <InventoryDashboard summary={summary} onNewReceipt={() => setActiveTab('receipts')} />}
|
{activeTab === 'dashboard' && <InventoryDashboard summary={summary} onNewReceipt={() => setActiveTab('receipts')} />}
|
||||||
{activeTab === 'tiny_stock' && <TinyStockTab stock={tinyStock} onRefresh={loadTinyStock} />}
|
{activeTab === 'stock' && <StockTab stock={stockSnapshot} onRefresh={loadStockSnapshot} />}
|
||||||
{activeTab === 'balance' && <BalanceTab summary={summary} onRefresh={loadSummary} />}
|
{activeTab === 'balance' && <BalanceTab summary={summary} onRefresh={loadSummary} />}
|
||||||
{activeTab === 'receipts' && (
|
{activeTab === 'receipts' && (
|
||||||
<ReceiptsTab
|
<ReceiptsTab
|
||||||
@@ -1589,8 +1658,66 @@ const FabricPlanningScreen = () => {
|
|||||||
{errorMessage}
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[420px_1fr] xl:items-start">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<form onSubmit={handleSubmit} className={`${panelClassName} p-5 xl:sticky xl:top-6`}>
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Planos ativos</p>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Kg planejado</p>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(totalKg)} kg</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Críticos</p>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.filter(plan => plan.priority === 'Crítico').length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 items-stretch gap-6 xl:grid-cols-[1fr_420px]">
|
||||||
|
<div className={`${panelClassName} flex flex-col overflow-hidden`}>
|
||||||
|
<div className="border-b border-dark-border p-5">
|
||||||
|
<h2 className="text-base font-bold text-dark-text">Planos de malha</h2>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-dark-muted">Itens planejados para compra ou recebimento.</p>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className={`${emptyStateClassName} min-h-[220px] flex-1`}>
|
||||||
|
<Ruler className="h-8 w-8 text-brand-primary" />
|
||||||
|
<h3 className="text-base font-bold text-dark-text">Carregando planos...</h3>
|
||||||
|
</div>
|
||||||
|
) : plans.length ? (
|
||||||
|
<div className="divide-y divide-dark-border">
|
||||||
|
{plans.map(plan => (
|
||||||
|
<div key={plan.id} className="grid grid-cols-1 gap-3 p-4 md:grid-cols-[1.4fr_1fr_100px_100px_44px] md:items-center">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-dark-text">{plan.material}</p>
|
||||||
|
<p className="text-xs font-semibold text-dark-muted">{plan.color}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-dark-muted">{plan.supplier}</p>
|
||||||
|
<p className="text-sm font-bold text-dark-text">{formatNumber(plan.quantityKg)} kg</p>
|
||||||
|
<span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-text">{plan.priority}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Remover plano"
|
||||||
|
aria-label={`Remover plano ${plan.material}`}
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => removePlan(plan.id)}
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center rounded-lg text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`${emptyStateClassName} flex-1`}>
|
||||||
|
<Ruler className="h-8 w-8 text-brand-primary" />
|
||||||
|
<h3 className="text-base font-bold text-dark-text">Nenhum plano de malha cadastrado</h3>
|
||||||
|
<p className="text-sm font-semibold text-dark-muted">Cadastre o primeiro plano no formulário ao lado.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className={`${panelClassName} p-5`}>
|
||||||
<h2 className="text-base font-bold text-dark-text">Novo plano de malha</h2>
|
<h2 className="text-base font-bold text-dark-text">Novo plano de malha</h2>
|
||||||
<div className="mt-4 grid grid-cols-1 gap-3">
|
<div className="mt-4 grid grid-cols-1 gap-3">
|
||||||
<label className="text-xs font-bold text-dark-muted">
|
<label className="text-xs font-bold text-dark-muted">
|
||||||
@@ -1625,66 +1752,6 @@ const FabricPlanningScreen = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
||||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Planos ativos</p>
|
|
||||||
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.length}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Kg planejado</p>
|
|
||||||
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(totalKg)} kg</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Críticos</p>
|
|
||||||
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.filter(plan => plan.priority === 'Crítico').length}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`${panelClassName} overflow-hidden`}>
|
|
||||||
<div className="border-b border-dark-border p-5">
|
|
||||||
<h2 className="text-base font-bold text-dark-text">Planos de malha</h2>
|
|
||||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Itens planejados para compra ou recebimento.</p>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className={`${emptyStateClassName} min-h-[220px]`}>
|
|
||||||
<Ruler className="h-8 w-8 text-brand-primary" />
|
|
||||||
<h3 className="text-base font-bold text-dark-text">Carregando planos...</h3>
|
|
||||||
</div>
|
|
||||||
) : plans.length ? (
|
|
||||||
<div className="divide-y divide-dark-border">
|
|
||||||
{plans.map(plan => (
|
|
||||||
<div key={plan.id} className="grid grid-cols-1 gap-3 p-4 md:grid-cols-[1.4fr_1fr_100px_100px_44px] md:items-center">
|
|
||||||
<div>
|
|
||||||
<p className="font-bold text-dark-text">{plan.material}</p>
|
|
||||||
<p className="text-xs font-semibold text-dark-muted">{plan.color}</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-semibold text-dark-muted">{plan.supplier}</p>
|
|
||||||
<p className="text-sm font-bold text-dark-text">{formatNumber(plan.quantityKg)} kg</p>
|
|
||||||
<span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-text">{plan.priority}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title="Remover plano"
|
|
||||||
aria-label={`Remover plano ${plan.material}`}
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={() => removePlan(plan.id)}
|
|
||||||
className="inline-flex h-10 w-10 items-center justify-center rounded-lg text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={emptyStateClassName}>
|
|
||||||
<Ruler className="h-8 w-8 text-brand-primary" />
|
|
||||||
<h3 className="text-base font-bold text-dark-text">Nenhum plano de malha cadastrado</h3>
|
|
||||||
<p className="text-sm font-semibold text-dark-muted">Cadastre o primeiro plano no formulário ao lado.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1714,6 +1781,26 @@ const summarizePurchaseNeeds = (needs: SupplyPurchaseNeed[]) => {
|
|||||||
return summaries.length ? summaries.join(' + ') : '0 kg';
|
return summaries.length ? summaries.join(' + ') : '0 kg';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getNeedDisplayName = (need: SupplyPurchaseNeed) => (
|
||||||
|
need.material.replace(/^Cadastrar (consumo|rendimento):\s*/i, '')
|
||||||
|
);
|
||||||
|
|
||||||
|
const getNeedPendingLabel = (need: SupplyPurchaseNeed) => {
|
||||||
|
if (!need.missingReference) return purchaseStatusLabels[need.status];
|
||||||
|
return /^Cadastrar rendimento:/i.test(need.material) ? 'Rendimento' : 'Referência';
|
||||||
|
};
|
||||||
|
|
||||||
|
const sumNeedProducts = (
|
||||||
|
need: SupplyPurchaseNeed,
|
||||||
|
field: 'suggestedQuantity' | 'quantitySold' | 'stockQuantity'
|
||||||
|
) => (
|
||||||
|
(need.products || []).reduce((total, product) => total + Number(product[field] || 0), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const countImpactedSkus = (needs: SupplyPurchaseNeed[]) => (
|
||||||
|
new Set(needs.flatMap(need => (need.products || []).map(product => product.productId).filter(Boolean))).size
|
||||||
|
);
|
||||||
|
|
||||||
const PurchaseNeedsScreen = () => {
|
const PurchaseNeedsScreen = () => {
|
||||||
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -1748,7 +1835,7 @@ const PurchaseNeedsScreen = () => {
|
|||||||
|
|
||||||
const normalizedSearch = normalizeSearch(search);
|
const normalizedSearch = normalizeSearch(search);
|
||||||
const visibleNeeds = summary.purchaseNeeds.filter(need => (
|
const visibleNeeds = summary.purchaseNeeds.filter(need => (
|
||||||
!normalizedSearch || normalizeSearch(`${need.material} ${need.suppliers.join(' ')} ${need.colors.join(' ')}`).includes(normalizedSearch)
|
!normalizedSearch || normalizeSearch(`${need.material} ${getNeedDisplayName(need)} ${need.suppliers.join(' ')} ${need.colors.join(' ')} ${(need.products || []).map(product => `${product.productId} ${product.name}`).join(' ')}`).includes(normalizedSearch)
|
||||||
));
|
));
|
||||||
const totalPages = Math.ceil(visibleNeeds.length / itemsPerPage);
|
const totalPages = Math.ceil(visibleNeeds.length / itemsPerPage);
|
||||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||||
@@ -1758,17 +1845,36 @@ const PurchaseNeedsScreen = () => {
|
|||||||
const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds);
|
const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds);
|
||||||
const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size;
|
const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size;
|
||||||
const missingReferenceCount = summary.purchaseNeeds.filter(need => need.missingReference).length;
|
const missingReferenceCount = summary.purchaseNeeds.filter(need => need.missingReference).length;
|
||||||
|
const mappedNeedCount = summary.purchaseNeeds.length - missingReferenceCount;
|
||||||
|
const impactedSkuCount = countImpactedSkus(summary.purchaseNeeds);
|
||||||
|
const setupMode = missingReferenceCount > 0 && missingReferenceCount >= mappedNeedCount;
|
||||||
|
const pageTitle = 'Necessidade de Compra';
|
||||||
|
const pageSubtitle = setupMode
|
||||||
|
? 'Complete referências de consumo para liberar o cálculo real de compra por material.'
|
||||||
|
: 'Materiais abaixo do mínimo e necessidade projetada para compra.';
|
||||||
|
const panelTitle = setupMode ? 'Pendências de cadastro' : 'Necessidade por material';
|
||||||
|
const panelSubtitle = setupMode
|
||||||
|
? 'Priorize os SKUs com maior impacto e cadastre material, rendimento e unidade de consumo.'
|
||||||
|
: 'Planejado - estoque aprovado - recebimentos pendentes.';
|
||||||
|
const stats = setupMode
|
||||||
|
? [
|
||||||
|
{ label: 'Pendências', value: `${missingReferenceCount}` },
|
||||||
|
{ label: 'SKUs impactados', value: `${impactedSkuCount || missingReferenceCount}` },
|
||||||
|
{ label: 'Impacto estimado', value: suggestedPurchaseSummary },
|
||||||
|
{ label: 'Referências prontas', value: `${mappedNeedCount}` },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ label: 'Itens a comprar', value: `${purchaseItemCount}` },
|
||||||
|
{ label: 'Compra sugerida', value: suggestedPurchaseSummary },
|
||||||
|
{ label: 'Fornecedores', value: `${pendingSupplierCount}` },
|
||||||
|
{ label: 'Sem referência', value: `${missingReferenceCount}` },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={pageClassName}>
|
<div className={pageClassName}>
|
||||||
<Header title="Necessidade de Compra" subtitle="Materiais abaixo do mínimo e necessidade projetada para compra." backTo="/supplies" />
|
<Header title={pageTitle} subtitle={pageSubtitle} backTo="/supplies" />
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
{[
|
{stats.map(stat => (
|
||||||
{ label: 'Itens a comprar', value: `${purchaseItemCount}` },
|
|
||||||
{ label: 'Compra sugerida', value: suggestedPurchaseSummary },
|
|
||||||
{ label: 'Fornecedores', value: `${pendingSupplierCount}` },
|
|
||||||
{ label: 'Sem referência', value: `${missingReferenceCount}` },
|
|
||||||
].map(stat => (
|
|
||||||
<div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
<div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{stat.label}</p>
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{stat.label}</p>
|
||||||
<p className="mt-2 text-3xl font-bold text-dark-text">{stat.value}</p>
|
<p className="mt-2 text-3xl font-bold text-dark-text">{stat.value}</p>
|
||||||
@@ -1778,19 +1884,20 @@ const PurchaseNeedsScreen = () => {
|
|||||||
<div className={`${panelClassName} p-5`}>
|
<div className={`${panelClassName} p-5`}>
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-dark-text">Necessidade por material</h2>
|
<h2 className="text-base font-bold text-dark-text">{panelTitle}</h2>
|
||||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Planejado - estoque aprovado - recebimentos pendentes.</p>
|
<p className="mt-1 text-sm font-semibold text-dark-muted">{panelSubtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button type="button" onClick={loadSummary} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Atualizar</button>
|
<button type="button" onClick={loadSummary} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Atualizar</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => exportCsv('necessidade-compra.csv', visibleNeeds.map(need => ({
|
onClick={() => exportCsv(setupMode ? 'pendencias-compra.csv' : 'necessidade-compra.csv', visibleNeeds.map(need => ({
|
||||||
material: need.material,
|
material: getNeedDisplayName(need),
|
||||||
planejado_kg: need.plannedKg,
|
pendencia: need.missingReference ? getNeedPendingLabel(need) : '',
|
||||||
estoque_kg: need.stockKg,
|
planejado: need.plannedKg,
|
||||||
pendente_kg: need.pendingKg,
|
estoque: need.stockKg,
|
||||||
comprar_kg: need.purchaseKg,
|
pendente: need.pendingKg,
|
||||||
|
comprar: need.purchaseKg,
|
||||||
unidade: getNeedUnit(need),
|
unidade: getNeedUnit(need),
|
||||||
prioridade: need.priority,
|
prioridade: need.priority,
|
||||||
cobertura: need.missingReference ? 'Sem referência de consumo' : purchaseStatusLabels[need.status],
|
cobertura: need.missingReference ? 'Sem referência de consumo' : purchaseStatusLabels[need.status],
|
||||||
@@ -1813,7 +1920,7 @@ const PurchaseNeedsScreen = () => {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
className={`${inputClassName} pl-9`}
|
className={`${inputClassName} pl-9`}
|
||||||
placeholder="Buscar por material, fornecedor ou cor..."
|
placeholder={setupMode ? 'Buscar por SKU, produto ou pendência...' : 'Buscar por material, fornecedor ou cor...'}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -1824,80 +1931,147 @@ const PurchaseNeedsScreen = () => {
|
|||||||
) : visibleNeeds.length ? (
|
) : visibleNeeds.length ? (
|
||||||
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[920px] table-fixed border-collapse">
|
{setupMode ? (
|
||||||
<colgroup>
|
<table className="w-full min-w-[880px] table-fixed border-collapse">
|
||||||
<col />
|
<colgroup>
|
||||||
<col className="w-[120px]" />
|
<col className="w-[34%]" />
|
||||||
<col className="w-[120px]" />
|
<col className="w-[12%]" />
|
||||||
<col className="w-[120px]" />
|
<col className="w-[14%]" />
|
||||||
<col className="w-[120px]" />
|
<col className="w-[15%]" />
|
||||||
<col className="w-[150px]" />
|
<col className="w-[15%]" />
|
||||||
</colgroup>
|
<col className="w-[10%]" />
|
||||||
<thead className="border-b border-dark-border bg-dark-input/40 text-xs font-bold uppercase tracking-widest text-dark-muted">
|
</colgroup>
|
||||||
<tr>
|
<thead className="border-b border-dark-border bg-dark-input/40 text-xs font-bold uppercase tracking-widest text-dark-muted">
|
||||||
<th scope="col" className="px-4 py-3 text-left">Material</th>
|
<tr>
|
||||||
<th scope="col" className="px-4 py-3 text-right">Planejado</th>
|
<th scope="col" className="px-4 py-3 text-left">Produto</th>
|
||||||
<th scope="col" className="px-4 py-3 text-right">Estoque</th>
|
<th scope="col" className="px-4 py-3 text-right">Vendido</th>
|
||||||
<th scope="col" className="px-4 py-3 text-right">Pendente</th>
|
<th scope="col" className="px-4 py-3 text-right">Estoque produto</th>
|
||||||
<th scope="col" className="px-4 py-3 text-right">Comprar</th>
|
<th scope="col" className="px-4 py-3 text-right">Impacto estimado</th>
|
||||||
<th scope="col" className="px-4 py-3 text-left">Cobertura</th>
|
<th scope="col" className="px-4 py-3 text-right">Pendência</th>
|
||||||
</tr>
|
<th scope="col" className="px-4 py-3 text-right">Ação</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody className="divide-y divide-dark-border bg-dark-card">
|
</thead>
|
||||||
{paginatedNeeds.map(need => {
|
<tbody className="divide-y divide-dark-border bg-dark-card">
|
||||||
const referenceProduct = need.products?.[0];
|
{paginatedNeeds.map(need => {
|
||||||
return (
|
const referenceProduct = need.products?.[0];
|
||||||
<tr key={need.material}>
|
const productName = referenceProduct?.name || getNeedDisplayName(need);
|
||||||
<td className="px-4 py-4 align-middle">
|
const productId = referenceProduct?.productId || '';
|
||||||
<div className="min-w-0">
|
const soldQuantity = referenceProduct?.quantitySold ?? sumNeedProducts(need, 'quantitySold');
|
||||||
<p className="text-sm font-bold text-dark-text">{need.material}</p>
|
const stockQuantity = referenceProduct?.stockQuantity ?? sumNeedProducts(need, 'stockQuantity');
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
|
||||||
<p className="text-xs font-semibold text-dark-muted">
|
return (
|
||||||
{need.missingReference
|
<tr key={need.material}>
|
||||||
? 'Cadastre produto/material em Cadastros > Referência de Consumo'
|
<td className="px-4 py-4 align-middle">
|
||||||
: `${(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · ${(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}`}
|
<div className="min-w-0">
|
||||||
</p>
|
<p className="truncate text-sm font-bold text-dark-text">{productName}</p>
|
||||||
{need.missingReference && referenceProduct ? (
|
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||||
<RouterLink
|
{productId ? `SKU ${productId}` : 'SKU não informado'}
|
||||||
to={buildConsumptionReferencePath({ sku: referenceProduct.productId, name: referenceProduct.name })}
|
</p>
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
|
</div>
|
||||||
title={`Cadastrar referência do SKU ${referenceProduct.productId}`}
|
</td>
|
||||||
aria-label={`Cadastrar referência do SKU ${referenceProduct.productId}`}
|
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNumber(soldQuantity, 0)} un.</td>
|
||||||
>
|
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNumber(stockQuantity, 0)} un.</td>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-amber-300">{formatNeedQuantity(need, need.purchaseKg)}</td>
|
||||||
</RouterLink>
|
<td className="px-4 py-4 text-right align-middle">
|
||||||
) : null}
|
<span className="inline-flex w-fit rounded-full border border-sky-400/30 bg-sky-400/10 px-2.5 py-1 text-xs font-bold text-sky-300">
|
||||||
</div>
|
{getNeedPendingLabel(need)}
|
||||||
{need.products?.length ? (
|
</span>
|
||||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
</td>
|
||||||
{need.products.slice(0, 2).map(product => product.productId).join(', ')}
|
<td className="px-4 py-4 text-right align-middle">
|
||||||
{need.products.length > 2 ? ` +${need.products.length - 2}` : ''}
|
{referenceProduct ? (
|
||||||
</p>
|
<RouterLink
|
||||||
) : null}
|
to={buildConsumptionReferencePath({ sku: referenceProduct.productId, name: referenceProduct.name })}
|
||||||
</div>
|
className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
|
||||||
</td>
|
title={`Cadastrar referência do SKU ${referenceProduct.productId}`}
|
||||||
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.plannedKg)}</td>
|
aria-label={`Cadastrar referência do SKU ${referenceProduct.productId}`}
|
||||||
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.stockKg)}</td>
|
>
|
||||||
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.pendingKg)}</td>
|
<Pencil className="h-4 w-4" />
|
||||||
<td className={`px-4 py-4 text-right align-middle text-sm font-bold ${need.purchaseKg > 0 ? 'text-amber-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}</td>
|
</RouterLink>
|
||||||
<td className="px-4 py-4 align-middle">
|
) : (
|
||||||
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${
|
<span className="text-xs font-semibold text-dark-muted">Sem SKU</span>
|
||||||
need.missingReference
|
)}
|
||||||
? 'border-red-400/30 bg-red-400/10 text-red-300'
|
</td>
|
||||||
: need.status === 'critical'
|
</tr>
|
||||||
? 'border-red-400/30 bg-red-400/10 text-red-300'
|
);
|
||||||
: need.status === 'attention'
|
})}
|
||||||
? 'border-amber-400/30 bg-amber-400/10 text-amber-300'
|
</tbody>
|
||||||
: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
</table>
|
||||||
}`}>
|
) : (
|
||||||
{need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]}
|
<table className="w-full min-w-[920px] table-fixed border-collapse">
|
||||||
</span>
|
<colgroup>
|
||||||
</td>
|
<col />
|
||||||
</tr>
|
<col className="w-[120px]" />
|
||||||
);
|
<col className="w-[120px]" />
|
||||||
})}
|
<col className="w-[120px]" />
|
||||||
</tbody>
|
<col className="w-[120px]" />
|
||||||
</table>
|
<col className="w-[150px]" />
|
||||||
|
</colgroup>
|
||||||
|
<thead className="border-b border-dark-border bg-dark-input/40 text-xs font-bold uppercase tracking-widest text-dark-muted">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-4 py-3 text-left">Material</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-right">Planejado</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-right">Estoque</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-right">Pendente</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-right">Comprar</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-left">Cobertura</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dark-border bg-dark-card">
|
||||||
|
{paginatedNeeds.map(need => {
|
||||||
|
const referenceProduct = need.products?.[0];
|
||||||
|
return (
|
||||||
|
<tr key={need.material}>
|
||||||
|
<td className="px-4 py-4 align-middle">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-bold text-dark-text">{getNeedDisplayName(need)}</p>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-xs font-semibold text-dark-muted">
|
||||||
|
{need.missingReference
|
||||||
|
? 'Cadastre produto/material em Cadastros > Referência de Consumo'
|
||||||
|
: `${(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · ${(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}`}
|
||||||
|
</p>
|
||||||
|
{need.missingReference && referenceProduct ? (
|
||||||
|
<RouterLink
|
||||||
|
to={buildConsumptionReferencePath({ sku: referenceProduct.productId, name: referenceProduct.name })}
|
||||||
|
className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
|
||||||
|
title={`Cadastrar referência do SKU ${referenceProduct.productId}`}
|
||||||
|
aria-label={`Cadastrar referência do SKU ${referenceProduct.productId}`}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</RouterLink>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{need.products?.length ? (
|
||||||
|
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||||
|
{need.products.slice(0, 2).map(product => product.productId).join(', ')}
|
||||||
|
{need.products.length > 2 ? ` +${need.products.length - 2}` : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.plannedKg)}</td>
|
||||||
|
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.stockKg)}</td>
|
||||||
|
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.pendingKg)}</td>
|
||||||
|
<td className={`px-4 py-4 text-right align-middle text-sm font-bold ${need.purchaseKg > 0 ? 'text-amber-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}</td>
|
||||||
|
<td className="px-4 py-4 align-middle">
|
||||||
|
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${
|
||||||
|
need.missingReference
|
||||||
|
? 'border-red-400/30 bg-red-400/10 text-red-300'
|
||||||
|
: need.status === 'critical'
|
||||||
|
? 'border-red-400/30 bg-red-400/10 text-red-300'
|
||||||
|
: need.status === 'attention'
|
||||||
|
? 'border-amber-400/30 bg-amber-400/10 text-amber-300'
|
||||||
|
: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
||||||
|
}`}>
|
||||||
|
{need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PaginationControls
|
<PaginationControls
|
||||||
totalItems={visibleNeeds.length}
|
totalItems={visibleNeeds.length}
|
||||||
|
|||||||
60
src/types.ts
60
src/types.ts
@@ -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
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user