Add catalog registrations workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s
This commit is contained in:
268
backend/services/catalogService.js
Normal file
268
backend/services/catalogService.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user