Compare commits

...

3 Commits

Author SHA1 Message Date
Cauê Faleiros
e993bfdaf3 Use Tiny compositions for purchase planning
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m41s
2026-08-03 10:22:39 -03:00
Cauê Faleiros
1a3fed3dda Update project context after composition import 2026-08-03 09:58:37 -03:00
Cauê Faleiros
37d4a7732e Import Tiny product compositions 2026-07-31 11:32:49 -03:00
19 changed files with 948 additions and 54 deletions

View File

@@ -38,6 +38,7 @@ This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a rea
* **Route Code Splitting:** Frontend routes are lazy-loaded with `React.lazy`/`Suspense` so the first JS payload stays smaller. Recharts-heavy page chunks are loaded on demand. * **Route Code Splitting:** Frontend routes are lazy-loaded with `React.lazy`/`Suspense` so the first JS payload stays smaller. Recharts-heavy page chunks are loaded on demand.
* **Analytics Client Cache:** Frontend analytics requests use a small shared cache/deduping layer in `src/dataService.ts`. Navigating between Dashboard, RFV, Products, and Clients should reuse fresh responses instead of refetching every route mount unless query params change or the cache expires. * **Analytics Client Cache:** Frontend analytics requests use a small shared cache/deduping layer in `src/dataService.ts`. Navigating between Dashboard, RFV, Products, and Clients should reuse fresh responses instead of refetching every route mount unless query params change or the cache expires.
* **Tiny as Current Source, Graphs as Future Operating Layer:** Tiny ERP is the current source for sales, product/order metadata, and stock. Graphs should increasingly become the place where the business manages production intelligence: cut configuration, SKU families, raw material relationships, consumption references, and operational charts. `NECESSIDADE DE CORTE.xlsx` is reference material only and must not become a runtime dependency. * **Tiny as Current Source, Graphs as Future Operating Layer:** Tiny ERP is the current source for sales, product/order metadata, and stock. Graphs should increasingly become the place where the business manages production intelligence: cut configuration, SKU families, raw material relationships, consumption references, and operational charts. `NECESSIDADE DE CORTE.xlsx` is reference material only and must not become a runtime dependency.
* **Tiny/Olist Composition Import:** Product structures from Tiny/Olist V3 are imported into `product_compositions` and `product_composition_components`. Sellable finished-product structures are also promoted into local catalog products/materials and `consumption_references` with source `tiny_structure`, so purchase planning can use real component consumption instead of guessed kg-per-piece rules.
## 4. Directory Structure ## 4. Directory Structure
```text ```text
@@ -111,16 +112,35 @@ yield: 4.8 units/kg
* **Finished SKU catalog:** SKU/id, name, color, size, product family, cut family, active/inactive. * **Finished SKU catalog:** SKU/id, name, color, size, product family, cut family, active/inactive.
* **Raw material catalog:** material SKU/id, name, type (`malha`, `ribana`, `fio`, etc.), color, supplier, unit (`kg`, `metro`, `unidade`), and whether it is raw input instead of finished stock. * **Raw material catalog:** material SKU/id, name, type (`malha`, `ribana`, `fio`, etc.), color, supplier, unit (`kg`, `metro`, `unidade`), and whether it is raw input instead of finished stock.
* **Consumption references:** finished SKU, material product, yield per kg, optional yield by size, optional color/family overrides, and secondary materials when needed. * **Consumption references:** finished SKU, material product, yield per kg, optional yield by size, optional color/family overrides, and secondary materials when needed.
* **Imported product compositions:** Tiny/Olist structures are stored as immutable-ish sync facts: finished SKU/Tiny ID/unit, component SKU/Tiny ID/name, quantity per finished unit, and component unit (`KG`, `UN`, etc.). These records support Product Details composition display and drive `tiny_structure` consumption references.
* **Cut family mapping:** rules that say which colors/sizes/products can be planned together for cutting. * **Cut family mapping:** rules that say which colors/sizes/products can be planned together for cutting.
* **Open production/cutting data:** quantities already in production, expected finish date, linked finished SKU, and linked raw material when available. * **Open production/cutting data:** quantities already in production, expected finish date, linked finished SKU, and linked raw material when available.
* **Business rules:** target coverage days, minimum stock, safety stock, purchase lead time, production lead time, and discontinued/ignored SKU rules. * **Business rules:** target coverage days, minimum stock, safety stock, purchase lead time, production lead time, and discontinued/ignored SKU rules.
**Current UI/data-flow behavior:** **Current UI/data-flow behavior:**
* `Cadastros` has an `Importar composições` JSON upload action for exports like `composicoes_produtos_YYYY-MM-DD.json`. The same import is available to scripts through `POST /api/production-orders/tiny-compositions/import` using `x-api-key`.
* Composition imports are idempotent. Re-importing the same Tiny/Olist export updates composition rows/components and refreshes derived `tiny_structure` consumption references instead of duplicating them.
* `Product Details > Composição` shows the synced component list for the current product when a composition exists.
* `Suprimentos > Necessidade de Compra` derives material pressure from demand and stock, but rows without a consumption reference are marked as missing reference instead of pretending to calculate kg. * `Suprimentos > Necessidade de Compra` derives material pressure from demand and stock, but rows without a consumption reference are marked as missing reference instead of pretending to calculate kg.
* Imported `tiny_structure` consumption references now allow `Necessidade de Compra` to calculate mixed-unit material demand from real BOM lines, e.g. malha/ribana in `KG` and etiqueta/ilhós/atacador in `UN`.
* Missing-reference rows link into `Cadastros > Referência de Consumo` with SKU context. If the SKU does not exist in the local catalog yet, `Cadastros` opens the product form first. * Missing-reference rows link into `Cadastros > Referência de Consumo` with SKU context. If the SKU does not exist in the local catalog yet, `Cadastros` opens the product form first.
* `Cadastros > Referência de Consumo` labels reference sources as `Manual`, `Tiny OP`, or `Tiny Estrutura`.
* `Plano de Corte` SKU edit actions deep-link into the cutting configuration drawer for that SKU. * `Plano de Corte` SKU edit actions deep-link into the cutting configuration drawer for that SKU.
* Product tables and group detail tables are tuned for high-volume values, including large `Total Vendido` quantities above 100k. * Product tables and group detail tables are tuned for high-volume values, including large `Total Vendido` quantities above 100k.
**Current composition import status:**
* Local import tested with `/home/farelos/Downloads/composicoes_produtos_2026-07-31.json`.
* Imported successfully: `437` compositions, `1,552` component rows, and `1,316` `tiny_structure` consumption references.
* Known data issues from that export: `4` sellable finished products are missing SKU; `54` structures are raw material/service structures and are stored as compositions but intentionally not promoted into sellable demand-planning references; `236` component/reference lines were skipped because their parent structure was non-sellable or the finished SKU was missing.
* Sellable-family detection currently includes `DTF`, `CAMISETA`, `CAMISA`, `MOLETOM`, `CANGURU`, `REGATA`, and `POLO`. Raw/input/service structures are excluded from demand-reference promotion when descriptions contain terms like `MALHA`, `RIBANA`, `FIO`, `TECIDO`, `SERVIÇO`, `TINTURARIA`, `TECELAGEM`, or `FRETE`.
**Recommended next steps now that composition data exists:**
* Make `Necessidade de Compra` clearer and more auditable: show `demanda do SKU -> consumo por unidade -> material necessário -> estoque atual -> comprar`.
* Add a data-health/review screen for missing finished SKU, missing component stock link, suspicious quantities, products with sales but no composition, and non-sellable structures.
* Connect material stock more explicitly to imported component SKUs/Tiny IDs so material purchase needs subtract the right current stock and pending receipts.
* Upgrade `Planejamento de Corte` to show material blockers by family/color/size, using imported composition references and current stock.
* Add production-plan status flow only after material needs and composition health are reliable.
## 7. CI/CD & Deployment ## 7. CI/CD & Deployment
* **Gitea Actions:** A workflow located in `.gitea/workflows/deploy.yml` triggers on pushes to the `main` branch. * **Gitea Actions:** A workflow located in `.gitea/workflows/deploy.yml` triggers on pushes to the `main` branch.
* **Docker Registry:** The pipeline builds the `frontend` and `backend` Docker images and pushes them directly to `gitea.blyzer.com.br/blyzer/`. * **Docker Registry:** The pipeline builds the `frontend` and `backend` Docker images and pushes them directly to `gitea.blyzer.com.br/blyzer/`.
@@ -168,6 +188,17 @@ docker compose up -d --build
``` ```
The local Docker services use `restart: unless-stopped`, so containers should come back after laptop restart if Docker starts. The local Docker services use `restart: unless-stopped`, so containers should come back after laptop restart if Docker starts.
**Importing Tiny/Olist Product Compositions:**
* From the app: open `Cadastros` and use `Importar composições` with the exported JSON file.
* From a script/API client:
```bash
curl -X POST http://localhost:3004/api/production-orders/tiny-compositions/import \
-H 'Content-Type: application/json' \
-H 'x-api-key: nexstar_secret_key_123' \
--data-binary @/path/to/composicoes_produtos_YYYY-MM-DD.json
```
* Expected response includes `imported`, `failed`, `componentCount`, `referenceCount`, `skippedReferenceCount`, and `issues`.
## 9. Coding Standards & AI Directives ## 9. Coding Standards & AI Directives
* **Strict Type Safety:** Use explicit TypeScript interfaces (defined in `types.ts`). Avoid `any` where possible. Do not bypass type checks with `// @ts-ignore`. * **Strict Type Safety:** Use explicit TypeScript interfaces (defined in `types.ts`). Avoid `any` where possible. Do not bypass type checks with `// @ts-ignore`.
* **Idiomatic React:** Use functional components and hooks (`useState`, `useEffect`, `useMemo`). Complex data transformations (like merging arrays into chart-ready datasets) MUST be wrapped in `useMemo` to prevent unnecessary re-renders. * **Idiomatic React:** Use functional components and hooks (`useState`, `useEffect`, `useMemo`). Complex data transformations (like merging arrays into chart-ready datasets) MUST be wrapped in `useMemo` to prevent unnecessary re-renders.
@@ -203,6 +234,9 @@ Files most relevant to RFV:
* `src/displayFormatters.ts` - shared seller/fantasy/client metadata display cleanup. * `src/displayFormatters.ts` - shared seller/fantasy/client metadata display cleanup.
Recent commits related to supply/cutting/data-flow and chart readability: Recent commits related to supply/cutting/data-flow and chart readability:
* `37d4a77 Import Tiny product compositions` - adds the Tiny/Olist composition JSON import flow, derived `tiny_structure` consumption references, app upload button, API-key import endpoint, and import tests.
* `800eb97 Derive consumption references from Tiny OP sync` - Tiny OP composition sync now creates catalog products/materials and reusable consumption references from OP component lines.
* `c07938c Add Tiny production order detail sync` - stores OP detail rows, components, steps, and observations from Tiny/Olist sync payloads.
* `060b4da Connect supplies to project demand` - Suprimentos purchase needs now derive from project/order demand and stock, while missing consumption references are surfaced explicitly. * `060b4da Connect supplies to project demand` - Suprimentos purchase needs now derive from project/order demand and stock, while missing consumption references are surfaced explicitly.
* `bc05fb4 Add SKU edit actions` - product/cutting/replenishment/group tables gained compact SKU view/edit actions. * `bc05fb4 Add SKU edit actions` - product/cutting/replenishment/group tables gained compact SKU view/edit actions.
* `63efb47 Route SKU actions to focused editors` - cut-plan edit opens SKU cutting configuration; missing-reference supply rows open the consumption reference flow. * `63efb47 Route SKU actions to focused editors` - cut-plan edit opens SKU cutting configuration; missing-reference supply rows open the consumption reference flow.
@@ -218,3 +252,6 @@ Files most relevant to supply/cutting/data-flow:
* `src/analytics/cutting.ts` - cut-family parsing, cut needs, stock coverage, and product override logic. * `src/analytics/cutting.ts` - cut-family parsing, cut needs, stock coverage, and product override logic.
* `src/pages/Products.tsx` and `src/pages/ProductGroupDetails.tsx` - product/group velocity, stock coverage, and high-volume display. * `src/pages/Products.tsx` and `src/pages/ProductGroupDetails.tsx` - product/group velocity, stock coverage, and high-volume display.
* `src/chartUtils.ts` - shared date bucketing, moving average, unknown-label cleanup, and chart date helpers. * `src/chartUtils.ts` - shared date bucketing, moving average, unknown-label cleanup, and chart date helpers.
* `backend/services/productionOrderService.js` - Tiny OP detail sync, Tiny/Olist structure import, composition storage, sellable structure filtering, and derived consumption-reference creation.
* `backend/routes/productionOrderRoutes.js` and `backend/routes/catalogRoutes.js` - API-key script import and authenticated app import endpoints for composition JSON.
* `backend/test/productCompositionService.test.js` - coverage for Tiny/Olist structure sync and bulk composition import behavior.

View File

@@ -11,6 +11,7 @@ const {
getRfmAnalytics getRfmAnalytics
} = require('../services/analyticsService'); } = require('../services/analyticsService');
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService'); const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
const { getDataHealthSummary } = require('../services/dataHealthService');
const router = express.Router(); const router = express.Router();
@@ -78,6 +79,15 @@ router.get('/analytics/product-compositions', verifyToken, async (req, res) => {
} }
}); });
router.get('/analytics/data-health', verifyToken, async (req, res) => {
try {
res.json(await getDataHealthSummary());
} catch (error) {
console.error('Error fetching data health:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/clients', verifyToken, async (req, res) => { router.get('/analytics/clients', verifyToken, async (req, res) => {
try { try {
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query))); res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));

View File

@@ -12,6 +12,7 @@ const {
listConsumptionReferences, listConsumptionReferences,
listProducts listProducts
} = require('../services/catalogService'); } = require('../services/catalogService');
const { importTinyProductCompositionExport } = require('../services/productionOrderService');
const router = express.Router(); const router = express.Router();
@@ -89,6 +90,14 @@ router.post('/catalog/consumption-references', verifyToken, async (req, res, nex
} }
}); });
router.post('/catalog/product-compositions/import', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await importTinyProductCompositionExport(req.body || {}));
} catch (error) {
next(error);
}
});
router.delete('/catalog/consumption-references/:id', verifyToken, async (req, res, next) => { router.delete('/catalog/consumption-references/:id', verifyToken, async (req, res, next) => {
try { try {
await deleteConsumptionReference(req.params.id); await deleteConsumptionReference(req.params.id);

View File

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

View File

@@ -18,7 +18,7 @@ const createApp = () => {
const app = express(); const app = express();
app.use(cors()); app.use(cors());
app.use(bodyParser.json()); app.use(bodyParser.json({ limit: '10mb' }));
app.use('/api', authRoutes); app.use('/api', authRoutes);
app.use('/api', dataRoutes); app.use('/api', dataRoutes);

View File

@@ -0,0 +1,85 @@
const { pool } = require('../db');
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
const normalizeSku = (value) => normalizeText(value).toUpperCase();
const isRawOrService = (value) => /\b(?:MALHA|RIBANA|FIO|TECIDO|SERVICO|SERVIÇO|TINTURARIA|TECELAGEM|FRETE)\b/i.test(value);
const getDataHealthSummary = async () => {
const [productsResult, compositionsResult, componentsResult, stockResult] = await Promise.all([
pool.query(`
SELECT produto_id AS id, MAX(NULLIF(produto_descricao, '')) AS name FROM orders GROUP BY produto_id
UNION
SELECT produto_id AS id, MAX(NULLIF(nome, '')) AS name FROM stock GROUP BY produto_id;
`),
pool.query(`
SELECT id, finished_tiny_product_id, finished_product_sku, finished_product_description, finished_product_unit
FROM product_compositions WHERE source = 'tiny_olist_v3';
`),
pool.query(`
SELECT product_composition_id, component_tiny_id, component_sku, component_name, quantity_per_unit, unit
FROM product_composition_components;
`),
pool.query(`SELECT produto_id, nome, COALESCE(saldo, 0)::numeric AS saldo FROM stock;`)
]);
const products = Array.from(new Map(productsResult.rows.map(row => [normalizeSku(row.id), row])).values());
const compositions = compositionsResult.rows;
const components = componentsResult.rows;
const stock = stockResult.rows;
const stockKeys = new Set(stock.flatMap(item => [normalizeSku(item.produto_id), normalizeSku(item.nome)]).filter(Boolean));
const compositionKeys = new Set(compositions.flatMap(item => [normalizeSku(item.finished_tiny_product_id), normalizeSku(item.finished_product_sku)]).filter(Boolean));
const productKeys = new Set(products.map(item => normalizeSku(item.id)).filter(Boolean));
const linkedComponentKeys = new Set([...stockKeys, ...productKeys]);
const materialKeys = new Set(components.map(item => normalizeSku(item.component_tiny_id) || normalizeSku(item.component_sku) || normalizeSku(item.component_name)).filter(Boolean));
const materialStockKeys = new Set([...materialKeys].filter(key => stockKeys.has(key)));
const rowsByComposition = new Map(compositions.map(item => [Number(item.id), item]));
const issues = [];
const addIssue = (type, severity, title, detail, productSku = '', component = '') => {
issues.push({ type, severity, title, detail, productSku, component });
};
compositions.filter(row => !normalizeText(row.finished_product_sku)).forEach(row => {
addIssue('missing_product_sku', 'critical', 'Produto sem SKU', normalizeText(row.finished_product_description) || 'Composição sem descrição', '', '');
});
components.filter(row => {
const key = normalizeSku(row.component_tiny_id) || normalizeSku(row.component_sku) || normalizeSku(row.component_name);
return !key || !linkedComponentKeys.has(key);
}).forEach(row => {
const parent = rowsByComposition.get(Number(row.product_composition_id));
addIssue('component_without_stock_link', 'attention', 'Componente sem vínculo de estoque', normalizeText(row.component_name) || 'Componente sem nome', normalizeText(parent?.finished_product_sku), normalizeText(row.component_sku || row.component_tiny_id));
});
components.filter(row => {
const quantity = Number(row.quantity_per_unit || 0);
return !Number.isFinite(quantity) || quantity <= 0 || quantity > 100 || !normalizeText(row.unit);
}).forEach(row => {
const parent = rowsByComposition.get(Number(row.product_composition_id));
addIssue('suspicious_quantity', 'attention', 'Quantidade suspeita', `${normalizeText(row.component_name)} · ${row.quantity_per_unit || 0} ${normalizeText(row.unit) || '(sem unidade)'}`, normalizeText(parent?.finished_product_sku), normalizeText(row.component_sku || row.component_tiny_id));
});
compositions.filter(row => isRawOrService(row.finished_product_description) || normalizeText(row.finished_product_unit).toLowerCase() !== 'un').forEach(row => {
addIssue('raw_or_service_structure', 'info', 'Estrutura de matéria-prima/serviço', normalizeText(row.finished_product_description), normalizeText(row.finished_product_sku), '');
});
products.filter(row => !compositionKeys.has(normalizeSku(row.id))).forEach(row => {
addIssue('product_without_composition', 'attention', 'Produto sem composição', normalizeText(row.name) || normalizeText(row.id), normalizeText(row.id), '');
});
const productsWithComposition = products.filter(row => compositionKeys.has(normalizeSku(row.id))).length;
const productsWithStockLink = products.filter(row => stockKeys.has(normalizeSku(row.id))).length;
return {
totals: {
products: productKeys.size,
productsWithComposition,
productsWithStockLink,
materials: materialKeys.size,
materialsWithStock: materialStockKeys.size,
compositions: compositions.length,
components: components.length
},
issues: issues.sort((a, b) => {
const severityOrder = { critical: 0, attention: 1, info: 2 };
return severityOrder[a.severity] - severityOrder[b.severity] || a.title.localeCompare(b.title);
})
};
};
module.exports = { getDataHealthSummary };

View File

@@ -39,6 +39,14 @@ const sampleSpecs = {
columns: ['id', 'production_order_id', 'step_number', 'name', 'start_date', 'end_date', 'status', 'color', 'created_at', 'updated_at'], columns: ['id', 'production_order_id', 'step_number', 'name', 'start_date', 'end_date', 'status', 'color', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id'] orderBy: ['updated_at', 'created_at', 'id']
}, },
product_compositions: {
columns: ['id', 'source', 'external_source_id', 'finished_product_identity', 'finished_product_sku', 'finished_product_description', 'finished_product_unit', 'finished_tiny_product_id', 'last_synced_at', 'created_at', 'updated_at'],
orderBy: ['last_synced_at', 'updated_at', 'id']
},
product_composition_components: {
columns: ['id', 'product_composition_id', 'component_identity', 'component_tiny_id', 'component_sku', 'component_name', 'quantity_per_unit', 'unit', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
production_orders: { production_orders: {
columns: ['id', 'tiny_id', 'number', 'status', 'order_reference', 'issue_date', 'expected_date', 'product_sku', 'product_description', 'quantity', 'unit', 'integration_status', 'notes', 'supplier', 'lot_code', 'roll_quantity', 'fabric_kg', 'rib_kg', 'yield_pieces_per_kg', 'created_at', 'updated_at'], columns: ['id', 'tiny_id', 'number', 'status', 'order_reference', 'issue_date', 'expected_date', 'product_sku', 'product_description', 'quantity', 'unit', 'integration_status', 'notes', 'supplier', 'lot_code', 'roll_quantity', 'fabric_kg', 'rib_kg', 'yield_pieces_per_kg', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id'] orderBy: ['updated_at', 'created_at', 'id']

View File

@@ -132,7 +132,10 @@ const upsertCatalogProductFromSync = async (client, { sku, name, type, categoryN
return result.rows[0].id; return result.rows[0].id;
}; };
const upsertConsumptionReferencesFromComponents = async (client, orderId, order, components) => { const upsertConsumptionReferencesFromComponents = async (client, orderId, order, components, options = {}) => {
const source = normalizeText(options.source) || 'tiny_op';
const sourceLabel = normalizeText(options.sourceLabel) || 'OP';
if (!order.productSku || !components.length) { if (!order.productSku || !components.length) {
return { referenceCount: 0, skippedReferenceCount: components.length }; return { referenceCount: 0, skippedReferenceCount: components.length };
} }
@@ -142,7 +145,7 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order,
name: order.productDescription, name: order.productDescription,
type: 'finished_product', type: 'finished_product',
categoryName: classifyCatalogCategoryName(order.productDescription, 'finished_product'), categoryName: classifyCatalogCategoryName(order.productDescription, 'finished_product'),
notes: `Sincronizado da OP ${order.number || order.tinyId || orderId}.` notes: `Sincronizado da ${sourceLabel} ${order.number || order.tinyId || orderId || ''}.`
}); });
if (!productId) return { referenceCount: 0, skippedReferenceCount: components.length }; if (!productId) return { referenceCount: 0, skippedReferenceCount: components.length };
@@ -156,7 +159,7 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order,
name: component.componentName, name: component.componentName,
type: 'raw_material', type: 'raw_material',
categoryName: classifyCatalogCategoryName(component.componentName, 'raw_material'), categoryName: classifyCatalogCategoryName(component.componentName, 'raw_material'),
notes: `Sincronizado da composição da OP ${order.number || order.tinyId || orderId}.` notes: `Sincronizado da composição da ${sourceLabel} ${order.number || order.tinyId || orderId || ''}.`
}); });
if (!materialId || !component.quantityPerUnit) { if (!materialId || !component.quantityPerUnit) {
@@ -173,9 +176,9 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order,
WHERE product_id = $1 WHERE product_id = $1
AND COALESCE(material_product_id, 0) = $2 AND COALESCE(material_product_id, 0) = $2
AND COALESCE(color, '') = '' AND COALESCE(color, '') = ''
AND COALESCE(source, 'manual') = 'tiny_op' AND COALESCE(source, 'manual') = $3
LIMIT 1; LIMIT 1;
`, [productId, materialId]); `, [productId, materialId, source]);
if (existingReferenceResult.rows.length) { if (existingReferenceResult.rows.length) {
await client.query(` await client.query(`
@@ -184,13 +187,15 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order,
consumption_quantity = $2, consumption_quantity = $2,
consumption_unit = $3, consumption_unit = $3,
last_production_order_id = $4, last_production_order_id = $4,
source = $5,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $5; WHERE id = $6;
`, [ `, [
generalYield, generalYield,
component.quantityPerUnit, component.quantityPerUnit,
component.unit || null, component.unit || null,
orderId, orderId,
source,
existingReferenceResult.rows[0].id existingReferenceResult.rows[0].id
]); ]);
} else { } else {
@@ -199,13 +204,14 @@ const upsertConsumptionReferencesFromComponents = async (client, orderId, order,
product_id, material_product_id, general_yield, consumption_quantity, product_id, material_product_id, general_yield, consumption_quantity,
consumption_unit, source, last_production_order_id, updated_at consumption_unit, source, last_production_order_id, updated_at
) )
VALUES ($1, $2, $3, $4, $5, 'tiny_op', $6, CURRENT_TIMESTAMP); VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP);
`, [ `, [
productId, productId,
materialId, materialId,
generalYield, generalYield,
component.quantityPerUnit, component.quantityPerUnit,
component.unit || null, component.unit || null,
source,
orderId orderId
]); ]);
} }
@@ -655,6 +661,16 @@ const TINY_OLIST_V3_COMPOSITION_PREFIX = 'STRUCTURE-V3-';
const isTinyOlistV3Composition = (order) => order.tinyId.startsWith(TINY_OLIST_V3_COMPOSITION_PREFIX); const isTinyOlistV3Composition = (order) => order.tinyId.startsWith(TINY_OLIST_V3_COMPOSITION_PREFIX);
const SELLABLE_PRODUCT_PATTERN = /\b(?:DTF|CAMISETA|CAMISA|MOLETOM|CANGURU|REGATA|POLO)\b/;
const NON_SELLABLE_STRUCTURE_PATTERN = /\b(?:MALHA|RIBANA|FIO|TECIDO|SERVICO|SERVIÇO|TINTURARIA|TECELAGEM|FRETE)\b/;
const isSellableComposition = (order) => {
const unit = normalizeUnit(order.unit);
const description = normalizeCategoryName(order.productDescription);
if (unit !== 'un') return false;
return SELLABLE_PRODUCT_PATTERN.test(description) && !NON_SELLABLE_STRUCTURE_PATTERN.test(description);
};
const getTinyProductIdFromCompositionReference = (tinyId) => ( const getTinyProductIdFromCompositionReference = (tinyId) => (
tinyId.slice(TINY_OLIST_V3_COMPOSITION_PREFIX.length) tinyId.slice(TINY_OLIST_V3_COMPOSITION_PREFIX.length)
); );
@@ -766,6 +782,80 @@ const getProductComposition = async (productId, client = pool) => (
const listProductCompositions = async (client = pool) => findProductCompositions('', client); const listProductCompositions = async (client = pool) => findProductCompositions('', client);
const buildCompositionPayload = (composition) => {
const existingPayload = composition?.sourceMetadata?.payload || composition?.source_metadata?.payload;
if (existingPayload?.order) return existingPayload;
const finishedTinyProductId = normalizeText(composition?.finishedTinyProductId || composition?.finished_tiny_product_id);
const finishedProductSku = normalizeSku(composition?.finishedProductSku || composition?.finished_product_sku);
const tinyId = normalizeText(composition?.externalSourceId || composition?.external_source_id)
|| (finishedTinyProductId ? `${TINY_OLIST_V3_COMPOSITION_PREFIX}${finishedTinyProductId}` : `${TINY_OLIST_V3_COMPOSITION_PREFIX}${finishedProductSku}`);
return {
order: {
tinyId,
number: normalizeText(composition?.externalSourceId || composition?.external_source_id) || tinyId,
status: 'completed',
productSku: finishedProductSku,
productDescription: normalizeText(composition?.finishedProductDescription || composition?.finished_product_description),
quantity: '1',
unit: normalizeText(composition?.finishedProductUnit || composition?.finished_product_unit) || 'UN',
notes: 'Composição importada de arquivo JSON.'
},
components: Array.isArray(composition?.components) ? composition.components.map(component => ({
componentTinyId: normalizeText(component.componentTinyId || component.component_tiny_id || component.productId || component.product_id),
componentSku: normalizeSku(component.componentSku || component.component_sku),
componentName: normalizeText(component.componentName || component.component_name),
quantityPerUnit: component.quantityPerUnit ?? component.quantity_per_unit,
totalQuantity: component.quantityPerUnit ?? component.quantity_per_unit,
unit: normalizeText(component.unit)
})) : [],
steps: []
};
};
const listCompositionImportIssues = (composition, order, components, result) => {
const issues = [];
if (!order.productSku) {
issues.push({
type: 'missing_finished_sku',
product: order.productDescription,
tinyProductId: getTinyProductIdFromCompositionReference(order.tinyId)
});
}
if (!isSellableComposition(order)) {
issues.push({
type: 'not_sellable_product',
productSku: order.productSku,
product: order.productDescription,
unit: order.unit
});
}
components
.filter(component => !component.componentSku || !component.quantityPerUnit)
.forEach(component => {
issues.push({
type: !component.componentSku ? 'missing_component_sku' : 'invalid_component_quantity',
productSku: order.productSku,
component: component.componentName,
componentSku: component.componentSku
});
});
if ((result?.skippedReferenceCount || 0) > 0) {
issues.push({
type: 'skipped_references',
productSku: order.productSku,
product: order.productDescription,
count: result.skippedReferenceCount
});
}
return issues;
};
const upsertTinyProductComposition = async (order, components) => { const upsertTinyProductComposition = async (order, components) => {
const finishedTinyProductId = getTinyProductIdFromCompositionReference(order.tinyId); const finishedTinyProductId = getTinyProductIdFromCompositionReference(order.tinyId);
const finishedProductSku = normalizeSku(order.productSku); const finishedProductSku = normalizeSku(order.productSku);
@@ -808,6 +898,7 @@ const upsertTinyProductComposition = async (order, components) => {
await client.query(` await client.query(`
UPDATE product_compositions UPDATE product_compositions
SET external_source_id = $1, SET external_source_id = $1,
finished_product_identity = $2,
finished_product_sku = $3, finished_product_sku = $3,
finished_product_description = $4, finished_product_description = $4,
finished_product_unit = $5, finished_product_unit = $5,
@@ -852,10 +943,19 @@ const upsertTinyProductComposition = async (order, components) => {
]); ]);
} }
const referenceSync = isSellableComposition(order)
? await upsertConsumptionReferencesFromComponents(client, null, order, normalizedComponents, {
source: 'tiny_structure',
sourceLabel: 'estrutura Tiny/Olist'
})
: { referenceCount: 0, skippedReferenceCount: normalizedComponents.length };
await client.query('COMMIT'); await client.query('COMMIT');
return { return {
composition: await getProductComposition(finishedTinyProductId || finishedProductSku), composition: await getProductComposition(finishedTinyProductId || finishedProductSku),
componentCount: normalizedComponents.length componentCount: normalizedComponents.length,
referenceCount: referenceSync.referenceCount,
skippedReferenceCount: referenceSync.skippedReferenceCount
}; };
} catch (error) { } catch (error) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -865,6 +965,55 @@ const upsertTinyProductComposition = async (order, components) => {
} }
}; };
const importTinyProductCompositionExport = async (payload = {}) => {
const compositions = Array.isArray(payload)
? payload
: Array.isArray(payload.compositions) ? payload.compositions : [];
if (!compositions.length) {
const error = new Error('Arquivo de composições sem itens para importar.');
error.statusCode = 400;
throw error;
}
const summary = {
imported: 0,
failed: 0,
componentCount: 0,
referenceCount: 0,
skippedReferenceCount: 0,
issues: []
};
for (const composition of compositions) {
try {
const compositionPayload = buildCompositionPayload(composition);
const order = resolveTinyOrderPayload(compositionPayload);
const components = normalizeComponents(
compositionPayload.components || compositionPayload.composition || compositionPayload.composicao,
order.quantity
);
const result = await upsertTinyProductComposition(order, components);
summary.imported += 1;
summary.componentCount += result.componentCount || 0;
summary.referenceCount += result.referenceCount || 0;
summary.skippedReferenceCount += result.skippedReferenceCount || 0;
summary.issues.push(...listCompositionImportIssues(composition, order, components, result));
} catch (error) {
summary.failed += 1;
summary.issues.push({
type: 'import_failed',
productSku: normalizeText(composition?.finishedProductSku || composition?.finished_product_sku),
product: normalizeText(composition?.finishedProductDescription || composition?.finished_product_description),
message: error.message
});
}
}
return summary;
};
const upsertTinyProductionOrderDetail = async (payload = {}) => { const upsertTinyProductionOrderDetail = async (payload = {}) => {
const order = resolveTinyOrderPayload(payload); const order = resolveTinyOrderPayload(payload);
const components = normalizeComponents(payload.components || payload.composition || payload.composicao, order.quantity); const components = normalizeComponents(payload.components || payload.composition || payload.composicao, order.quantity);
@@ -1057,6 +1206,7 @@ const updateProductionOrderStatus = async (id, status) => {
module.exports = { module.exports = {
createProductionOrders, createProductionOrders,
getProductComposition, getProductComposition,
importTinyProductCompositionExport,
listProductCompositions, listProductCompositions,
listProductionOrders, listProductionOrders,
normalizeStatus, normalizeStatus,

View File

@@ -269,6 +269,37 @@ const listConsumptionReferenceRows = async () => {
return result.rows; return result.rows;
}; };
// The Tiny structure is the source of truth when it exists. Consumption
// references remain useful as a fallback for legacy/manual products, but they
// must not make an imported structure invisible to purchase planning.
const listCompositionRows = async () => {
const result = await pool.query(`
SELECT
composition.finished_tiny_product_id,
composition.finished_product_sku,
component.component_tiny_id,
component.component_sku,
component.component_name,
component.quantity_per_unit,
component.unit
FROM product_compositions composition
JOIN product_composition_components component
ON component.product_composition_id = composition.id
WHERE composition.source = 'tiny_olist_v3'
ORDER BY composition.id, component.id;
`);
return result.rows;
};
const listTinyStockRows = async () => {
const result = await pool.query(`
SELECT produto_id, nome, COALESCE(saldo, 0)::numeric AS saldo
FROM stock
WHERE COALESCE(produto_id, '') <> '' OR COALESCE(nome, '') <> '';
`);
return result.rows;
};
const mergeNeedLine = (needsByMaterial, key, patch) => { const mergeNeedLine = (needsByMaterial, key, patch) => {
const current = needsByMaterial.get(key) || { const current = needsByMaterial.get(key) || {
material: patch.material, material: patch.material,
@@ -302,9 +333,11 @@ const mergeNeedLine = (needsByMaterial, key, patch) => {
}; };
const buildProjectPurchaseNeeds = async (lots, receipts) => { const buildProjectPurchaseNeeds = async (lots, receipts) => {
const [demandRows, referenceRows] = await Promise.all([ const [demandRows, referenceRows, compositionRows, tinyStockRows] = await Promise.all([
listProjectDemandRows(), listProjectDemandRows(),
listConsumptionReferenceRows() listConsumptionReferenceRows(),
listCompositionRows(),
listTinyStockRows()
]); ]);
const referencesBySku = referenceRows.reduce((references, reference) => { const referencesBySku = referenceRows.reduce((references, reference) => {
const sku = normalizeSku(reference.product_sku); const sku = normalizeSku(reference.product_sku);
@@ -314,6 +347,70 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
}, new Map()); }, new Map());
const needsByMaterial = new Map(); const needsByMaterial = new Map();
const compositionsByProduct = compositionRows.reduce((compositions, component) => {
[component.finished_tiny_product_id, component.finished_product_sku]
.map(normalizeSku)
.filter(Boolean)
.forEach(identity => {
compositions.set(identity, [...(compositions.get(identity) || []), component]);
});
return compositions;
}, new Map());
// A need is keyed by the imported Tiny component identity whenever
// possible. Names are retained for display and for matching manual lots.
const componentNeedKeys = new Map();
const registerNeedKey = (identity, key) => {
const normalizedIdentity = normalizeSku(identity);
if (normalizedIdentity) componentNeedKeys.set(normalizedIdentity, key);
};
const addCompositionNeed = (component, row, productId, suggestedQuantity, quantitySold, stockQuantity) => {
const unit = normalizeUnit(component.unit);
const material = normalizeText(component.component_name)
|| normalizeText(component.component_sku)
|| normalizeText(component.component_tiny_id)
|| 'Material sem cadastro';
const quantityPerUnit = Number(component.quantity_per_unit || 0);
const tinyId = normalizeText(component.component_tiny_id);
const sku = normalizeSku(component.component_sku);
const identity = tinyId || sku || normalizeKey(material);
const key = `${unit}:${normalizeSku(identity) || normalizeKey(material)}`;
if (!Number.isFinite(quantityPerUnit) || quantityPerUnit <= 0) {
mergeNeedLine(needsByMaterial, `missing-quantity:${productId}:${identity}`, {
material: `Revisar quantidade: ${normalizeText(row.product_name) || productId}`,
plannedKg: suggestedQuantity,
priority: 'Crítico',
unit: 'un.',
source: 'tiny_composition',
missingReference: true,
product: { productId, name: normalizeText(row.product_name), suggestedQuantity, quantitySold, stockQuantity }
});
return;
}
mergeNeedLine(needsByMaterial, key, {
material,
plannedKg: suggestedQuantity * quantityPerUnit,
priority: 'Atenção',
unit,
source: 'tiny_composition',
product: {
productId,
name: normalizeText(row.product_name),
suggestedQuantity,
quantitySold,
stockQuantity,
consumptionQuantity: quantityPerUnit,
consumptionUnit: unit
}
});
registerNeedKey(tinyId, key);
registerNeedKey(sku, key);
// Lots are normally registered by material name, not Tiny ID.
registerNeedKey(material, key);
};
demandRows.forEach(row => { demandRows.forEach(row => {
const productId = normalizeSku(row.product_id); const productId = normalizeSku(row.product_id);
if (!productId) return; if (!productId) return;
@@ -324,6 +421,12 @@ 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 composition = compositionsByProduct.get(productId) || [];
if (composition.length) {
composition.forEach(component => addCompositionNeed(component, row, productId, suggestedQuantity, quantitySold, stockQuantity));
return;
}
const references = referencesBySku.get(productId) || []; const references = referencesBySku.get(productId) || [];
if (!references.length) { if (!references.length) {
mergeNeedLine(needsByMaterial, `missing:${productId}`, { mergeNeedLine(needsByMaterial, `missing:${productId}`, {
@@ -410,14 +513,32 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
}); });
}); });
const getNeedForInventory = (unit, id, name) => {
const normalizedUnit = normalizeUnit(unit);
const key = componentNeedKeys.get(normalizeSku(id)) || componentNeedKeys.get(normalizeSku(name));
return key && key.startsWith(`${normalizedUnit}:`) ? needsByMaterial.get(key) : undefined;
};
// Tiny saldo is material stock too. It is matched using the component ID
// first, then SKU/name, which lets "MALHA PRETA" stock directly reduce the
// material purchase suggestion imported from the product structure.
tinyStockRows.forEach(stock => {
const need = getNeedForInventory('un.', stock.produto_id, stock.nome)
|| getNeedForInventory('kg', stock.produto_id, stock.nome)
|| getNeedForInventory('', stock.produto_id, stock.nome);
if (need) need.stockKg += Number(stock.saldo || 0);
});
lots.forEach(lot => { lots.forEach(lot => {
const need = needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`); const need = getNeedForInventory(lot.unit, '', lot.product)
|| needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
if (need) need.stockKg += lot.quantity; if (need) need.stockKg += lot.quantity;
}); });
receipts.forEach(receipt => { receipts.forEach(receipt => {
if (receipt.status !== 'pending') return; if (receipt.status !== 'pending') return;
const need = needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`); const need = getNeedForInventory(receipt.unit, '', receipt.product)
|| needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`);
if (need) need.pendingKg += receipt.quantity; if (need) need.pendingKg += receipt.quantity;
}); });
@@ -462,9 +583,11 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
} }
current.plannedKg += need.plannedKg; current.plannedKg += need.plannedKg;
current.stockKg += need.stockKg; // Stock/receipts are the same physical inventory for a manual plan and
current.pendingKg += need.pendingKg; // an imported-composition need. They are alternatives views of the
current.purchaseKg += need.purchaseKg; // balance, never amounts to add together.
current.stockKg = Math.max(current.stockKg, need.stockKg);
current.pendingKg = Math.max(current.pendingKg, need.pendingKg);
current.priority = need.priority === 'Crítico' || current.priority === 'Crítico' current.priority = need.priority === 'Crítico' || current.priority === 'Crítico'
? 'Crítico' ? 'Crítico'
: need.priority === 'Atenção' || current.priority === 'Atenção' : need.priority === 'Atenção' || current.priority === 'Atenção'
@@ -483,11 +606,18 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
}); });
return Array.from(mergedByKey.values()) return Array.from(mergedByKey.values())
.map(need => ({ .map(need => {
...need, const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0);
suppliers: Array.from(need.suppliers), return {
colors: Array.from(need.colors) ...need,
})) purchaseKg,
status: purchaseKg > 0
? (need.priority === 'Crítico' || need.stockKg === 0 ? 'critical' : 'attention')
: 'ok',
suppliers: Array.from(need.suppliers),
colors: Array.from(need.colors)
};
})
.sort((a, b) => { .sort((a, b) => {
const statusOrder = { critical: 1, attention: 2, ok: 3 }; const statusOrder = { critical: 1, attention: 2, ok: 3 };
return statusOrder[a.status] - statusOrder[b.status] || b.purchaseKg - a.purchaseKg || a.material.localeCompare(b.material); return statusOrder[a.status] - statusOrder[b.status] || b.purchaseKg - a.purchaseKg || a.material.localeCompare(b.material);

View File

@@ -12,6 +12,7 @@ const getRouteHandler = (router, method, path) => {
}; };
const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync'); const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync');
const tinyCompositionImportHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-compositions/import');
const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition'); const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition');
const productCompositionsHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/product-compositions'); const productCompositionsHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/product-compositions');
@@ -70,9 +71,15 @@ const withFakeDatabase = async (run) => {
const state = { const state = {
nextCompositionId: 1, nextCompositionId: 1,
nextOrderId: 1, nextOrderId: 1,
nextCategoryId: 1,
nextCatalogProductId: 1,
nextConsumptionReferenceId: 1,
compositions: [], compositions: [],
compositionComponents: [], compositionComponents: [],
productionOrders: [] productionOrders: [],
categories: [],
catalogProducts: [],
consumptionReferences: []
}; };
const query = async (sql, params = []) => { const query = async (sql, params = []) => {
@@ -80,6 +87,72 @@ const withFakeDatabase = async (run) => {
if (/^(BEGIN|COMMIT|ROLLBACK);?$/.test(normalizedSql)) return { rows: [] }; if (/^(BEGIN|COMMIT|ROLLBACK);?$/.test(normalizedSql)) return { rows: [] };
if (normalizedSql.startsWith('INSERT INTO catalog_categories')) {
let category = state.categories.find(item => item.name === params[0]);
if (!category) {
category = { id: state.nextCategoryId++, name: params[0], description: params[1] };
state.categories.push(category);
}
return { rows: [{ id: category.id }] };
}
if (normalizedSql.startsWith('INSERT INTO catalog_products')) {
let product = state.catalogProducts.find(item => item.sku === params[1]);
if (!product) {
product = {
id: state.nextCatalogProductId++,
type: params[0],
sku: params[1],
name: params[2],
category_id: params[3],
notes: params[4]
};
state.catalogProducts.push(product);
} else {
product.type = params[0];
product.name = params[2];
product.category_id = product.category_id || params[3];
product.notes = product.notes || params[4];
}
return { rows: [{ id: product.id }] };
}
if (normalizedSql.startsWith('SELECT id FROM consumption_references')) {
const reference = state.consumptionReferences.find(item => (
item.product_id === params[0]
&& (item.material_product_id || 0) === params[1]
&& (item.color || '') === ''
&& (item.source || 'manual') === params[2]
));
return { rows: reference ? [{ id: reference.id }] : [] };
}
if (normalizedSql.startsWith('UPDATE consumption_references')) {
const reference = state.consumptionReferences.find(item => item.id === params[5]);
if (reference) {
reference.general_yield = params[0];
reference.consumption_quantity = params[1];
reference.consumption_unit = params[2];
reference.last_production_order_id = params[3];
reference.source = params[4];
}
return { rows: [] };
}
if (normalizedSql.startsWith('INSERT INTO consumption_references')) {
state.consumptionReferences.push({
id: state.nextConsumptionReferenceId++,
product_id: params[0],
material_product_id: params[1],
general_yield: params[2],
consumption_quantity: params[3],
consumption_unit: params[4],
source: params[5],
last_production_order_id: params[6]
});
return { rows: [] };
}
if (normalizedSql.startsWith('SELECT id FROM product_compositions WHERE source')) { 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]); const composition = state.compositions.find(item => item.source === params[0] && item.finished_product_identity === params[1]);
return { rows: composition ? [{ id: composition.id }] : [] }; return { rows: composition ? [{ id: composition.id }] : [] };
@@ -197,10 +270,36 @@ test('Tiny/Olist V3 first import creates a composition without creating an OP',
assert.equal(state.compositions.length, 1); assert.equal(state.compositions.length, 1);
assert.equal(state.compositionComponents.length, 2); assert.equal(state.compositionComponents.length, 2);
assert.equal(state.productionOrders.length, 0); assert.equal(state.productionOrders.length, 0);
assert.equal(state.consumptionReferences.length, 2);
assert.equal(result.composition.finishedTinyProductId, '976058813'); assert.equal(result.composition.finishedTinyProductId, '976058813');
}); });
}); });
test('Tiny/Olist V3 bulk import stores compositions and consumption references', async () => {
await withFakeDatabase(async (state) => {
const response = await invokeHandler(tinyCompositionImportHandler, {
body: {
exportedAt: '2026-07-31T13:37:01.340Z',
compositions: [{
finishedProductSku: 'BLCS.CAF.GG',
finishedProductDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG',
finishedProductUnit: 'UN',
finishedTinyProductId: '976058813',
sourceMetadata: { payload: compositionPayload() },
components: []
}]
}
});
assert.equal(response.statusCode, 201);
assert.equal(response.body.imported, 1);
assert.equal(response.body.referenceCount, 2);
assert.equal(state.compositions.length, 1);
assert.equal(state.consumptionReferences.length, 2);
assert.equal(state.consumptionReferences[0].source, 'tiny_structure');
});
});
test('Tiny/Olist V3 re-sync replaces composition components without duplicates', async () => { test('Tiny/Olist V3 re-sync replaces composition components without duplicates', async () => {
await withFakeDatabase(async (state) => { await withFakeDatabase(async (state) => {
await invokeHandler(tinySyncHandler, { body: compositionPayload() }); await invokeHandler(tinySyncHandler, { body: compositionPayload() });

View File

@@ -11,6 +11,7 @@ const ProductGroupDetails = React.lazy(() => import('./pages/ProductGroupDetails
const Replenishment = React.lazy(() => import('./pages/Replenishment')); const Replenishment = React.lazy(() => import('./pages/Replenishment'));
const Cutting = React.lazy(() => import('./pages/Cutting')); const Cutting = React.lazy(() => import('./pages/Cutting'));
const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues')); const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues'));
const DataHealth = React.lazy(() => import('./pages/DataHealth'));
const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders'));
const Supplies = React.lazy(() => import('./pages/Supplies')); const Supplies = React.lazy(() => import('./pages/Supplies'));
const Clients = React.lazy(() => import('./pages/Clients')); const Clients = React.lazy(() => import('./pages/Clients'));
@@ -56,6 +57,7 @@ function App() {
<Route path="replenishment" element={<Replenishment />} /> <Route path="replenishment" element={<Replenishment />} />
<Route path="cutting" element={<Cutting />} /> <Route path="cutting" element={<Cutting />} />
<Route path="planning-issues" element={<PlanningIssues />} /> <Route path="planning-issues" element={<PlanningIssues />} />
<Route path="data-health" element={<DataHealth />} />
<Route path="supplies" element={<Supplies />} /> <Route path="supplies" element={<Supplies />} />
<Route path="supplies/:section" element={<Supplies />} /> <Route path="supplies/:section" element={<Supplies />} />
<Route path="stock" element={<Navigate to="/products" replace />} /> <Route path="stock" element={<Navigate to="/products" replace />} />

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList } from 'lucide-react'; import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList, Database } from 'lucide-react';
import type { DateRange, OrderData } from '../types'; import type { DateRange, OrderData } from '../types';
import { isSuperAdmin, logout } from '../dataService'; import { isSuperAdmin, logout } from '../dataService';
import { rangeForLastDays } from '../dateRanges'; import { rangeForLastDays } from '../dateRanges';
@@ -74,6 +74,7 @@ const Layout = () => {
items: [ items: [
{ name: 'Produtos', href: '/products', icon: Package }, { name: 'Produtos', href: '/products', icon: Package },
{ name: 'Dados Pendentes', href: '/planning-issues', icon: ClipboardList }, { name: 'Dados Pendentes', href: '/planning-issues', icon: ClipboardList },
{ name: 'Saúde dos Dados', href: '/data-health', icon: Database },
{ name: 'Cadastros', href: '/registrations', icon: Tags }, { name: 'Cadastros', href: '/registrations', icon: Tags },
{ name: 'Suprimentos', href: '/supplies', icon: Boxes }, { name: 'Suprimentos', href: '/supplies', icon: Boxes },
], ],

View File

@@ -1,4 +1,4 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, 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, DataHealthSummary, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, 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';
@@ -345,6 +345,22 @@ export const deleteConsumptionReference = async (id: number): Promise<void> => {
} }
}; };
export const importProductCompositions = async (payload: unknown): Promise<ProductCompositionImportSummary> => {
const response = await authFetch('/catalog/product-compositions/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(data?.error || 'Não foi possível importar as composições.');
}
analyticsCache.clear();
return data as ProductCompositionImportSummary;
};
export const fetchSupplySummary = async (): Promise<SupplySummary> => { export const fetchSupplySummary = async (): Promise<SupplySummary> => {
const emptySummary: SupplySummary = { const emptySummary: SupplySummary = {
receipts: [], receipts: [],
@@ -551,6 +567,29 @@ export const fetchProductComposition = async (productId: string): Promise<Produc
} }
}; };
export const fetchProductCompositions = async (): Promise<ProductComposition[]> => {
try {
const response = await authFetch('/analytics/product-compositions');
if (!response.ok) return [];
const data = await response.json() as { compositions?: ProductComposition[] };
return data.compositions || [];
} catch (error) {
console.error('Fetch product compositions failed', error);
return [];
}
};
export const fetchDataHealth = async (): Promise<DataHealthSummary | null> => {
try {
const response = await authFetch('/analytics/data-health');
if (!response.ok) return null;
return await response.json() as DataHealthSummary;
} catch (error) {
console.error('Fetch data health failed', error);
return null;
}
};
export const exportProductCompositions = async (): Promise<number> => { export const exportProductCompositions = async (): Promise<number> => {
const response = await authFetch('/analytics/product-compositions'); const response = await authFetch('/analytics/product-compositions');
const data = await response.json().catch(() => null) as { compositions?: ProductComposition[]; error?: string } | null; const data = await response.json().catch(() => null) as { compositions?: ProductComposition[]; error?: string } | null;

View File

@@ -7,8 +7,9 @@ import ProductColorBadge from '../components/ProductColorBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import { buildCuttingSkuConfigPath } from '../catalogLinks'; import { buildCuttingSkuConfigPath } from '../catalogLinks';
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting'; import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService'; import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchStock, saveCuttingSettings } from '../dataService';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, StockData } from '../types';
import { getPlanningStock } from '../planningStock';
type CutFilter = 'need' | 'all' | 'issues' | 'covered'; type CutFilter = 'need' | 'all' | 'issues' | 'covered';
type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc'; type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc';
@@ -17,6 +18,7 @@ type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
type CorrectionIssueFilter = CutProductIssue | 'all'; type CorrectionIssueFilter = CutProductIssue | 'all';
type SettingsSection = 'rules' | 'corrections'; type SettingsSection = 'rules' | 'corrections';
type CuttingView = 'plan' | 'families' | 'issues'; type CuttingView = 'plan' | 'families' | 'issues';
type MaterialReadiness = { status: 'ready' | 'blocked' | 'missing'; blockers: string[] };
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings'; const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
const coverageTargetOptions = [7, 15, 30, 60]; const coverageTargetOptions = [7, 15, 30, 60];
@@ -66,6 +68,13 @@ const formatNumber = (value: number, maximumFractionDigits = 0) => (
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value) new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
); );
const normalizeMaterialKey = (value: string) => value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toUpperCase();
const formatDateKey = (date: Date) => { const formatDateKey = (date: Date) => {
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -128,6 +137,8 @@ const Cutting = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]); const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]); const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
const [compositions, setCompositions] = useState<ProductComposition[]>([]);
const [materialStock, setMaterialStock] = useState<StockData[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [targetCoverageDays, setTargetCoverageDays] = useState(30); const [targetCoverageDays, setTargetCoverageDays] = useState(30);
@@ -181,13 +192,17 @@ const Cutting = () => {
const loadProducts = async () => { const loadProducts = async () => {
setIsLoading(true); setIsLoading(true);
const [productData, productionOrderData] = await Promise.all([ const [productData, productionOrderData, compositionData, stockData] = await Promise.all([
fetchProductAnalytics(dateRange), fetchProductAnalytics(dateRange),
fetchProductionOrders(allProductionOrdersRange) fetchProductionOrders(allProductionOrdersRange),
fetchProductCompositions(),
fetchStock()
]); ]);
if (isMounted) { if (isMounted) {
setProducts(productData); setProducts(productData);
setProductionOrders(productionOrderData.orders); setProductionOrders(productionOrderData.orders);
setCompositions(compositionData);
setMaterialStock(stockData);
setIsLoading(false); setIsLoading(false);
} }
}; };
@@ -209,6 +224,35 @@ const Cutting = () => {
[cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays] [cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays]
); );
const materialReadinessByProductId = useMemo(() => {
const compositionsById = new Map<string, ProductComposition>();
compositions.forEach(composition => {
[composition.finishedTinyProductId, composition.finishedProductSku]
.map(normalizeMaterialKey)
.filter(Boolean)
.forEach(key => compositionsById.set(key, composition));
});
const readinessByProductId = new Map<string, MaterialReadiness>();
cutPlan.rows.forEach(row => {
const composition = compositionsById.get(normalizeMaterialKey(row.id));
if (!composition?.components.length) {
readinessByProductId.set(row.id, { status: 'missing', blockers: [] });
return;
}
const blockers = composition.components.flatMap(component => {
const stockKeys = new Set([component.componentTinyId, component.componentSku, component.productId || '', component.componentName].map(normalizeMaterialKey).filter(Boolean));
const match = materialStock.find(stock => stockKeys.has(normalizeMaterialKey(stock.produto_id)) || stockKeys.has(normalizeMaterialKey(stock.nome)));
const available = match ? getPlanningStock(match.saldo) : null;
const required = row.suggestedCutQuantity * component.quantityPerUnit;
return available === null || available < required
? [`${component.componentName}${available === null ? ' (sem estoque vinculado)' : ` (${formatNumber(available)} / ${formatNumber(required)} ${component.unit || ''})`}`]
: [];
});
readinessByProductId.set(row.id, { status: blockers.length ? 'blocked' : 'ready', blockers });
});
return readinessByProductId;
}, [compositions, cutPlan.rows, materialStock]);
const issueSummaries = useMemo(() => { const issueSummaries = useMemo(() => {
const counts = new Map<CutIssue, number>(); const counts = new Map<CutIssue, number>();
cutPlan.needRows.forEach(row => { cutPlan.needRows.forEach(row => {
@@ -254,8 +298,8 @@ const Cutting = () => {
}, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]); }, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]);
const rowsAvailableForOrder = useMemo(() => ( const rowsAvailableForOrder = useMemo(() => (
filteredRows.filter(row => row.suggestedCutQuantity > 0) filteredRows.filter(row => row.suggestedCutQuantity > 0 && materialReadinessByProductId.get(row.id)?.status === 'ready')
), [filteredRows]); ), [filteredRows, materialReadinessByProductId]);
const orderPreviewTotalUnits = useMemo(() => ( const orderPreviewTotalUnits = useMemo(() => (
rowsAvailableForOrder.reduce((total, row) => total + row.suggestedCutQuantity, 0) rowsAvailableForOrder.reduce((total, row) => total + row.suggestedCutQuantity, 0)
@@ -420,13 +464,15 @@ const Cutting = () => {
'Rendimento un/rolo': row.family.unitsPerRoll || '', 'Rendimento un/rolo': row.family.unitsPerRoll || '',
'Rolos estimados': row.estimatedRolls || '', 'Rolos estimados': row.estimatedRolls || '',
'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','), 'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
'Pronto para cortar': materialReadinessByProductId.get(row.id)?.status === 'ready' ? 'Sim' : materialReadinessByProductId.get(row.id)?.status === 'blocked' ? 'Não - falta material' : 'Sem composição',
'Materiais bloqueando': materialReadinessByProductId.get(row.id)?.blockers.join(' | ') || '',
'Dados pendentes': row.issues.map(issue => issueLabels[issue]).join(' | ') 'Dados pendentes': row.issues.map(issue => issueLabels[issue]).join(' | ')
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`); })), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
}; };
const openProductionOrderPreview = () => { const openProductionOrderPreview = () => {
if (!rowsAvailableForOrder.length) { if (!rowsAvailableForOrder.length) {
setGenerationMessage('Não há necessidade de corte no filtro atual.'); setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.');
return; return;
} }
@@ -436,7 +482,7 @@ const Cutting = () => {
const generateProductionOrders = async () => { const generateProductionOrders = async () => {
if (!rowsAvailableForOrder.length) { if (!rowsAvailableForOrder.length) {
setGenerationMessage('Não há necessidade de corte no filtro atual.'); setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.');
return; return;
} }
@@ -459,7 +505,7 @@ const Cutting = () => {
markers: [ markers: [
{ label: row.family.materialLabel, color: '#38bdf8' }, { label: row.family.materialLabel, color: '#38bdf8' },
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []), ...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
{ label: 'Material pendente', color: '#facc15' }, { label: 'Material confirmado', color: '#52DFA0' },
...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : []) ...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : [])
], ],
metadata: { metadata: {
@@ -509,6 +555,13 @@ const Cutting = () => {
); );
}; };
const renderMaterialReadiness = (row: CutPlanSkuRow) => {
const readiness = materialReadinessByProductId.get(row.id);
if (readiness?.status === 'ready') return <span className="text-xs font-bold text-emerald-300">Pode cortar</span>;
if (readiness?.status === 'missing') return <span className="text-xs font-bold text-amber-300">Sem composição</span>;
return <span className="inline-flex max-w-[170px] truncate rounded-full border border-red-400/30 bg-red-400/10 px-2 py-1 text-xs font-bold text-red-300" title={readiness?.blockers.join(' · ')}>Falta: {readiness?.blockers[0] || 'material'}</span>;
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start"> <div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
@@ -1209,7 +1262,7 @@ const Cutting = () => {
) : ( ) : (
<div className={`overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}> <div className={`overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[1180px] table-fixed text-left text-sm"> <table className="w-full min-w-[1320px] table-fixed text-left text-sm">
<colgroup> <colgroup>
<col className="w-[105px]" /> <col className="w-[105px]" />
<col className="w-[320px]" /> <col className="w-[320px]" />
@@ -1220,6 +1273,7 @@ const Cutting = () => {
<col className="w-[140px]" /> <col className="w-[140px]" />
<col className="w-[80px]" /> <col className="w-[80px]" />
<col className="w-[130px]" /> <col className="w-[130px]" />
<col className="w-[170px]" />
<col className="w-[110px]" /> <col className="w-[110px]" />
</colgroup> </colgroup>
<thead className="border-b border-zinc-100 bg-zinc-50 text-zinc-500 dark:border-dark-border dark:bg-dark-header dark:text-dark-muted"> <thead className="border-b border-zinc-100 bg-zinc-50 text-zinc-500 dark:border-dark-border dark:bg-dark-header dark:text-dark-muted">
@@ -1232,6 +1286,7 @@ const Cutting = () => {
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th> <th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Necessidade</th> <th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Rolos</th> <th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Rolos</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Materiais</th>
<th <th
className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider" className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider"
title="Dados que ainda faltam para fechar o plano de corte do SKU." title="Dados que ainda faltam para fechar o plano de corte do SKU."
@@ -1280,6 +1335,7 @@ const Cutting = () => {
<td className="px-4 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text"> <td className="px-4 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">
{row.estimatedRolls === null ? '-' : formatNumber(row.estimatedRolls)} {row.estimatedRolls === null ? '-' : formatNumber(row.estimatedRolls)}
</td> </td>
<td className="px-4 py-2.5">{renderMaterialReadiness(row)}</td>
<td className="px-4 py-2.5">{renderIssueBadge(row)}</td> <td className="px-4 py-2.5">{renderIssueBadge(row)}</td>
<td className="px-4 py-2.5 text-right"> <td className="px-4 py-2.5 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">

107
src/pages/DataHealth.tsx Normal file
View File

@@ -0,0 +1,107 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Database, PackageSearch, RefreshCw } from 'lucide-react';
import PaginationControls from '../components/PaginationControls';
import { fetchDataHealth } from '../dataService';
import type { DataHealthIssue, DataHealthSummary } from '../types';
const emptySummary: DataHealthSummary = {
totals: { products: 0, productsWithComposition: 0, productsWithStockLink: 0, materials: 0, materialsWithStock: 0, compositions: 0, components: 0 },
issues: [],
};
const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR').format(value);
const percent = (part: number, total: number) => total ? Math.round((part / total) * 100) : 0;
const severityStyle: Record<DataHealthIssue['severity'], string> = {
critical: 'border-red-400/30 bg-red-400/10 text-red-300',
attention: 'border-amber-400/30 bg-amber-400/10 text-amber-300',
info: 'border-sky-400/30 bg-sky-400/10 text-sky-300',
};
const DataHealth = () => {
const [summary, setSummary] = useState<DataHealthSummary>(emptySummary);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState<'all' | DataHealthIssue['type']>('all');
const [search, setSearch] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const load = async () => {
setIsLoading(true);
const next = await fetchDataHealth();
if (next) setSummary(next);
setIsLoading(false);
};
useEffect(() => {
const timer = window.setTimeout(() => { void load(); }, 0);
return () => window.clearTimeout(timer);
}, []);
const filteredIssues = useMemo(() => {
const query = search.trim().toLowerCase();
return summary.issues.filter(issue => (
(filter === 'all' || issue.type === filter) &&
(!query || `${issue.title} ${issue.detail} ${issue.productSku} ${issue.component}`.toLowerCase().includes(query))
));
}, [filter, search, summary.issues]);
const totalPages = Math.ceil(filteredIssues.length / itemsPerPage);
const safePage = Math.min(currentPage, totalPages || 1);
const startIndex = (safePage - 1) * itemsPerPage;
const visibleIssues = filteredIssues.slice(startIndex, startIndex + itemsPerPage);
const count = (type: DataHealthIssue['type']) => summary.issues.filter(issue => issue.type === type).length;
const metrics = [
{ label: 'Produtos com composição', value: percent(summary.totals.productsWithComposition, summary.totals.products), detail: `${formatNumber(summary.totals.productsWithComposition)} de ${formatNumber(summary.totals.products)}` },
{ label: 'Produtos com estoque', value: percent(summary.totals.productsWithStockLink, summary.totals.products), detail: `${formatNumber(summary.totals.productsWithStockLink)} de ${formatNumber(summary.totals.products)}` },
{ label: 'Materiais com estoque', value: percent(summary.totals.materialsWithStock, summary.totals.materials), detail: `${formatNumber(summary.totals.materialsWithStock)} de ${formatNumber(summary.totals.materials)}` },
];
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-2xl font-bold text-dark-text">Saúde dos Dados</h1>
<p className="mt-1 font-medium text-dark-muted">Confira se composição, vínculo de estoque e consumo estão prontos para orientar compra e corte.</p>
</div>
<button type="button" onClick={() => void load()} 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 hover:border-brand-primary cursor-pointer">
<RefreshCw className={`h-4 w-4 text-brand-primary ${isLoading ? 'animate-spin' : ''}`} /> Atualizar
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{metrics.map(metric => (
<div key={metric.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">{metric.label}</p>
<div className="mt-3 flex items-end justify-between gap-3"><p className="text-3xl font-bold text-dark-text">{metric.value}%</p><p className="text-xs font-semibold text-dark-muted">{metric.detail}</p></div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-dark-border"><div className="h-full rounded-full bg-brand-primary" style={{ width: `${metric.value}%` }} /></div>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
{[
['missing_product_sku', 'Sem SKU'], ['component_without_stock_link', 'Sem vínculo'], ['suspicious_quantity', 'Qtd. suspeita'], ['raw_or_service_structure', 'Matéria-prima/serviço'], ['product_without_composition', 'Sem composição'],
].map(([type, label]) => (
<button key={type} type="button" onClick={() => { setFilter(type as DataHealthIssue['type']); setCurrentPage(1); }} className={`rounded-xl border p-4 text-left transition-colors cursor-pointer ${filter === type ? 'border-brand-primary/50 bg-brand-primary/10' : 'border-dark-border bg-dark-card hover:border-brand-primary/30'}`}>
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">{label}</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(count(type as DataHealthIssue['type']))}</p>
</button>
))}
</div>
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="flex flex-col gap-3 border-b border-dark-border p-4 md:flex-row">
<input value={search} onChange={event => { setSearch(event.target.value); setCurrentPage(1); }} placeholder="Buscar produto, SKU ou componente..." className="h-10 flex-1 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" />
<select value={filter} onChange={event => { setFilter(event.target.value as typeof filter); setCurrentPage(1); }} className="h-10 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text">
<option value="all">Todas as revisões</option><option value="missing_product_sku">Produtos sem SKU</option><option value="component_without_stock_link">Componentes sem vínculo</option><option value="suspicious_quantity">Quantidades suspeitas</option><option value="raw_or_service_structure">Matéria-prima / serviço</option><option value="product_without_composition">Sem composição</option>
</select>
</div>
{isLoading ? <div className="flex h-48 items-center justify-center text-sm font-bold text-dark-muted"><Database className="mr-2 h-5 w-5 text-brand-primary" /> Verificando dados…</div> : !visibleIssues.length ? <div className="flex h-48 flex-col items-center justify-center text-sm font-bold text-emerald-300"><CheckCircle2 className="mb-2 h-7 w-7" /> Nenhuma pendência neste filtro.</div> : (
<><div className="divide-y divide-dark-border">{visibleIssues.map((issue, index) => <div key={`${issue.type}-${issue.productSku}-${issue.component}-${index}`} className="flex gap-3 p-4"><AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" /><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><p className="font-bold text-dark-text">{issue.title}</p><span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${severityStyle[issue.severity]}`}>{issue.severity === 'critical' ? 'Crítico' : issue.severity === 'attention' ? 'Revisar' : 'Informativo'}</span></div><p className="mt-1 text-sm font-medium text-dark-muted">{issue.detail}</p>{(issue.productSku || issue.component) && <p className="mt-1 font-mono text-[11px] text-dark-muted">{[issue.productSku && `SKU ${issue.productSku}`, issue.component && `Comp. ${issue.component}`].filter(Boolean).join(' · ')}</p>}</div><PackageSearch className="h-4 w-4 shrink-0 text-dark-muted" /></div>)}</div>
<PaginationControls totalItems={filteredIssues.length} currentPage={safePage} totalPages={totalPages} pageSize={itemsPerPage} pageSizeOptions={[20, 50, 100]} itemLabel="pendências" pageSizeLabel="itens por página" startIndex={startIndex} endIndex={Math.min(startIndex + itemsPerPage, filteredIssues.length)} onPageChange={setCurrentPage} onPageSizeChange={size => { setItemsPerPage(size); setCurrentPage(1); }} className="border-t border-dark-border px-4 py-3" /></>
)}
</section>
</div>
);
};
export default DataHealth;

View File

@@ -7,8 +7,8 @@ import DateRangePicker from '../components/DateRangePicker';
import SkuPlanningModal from '../components/SkuPlanningModal'; import SkuPlanningModal from '../components/SkuPlanningModal';
import ProductTypeBadge from '../components/ProductTypeBadge'; import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types'; import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics, StockData } from '../types';
import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService'; import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, fetchStock, 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';
@@ -132,6 +132,7 @@ const ProductDetails = () => {
}>(); }>();
const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null); const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null);
const [composition, setComposition] = useState<ProductComposition | null>(null); const [composition, setComposition] = useState<ProductComposition | null>(null);
const [materialStock, setMaterialStock] = useState<StockData[]>([]);
const [isCompositionLoading, setIsCompositionLoading] = useState(true); 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');
@@ -171,14 +172,16 @@ const ProductDetails = () => {
setIsLoading(true); setIsLoading(true);
setIsCompositionLoading(true); setIsCompositionLoading(true);
const [productDetails, productComposition] = await Promise.all([ const [productDetails, productComposition, stock] = await Promise.all([
fetchProductDetailsAnalytics(id, dateRange), fetchProductDetailsAnalytics(id, dateRange),
fetchProductComposition(id) fetchProductComposition(id),
fetchStock()
]); ]);
if (isMounted) { if (isMounted) {
setDetails(productDetails); setDetails(productDetails);
setComposition(productComposition); setComposition(productComposition);
setMaterialStock(stock);
setIsLoading(false); setIsLoading(false);
setIsCompositionLoading(false); setIsCompositionLoading(false);
} }
@@ -331,6 +334,35 @@ const ProductDetails = () => {
: formatDateBucketLongLabel(selectedProductPoint.date, dateBucket) : formatDateBucketLongLabel(selectedProductPoint.date, dateBucket)
: ''; : '';
const maxVariantQuantity = Math.max(...variantBreakdown.map(variant => variant.quantitySold), 0); const maxVariantQuantity = Math.max(...variantBreakdown.map(variant => variant.quantitySold), 0);
const normalizeMaterialKey = (value: string) => value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toUpperCase();
const materialPlan = (composition?.components || []).map(component => {
const candidates = new Set([
component.componentTinyId,
component.componentSku,
component.productId || '',
component.componentName
].map(normalizeMaterialKey).filter(Boolean));
const stockMatch = materialStock.find(item => (
candidates.has(normalizeMaterialKey(item.produto_id)) || candidates.has(normalizeMaterialKey(item.nome))
));
const availableStock = stockMatch ? getPlanningStock(stockMatch.saldo) : null;
const requiredForPeriod = totalSold * component.quantityPerUnit;
const productionCapacity = availableStock === null || component.quantityPerUnit <= 0
? null
: Math.floor(availableStock / component.quantityPerUnit);
const periodCoverage = requiredForPeriod > 0 && availableStock !== null
? (availableStock / requiredForPeriod) * periodDays
: null;
return { component, availableStock, requiredForPeriod, productionCapacity, periodCoverage };
});
const blockingMaterial = materialPlan
.filter(item => item.productionCapacity !== null)
.sort((a, b) => (a.productionCapacity || 0) - (b.productionCapacity || 0))[0];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -414,12 +446,23 @@ const ProductDetails = () => {
</div> </div>
<section className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm"> <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"> <div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3> <div>
{composition && ( <h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3>
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted"> {composition && (
Para produzir 1 UN de {composition.finishedProductSku || productInfo.id} <p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
</p> Necessidade para as {formatNumber(totalSold)} un. vendidas no período e saldo atual do Tiny.
</p>
)}
</div>
{blockingMaterial && (
<div className={`rounded-xl border px-3 py-2 text-xs font-bold ${
(blockingMaterial.productionCapacity || 0) < totalSold
? 'border-red-400/30 bg-red-400/10 text-red-300'
: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
}`}>
Limitante: {blockingMaterial.component.componentName} · {formatNumber(blockingMaterial.productionCapacity || 0)} un. possíveis
</div>
)} )}
</div> </div>
{isCompositionLoading ? ( {isCompositionLoading ? (
@@ -434,17 +477,20 @@ const ProductDetails = () => {
</div> </div>
) : ( ) : (
<div className="overflow-x-auto rounded-xl border border-dark-border"> <div className="overflow-x-auto rounded-xl border border-dark-border">
<table className="w-full min-w-[640px] text-left text-sm"> <table className="w-full min-w-[920px] text-left text-sm">
<thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted"> <thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted">
<tr> <tr>
<th className="px-4 py-3">Produto / insumo</th> <th className="px-4 py-3">Produto / insumo</th>
<th className="px-4 py-3">SKU</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 text-right">Quantidade por unidade</th>
<th className="px-4 py-3">Unidade</th> <th className="px-4 py-3">Unidade</th>
<th className="px-4 py-3 text-right">Necessário no período</th>
<th className="px-4 py-3 text-right">Estoque Tiny</th>
<th className="px-4 py-3 text-right">Cobertura</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-dark-border"> <tbody className="divide-y divide-dark-border">
{composition.components.map(component => ( {materialPlan.map(({ component, requiredForPeriod, availableStock, productionCapacity, periodCoverage }) => (
<tr key={component.id} className="text-dark-text"> <tr key={component.id} className="text-dark-text">
<td className="px-4 py-3 font-semibold"> <td className="px-4 py-3 font-semibold">
{component.productId ? ( {component.productId ? (
@@ -456,6 +502,13 @@ const ProductDetails = () => {
<td className="px-4 py-3 font-mono text-xs text-dark-muted">{component.componentSku || '—'}</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-right font-semibold">{formatNumber(component.quantityPerUnit)}</td>
<td className="px-4 py-3 text-dark-muted">{component.unit || '—'}</td> <td className="px-4 py-3 text-dark-muted">{component.unit || '—'}</td>
<td className="px-4 py-3 text-right font-semibold">{formatNumber(requiredForPeriod)} {component.unit || ''}</td>
<td className="px-4 py-3 text-right font-semibold">{availableStock === null ? 'Sem vínculo' : `${formatNumber(availableStock)} ${component.unit || ''}`}</td>
<td className={`px-4 py-3 text-right font-bold ${availableStock === null || (productionCapacity || 0) < totalSold ? 'text-red-300' : 'text-emerald-300'}`}>
{availableStock === null
? 'Revisar link'
: `${formatNumber(productionCapacity || 0)} un. · ${periodCoverage === null ? '-' : `${formatNumber(periodCoverage)} dias`}`}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { ClipboardList, Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2 } from 'lucide-react'; import { ClipboardList, Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2, Upload } from 'lucide-react';
import { import {
deleteCatalogCategory, deleteCatalogCategory,
deleteCatalogProduct, deleteCatalogProduct,
deleteConsumptionReference, deleteConsumptionReference,
fetchCatalogSummary, fetchCatalogSummary,
importProductCompositions,
saveCatalogCategory, saveCatalogCategory,
saveCatalogProduct, saveCatalogProduct,
saveConsumptionReference saveConsumptionReference
@@ -93,7 +94,9 @@ const getAverageYield = (reference: ConsumptionReference) => {
}; };
const getReferenceSourceLabel = (reference: ConsumptionReference) => ( const getReferenceSourceLabel = (reference: ConsumptionReference) => (
reference.source === 'tiny_op' ? 'Tiny OP' : 'Manual' reference.source === 'tiny_op'
? 'Tiny OP'
: reference.source === 'tiny_structure' ? 'Tiny Estrutura' : 'Manual'
); );
const formatConsumptionPerPiece = (reference: ConsumptionReference) => { const formatConsumptionPerPiece = (reference: ConsumptionReference) => {
@@ -111,6 +114,7 @@ const Registrations = () => {
const [status, setStatus] = useState<SaveStatus>('idle'); const [status, setStatus] = useState<SaveStatus>('idle');
const [feedback, setFeedback] = useState(''); const [feedback, setFeedback] = useState('');
const appliedSkuPrefillRef = useRef(''); const appliedSkuPrefillRef = useRef('');
const compositionFileInputRef = useRef<HTMLInputElement>(null);
const [categoryForm, setCategoryForm] = useState({ name: '', description: '' }); const [categoryForm, setCategoryForm] = useState({ name: '', description: '' });
const [productForm, setProductForm] = useState(defaultProductForm); const [productForm, setProductForm] = useState(defaultProductForm);
@@ -323,6 +327,29 @@ const Registrations = () => {
setSizeYields({}); setSizeYields({});
}, 'Referencia salva.'); }, 'Referencia salva.');
const importCompositionFile = async (file: File | null) => {
if (!file) return;
setStatus('saving');
setFeedback('Importando composições...');
try {
const payload = JSON.parse(await file.text()) as unknown;
const result = await importProductCompositions(payload);
await loadCatalog();
setStatus(result.failed ? 'error' : 'saved');
setFeedback(
`${result.imported} composições importadas, ${result.referenceCount} referências criadas/atualizadas` +
`${result.failed ? `, ${result.failed} falhas` : ''}.`
);
} catch (error) {
setStatus('error');
setFeedback(error instanceof Error ? error.message : 'Não foi possível importar o arquivo.');
} finally {
if (compositionFileInputRef.current) compositionFileInputRef.current.value = '';
}
};
const removeCategory = (category: CatalogCategory) => runAction( const removeCategory = (category: CatalogCategory) => runAction(
() => deleteCatalogCategory(category.id), () => deleteCatalogCategory(category.id),
`Categoria ${category.name} excluida.` `Categoria ${category.name} excluida.`
@@ -363,6 +390,22 @@ const Registrations = () => {
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<input
ref={compositionFileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(event) => void importCompositionFile(event.target.files?.[0] || null)}
/>
<button
type="button"
onClick={() => compositionFileInputRef.current?.click()}
disabled={status === 'saving'}
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 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
>
<Upload className="h-4 w-4 text-brand-primary" />
Importar composições
</button>
<Link <Link
to="/planning-issues" to="/planning-issues"
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" 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"
@@ -674,7 +717,7 @@ const Registrations = () => {
{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 ${ <span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${
reference.source === 'tiny_op' reference.source === 'tiny_op' || reference.source === 'tiny_structure'
? 'border-sky-400/30 bg-sky-400/10 text-sky-300' ? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
: 'border-dark-border bg-dark-input text-dark-muted' : 'border-dark-border bg-dark-input text-dark-muted'
}`}> }`}>

View File

@@ -1997,8 +1997,9 @@ const PurchaseNeedsScreen = () => {
</tbody> </tbody>
</table> </table>
) : ( ) : (
<table className="w-full min-w-[920px] table-fixed border-collapse"> <table className="w-full min-w-[1120px] table-fixed border-collapse">
<colgroup> <colgroup>
<col className="w-[280px]" />
<col /> <col />
<col className="w-[120px]" /> <col className="w-[120px]" />
<col className="w-[120px]" /> <col className="w-[120px]" />
@@ -2008,9 +2009,10 @@ const PurchaseNeedsScreen = () => {
</colgroup> </colgroup>
<thead className="border-b border-dark-border bg-dark-input/40 text-xs font-bold uppercase tracking-widest text-dark-muted"> <thead className="border-b border-dark-border bg-dark-input/40 text-xs font-bold uppercase tracking-widest text-dark-muted">
<tr> <tr>
<th scope="col" className="px-4 py-3 text-left">Material</th> <th scope="col" className="px-4 py-3 text-left">Demanda de produto</th>
<th scope="col" className="px-4 py-3 text-right">Planejado</th> <th scope="col" className="px-4 py-3 text-left">Material necessário</th>
<th scope="col" className="px-4 py-3 text-right">Estoque</th> <th scope="col" className="px-4 py-3 text-right">Necessário</th>
<th scope="col" className="px-4 py-3 text-right">Estoque atual</th>
<th scope="col" className="px-4 py-3 text-right">Pendente</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-right">Comprar</th>
<th scope="col" className="px-4 py-3 text-left">Cobertura</th> <th scope="col" className="px-4 py-3 text-left">Cobertura</th>
@@ -2021,6 +2023,19 @@ const PurchaseNeedsScreen = () => {
const referenceProduct = need.products?.[0]; const referenceProduct = need.products?.[0];
return ( return (
<tr key={need.material}> <tr key={need.material}>
<td className="px-4 py-4 align-middle">
{need.products?.length ? (
<div className="min-w-0">
{need.products.slice(0, 2).map(product => (
<div key={product.productId} className="mb-1 min-w-0 last:mb-0" title={product.name}>
<p className="truncate text-xs font-bold text-dark-text">{product.name || product.productId}</p>
<p className="text-[10px] font-semibold text-dark-muted">SKU {product.productId} · demanda {formatNumber(product.suggestedQuantity)} un.</p>
</div>
))}
{need.products.length > 2 && <p className="mt-1 text-[10px] font-semibold text-dark-muted">+{need.products.length - 2} SKUs impactados</p>}
</div>
) : <span className="text-xs font-semibold text-dark-muted">Plano manual</span>}
</td>
<td className="px-4 py-4 align-middle"> <td className="px-4 py-4 align-middle">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-bold text-dark-text">{getNeedDisplayName(need)}</p> <p className="text-sm font-bold text-dark-text">{getNeedDisplayName(need)}</p>

View File

@@ -498,6 +498,47 @@ export interface ProductComposition {
components: ProductCompositionComponent[]; components: ProductCompositionComponent[];
} }
export interface ProductCompositionImportSummary {
imported: number;
failed: number;
componentCount: number;
referenceCount: number;
skippedReferenceCount: number;
issues: Array<{
type: string;
productSku?: string;
product?: string;
component?: string;
componentSku?: string;
tinyProductId?: string;
unit?: string;
count?: number;
message?: string;
}>;
}
export interface DataHealthIssue {
type: 'missing_product_sku' | 'component_without_stock_link' | 'suspicious_quantity' | 'raw_or_service_structure' | 'product_without_composition';
severity: 'critical' | 'attention' | 'info';
title: string;
detail: string;
productSku: string;
component: string;
}
export interface DataHealthSummary {
totals: {
products: number;
productsWithComposition: number;
productsWithStockLink: number;
materials: number;
materialsWithStock: number;
compositions: number;
components: number;
};
issues: DataHealthIssue[];
}
export interface ClientAnalyticsItem { export interface ClientAnalyticsItem {
customerKey: string; customerKey: string;
clientToken: string; clientToken: string;