Add catalog registrations workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s

This commit is contained in:
Cauê Faleiros
2026-07-09 15:12:27 -03:00
parent d3d8886a90
commit c7808702fa
10 changed files with 1353 additions and 21 deletions

View File

@@ -140,6 +140,54 @@ const initDB = async () => {
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS catalog_categories (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS catalog_products (
id SERIAL PRIMARY KEY,
type VARCHAR(40) NOT NULL DEFAULT 'finished_product',
sku VARCHAR(100) NOT NULL UNIQUE,
name TEXT NOT NULL,
category_id INTEGER REFERENCES catalog_categories(id) ON DELETE SET NULL,
composition TEXT,
notes TEXT,
gramature NUMERIC(14, 4),
material_yield NUMERIC(14, 4),
width_cm NUMERIC(14, 4),
color VARCHAR(100),
subcategory VARCHAR(80),
sizes TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS consumption_references (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES catalog_products(id) ON DELETE CASCADE,
material_product_id INTEGER REFERENCES catalog_products(id) ON DELETE SET NULL,
color VARCHAR(100),
general_yield NUMERIC(14, 4),
size_yields JSONB DEFAULT '{}'::jsonb,
size_areas JSONB DEFAULT '{}'::jsonb,
gramature NUMERIC(14, 4),
efficiency_percent NUMERIC(7, 3),
rib_g_per_piece NUMERIC(14, 4),
material_cost_per_kg NUMERIC(14, 4),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
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',
@@ -160,6 +208,30 @@ const initDB = async () => {
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE catalog_categories
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE catalog_products
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE consumption_references
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
CREATE TABLE IF NOT EXISTS app_users (
id SERIAL PRIMARY KEY,
@@ -228,6 +300,10 @@ const initDB = async () => {
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_catalog_products_type ON catalog_products (type);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_category_id ON catalog_products (category_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_product_id ON consumption_references (product_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_material_product_id ON consumption_references (material_product_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome

View File

@@ -0,0 +1,101 @@
const express = require('express');
const { verifyToken } = require('../auth');
const {
createCategory,
createConsumptionReference,
createProduct,
deleteCategory,
deleteConsumptionReference,
deleteProduct,
getCatalogSummary,
listCategories,
listConsumptionReferences,
listProducts
} = require('../services/catalogService');
const router = express.Router();
router.get('/catalog', verifyToken, async (req, res, next) => {
try {
res.json(await getCatalogSummary());
} catch (error) {
next(error);
}
});
router.get('/catalog/categories', verifyToken, async (req, res, next) => {
try {
res.json(await listCategories());
} catch (error) {
next(error);
}
});
router.post('/catalog/categories', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createCategory(req.body || {}));
} catch (error) {
next(error);
}
});
router.delete('/catalog/categories/:id', verifyToken, async (req, res, next) => {
try {
await deleteCategory(req.params.id);
res.status(204).end();
} catch (error) {
next(error);
}
});
router.get('/catalog/products', verifyToken, async (req, res, next) => {
try {
res.json(await listProducts());
} catch (error) {
next(error);
}
});
router.post('/catalog/products', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createProduct(req.body || {}));
} catch (error) {
next(error);
}
});
router.delete('/catalog/products/:id', verifyToken, async (req, res, next) => {
try {
await deleteProduct(req.params.id);
res.status(204).end();
} catch (error) {
next(error);
}
});
router.get('/catalog/consumption-references', verifyToken, async (req, res, next) => {
try {
res.json(await listConsumptionReferences());
} catch (error) {
next(error);
}
});
router.post('/catalog/consumption-references', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createConsumptionReference(req.body || {}));
} catch (error) {
next(error);
}
});
router.delete('/catalog/consumption-references/:id', verifyToken, async (req, res, next) => {
try {
await deleteConsumptionReference(req.params.id);
res.status(204).end();
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -10,6 +10,7 @@ const analyticsRoutes = require('./routes/analyticsRoutes');
const userRoutes = require('./routes/userRoutes');
const productionOrderRoutes = require('./routes/productionOrderRoutes');
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
const catalogRoutes = require('./routes/catalogRoutes');
const createApp = () => {
const app = express();
@@ -23,6 +24,7 @@ const createApp = () => {
app.use('/api', campaignRoutes);
app.use('/api', productionOrderRoutes);
app.use('/api', cuttingSettingsRoutes);
app.use('/api', catalogRoutes);
app.use('/api', analyticsRoutes);
app.use('/api', userRoutes);
app.use('/api/internal', internalRoutes);

View File

@@ -0,0 +1,268 @@
const { pool } = require('../db');
const PRODUCT_TYPES = new Set(['finished_product', 'raw_material']);
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
const normalizeNumber = (value) => {
if (value === '' || value === null || value === undefined) return null;
const number = Number(String(value).replace(',', '.'));
return Number.isFinite(number) && number > 0 ? number : null;
};
const normalizeProductType = (value) => {
const normalized = normalizeText(value);
return PRODUCT_TYPES.has(normalized) ? normalized : 'finished_product';
};
const normalizeStringArray = (value) => {
if (!Array.isArray(value)) return [];
return value.map(item => normalizeText(item)).filter(Boolean);
};
const normalizeNumericMap = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.entries(value).reduce((normalized, [key, rawValue]) => {
const normalizedKey = normalizeText(key).toUpperCase();
const number = normalizeNumber(rawValue);
if (normalizedKey && number) normalized[normalizedKey] = number;
return normalized;
}, {});
};
const mapCategory = (row) => ({
id: row.id,
name: row.name,
description: row.description || '',
createdAt: row.created_at,
updatedAt: row.updated_at
});
const mapProduct = (row) => ({
id: row.id,
type: row.type,
sku: row.sku,
name: row.name,
categoryId: row.category_id,
categoryName: row.category_name || '',
composition: row.composition || '',
notes: row.notes || '',
gramature: row.gramature === null ? null : Number(row.gramature),
materialYield: row.material_yield === null ? null : Number(row.material_yield),
widthCm: row.width_cm === null ? null : Number(row.width_cm),
color: row.color || '',
subcategory: row.subcategory || '',
sizes: row.sizes || [],
createdAt: row.created_at,
updatedAt: row.updated_at
});
const mapConsumptionReference = (row) => ({
id: row.id,
productId: row.product_id,
productSku: row.product_sku,
productName: row.product_name,
materialProductId: row.material_product_id,
materialSku: row.material_sku || '',
materialName: row.material_name || '',
color: row.color || '',
generalYield: row.general_yield === null ? null : Number(row.general_yield),
sizeYields: row.size_yields || {},
sizeAreas: row.size_areas || {},
gramature: row.gramature === null ? null : Number(row.gramature),
efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent),
ribGPerPiece: row.rib_g_per_piece === null ? null : Number(row.rib_g_per_piece),
materialCostPerKg: row.material_cost_per_kg === null ? null : Number(row.material_cost_per_kg),
createdAt: row.created_at,
updatedAt: row.updated_at
});
const listCategories = async () => {
const result = await pool.query(`
SELECT id, name, description, created_at, updated_at
FROM catalog_categories
ORDER BY name
`);
return result.rows.map(mapCategory);
};
const createCategory = async ({ name, description }) => {
const normalizedName = normalizeText(name);
if (!normalizedName) {
const error = new Error('Nome da categoria é obrigatório.');
error.statusCode = 400;
throw error;
}
const result = await pool.query(`
INSERT INTO catalog_categories (name, description, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT (name) DO UPDATE
SET description = EXCLUDED.description,
updated_at = CURRENT_TIMESTAMP
RETURNING id, name, description, created_at, updated_at
`, [normalizedName, normalizeText(description) || null]);
return mapCategory(result.rows[0]);
};
const deleteCategory = async (id) => {
await pool.query('DELETE FROM catalog_categories WHERE id = $1', [id]);
};
const listProducts = async () => {
const result = await pool.query(`
SELECT
p.id, p.type, p.sku, p.name, p.category_id, c.name AS category_name,
p.composition, p.notes, p.gramature, p.material_yield, p.width_cm,
p.color, p.subcategory, p.sizes, p.created_at, p.updated_at
FROM catalog_products p
LEFT JOIN catalog_categories c ON c.id = p.category_id
ORDER BY p.type, p.name, p.sku
`);
return result.rows.map(mapProduct);
};
const createProduct = async (payload) => {
const type = normalizeProductType(payload.type);
const sku = normalizeText(payload.sku).toUpperCase();
const name = normalizeText(payload.name);
if (!sku || !name) {
const error = new Error('SKU e nome do produto são obrigatórios.');
error.statusCode = 400;
throw error;
}
const categoryId = Number(payload.categoryId) || null;
const result = await pool.query(`
INSERT INTO catalog_products (
type, sku, name, category_id, composition, notes, gramature,
material_yield, width_cm, color, subcategory, sizes, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CURRENT_TIMESTAMP)
ON CONFLICT (sku) DO UPDATE
SET type = EXCLUDED.type,
name = EXCLUDED.name,
category_id = EXCLUDED.category_id,
composition = EXCLUDED.composition,
notes = EXCLUDED.notes,
gramature = EXCLUDED.gramature,
material_yield = EXCLUDED.material_yield,
width_cm = EXCLUDED.width_cm,
color = EXCLUDED.color,
subcategory = EXCLUDED.subcategory,
sizes = EXCLUDED.sizes,
updated_at = CURRENT_TIMESTAMP
RETURNING id
`, [
type,
sku,
name,
categoryId,
normalizeText(payload.composition) || null,
normalizeText(payload.notes) || null,
normalizeNumber(payload.gramature),
normalizeNumber(payload.materialYield),
normalizeNumber(payload.widthCm),
normalizeText(payload.color) || null,
normalizeText(payload.subcategory) || null,
normalizeStringArray(payload.sizes)
]);
const products = await listProducts();
return products.find(product => product.id === result.rows[0].id);
};
const deleteProduct = async (id) => {
await pool.query('DELETE FROM catalog_products WHERE id = $1', [id]);
};
const listConsumptionReferences = async () => {
const result = await pool.query(`
SELECT
r.id, r.product_id, p.sku AS product_sku, p.name AS product_name,
r.material_product_id, m.sku AS material_sku, m.name AS material_name,
r.color, r.general_yield, r.size_yields, r.size_areas,
r.gramature, r.efficiency_percent, r.rib_g_per_piece,
r.material_cost_per_kg, r.created_at, r.updated_at
FROM consumption_references r
JOIN catalog_products p ON p.id = r.product_id
LEFT JOIN catalog_products m ON m.id = r.material_product_id
ORDER BY p.name, m.name NULLS FIRST, r.color NULLS FIRST
`);
return result.rows.map(mapConsumptionReference);
};
const createConsumptionReference = async (payload) => {
const productId = Number(payload.productId);
if (!productId) {
const error = new Error('Produto é obrigatório.');
error.statusCode = 400;
throw error;
}
const sizeYields = normalizeNumericMap(payload.sizeYields);
const sizeAreas = normalizeNumericMap(payload.sizeAreas);
const generalYield = normalizeNumber(payload.generalYield);
const calculatedYield = Object.values(sizeYields).length
? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length
: null;
if (!generalYield && !calculatedYield) {
const error = new Error('Informe o rendimento geral ou por tamanho.');
error.statusCode = 400;
throw error;
}
const result = await pool.query(`
INSERT INTO consumption_references (
product_id, material_product_id, color, general_yield, size_yields,
size_areas, gramature, efficiency_percent, rib_g_per_piece,
material_cost_per_kg, updated_at
)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, CURRENT_TIMESTAMP)
RETURNING id
`, [
productId,
Number(payload.materialProductId) || null,
normalizeText(payload.color) || null,
generalYield || calculatedYield,
JSON.stringify(sizeYields),
JSON.stringify(sizeAreas),
normalizeNumber(payload.gramature),
normalizeNumber(payload.efficiencyPercent),
normalizeNumber(payload.ribGPerPiece),
normalizeNumber(payload.materialCostPerKg)
]);
const references = await listConsumptionReferences();
return references.find(reference => reference.id === result.rows[0].id);
};
const deleteConsumptionReference = async (id) => {
await pool.query('DELETE FROM consumption_references WHERE id = $1', [id]);
};
const getCatalogSummary = async () => {
const [categories, products, consumptionReferences] = await Promise.all([
listCategories(),
listProducts(),
listConsumptionReferences()
]);
return { categories, products, consumptionReferences };
};
module.exports = {
createCategory,
createConsumptionReference,
createProduct,
deleteCategory,
deleteConsumptionReference,
deleteProduct,
getCatalogSummary,
listCategories,
listConsumptionReferences,
listProducts
};