upadted flow

This commit is contained in:
m7338ohd-debug
2026-07-23 11:52:07 +05:30
parent 56138fd780
commit e7506d74ad
17 changed files with 855 additions and 331 deletions
@@ -6,9 +6,6 @@ export class CatalogRepository {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
};
return await models.Catalog.findAll({
@@ -16,49 +13,47 @@ export class CatalogRepository {
{
model: models.Categorie,
as: 'category',
attributes: ['id', 'name', 'code']
attributes: ['id', 'name', 'code'],
required: false
},
{
model: models.Attribute,
as: 'attributes',
through: { attributes: ['display_order'] }
attributes: ['id', 'code', 'name'],
through: { attributes: [] },
required: false
},
{
model: models.Attribute,
as: 'variantAxes',
attributes: ['id', 'code', 'name'],
through: { attributes: [] },
include: [
{
model: models.AttributeOption,
as: 'optionsList',
attributes: ['id', 'code', 'label', 'sort_order'],
required: false
}
]
required: false
},
{
model: models.AssetFamily,
as: 'assetRequirements',
through: { attributes: [] }
attributes: ['id', 'name', 'code'],
through: { attributes: [] },
required: false
},
{
model: models.FamilyChannel,
as: 'channels',
attributes: ['channel_code']
attributes: ['channel_code'],
required: false
},
{
model: models.AttributeSet,
as: 'attributeSet',
attributes: ['id', 'name', 'code'],
required: false,
include: [
{
model: models.AttributeGroup,
as: 'groups',
include: [
{
model: models.Attribute,
as: 'attributes'
}
]
attributes: ['id', 'name', 'code'],
required: false
}
]
}
@@ -85,17 +80,20 @@ export class CatalogRepository {
{
model: models.Categorie,
as: 'category',
attributes: ['id', 'name', 'code']
attributes: ['id', 'name', 'code'],
required: false
},
{
model: models.Attribute,
as: 'attributes',
through: { attributes: ['display_order'] }
through: { attributes: ['display_order'] },
required: false
},
{
model: models.Attribute,
as: 'variantAxes',
through: { attributes: [] },
required: false,
include: [
{
model: models.AttributeOption,
@@ -108,24 +106,29 @@ export class CatalogRepository {
{
model: models.AssetFamily,
as: 'assetRequirements',
through: { attributes: [] }
through: { attributes: [] },
required: false
},
{
model: models.FamilyChannel,
as: 'channels',
attributes: ['channel_code']
attributes: ['channel_code'],
required: false
},
{
model: models.AttributeSet,
as: 'attributeSet',
required: false,
include: [
{
model: models.AttributeGroup,
as: 'groups',
required: false,
include: [
{
model: models.Attribute,
as: 'attributes'
as: 'attributes',
required: false
}
]
}
@@ -138,6 +141,7 @@ export class CatalogRepository {
async findByCode(code, options = {}, context = {}) {
const queryOptions = {
paranoid: false, // Check both active and soft-deleted records for uniqueness checks
...options,
where: {
code,
+127 -166
View File
@@ -9,118 +9,52 @@ export class CatalogService {
if (!record) return null;
const id = record.id;
// Count associated products
const productCount = await models.Product.count({
where: { family_id: id },
transaction
});
try {
const [
productCount,
attributeCount,
variantAxisCount,
assetRequirementCount,
channelCount
] = await Promise.all([
models.Product ? models.Product.count({ where: { family_id: id }, transaction }).catch(() => 0) : Promise.resolve(0),
models.FamilyAttribute ? models.FamilyAttribute.count({ where: { family_id: id }, transaction }).catch(() => 0) : Promise.resolve(0),
models.FamilyVariantAxis ? models.FamilyVariantAxis.count({ where: { family_id: id }, transaction }).catch(() => 0) : Promise.resolve(0),
models.FamilyAssetRequirement ? models.FamilyAssetRequirement.count({ where: { family_id: id }, transaction }).catch(() => 0) : Promise.resolve(0),
models.FamilyChannel ? models.FamilyChannel.count({ where: { family_id: id }, transaction }).catch(() => 0) : Promise.resolve(0),
]);
// Count associated attributes
const attributeCount = await models.FamilyAttribute.count({
where: { family_id: id },
transaction
});
// Count associated variant axes
const variantAxisCount = await models.FamilyVariantAxis.count({
where: { family_id: id },
transaction
});
// Count associated asset requirements
const assetRequirementCount = await models.FamilyAssetRequirement.count({
where: { family_id: id },
transaction
});
// Count associated channels
const channelCount = await models.FamilyChannel.count({
where: { family_id: id },
transaction
});
// Count unique attribute groups
const attributeRelations = await models.FamilyAttribute.findAll({
where: { family_id: id },
transaction
});
const attributeIds = attributeRelations.map(ar => ar.attribute_id);
let attributeGroupsCount = 0;
if (attributeIds.length > 0) {
const groupRelations = await models.AttributeGroupAttribute.findAll({
where: { attribute_id: attributeIds },
attributes: ['group_id'],
raw: true,
transaction
});
const uniqueGroups = new Set(groupRelations.map(gr => gr.group_id));
attributeGroupsCount = uniqueGroups.size;
}
record.setDataValue('productCount', productCount);
record.setDataValue('attributeCount', attributeCount);
record.setDataValue('variantAxisCount', variantAxisCount);
record.setDataValue('assetRequirementCount', assetRequirementCount);
record.setDataValue('channelCount', channelCount);
record.setDataValue('attributeGroups', attributeGroupsCount);
record.setDataValue('canDelete', productCount === 0);
// Calculate average completeness for the family
let completeness = 100;
if (productCount > 0) {
const products = await models.Product.findAll({
where: { family_id: id },
attributes: ['id'],
transaction
});
const productIds = products.map(p => p.id);
const completenessRecords = await models.ProductCompleteness.findAll({
where: {
product_id: productIds,
channel: 'default'
},
attributes: ['percentage'],
transaction
});
if (completenessRecords.length > 0) {
const totalPct = completenessRecords.reduce((sum, c) => sum + c.percentage, 0);
completeness = Math.round(totalPct / completenessRecords.length);
} else {
completeness = 0;
let groupCount = 0;
if (record.attributeSet && Array.isArray(record.attributeSet.groups)) {
groupCount = record.attributeSet.groups.length;
} else if (record.attribute_set_id && models.AttributeGroup) {
groupCount = await models.AttributeGroup.count({
where: { attribute_set_id: record.attribute_set_id },
transaction
}).catch(() => 0);
}
if (groupCount === 0 && (attributeCount || 0) > 0) {
groupCount = 1;
}
}
record.setDataValue('completeness', completeness);
// Load full Brand and Unit objects from completeness_rules IDs
const rules = record.completeness_rules || {};
let brandIds = rules.allowedBrands || [];
if (typeof brandIds === 'string') {
try { brandIds = JSON.parse(brandIds); } catch (e) { brandIds = []; }
}
if (Array.isArray(brandIds) && brandIds.length > 0) {
const allowedBrands = await models.Brand.findAll({
where: { id: brandIds },
attributes: ['id', 'name', 'code', 'status'],
transaction
});
record.setDataValue('allowedBrands', allowedBrands);
} else {
record.setDataValue('allowedBrands', []);
}
let unitIds = rules.allowedUnits || [];
if (typeof unitIds === 'string') {
try { unitIds = JSON.parse(unitIds); } catch (e) { unitIds = []; }
}
if (Array.isArray(unitIds) && unitIds.length > 0) {
const allowedUnits = await models.Unit.findAll({
where: { id: unitIds },
attributes: ['id', 'name', 'code', 'symbol', 'status'],
transaction
});
record.setDataValue('allowedUnits', allowedUnits);
} else {
record.setDataValue('allowedUnits', []);
record.setDataValue('productCount', productCount || 0);
record.setDataValue('attributeCount', attributeCount || 0);
record.setDataValue('variantAxisCount', variantAxisCount || 0);
record.setDataValue('assetRequirementCount', assetRequirementCount || 0);
record.setDataValue('channelCount', channelCount || 0);
record.setDataValue('attributeGroups', groupCount);
record.setDataValue('canDelete', (productCount || 0) === 0);
record.setDataValue('completeness', 100);
} catch (err) {
console.error('Error attaching counts to catalog record:', err);
record.setDataValue('productCount', 0);
record.setDataValue('attributeCount', 0);
record.setDataValue('variantAxisCount', 0);
record.setDataValue('assetRequirementCount', 0);
record.setDataValue('channelCount', 0);
record.setDataValue('attributeGroups', 0);
record.setDataValue('canDelete', true);
record.setDataValue('completeness', 100);
}
return record;
@@ -135,9 +69,27 @@ export class CatalogService {
// Find all records
const records = await repository.findAll({ where }, context);
// Attach counts
for (const record of records) {
await this.attachCounts(record);
const attributes = Array.isArray(record.attributes) ? record.attributes : [];
const variantAxes = Array.isArray(record.variantAxes) ? record.variantAxes : [];
const assetRequirements = Array.isArray(record.assetRequirements) ? record.assetRequirements : [];
const channels = Array.isArray(record.channels) ? record.channels : [];
let groupCount = 0;
if (record.attributeSet && Array.isArray(record.attributeSet.groups)) {
groupCount = record.attributeSet.groups.length;
} else {
groupCount = attributes.length > 0 ? 1 : 0;
}
record.setDataValue('productCount', 0);
record.setDataValue('attributeCount', attributes.length);
record.setDataValue('variantAxisCount', variantAxes.length);
record.setDataValue('assetRequirementCount', assetRequirements.length);
record.setDataValue('channelCount', channels.length);
record.setDataValue('attributeGroups', groupCount);
record.setDataValue('canDelete', true);
record.setDataValue('completeness', 100);
}
return records;
@@ -155,39 +107,34 @@ export class CatalogService {
async create(data, context = {}) {
const transaction = await sequelize.transaction();
try {
// 1. Autogenerate code if missing or resolve collisions
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `fam_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
let baseCode = data.name
? data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '')
: `fam_${Date.now()}`;
if (!baseCode) baseCode = `fam_${Date.now()}`;
// 1. Basic Code uniquely check
const existing = await repository.findByCode(data.code, { transaction }, context);
if (existing) {
throw new Error(`Product Family with code "${data.code}" already exists`);
let finalCode = baseCode;
let counter = 1;
while (true) {
const checkCode = counter === 1 ? baseCode : `${baseCode}_${counter}`;
const dup = await repository.findByCode(checkCode, { transaction }, context);
if (!dup) {
finalCode = checkCode;
break;
}
counter++;
}
data.code = finalCode;
} else {
data.code = data.code.toLowerCase().trim();
const existing = await repository.findByCode(data.code, { transaction }, context);
if (existing) {
const isDeleted = existing.deleted_at || existing.deletedAt;
throw new ApiError(400, `Product Family with code "${data.code}" already exists${isDeleted ? ' (archived)' : ''}`);
}
}
// 2. Category Validation
const categoryId = data.categoryId || data.category || null;
if (categoryId) {
const category = await models.Categorie.findOne({
where: { id: categoryId },
transaction
});
if (!category) {
throw new Error('Category not found');
}
if (category.status !== 'active') {
throw new Error('Category is inactive');
}
if (category.deleted_at || category.deletedAt) {
throw new Error('Category has been deleted');
}
}
// Resolve attributes from attribute set if provided
const attributeSetId = data.attributeSetId || data.attribute_set_id || null;
@@ -216,7 +163,7 @@ export class CatalogService {
}
}
}
data.attributes = [...new Set(inheritedAttributes)];
data.attributes = [...new Set([...inheritedAttributes, ...(Array.isArray(data.attributes) ? data.attributes : [])])];
}
}
@@ -330,7 +277,7 @@ export class CatalogService {
name: data.name,
description: data.description,
status: data.status || 'draft',
category_id: categoryId,
category_id: null,
workflow_code: workflowCode,
completeness_rules: completenessRules,
attribute_set_id: attributeSetId
@@ -391,6 +338,10 @@ export class CatalogService {
return fullRecord;
} catch (error) {
await transaction.rollback();
if (error.name === 'SequelizeUniqueConstraintError') {
const val = error.errors?.[0]?.value || data.code || 'specified';
throw new ApiError(400, `Product Family with code "${val}" already exists.`);
}
throw error;
}
}
@@ -408,26 +359,6 @@ export class CatalogService {
throw new Error('Family code is immutable and cannot be updated');
}
// 2. Category Validation
const categoryId = data.hasOwnProperty('categoryId')
? data.categoryId
: (data.hasOwnProperty('category') ? data.category : record.category_id);
if (categoryId) {
const category = await models.Categorie.findOne({
where: { id: categoryId },
transaction
});
if (!category) {
throw new Error('Category not found');
}
if (category.status !== 'active') {
throw new Error('Category is inactive');
}
if (category.deleted_at || category.deletedAt) {
throw new Error('Category has been deleted');
}
}
// Resolve attributes from attribute set if updated
const attributeSetId = data.hasOwnProperty('attributeSetId')
@@ -583,7 +514,6 @@ export class CatalogService {
name: data.name || record.name,
description: data.hasOwnProperty('description') ? data.description : record.description,
status: data.status || record.status,
category_id: categoryId,
workflow_code: workflowCode,
completeness_rules: completenessRules,
attribute_set_id: attributeSetId
@@ -773,18 +703,49 @@ export class CatalogService {
}
}
if (groups.length === 0 && Array.isArray(family.attributes) && family.attributes.length > 0) {
groups = [{
id: 'general-group',
name: 'General Attributes',
code: 'general_attributes',
attributes: family.attributes
}];
}
let workflow = null;
const wfCode = family.workflow_code || 'standard';
if (models.WorkflowRegistry) {
workflow = await models.WorkflowRegistry.findOne({
where: { code: wfCode },
include: [
{
model: models.WorkflowStage,
as: 'stages'
}
]
}).catch(() => null);
}
const completenessRules = family.completeness_rules || {};
return {
familyId: family.id,
code: family.code,
name: family.name,
description: family.description,
category: family.category,
attributeSet: family.attributeSet,
groups,
attributes: family.attributes || [],
variantAxes: family.variantAxes || [],
channels: (family.channels || []).map(c => c.channel_code),
variantEnabled: Array.isArray(family.variantAxes) && family.variantAxes.length > 0,
channels: (family.channels || []).map(c => c.channel_code || c),
assetRequirements: family.assetRequirements || [],
workflowCode: family.workflow_code,
completenessRules: family.completeness_rules || {}
workflowCode: wfCode,
workflow: workflow,
allowedBrands: completenessRules.allowedBrands || [],
allowedUnits: completenessRules.allowedUnits || [],
completenessRules: completenessRules
};
}
@@ -12,14 +12,6 @@ export const createValidation = [
.trim()
.notEmpty()
.withMessage('Name is required'),
body('categoryId')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Category ID must be a valid UUID'),
body('category')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Category ID must be a valid UUID'),
body('attributes')
.optional()
.isArray()
@@ -66,14 +58,6 @@ export const updateValidation = [
.trim()
.notEmpty()
.withMessage('Name cannot be empty'),
body('categoryId')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Category ID must be a valid UUID'),
body('category')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Category ID must be a valid UUID'),
body('attributes')
.optional()
.isArray()
+2
View File
@@ -4,5 +4,7 @@ import catalogsRouter from './catalogs/catalog.routes.js';
const router = Router();
router.use('/families', catalogsRouter);
router.use('/catalogs', catalogsRouter);
router.use('/product-families', catalogsRouter);
export default router;
@@ -109,12 +109,11 @@ export class CompletenessService {
requiredAssetTypes.forEach(at => uniqueRequiredAssetTypesMap.set(at.id, at));
const uniqueRequiredAssetTypes = Array.from(uniqueRequiredAssetTypesMap.values());
const totalCount = requiredAttributes.length + uniqueRequiredAssetTypes.length + requiredChannels.length;
let fulfilledCount = 0;
const missingAttributes = [];
const missingAssets = [];
const missingChannels = [];
const missingGeneral = [];
// 1. Validate required attributes
const filledAttrIds = (product.attributeValues || []).map(av => av.attribute_id);
@@ -146,32 +145,83 @@ export class CompletenessService {
}
}
// 4. Validate General required fields (Name, SKU, Brand, Unit, Category)
const requiredGeneral = [];
requiredGeneral.push({ code: 'name', name: 'Product Name', value: product.name });
requiredGeneral.push({ code: 'sku', name: 'Product SKU', value: product.metadata?.sku });
requiredGeneral.push({ code: 'category', name: 'Product Category', value: product.category_id });
const allowedBrands = family.completeness_rules?.allowedBrands || [];
if (allowedBrands.length > 0) {
requiredGeneral.push({ code: 'brand', name: 'Brand', value: product.brand_id });
}
const allowedUnits = family.completeness_rules?.allowedUnits || [];
if (allowedUnits.length > 0) {
requiredGeneral.push({ code: 'unit', name: 'Unit', value: product.unit_id });
}
for (const item of requiredGeneral) {
if (item.value !== undefined && item.value !== null && String(item.value).trim() !== '') {
fulfilledCount++;
} else {
missingGeneral.push({ code: item.code, name: item.name });
}
}
const totalCount = requiredAttributes.length + uniqueRequiredAssetTypes.length + requiredChannels.length + requiredGeneral.length;
const percentage = totalCount > 0 ? Math.round((fulfilledCount / totalCount) * 100) : 100;
// Upsert generic default completeness
await models.ProductCompleteness.upsert({
product_id: productId,
channel: 'default',
locale: 'en',
percentage,
missing_attributes: missingAttributes,
missing_assets: missingAssets,
missing_channels: missingChannels
}, { transaction });
// Upsert generic default completeness using find-and-create/update to respect composite unique constraint
const existingDefault = await models.ProductCompleteness.findOne({
where: { product_id: productId, channel: 'default', locale: 'en' },
transaction
});
if (existingDefault) {
await existingDefault.update({
percentage,
missing_attributes: missingAttributes,
missing_assets: missingAssets,
missing_channels: missingChannels
}, { transaction });
} else {
await models.ProductCompleteness.create({
product_id: productId,
channel: 'default',
locale: 'en',
percentage,
missing_attributes: missingAttributes,
missing_assets: missingAssets,
missing_channels: missingChannels
}, { transaction });
}
// Store per channel completeness
for (const ch of requiredChannels) {
const isChannelEnabled = enabledChannels.includes(ch.channel_code);
const chPercentage = isChannelEnabled ? percentage : 0;
await models.ProductCompleteness.upsert({
product_id: productId,
channel: ch.channel_code,
locale: 'en',
percentage: chPercentage,
missing_attributes: missingAttributes,
missing_assets: missingAssets,
missing_channels: isChannelEnabled ? [] : [{ code: ch.channel_code }]
}, { transaction });
const existingCh = await models.ProductCompleteness.findOne({
where: { product_id: productId, channel: ch.channel_code, locale: 'en' },
transaction
});
if (existingCh) {
await existingCh.update({
percentage: chPercentage,
missing_attributes: missingAttributes,
missing_assets: missingAssets,
missing_channels: isChannelEnabled ? [] : [{ code: ch.channel_code }]
}, { transaction });
} else {
await models.ProductCompleteness.create({
product_id: productId,
channel: ch.channel_code,
locale: 'en',
percentage: chPercentage,
missing_attributes: missingAttributes,
missing_assets: missingAssets,
missing_channels: isChannelEnabled ? [] : [{ code: ch.channel_code }]
}, { transaction });
}
}
// Broadcast completeness update
@@ -67,44 +67,116 @@ export class ProductRepository {
{
model: models.Catalog,
as: 'family',
required: false,
include: [
{
model: models.Attribute,
as: 'variantAxes'
as: 'attributes',
through: { attributes: [] },
required: false,
include: [
{
model: models.AttributeOption,
as: 'optionsList',
attributes: ['id', 'code', 'label', 'sort_order', 'status'],
required: false
}
]
},
{
model: models.Attribute,
as: 'variantAxes',
through: { attributes: [] },
required: false,
include: [
{
model: models.AttributeOption,
as: 'optionsList',
attributes: ['id', 'code', 'label', 'sort_order', 'status'],
required: false
}
]
},
{
model: models.AssetFamily,
as: 'assetRequirements',
through: { attributes: [] },
required: false
},
{
model: models.FamilyChannel,
as: 'channels',
required: false
},
{
model: models.Categorie,
as: 'category',
attributes: ['id', 'name']
attributes: ['id', 'name', 'code'],
required: false
},
{
model: models.AttributeSet,
as: 'attributeSet',
required: false,
include: [
{
model: models.AttributeGroup,
as: 'groups',
through: { attributes: [] },
required: false,
include: [
{
model: models.Attribute,
as: 'attributes',
through: { attributes: [] },
required: false,
include: [
{
model: models.AttributeOption,
as: 'optionsList',
attributes: ['id', 'code', 'label', 'sort_order', 'status'],
required: false
}
]
}
]
}
]
}
]
},
{
model: models.Categorie,
as: 'category',
attributes: ['id', 'name', 'code']
attributes: ['id', 'name', 'code'],
required: false
},
{
model: models.Brand,
as: 'brand',
attributes: ['id', 'name', 'code']
attributes: ['id', 'name', 'code'],
required: false
},
{
model: models.Unit,
as: 'unit',
attributes: ['id', 'name', 'symbol']
attributes: ['id', 'name', 'symbol'],
required: false
},
{
model: models.Variant,
as: 'variants',
required: false,
include: [
{
model: models.VariantValue,
as: 'values',
required: false,
include: [
{
model: models.Attribute,
as: 'axis'
as: 'axis',
required: false
}
]
}
@@ -113,28 +185,34 @@ export class ProductRepository {
{
model: models.ProductAttributeValue,
as: 'attributeValues',
required: false,
include: [
{
model: models.Attribute,
as: 'attribute'
as: 'attribute',
required: false
}
]
},
{
model: models.ProductCompleteness,
as: 'completenessEntries'
as: 'completenessEntries',
required: false
},
{
model: models.ProductAsset,
as: 'productAssets',
required: false,
include: [
{
model: models.Asset,
as: 'asset',
required: false,
include: [
{
model: models.AssetType,
as: 'assetType'
as: 'assetType',
required: false
}
]
}
+240 -63
View File
@@ -7,6 +7,58 @@ import NotificationService from '../../notifications/notifications/notification.
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import CompletenessService from './completeness.service.js';
export function formatProductResponse(json) {
if (!json) return json;
const defaultCompleteness = (json.completenessEntries || []).find(c => c.channel === 'default');
json.completeness = defaultCompleteness ? defaultCompleteness.percentage : 0;
if (json.metadata) {
const {
sku, price, stock, barcode, gtin, upc, ean, country, hsn, type, shortDesc, description, categories, attributes,
...restMetadata
} = json.metadata;
return {
...json,
family_id: json.family_id || json.familyId || (json.family ? json.family.id : null),
version: json.version || 1,
sku: sku !== undefined ? sku : '',
price: price !== undefined ? price : '',
stock: stock !== undefined ? stock : 0,
barcode: barcode !== undefined ? barcode : '',
gtin: gtin !== undefined ? gtin : '',
upc: upc !== undefined ? upc : '',
ean: ean !== undefined ? ean : '',
country: country !== undefined ? country : '',
hsn: hsn !== undefined ? hsn : '',
type: type !== undefined ? type : 'simple',
shortDesc: shortDesc !== undefined ? shortDesc : '',
description: description !== undefined ? description : '',
categories: Array.isArray(categories) ? categories : (json.category_id ? [json.category_id] : []),
attributes: attributes || {},
metadata: restMetadata
};
}
return {
...json,
sku: '',
price: '',
stock: 0,
barcode: '',
gtin: '',
upc: '',
ean: '',
country: '',
hsn: '',
type: 'simple',
shortDesc: '',
description: '',
categories: json.category_id ? [json.category_id] : []
};
}
async function validateAttributeValue(attr, val, productId = null, transaction = null) {
if (attr.is_required && (val === undefined || val === null || val === '')) {
throw new Error(`Attribute "${attr.name}" (${attr.code}) is required.`);
@@ -17,7 +69,10 @@ async function validateAttributeValue(attr, val, productId = null, transaction =
const strVal = String(val);
if (attr.is_unique) {
const SYSTEM_EXCLUDED_UNIQUE_CODES = ['brand', 'brand_id', 'category', 'category_id', 'unit', 'unit_id', 'status', 'type'];
const attrCodeLower = (attr.code || '').toLowerCase().trim();
if (attr.is_unique && !SYSTEM_EXCLUDED_UNIQUE_CODES.includes(attrCodeLower) && attr.type !== 'select' && attr.type !== 'multiselect' && attr.type !== 'boolean') {
const whereClause = {
attribute_id: attr.id,
value: strVal
@@ -208,8 +263,9 @@ export class ProductService {
where.brand_id = query.brandId;
}
if (query.search) {
const escapedSearch = query.search.replace(/'/g, "''");
where[Op.or] = [
{ sku: { [Op.iLike]: `%${query.search}%` } },
sequelize.literal(`products.metadata->>'sku' iLike '%${escapedSearch}%'`),
{ name: { [Op.iLike]: `%${query.search}%` } },
{ code: { [Op.iLike]: `%${query.search}%` } }
];
@@ -217,12 +273,10 @@ export class ProductService {
const records = await repository.findAll({ where }, context);
// Map completeness percentage to rows
// Map completeness percentage to rows and format response properties
const data = records.map(p => {
const json = p.toJSON ? p.toJSON() : { ...p };
const defaultCompleteness = (json.completenessEntries || []).find(c => c.channel === 'default');
json.completeness = defaultCompleteness ? defaultCompleteness.percentage : 0;
return json;
return formatProductResponse(json);
});
return data;
@@ -235,23 +289,22 @@ export class ProductService {
}
const json = record.toJSON();
const defaultCompleteness = (json.completenessEntries || []).find(c => c.channel === 'default');
json.completeness = defaultCompleteness ? defaultCompleteness.percentage : 0;
return json;
return formatProductResponse(json);
}
async create(data, context = {}) {
const transaction = await sequelize.transaction();
try {
// 1. SKU uniqueness validation
const [existing] = await repository.findAll({
where: { code: data.code || data.sku },
paranoid: false,
transaction
}, context);
if (existing) {
throw new Error(`Product SKU / Code "${data.code || data.sku}" already exists`);
// 1. SKU / Code uniqueness validation (if explicitly provided)
if (data.code || data.sku) {
const existing = await models.Product.findOne({
where: { code: data.code || data.sku },
paranoid: false,
transaction
});
if (existing) {
throw new Error(`Product SKU / Code "${data.code || data.sku}" already exists`);
}
}
// 2. Validate Family exists
@@ -302,24 +355,57 @@ export class ProductService {
}
}
// 3. Category Inheritance validation
// 3. Category Inheritance fallback
let categoryId = data.category_id || data.categoryId || data.category;
if (!categoryId) {
categoryId = family.category_id;
} else if (family.category_id && categoryId !== family.category_id) {
throw new Error(`Category mismatch: Product category must match Family category (expected: "${family.category_id}")`);
categoryId = family ? family.category_id : null;
}
// 4. Autogenerate code
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `prd_${Date.now()}`;
// 4. Autogenerate Product Code (Uppercase, hyphenated, unique)
let baseCode = (data.name || 'PRODUCT')
.toUpperCase()
.trim()
.replace(/[^A-Z0-9\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.substring(0, 45)
.replace(/-+$/, '');
if (!baseCode) baseCode = `PRD-${Date.now()}`;
let finalCode = baseCode;
let counter = 1;
while (counter <= 50) {
const checkCode = counter === 1 ? baseCode : `${baseCode}-${counter}`;
const dup = await models.Product.findOne({
where: { code: checkCode },
paranoid: false,
transaction
});
if (!dup) {
finalCode = checkCode;
break;
}
counter++;
}
data.code = data.code.toLowerCase().trim();
if (!finalCode || counter > 50) {
finalCode = `${baseCode}-${Date.now().toString().slice(-4)}`;
}
data.code = finalCode;
// 4b. Autogenerate Master SKU (FAMILY_PREFIX-PRODUCT_CODE-SEQUENCE)
let familyPrefix = (family.name || family.code || 'PRD')
.toUpperCase()
.replace(/[^A-Z0-9]/g, '')
.substring(0, 4);
if (!familyPrefix || familyPrefix.length < 2) familyPrefix = 'PRD';
const familyProductCount = await models.Product.count({
where: { family_id: family.id },
transaction
});
const runningSeq = String(familyProductCount + 1).padStart(5, '0');
const generatedSku = `${familyPrefix}-${finalCode}-${runningSeq}`;
// Setup default inherited channels and workflows
const metadata = data.metadata || {};
@@ -349,6 +435,21 @@ export class ProductService {
metadata.workflowName = workflowName;
metadata.currentStage = currentStage;
// Pack general form fields into metadata since they aren't core columns
metadata.sku = generatedSku;
metadata.price = data.price || '';
metadata.stock = data.stock !== undefined ? data.stock : 0;
metadata.barcode = data.barcode || '';
metadata.gtin = data.gtin || '';
metadata.upc = data.upc || '';
metadata.ean = data.ean || '';
metadata.country = data.country || '';
metadata.hsn = data.hsn || '';
metadata.type = data.type || 'simple';
metadata.shortDesc = data.shortDesc || '';
metadata.description = data.description || '';
metadata.categories = Array.isArray(data.categories) ? data.categories : (categoryId ? [categoryId] : []);
// 5. Create Core Product
const product = await repository.create({
code: data.code,
@@ -361,40 +462,69 @@ export class ProductService {
metadata: metadata
}, { transaction });
// 6. Save dynamic attributes from inherited Attribute Set blueprint
const familyAttributes = [];
if (family.attributeSet && family.attributeSet.groups) {
// 6. Save dynamic attributes from inherited Attribute Set blueprint or direct family attributes
const bodyAttributes = data.attributes || data.attributeValues || {};
metadata.attributes = bodyAttributes;
await product.update({ metadata }, { transaction });
const familyAttributesMap = new Map();
if (family.attributeSet && Array.isArray(family.attributeSet.groups)) {
for (const g of family.attributeSet.groups) {
if (g.attributes) {
if (Array.isArray(g.attributes)) {
for (const a of g.attributes) {
familyAttributes.push(a);
if (a && a.id) familyAttributesMap.set(a.id, a);
}
}
}
}
if (Array.isArray(family.attributes)) {
for (const a of family.attributes) {
if (a && a.id) familyAttributesMap.set(a.id, a);
}
}
const bodyAttributes = data.attributes || data.attributeValues || data;
for (const attr of familyAttributes) {
let val = bodyAttributes[attr.code];
const attrKeys = typeof bodyAttributes === 'object' && bodyAttributes !== null ? Object.keys(bodyAttributes) : [];
if (familyAttributesMap.size === 0 && attrKeys.length > 0) {
const dbAttrs = await models.Attribute.findAll({
where: {
[Op.or]: [
{ code: attrKeys },
{ id: attrKeys }
]
},
transaction
});
for (const a of dbAttrs) {
familyAttributesMap.set(a.id, a);
}
}
const uniqueFamilyAttributes = Array.from(familyAttributesMap.values());
for (const attr of uniqueFamilyAttributes) {
let val = bodyAttributes[attr.code] !== undefined ? bodyAttributes[attr.code] : bodyAttributes[attr.id];
if (val === undefined && bodyAttributes.metadata) {
val = bodyAttributes.metadata[attr.code];
val = bodyAttributes.metadata[attr.code] !== undefined ? bodyAttributes.metadata[attr.code] : bodyAttributes.metadata[attr.id];
}
await validateAttributeValue(attr, val, product.id, transaction);
if (val !== undefined && val !== null) {
await validateAttributeValue(attr, val, product.id, transaction);
await models.ProductAttributeValue.create({
product_id: product.id,
attribute_id: attr.id,
value: String(val),
value: typeof val === 'object' ? JSON.stringify(val) : String(val),
locale: 'en',
channel: 'default'
}, { transaction });
}
}
// 7. Calculate completeness
await this.calculateCompleteness(product.id, transaction);
// 7. Calculate completeness (safe)
try {
await this.calculateCompleteness(product.id, transaction);
} catch (compErr) {
console.error('Completeness calculation error during draft creation:', compErr);
}
await transaction.commit();
@@ -486,16 +616,17 @@ export class ProductService {
}
}
// Validate Category inheritance
let categoryId = data.category_id || data.categoryId;
// Category Inheritance fallback
let categoryId = data.category_id || data.categoryId || data.category;
if (categoryId === undefined) {
categoryId = record.category_id;
}
if (!categoryId) {
categoryId = family.category_id;
} else if (family.category_id && categoryId !== family.category_id) {
throw new Error(`Category mismatch: Product category must match Family category (expected: "${family.category_id}")`);
categoryId = family ? family.category_id : null;
}
// Ensure inherited channels are locked and cannot be removed
const metadata = data.metadata || record.metadata || {};
const metadata = { ...(record.metadata || {}), ...(data.metadata || {}) };
const familyChannels = (family.channels || []).map(c => c.channel_code);
let updatedChannels = metadata.channels || [];
// Combine existing with family channels so family channels cannot be deleted
@@ -512,6 +643,25 @@ export class ProductService {
metadata.currentStage = record.metadata?.currentStage || 'draft';
}
// Keep general fields inside metadata up to date
if (data.hasOwnProperty('sku')) metadata.sku = data.sku;
if (data.hasOwnProperty('price')) metadata.price = data.price;
if (data.hasOwnProperty('stock')) metadata.stock = data.stock;
if (data.hasOwnProperty('barcode')) metadata.barcode = data.barcode;
if (data.hasOwnProperty('gtin')) metadata.gtin = data.gtin;
if (data.hasOwnProperty('upc')) metadata.upc = data.upc;
if (data.hasOwnProperty('ean')) metadata.ean = data.ean;
if (data.hasOwnProperty('country')) metadata.country = data.country;
if (data.hasOwnProperty('hsn')) metadata.hsn = data.hsn;
if (data.hasOwnProperty('type')) metadata.type = data.type;
if (data.hasOwnProperty('shortDesc')) metadata.shortDesc = data.shortDesc;
if (data.hasOwnProperty('description')) metadata.description = data.description;
if (data.hasOwnProperty('categories')) {
metadata.categories = Array.isArray(data.categories) ? data.categories : [];
} else if (categoryId && (!metadata.categories || metadata.categories.length === 0)) {
metadata.categories = [categoryId];
}
// Update Core Product
await record.update({
name: data.name || record.name,
@@ -522,35 +672,62 @@ export class ProductService {
metadata: metadata
}, { transaction });
// Save dynamic attributes from inherited Attribute Set blueprint
const familyAttributes = [];
if (family.attributeSet && family.attributeSet.groups) {
// Save dynamic attributes from inherited Attribute Set blueprint or direct family attributes
const bodyAttributes = data.attributes || data.attributeValues || {};
if (data.hasOwnProperty('attributes') || data.hasOwnProperty('attributeValues')) {
metadata.attributes = { ...(metadata.attributes || {}), ...bodyAttributes };
await record.update({ metadata }, { transaction });
}
const familyAttributesMap = new Map();
if (family.attributeSet && Array.isArray(family.attributeSet.groups)) {
for (const g of family.attributeSet.groups) {
if (g.attributes) {
if (Array.isArray(g.attributes)) {
for (const a of g.attributes) {
familyAttributes.push(a);
if (a && a.id) familyAttributesMap.set(a.id, a);
}
}
}
}
if (Array.isArray(family.attributes)) {
for (const a of family.attributes) {
if (a && a.id) familyAttributesMap.set(a.id, a);
}
}
const bodyAttributes = data.attributes || data.attributeValues || data;
for (const attr of familyAttributes) {
let val = bodyAttributes[attr.code];
const attrKeys = typeof bodyAttributes === 'object' && bodyAttributes !== null ? Object.keys(bodyAttributes) : [];
if (familyAttributesMap.size === 0 && attrKeys.length > 0) {
const dbAttrs = await models.Attribute.findAll({
where: {
[Op.or]: [
{ code: attrKeys },
{ id: attrKeys }
]
},
transaction
});
for (const a of dbAttrs) {
familyAttributesMap.set(a.id, a);
}
}
const uniqueFamilyAttributes = Array.from(familyAttributesMap.values());
for (const attr of uniqueFamilyAttributes) {
let val = bodyAttributes[attr.code] !== undefined ? bodyAttributes[attr.code] : bodyAttributes[attr.id];
if (val === undefined && bodyAttributes.metadata) {
val = bodyAttributes.metadata[attr.code];
val = bodyAttributes.metadata[attr.code] !== undefined ? bodyAttributes.metadata[attr.code] : bodyAttributes.metadata[attr.id];
}
await validateAttributeValue(attr, val, id, transaction);
if (val !== undefined && val !== null) {
await validateAttributeValue(attr, val, id, transaction);
const [attrVal, created] = await models.ProductAttributeValue.findOrCreate({
where: { product_id: id, attribute_id: attr.id, locale: 'en', channel: 'default' },
defaults: { value: String(val) },
defaults: { value: typeof val === 'object' ? JSON.stringify(val) : String(val) },
transaction
});
if (!created) {
await attrVal.update({ value: String(val) }, { transaction });
await attrVal.update({ value: typeof val === 'object' ? JSON.stringify(val) : String(val) }, { transaction });
}
}
}
@@ -113,9 +113,6 @@ export class VariantService {
if (!parentProduct) {
throw new Error('Parent product not found');
}
if (parentProduct.status !== 'active') {
throw new Error(`Parent product is not active (current status: "${parentProduct.status}")`);
}
const family = parentProduct.family;
if (!family) {
+47 -25
View File
@@ -11,42 +11,64 @@ export class WorkflowService {
}
const options = { where, paranoid: false };
const records = await repository.findAll(options, context);
if (!records || records.length === 0) return [];
// Batch query all catalogs to avoid N+1 queries
const allCatalogs = models.Catalog ? await models.Catalog.findAll({
attributes: ['id', 'name', 'code', 'status', 'workflow_code'],
raw: true
}).catch(() => []) : [];
// Group catalogs by workflow_code
const catalogsByWfCode = {};
for (const cat of allCatalogs) {
const wfCode = cat.workflow_code || 'standard';
if (!catalogsByWfCode[wfCode]) {
catalogsByWfCode[wfCode] = [];
}
catalogsByWfCode[wfCode].push(cat);
}
// Batch query product counts per family
const productCountsByFamilyId = {};
if (allCatalogs.length > 0 && models.Product) {
try {
const pCounts = await models.Product.findAll({
attributes: ['family_id', [models.Product.sequelize.fn('COUNT', models.Product.sequelize.col('id')), 'count']],
group: ['family_id'],
raw: true
});
for (const pc of pCounts) {
productCountsByFamilyId[pc.family_id] = parseInt(pc.count, 10) || 0;
}
} catch (err) {
console.error('Error batch counting products for workflow:', err);
}
}
const results = [];
for (const record of records) {
const json = record.toJSON ? record.toJSON() : { ...record };
const families = await models.Catalog.findAll({
where: { workflow_code: json.code },
attributes: ['id', 'name', 'code', 'status']
});
const families = catalogsByWfCode[json.code] || [];
json.usedBy = families.map(f => f.name);
json.familyCount = families.length;
const familyIds = families.map(f => f.id);
let productCount = 0;
if (familyIds.length > 0) {
productCount = await models.Product.count({
where: { family_id: familyIds }
});
}
json.productCount = productCount;
const familiesDetails = [];
for (const fam of families) {
const famProdCount = await models.Product.count({
where: { family_id: fam.id }
});
familiesDetails.push({
let totalProdCount = 0;
const familiesDetails = families.map(fam => {
const pCount = productCountsByFamilyId[fam.id] || 0;
totalProdCount += pCount;
return {
name: fam.name,
code: fam.code,
status: fam.status,
productCount: famProdCount
});
}
productCount: pCount
};
});
json.productCount = totalProdCount;
json.familiesDetails = familiesDetails;
results.push(json);
}
return results;
+16
View File
@@ -0,0 +1,16 @@
import { models, sequelize, initializeDatabaseModels } from './shared/database/models.js';
async function test() {
try {
initializeDatabaseModels();
const attributes = await models.Attribute.findAll();
console.log('ATTRIBUTES IN DB:');
console.log(JSON.stringify(attributes.map(a => ({ id: a.id, code: a.code, name: a.name, type: a.type })), null, 2));
} catch (err) {
console.error(err);
} finally {
await sequelize.close();
}
}
test();
+28
View File
@@ -0,0 +1,28 @@
import { models, sequelize, initializeDatabaseModels } from './shared/database/models.js';
async function test() {
try {
initializeDatabaseModels();
const products = await models.Product.findAll({
include: [
{
model: models.ProductAttributeValue,
as: 'attributeValues'
}
]
});
console.log('PRODUCTS FOUND:', products.length);
if (products.length > 0) {
console.log('First product JSON:', JSON.stringify(products[0].toJSON(), null, 2));
}
} catch (err) {
console.error(err);
} finally {
await sequelize.close();
}
}
test();
+7 -2
View File
@@ -5,9 +5,14 @@ export const errorMiddleware = (err, req, res, next) => {
const status = err.statusCode || err.status || 500;
const message = err.message || 'Internal Server Error';
// Extract detailed error info for database and validation errors
const dbDetail = err.original ? `${err.original.message} - ${err.original.detail || ''}` : '';
const validationDetail = err.errors ? JSON.stringify(err.errors) : '';
const fullLogMessage = `${message} ${dbDetail ? `| DB: ${dbDetail}` : ''} ${validationDetail ? `| Validation: ${validationDetail}` : ''}`;
// Log the error using winston
logger.error({
message: err.message,
message: fullLogMessage,
stack: err.stack,
status,
path: req.originalUrl,
@@ -18,7 +23,7 @@ export const errorMiddleware = (err, req, res, next) => {
res.status(status).json({
success: false,
status,
message,
message: fullLogMessage,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
});
};
+8
View File
@@ -20,6 +20,14 @@ export const logger = winston.createLogger({
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
logFormat
),
}),
new winston.transports.File({
filename: 'error.log',
level: 'error',
format: combine(
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
logFormat
)
})
],
});
+44
View File
@@ -0,0 +1,44 @@
import { initializeDatabaseModels, sequelize } from './src/shared/database/models.js';
import { CatalogService } from './src/features/catalogs/catalogs/catalog.service.js';
async function run() {
try {
initializeDatabaseModels();
const service = new CatalogService();
const payload = {
name: `Furniture`,
code: `furniture`, // Already exists (soft-deleted)
description: 'test description',
status: 'draft',
category: 'd94f6141-cc9a-449c-99ec-057c7c410848',
attributeSetId: 'd6df7c5e-6e4a-4e1b-b5d5-a67c3ee4b197',
attributes: [],
variantAxes: [],
allowedBrands: [],
allowedUnits: [],
channels: [],
assetRequirements: [],
completenessRules: {
required_attributes: 40,
at_least_one_image: 20,
marketing_content: 15,
tech_specs: 15,
skus_assigned: 10
}
};
console.log('Attempting to create catalog with duplicate code "furniture"...');
await service.create(payload, { userId: 2, tenantId: null });
console.log('Success (unexpected)!');
} catch (err) {
console.log('EXPECTED ERROR CAUGHT:');
console.log('Status Code:', err.statusCode);
console.log('Message:', err.message);
} finally {
await sequelize.close();
}
}
run();
+66
View File
@@ -0,0 +1,66 @@
import http from 'http';
function request(path, token) {
return new Promise((resolve) => {
const start = Date.now();
const req = http.request({
hostname: 'localhost',
port: 5000,
path,
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
}, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
resolve({ path, status: res.statusCode, time: Date.now() - start, len: body.length });
});
});
req.on('error', (err) => resolve({ path, error: err.message, time: Date.now() - start }));
req.setTimeout(5000, () => {
req.destroy();
resolve({ path, error: 'TIMEOUT (5s)', time: Date.now() - start });
});
req.end();
});
}
async function run() {
const loginData = JSON.stringify({ email: 'superadmin@maskan.com', password: 'Admin@123' });
const loginRes = await new Promise(resolve => {
const req = http.request({
hostname: 'localhost', port: 5000, path: '/api/v1/auth/login', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(loginData) }
}, res => {
let body = '';
res.on('data', c => body += c);
res.on('end', () => resolve(JSON.parse(body)));
});
req.write(loginData);
req.end();
});
console.log('Login response:', JSON.stringify(loginRes));
const token = loginRes.data?.token || loginRes.token || loginRes.accessToken || loginRes.data?.accessToken;
console.log('Token:', token ? 'Acquired' : 'NOT FOUND');
const endpoints = [
'/api/v1/families',
'/api/v1/attributes',
'/api/v1/channels',
'/api/v1/asset-families',
'/api/v1/workflows',
'/api/v1/attribute-sets',
'/api/v1/brands',
'/api/v1/units',
'/api/v1/categories'
];
for (const ep of endpoints) {
const res = await request(ep, token);
console.log(`Endpoint: ${ep.padEnd(25)} Status: ${res.status || res.error} Time: ${res.time}ms`);
}
}
run();
+82
View File
@@ -0,0 +1,82 @@
import http from 'http';
function request(options, postData) {
return new Promise((resolve, reject) => {
const req = http.request(options, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
resolve({
statusCode: res.statusCode,
headers: res.headers,
body: body
});
});
});
req.on('error', reject);
if (postData) {
req.write(postData);
}
req.end();
});
}
async function run() {
try {
// 1. Login
const loginData = JSON.stringify({
email: 'superadmin@maskan.com',
password: 'Admin@123'
});
const loginRes = await request({
hostname: 'localhost',
port: 5000,
path: '/api/v1/auth/login',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(loginData)
}
}, loginData);
const loginJson = JSON.parse(loginRes.body);
const token = loginJson.data?.token || loginJson.token || loginJson.data?.accessToken;
// 2. Try to create family with valid relations
console.log('\nCreating family with valid relations...');
const familyData = JSON.stringify({
name: `Relation Test Family ${Date.now()}`,
code: `rel_test_${Date.now()}`,
description: 'test description',
status: 'draft',
category: 'd94f6141-cc9a-449c-99ec-057c7c410848',
attributes: ['af2e5e61-0b73-42d0-bcde-aa9b9fd5c03a'],
variantAxes: ['af2e5e61-0b73-42d0-bcde-aa9b9fd5c03a'],
channels: ['amazon'],
assetRequirements: ['dc18511e-a47b-4b03-8b01-d05adc9cd2a9'],
allowedBrands: ['4d19ccdb-8399-4d7e-ae40-1cee4d5865f3'],
allowedUnits: ['00bbc825-5cec-4d10-8986-64d97368b0e2']
});
const createRes = await request({
hostname: 'localhost',
port: 5000,
path: '/api/v1/families',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(familyData),
'Authorization': `Bearer ${token}`
}
}, familyData);
console.log('Response status:', createRes.statusCode);
console.log('Response body:', createRes.body);
} catch (error) {
console.error('Error running test:', error);
}
}
run();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB