upadted flow
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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 |
Reference in New Issue
Block a user