From 2dcef87229b3245391fdf814333fc61fad7835d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 8 Jul 2026 09:32:28 -0300 Subject: [PATCH] Persist cutting rules in database --- backend/db.js | 31 +++++ backend/routes/cuttingSettingsRoutes.js | 23 ++++ backend/server.js | 2 + backend/services/cuttingSettingsService.js | 128 ++++++++++++++++++++ backend/test/cuttingSettingsService.test.js | 40 ++++++ src/analytics/cutting.ts | 11 +- src/dataService.ts | 28 ++++- src/pages/Cutting.tsx | 91 ++++++++++++-- src/types.ts | 13 ++ 9 files changed, 348 insertions(+), 19 deletions(-) create mode 100644 backend/routes/cuttingSettingsRoutes.js create mode 100644 backend/services/cuttingSettingsService.js create mode 100644 backend/test/cuttingSettingsService.test.js diff --git a/backend/db.js b/backend/db.js index a3cb447..d77bf03 100644 --- a/backend/db.js +++ b/backend/db.js @@ -122,6 +122,24 @@ const initDB = async () => { ); `); + await pool.query(` + CREATE TABLE IF NOT EXISTS cutting_family_rules ( + family_key VARCHAR(20) PRIMARY KEY, + units_per_roll NUMERIC(14, 4), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ); + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS cutting_product_overrides ( + product_id VARCHAR(100) PRIMARY KEY, + family_key VARCHAR(20), + color VARCHAR(100), + size VARCHAR(40), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ); + `); + await pool.query(` ALTER TABLE production_orders ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', @@ -130,6 +148,18 @@ const initDB = async () => { ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; `).catch(() => {}); + await pool.query(` + ALTER TABLE cutting_family_rules + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo', + ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; + `).catch(() => {}); + + await pool.query(` + ALTER TABLE cutting_product_overrides + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo', + ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; + `).catch(() => {}); + await pool.query(` CREATE TABLE IF NOT EXISTS app_users ( id SERIAL PRIMARY KEY, @@ -197,6 +227,7 @@ const initDB = async () => { await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_cutting_product_overrides_family_key ON cutting_product_overrides (family_key);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`); await pool.query(` CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome diff --git a/backend/routes/cuttingSettingsRoutes.js b/backend/routes/cuttingSettingsRoutes.js new file mode 100644 index 0000000..b3d3b67 --- /dev/null +++ b/backend/routes/cuttingSettingsRoutes.js @@ -0,0 +1,23 @@ +const express = require('express'); +const { verifyToken } = require('../auth'); +const { listCuttingSettings, saveCuttingSettings } = require('../services/cuttingSettingsService'); + +const router = express.Router(); + +router.get('/cutting-settings', verifyToken, async (req, res, next) => { + try { + res.json(await listCuttingSettings()); + } catch (error) { + next(error); + } +}); + +router.put('/cutting-settings', verifyToken, async (req, res, next) => { + try { + res.json(await saveCuttingSettings(req.body || {})); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 34d3431..bec0174 100644 --- a/backend/server.js +++ b/backend/server.js @@ -9,6 +9,7 @@ const internalRoutes = require('./routes/internalRoutes'); const analyticsRoutes = require('./routes/analyticsRoutes'); const userRoutes = require('./routes/userRoutes'); const productionOrderRoutes = require('./routes/productionOrderRoutes'); +const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes'); const createApp = () => { const app = express(); @@ -21,6 +22,7 @@ const createApp = () => { app.use('/api', stockRoutes); app.use('/api', campaignRoutes); app.use('/api', productionOrderRoutes); + app.use('/api', cuttingSettingsRoutes); app.use('/api', analyticsRoutes); app.use('/api', userRoutes); app.use('/api/internal', internalRoutes); diff --git a/backend/services/cuttingSettingsService.js b/backend/services/cuttingSettingsService.js new file mode 100644 index 0000000..cbb3873 --- /dev/null +++ b/backend/services/cuttingSettingsService.js @@ -0,0 +1,128 @@ +const { pool } = require('../db'); + +const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM']; +const FAMILY_KEY_SET = new Set(FAMILY_KEYS); + +const normalizeFamilyKey = (value) => { + const familyKey = String(value || '').trim().toUpperCase(); + return FAMILY_KEY_SET.has(familyKey) ? familyKey : ''; +}; + +const normalizeNumber = (value) => { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : null; +}; + +const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); + +const normalizeFamilyYields = (familyYields = {}) => { + return FAMILY_KEYS.reduce((normalized, familyKey) => { + const unitsPerRoll = normalizeNumber(familyYields[familyKey]); + if (unitsPerRoll) normalized[familyKey] = unitsPerRoll; + return normalized; + }, {}); +}; + +const normalizeProductOverrides = (productOverrides = {}) => { + return Object.entries(productOverrides).reduce((normalized, [productId, override]) => { + const normalizedProductId = normalizeText(productId); + if (!normalizedProductId || !override || typeof override !== 'object') return normalized; + + const familyKey = normalizeFamilyKey(override.familyKey); + const color = normalizeText(override.color); + const size = normalizeText(override.size).toUpperCase(); + + if (!familyKey && !color && !size) return normalized; + + normalized[normalizedProductId] = { + familyKey, + color, + size + }; + + return normalized; + }, {}); +}; + +const listCuttingSettings = async () => { + const [familyResult, overrideResult] = await Promise.all([ + pool.query(` + SELECT family_key, units_per_roll + FROM cutting_family_rules + WHERE units_per_roll IS NOT NULL AND units_per_roll > 0 + ORDER BY family_key + `), + pool.query(` + SELECT product_id, family_key, color, size + FROM cutting_product_overrides + ORDER BY product_id + `) + ]); + + return { + familyYields: familyResult.rows.reduce((settings, row) => { + settings[row.family_key] = Number(row.units_per_roll); + return settings; + }, {}), + productOverrides: overrideResult.rows.reduce((settings, row) => { + settings[row.product_id] = { + familyKey: row.family_key || '', + color: row.color || '', + size: row.size || '' + }; + return settings; + }, {}) + }; +}; + +const saveCuttingSettings = async ({ familyYields = {}, productOverrides = {} }) => { + const normalizedFamilyYields = normalizeFamilyYields(familyYields); + const normalizedProductOverrides = normalizeProductOverrides(productOverrides); + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + await client.query('DELETE FROM cutting_family_rules'); + for (const [familyKey, unitsPerRoll] of Object.entries(normalizedFamilyYields)) { + await client.query(` + INSERT INTO cutting_family_rules (family_key, units_per_roll, updated_at) + VALUES ($1, $2, CURRENT_TIMESTAMP) + `, [familyKey, unitsPerRoll]); + } + + await client.query('DELETE FROM cutting_product_overrides'); + for (const [productId, override] of Object.entries(normalizedProductOverrides)) { + await client.query(` + INSERT INTO cutting_product_overrides (product_id, family_key, color, size, updated_at) + VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP) + `, [ + productId, + override.familyKey || null, + override.color || null, + override.size || null + ]); + } + + await client.query('COMMIT'); + + return { + familyYields: normalizedFamilyYields, + productOverrides: normalizedProductOverrides + }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + +module.exports = { + FAMILY_KEYS, + listCuttingSettings, + normalizeFamilyKey, + normalizeFamilyYields, + normalizeProductOverrides, + saveCuttingSettings +}; diff --git a/backend/test/cuttingSettingsService.test.js b/backend/test/cuttingSettingsService.test.js new file mode 100644 index 0000000..01a2b59 --- /dev/null +++ b/backend/test/cuttingSettingsService.test.js @@ -0,0 +1,40 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + normalizeFamilyKey, + normalizeFamilyYields, + normalizeProductOverrides +} = require('../services/cuttingSettingsService'); + +test('normalizeFamilyKey accepts only known cut families', () => { + assert.equal(normalizeFamilyKey('blcs'), 'BLCS'); + assert.equal(normalizeFamilyKey(' BLOS '), 'BLOS'); + assert.equal(normalizeFamilyKey('OUTROS'), ''); + assert.equal(normalizeFamilyKey('unknown'), ''); +}); + +test('normalizeFamilyYields keeps positive numeric yield rules', () => { + assert.deepEqual(normalizeFamilyYields({ + BLCS: '50', + BLOS: 0, + BLMC: -1, + BLPM: '12.5', + OUTROS: 99 + }), { + BLCS: 50, + BLPM: 12.5 + }); +}); + +test('normalizeProductOverrides trims and removes empty overrides', () => { + assert.deepEqual(normalizeProductOverrides({ + ' SKU-1 ': { familyKey: 'blcs', color: ' Preto ', size: ' m ' }, + 'SKU-2': { familyKey: 'OUTROS', color: '', size: '' }, + 'SKU-3': { familyKey: '', color: ' Branco ', size: '' }, + 'SKU-4': null + }), { + 'SKU-1': { familyKey: 'BLCS', color: 'Preto', size: 'M' }, + 'SKU-3': { familyKey: '', color: 'Branco', size: '' } + }); +}); diff --git a/src/analytics/cutting.ts b/src/analytics/cutting.ts index 24fb097..2d48236 100644 --- a/src/analytics/cutting.ts +++ b/src/analytics/cutting.ts @@ -1,7 +1,8 @@ -import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; +import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts'; -export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS'; +export type { CutFamilyKey, CutProductOverride }; + export type CutIssue = 'missing_family_rule' | 'missing_color' | 'missing_size' | 'missing_yield_rule'; export interface CutFamilyRule { @@ -12,12 +13,6 @@ export interface CutFamilyRule { unitsPerRoll: number | null; } -export interface CutProductOverride { - familyKey?: CutFamilyKey | ''; - color?: string; - size?: string; -} - export interface CutPlanOptions { familyYields?: Partial>; productOverrides?: Record; diff --git a/src/dataService.ts b/src/dataService.ts index d97b980..176f4ae 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -183,6 +183,32 @@ export const fetchProductionOrders = async ( }, options); }; +export const fetchCuttingSettings = async (): Promise => { + try { + const response = await authFetch('/cutting-settings'); + if (!response.ok) return { familyYields: {}, productOverrides: {} }; + return await response.json(); + } catch (error) { + console.error('Fetch cutting settings failed', error); + return { familyYields: {}, productOverrides: {} }; + } +}; + +export const saveCuttingSettings = async (settings: CuttingSettings): Promise => { + const response = await authFetch('/cutting-settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings) + }); + + const data = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(data?.error || 'Não foi possível salvar as regras de corte.'); + } + + return data as CuttingSettings; +}; + export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise => { const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`; return getCachedAnalytics(path, async () => { diff --git a/src/pages/Cutting.tsx b/src/pages/Cutting.tsx index 7163db0..ea02bd8 100644 --- a/src/pages/Cutting.tsx +++ b/src/pages/Cutting.tsx @@ -1,19 +1,16 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; -import { AlertTriangle, ClipboardList, Download, Layers3, Package, Palette, RotateCcw, Ruler, Scissors, Search, Settings2 } from 'lucide-react'; +import { AlertTriangle, ClipboardList, Download, Layers3, Package, Palette, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2 } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; import RefreshStatus from '../components/RefreshStatus'; import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting'; -import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService'; -import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; +import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService'; +import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; type CutFilter = 'need' | 'all' | 'issues' | 'covered'; type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc'; -type CuttingSettings = { - familyYields: Partial>; - productOverrides: Record; -}; +type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings'; const coverageTargetOptions = [7, 15, 30, 60]; @@ -81,6 +78,11 @@ const loadCuttingSettings = (): CuttingSettings => { } }; +const hasCuttingSettings = (settings: CuttingSettings) => ( + Object.keys(settings.familyYields).length > 0 || + Object.keys(settings.productOverrides).length > 0 +); + const CuttingSkeleton = () => (
@@ -113,12 +115,36 @@ const Cutting = () => { const [sortBy, setSortBy] = useState('need_desc'); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [cuttingSettings, setCuttingSettings] = useState(loadCuttingSettings); + const [saveStatus, setSaveStatus] = useState('idle'); + const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); useEffect(() => { - localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(cuttingSettings)); - }, [cuttingSettings]); + let isMounted = true; + + const loadSettings = async () => { + const settings = await fetchCuttingSettings(); + if (!isMounted) return; + + if (hasCuttingSettings(settings)) { + setCuttingSettings(settings); + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); + setHasUnsavedSettings(false); + } else { + const localSettings = loadCuttingSettings(); + setCuttingSettings(localSettings); + setHasUnsavedSettings(hasCuttingSettings(localSettings)); + } + setSaveStatus('idle'); + }; + + void loadSettings(); + + return () => { + isMounted = false; + }; + }, []); useEffect(() => { let isMounted = true; @@ -219,6 +245,8 @@ const Cutting = () => { } return { ...current, familyYields }; }); + setHasUnsavedSettings(true); + setSaveStatus('idle'); }; const updateProductOverride = (productId: string, patch: CutProductOverride) => { @@ -238,6 +266,8 @@ const Cutting = () => { } return { ...current, productOverrides }; }); + setHasUnsavedSettings(true); + setSaveStatus('idle'); }; const clearProductOverride = (productId: string) => { @@ -246,6 +276,29 @@ const Cutting = () => { delete productOverrides[productId]; return { ...current, productOverrides }; }); + setHasUnsavedSettings(true); + setSaveStatus('idle'); + }; + + const persistSettings = async (settings = cuttingSettings) => { + setSaveStatus('saving'); + try { + const savedSettings = await saveCuttingSettings(settings); + setCuttingSettings(savedSettings); + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(savedSettings)); + setHasUnsavedSettings(false); + setSaveStatus('saved'); + } catch (error) { + console.error('Save cutting settings failed', error); + setSaveStatus('error'); + } + }; + + const clearSettings = () => { + const emptySettings = { familyYields: {}, productOverrides: {} }; + setCuttingSettings(emptySettings); + setHasUnsavedSettings(true); + setSaveStatus('idle'); }; const exportRows = () => { @@ -337,13 +390,31 @@ const Cutting = () => {
+
+ {saveStatus === 'saved' && ( +

Regras salvas no banco de dados.

+ )} + {saveStatus === 'error' && ( +

Não foi possível salvar as regras. Tente novamente.

+ )} + {hasUnsavedSettings && saveStatus !== 'saving' && ( +

Existem alterações ainda não salvas.

+ )}
{CUT_FAMILY_RULES.map(rule => ( diff --git a/src/types.ts b/src/types.ts index e35cf07..5804723 100644 --- a/src/types.ts +++ b/src/types.ts @@ -65,6 +65,19 @@ export interface ProductionOrderSummary { counts: ProductionOrderCounts; } +export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS'; + +export interface CutProductOverride { + familyKey?: CutFamilyKey | ''; + color?: string; + size?: string; +} + +export interface CuttingSettings { + familyYields: Partial>; + productOverrides: Record; +} + export interface DateRange { start: Date; end: Date;