diff --git a/backend/db.js b/backend/db.js index b1fda97..be65b98 100644 --- a/backend/db.js +++ b/backend/db.js @@ -136,6 +136,8 @@ const initDB = async () => { family_key VARCHAR(20), color VARCHAR(100), size VARCHAR(40), + product_type VARCHAR(40), + planning_notes TEXT, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); `); @@ -270,6 +272,12 @@ const initDB = async () => { ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; `).catch(() => {}); + await pool.query(` + ALTER TABLE cutting_product_overrides + ADD COLUMN IF NOT EXISTS product_type VARCHAR(40), + ADD COLUMN IF NOT EXISTS planning_notes TEXT; + `).catch(() => {}); + await pool.query(` ALTER TABLE catalog_categories ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', diff --git a/backend/services/cuttingSettingsService.js b/backend/services/cuttingSettingsService.js index cbb3873..bb40ba9 100644 --- a/backend/services/cuttingSettingsService.js +++ b/backend/services/cuttingSettingsService.js @@ -2,6 +2,21 @@ const { pool } = require('../db'); const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM']; const FAMILY_KEY_SET = new Set(FAMILY_KEYS); +const PRODUCT_TYPE_KEYS = [ + 'finished_apparel', + 'finished_accessory', + 'raw_material', + 'packaging', + 'dtf_input', + 'dtf_service', + 'kit_bundle', + 'service', + 'machine_part', + 'equipment', + 'ignore_from_planning', + 'unknown' +]; +const PRODUCT_TYPE_KEY_SET = new Set(PRODUCT_TYPE_KEYS); const normalizeFamilyKey = (value) => { const familyKey = String(value || '').trim().toUpperCase(); @@ -15,6 +30,11 @@ const normalizeNumber = (value) => { const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); +const normalizeProductType = (value) => { + const productType = normalizeText(value); + return PRODUCT_TYPE_KEY_SET.has(productType) ? productType : ''; +}; + const normalizeFamilyYields = (familyYields = {}) => { return FAMILY_KEYS.reduce((normalized, familyKey) => { const unitsPerRoll = normalizeNumber(familyYields[familyKey]); @@ -31,13 +51,17 @@ const normalizeProductOverrides = (productOverrides = {}) => { const familyKey = normalizeFamilyKey(override.familyKey); const color = normalizeText(override.color); const size = normalizeText(override.size).toUpperCase(); + const productType = normalizeProductType(override.productType); + const planningNotes = normalizeText(override.planningNotes); - if (!familyKey && !color && !size) return normalized; + if (!familyKey && !color && !size && !productType && !planningNotes) return normalized; normalized[normalizedProductId] = { familyKey, color, - size + size, + productType, + planningNotes }; return normalized; @@ -53,7 +77,7 @@ const listCuttingSettings = async () => { ORDER BY family_key `), pool.query(` - SELECT product_id, family_key, color, size + SELECT product_id, family_key, color, size, product_type, planning_notes FROM cutting_product_overrides ORDER BY product_id `) @@ -68,7 +92,9 @@ const listCuttingSettings = async () => { settings[row.product_id] = { familyKey: row.family_key || '', color: row.color || '', - size: row.size || '' + size: row.size || '', + productType: row.product_type || '', + planningNotes: row.planning_notes || '' }; return settings; }, {}) @@ -94,13 +120,15 @@ const saveCuttingSettings = async ({ familyYields = {}, productOverrides = {} }) 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) + INSERT INTO cutting_product_overrides (product_id, family_key, color, size, product_type, planning_notes, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP) `, [ productId, override.familyKey || null, override.color || null, - override.size || null + override.size || null, + override.productType || null, + override.planningNotes || null ]); } @@ -123,6 +151,7 @@ module.exports = { listCuttingSettings, normalizeFamilyKey, normalizeFamilyYields, + normalizeProductType, normalizeProductOverrides, saveCuttingSettings }; diff --git a/backend/test/cuttingSettingsService.test.js b/backend/test/cuttingSettingsService.test.js index 01a2b59..fefe73e 100644 --- a/backend/test/cuttingSettingsService.test.js +++ b/backend/test/cuttingSettingsService.test.js @@ -4,6 +4,7 @@ const test = require('node:test'); const { normalizeFamilyKey, normalizeFamilyYields, + normalizeProductType, normalizeProductOverrides } = require('../services/cuttingSettingsService'); @@ -27,14 +28,23 @@ test('normalizeFamilyYields keeps positive numeric yield rules', () => { }); }); +test('normalizeProductType accepts only known planning product types', () => { + assert.equal(normalizeProductType('finished_apparel'), 'finished_apparel'); + assert.equal(normalizeProductType('raw_material'), 'raw_material'); + assert.equal(normalizeProductType('finished_product'), ''); + assert.equal(normalizeProductType('unknown type'), ''); +}); + test('normalizeProductOverrides trims and removes empty overrides', () => { assert.deepEqual(normalizeProductOverrides({ - ' SKU-1 ': { familyKey: 'blcs', color: ' Preto ', size: ' m ' }, + ' SKU-1 ': { familyKey: 'blcs', color: ' Preto ', size: ' m ', productType: 'finished_apparel', planningNotes: ' revisar corte ' }, 'SKU-2': { familyKey: 'OUTROS', color: '', size: '' }, 'SKU-3': { familyKey: '', color: ' Branco ', size: '' }, - 'SKU-4': null + 'SKU-4': { productType: 'raw_material' }, + 'SKU-5': null }), { - 'SKU-1': { familyKey: 'BLCS', color: 'Preto', size: 'M' }, - 'SKU-3': { familyKey: '', color: 'Branco', size: '' } + 'SKU-1': { familyKey: 'BLCS', color: 'Preto', size: 'M', productType: 'finished_apparel', planningNotes: 'revisar corte' }, + 'SKU-3': { familyKey: '', color: 'Branco', size: '', productType: '', planningNotes: '' }, + 'SKU-4': { familyKey: '', color: '', size: '', productType: 'raw_material', planningNotes: '' } }); }); diff --git a/src/analytics/cutting.test.ts b/src/analytics/cutting.test.ts index fa6e9d6..16251e7 100644 --- a/src/analytics/cutting.test.ts +++ b/src/analytics/cutting.test.ts @@ -122,6 +122,31 @@ test('buildCutPlan excludes non-apparel products from cut planning', () => { assert.equal(plan.summary.skuCount, 0); }); +test('buildCutPlan includes non-apparel products manually marked as finished apparel', () => { + const plan = buildCutPlan([ + product({ + id: 'SKU-2', + name: 'BONÉ PRETO', + quantitySold: 70, + stock: 0 + }) + ], range, 7, {}, { + familyYields: { BLCS: 35 }, + productOverrides: { + 'SKU-2': { + productType: 'finished_apparel', + familyKey: 'BLCS', + color: 'Preto', + size: 'M' + } + } + }); + + assert.equal(plan.needRows.length, 1); + assert.equal(plan.needRows[0].family.key, 'BLCS'); + assert.equal(plan.needRows[0].suggestedCutQuantity, 70); +}); + test('buildOpenProductionByProductId matches open OPs by SKU and normalized product variant', () => { const products = [ product({ id: 'SKU-1' }), diff --git a/src/analytics/cutting.ts b/src/analytics/cutting.ts index af49925..830d192 100644 --- a/src/analytics/cutting.ts +++ b/src/analytics/cutting.ts @@ -1,6 +1,6 @@ import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts'; -import { classifyProductType } from '../productClassification.ts'; +import { resolveProductType } from '../productClassification.ts'; export type { CutFamilyKey, CutProductOverride }; @@ -202,7 +202,9 @@ export const buildCutPlan = ( options: CutPlanOptions = {} ): CutPlan => { const rangeDays = getRangeDays(dateRange); - const cuttableProducts = products.filter(product => classifyProductType(product.name) === 'finished_apparel'); + const cuttableProducts = products.filter(product => ( + resolveProductType(product.name, options.productOverrides?.[product.id]) === 'finished_apparel' + )); const rows = cuttableProducts.map(product => { const metadata = parseProductName(product.name); diff --git a/src/components/SkuPlanningModal.tsx b/src/components/SkuPlanningModal.tsx new file mode 100644 index 0000000..3e3187e --- /dev/null +++ b/src/components/SkuPlanningModal.tsx @@ -0,0 +1,201 @@ +import { useState } from 'react'; +import { RotateCcw, Save, X } from 'lucide-react'; +import { CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting'; +import { editableProductTypeOptions, getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification'; +import type { CutProductOverride } from '../types'; +import ProductTypeBadge from './ProductTypeBadge'; + +type SkuPlanningModalProduct = { + id: string; + name: string; + color?: string; + size?: string; +}; + +type SkuPlanningModalProps = { + product: SkuPlanningModalProduct; + override?: CutProductOverride; + isSaving?: boolean; + onClose: () => void; + onSave: (override: CutProductOverride | null) => void | Promise; +}; + +const familyOptions: Array<{ value: CutFamilyKey | ''; label: string }> = [ + { value: '', label: 'Auto' }, + ...CUT_FAMILY_RULES.map(rule => ({ value: rule.key, label: `${rule.materialLabel} · ${rule.label}` })), + { value: 'OUTROS', label: 'Sem regra' } +]; + +const buildOverride = ({ + familyKey, + color, + size, + productType, + planningNotes +}: Required> & { + familyKey: CutFamilyKey | ''; + productType: ProductTypeKey | ''; +}): CutProductOverride | null => { + const next: CutProductOverride = { + familyKey, + color: color.trim(), + size: size.trim().toUpperCase(), + productType, + planningNotes: planningNotes.trim() + }; + + if (!next.familyKey && !next.color && !next.size && !next.productType && !next.planningNotes) { + return null; + } + + return next; +}; + +const SkuPlanningModal = ({ product, override, isSaving = false, onClose, onSave }: SkuPlanningModalProps) => { + const [familyKey, setFamilyKey] = useState(override?.familyKey || ''); + const [color, setColor] = useState(override?.color || ''); + const [size, setSize] = useState(override?.size || ''); + const [productType, setProductType] = useState(override?.productType || ''); + const [planningNotes, setPlanningNotes] = useState(override?.planningNotes || ''); + + const resolvedType = resolveProductType(product.name, { productType }); + const resolvedConfig = getProductTypeConfig(resolvedType); + + const handleSave = () => { + void onSave(buildOverride({ familyKey, color, size, productType, planningNotes })); + }; + + const handleClear = () => { + void onSave(null); + }; + + return ( +
+
event.stopPropagation()} + > +
+
+
SKU #{product.id}
+

{product.name}

+
+ + {resolvedConfig.description} +
+
+ +
+ +
+ + + + + + + + +