upadted flow

This commit is contained in:
m7338ohd-debug
2026-07-25 16:01:13 +05:30
parent b998025ead
commit 6f18030f88
16 changed files with 569 additions and 25 deletions
@@ -92,7 +92,7 @@ export class CatalogRepository {
{
model: models.Attribute,
as: 'variantAxes',
through: { attributes: [] },
through: { attributes: ['id', 'required', 'display_order', 'active'] },
required: false,
include: [
{
+111 -13
View File
@@ -101,6 +101,22 @@ export class CatalogService {
throw new ApiError(404, 'Product Family not found');
}
await this.attachCounts(record);
if (record.variantAxes && Array.isArray(record.variantAxes)) {
for (const axisAttr of record.variantAxes) {
const throughId = axisAttr.FamilyVariantAxis ? axisAttr.FamilyVariantAxis.id : null;
if (throughId) {
const suggestedVals = await models.FamilyVariantAxisValue.findAll({
where: { axis_id: throughId },
order: [['sort_order', 'ASC']]
});
axisAttr.setDataValue('suggestedValues', suggestedVals.map(v => v.value));
} else {
axisAttr.setDataValue('suggestedValues', []);
}
}
}
return record;
}
@@ -189,13 +205,15 @@ export class CatalogService {
// 4. Variant Strategy Validation & Deduplication
let variantAxes = [];
if (data.variantAxes && Array.isArray(data.variantAxes)) {
const uniqueAxes = [...new Set(data.variantAxes)];
const attributesSet = new Set(attributes);
const invalidAxes = uniqueAxes.filter(axis => !attributesSet.has(axis));
const invalidAxes = data.variantAxes.filter(axis => {
const attrId = typeof axis === 'string' ? axis : (axis.attributeId || axis.attribute_id || axis.id);
return !attrId || !attributesSet.has(attrId);
});
if (invalidAxes.length > 0) {
throw new Error('Variant axes must be a subset of family attributes');
}
variantAxes = uniqueAxes;
variantAxes = data.variantAxes;
}
// 5. Asset Family Validation & Deduplication
@@ -271,13 +289,23 @@ export class CatalogService {
throw new Error(`Total completeness rules weight must equal 100% (currently ${totalWeight}%)`);
}
// Category Resolution
const rawCategoryId = data.category || data.category_id || data.categoryId || null;
let categoryId = null;
if (rawCategoryId) {
const catObj = await models.Categorie.findByPk(rawCategoryId, { transaction });
if (catObj) {
categoryId = catObj.id;
}
}
// 9. Save Catalog
const createData = {
code: data.code,
name: data.name,
description: data.description,
status: data.status || 'draft',
category_id: null,
category_id: categoryId,
workflow_code: workflowCode,
completeness_rules: completenessRules,
attribute_set_id: attributeSetId
@@ -295,11 +323,32 @@ export class CatalogService {
}
// Save variant axis relationships
for (const attributeId of variantAxes) {
await models.FamilyVariantAxis.create({
for (let i = 0; i < variantAxes.length; i++) {
const item = variantAxes[i];
const attrId = typeof item === 'string' ? item : (item.attributeId || item.attribute_id || item.id);
const required = typeof item === 'object' && item.required !== undefined ? !!item.required : true;
const active = typeof item === 'object' && item.active !== undefined ? !!item.active : true;
const displayOrder = typeof item === 'object' && item.display_order !== undefined ? item.display_order : i;
const axisRecord = await models.FamilyVariantAxis.create({
family_id: record.id,
attribute_id: attributeId
attribute_id: attrId,
required,
display_order: displayOrder,
active
}, { transaction });
const suggestedValues = typeof item === 'object' ? (item.suggestedValues || item.suggested_values || []) : [];
for (let j = 0; j < suggestedValues.length; j++) {
const valItem = suggestedValues[j];
const valText = typeof valItem === 'string' ? valItem : valItem.value;
if (!valText) continue;
await models.FamilyVariantAxisValue.create({
axis_id: axisRecord.id,
value: valText,
sort_order: typeof valItem === 'object' && valItem.sort_order !== undefined ? valItem.sort_order : j
}, { transaction });
}
}
// Save asset requirements
@@ -420,14 +469,16 @@ export class CatalogService {
// 4. Variant Strategy Validation & Deduplication
let variantAxes = null;
if (data.variantAxes && Array.isArray(data.variantAxes)) {
const uniqueAxes = [...new Set(data.variantAxes)];
const targetAttributes = attributes || (await record.getAttributes({ transaction })).map(a => a.id);
const attributesSet = new Set(targetAttributes);
const invalidAxes = uniqueAxes.filter(axis => !attributesSet.has(axis));
const invalidAxes = data.variantAxes.filter(axis => {
const attrId = typeof axis === 'string' ? axis : (axis.attributeId || axis.attribute_id || axis.id);
return !attrId || !attributesSet.has(attrId);
});
if (invalidAxes.length > 0) {
throw new Error('Variant axes must be a subset of family attributes');
}
variantAxes = uniqueAxes;
variantAxes = data.variantAxes;
}
// 5. Asset Family Validation & Deduplication
@@ -509,6 +560,22 @@ export class CatalogService {
throw new Error(`Total completeness rules weight must equal 100% (currently ${totalWeight}%)`);
}
// Category Resolution for Update
const rawCategoryId = data.hasOwnProperty('category') ? data.category : (data.hasOwnProperty('category_id') ? data.category_id : (data.hasOwnProperty('categoryId') ? data.categoryId : undefined));
let categoryId = undefined;
if (rawCategoryId !== undefined) {
if (rawCategoryId) {
const catObj = await models.Categorie.findByPk(rawCategoryId, { transaction });
if (catObj) {
categoryId = catObj.id;
} else {
categoryId = null;
}
} else {
categoryId = null;
}
}
// 9. Update Catalog
const updateData = {
name: data.name || record.name,
@@ -518,6 +585,9 @@ export class CatalogService {
completeness_rules: completenessRules,
attribute_set_id: attributeSetId
};
if (categoryId !== undefined) {
updateData.category_id = categoryId;
}
await record.update(updateData, { transaction });
@@ -535,12 +605,40 @@ export class CatalogService {
}
if (variantAxes !== null) {
// Find existing axis records to clean up suggested values
const existingAxes = await models.FamilyVariantAxis.findAll({ where: { family_id: id }, transaction });
const existingAxisIds = existingAxes.map(a => a.id);
if (existingAxisIds.length > 0) {
await models.FamilyVariantAxisValue.destroy({ where: { axis_id: existingAxisIds }, transaction });
}
await models.FamilyVariantAxis.destroy({ where: { family_id: id }, transaction });
for (const attributeId of variantAxes) {
await models.FamilyVariantAxis.create({
for (let i = 0; i < variantAxes.length; i++) {
const item = variantAxes[i];
const attrId = typeof item === 'string' ? item : (item.attributeId || item.attribute_id || item.id);
const required = typeof item === 'object' && item.required !== undefined ? !!item.required : true;
const active = typeof item === 'object' && item.active !== undefined ? !!item.active : true;
const displayOrder = typeof item === 'object' && item.display_order !== undefined ? item.display_order : i;
const axisRecord = await models.FamilyVariantAxis.create({
family_id: id,
attribute_id: attributeId
attribute_id: attrId,
required,
display_order: displayOrder,
active
}, { transaction });
const suggestedValues = typeof item === 'object' ? (item.suggestedValues || item.suggested_values || []) : [];
for (let j = 0; j < suggestedValues.length; j++) {
const valItem = suggestedValues[j];
const valText = typeof valItem === 'string' ? valItem : valItem.value;
if (!valText) continue;
await models.FamilyVariantAxisValue.create({
axis_id: axisRecord.id,
value: valText,
sort_order: typeof valItem === 'object' && valItem.sort_order !== undefined ? valItem.sort_order : j
}, { transaction });
}
}
await AuditService.log({ action: 'VARIANT_AXES_CHANGED', resource: 'Catalog', resourceId: id, userId: context.userId || 'system', details: variantAxes });
}
@@ -39,7 +39,16 @@ export const createValidation = [
.isArray()
.withMessage('Allowed units must be an array')
.custom((value) => value.every(val => typeof val === 'string'))
.withMessage('Allowed units must be an array of unit IDs')
.withMessage('Allowed units must be an array of unit IDs'),
body('category')
.optional({ nullable: true })
.isString(),
body('category_id')
.optional({ nullable: true })
.isString(),
body('categoryId')
.optional({ nullable: true })
.isString()
];
export const updateValidation = [
@@ -85,7 +94,16 @@ export const updateValidation = [
.isArray()
.withMessage('Allowed units must be an array')
.custom((value) => value.every(val => typeof val === 'string'))
.withMessage('Allowed units must be an array of unit IDs')
.withMessage('Allowed units must be an array of unit IDs'),
body('category')
.optional({ nullable: true })
.isString(),
body('category_id')
.optional({ nullable: true })
.isString(),
body('categoryId')
.optional({ nullable: true })
.isString()
];
export const deleteValidation = [
@@ -1,20 +1,52 @@
import { Model, DataTypes } from 'sequelize';
export class FamilyVariantAxis extends Model {
static associate(models) {}
static associate(models) {
FamilyVariantAxis.belongsTo(models.Catalog, {
foreignKey: 'family_id',
as: 'family'
});
FamilyVariantAxis.belongsTo(models.Attribute, {
foreignKey: 'attribute_id',
as: 'attribute'
});
FamilyVariantAxis.hasMany(models.FamilyVariantAxisValue, {
foreignKey: 'axis_id',
as: 'suggestedValues'
});
}
}
export default (sequelize) => {
FamilyVariantAxis.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
family_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
allowNull: false
},
attribute_id: {
type: DataTypes.UUID,
allowNull: false
},
required: {
type: DataTypes.BOOLEAN,
allowNull: false,
primaryKey: true
defaultValue: true
},
display_order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
}
}, {
sequelize,
@@ -0,0 +1,42 @@
import { Model, DataTypes } from 'sequelize';
export class FamilyVariantAxisValue extends Model {
static associate(models) {
FamilyVariantAxisValue.belongsTo(models.FamilyVariantAxis, {
foreignKey: 'axis_id',
as: 'axis'
});
}
}
export default (sequelize) => {
FamilyVariantAxisValue.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
axis_id: {
type: DataTypes.UUID,
allowNull: false
},
value: {
type: DataTypes.TEXT,
allowNull: false
},
sort_order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
}
}, {
sequelize,
modelName: 'FamilyVariantAxisValue',
tableName: 'family_variant_axis_values',
timestamps: true,
underscored: true
});
return FamilyVariantAxisValue;
};
@@ -99,6 +99,39 @@ export class ProductController {
next(error);
}
}
getVariantValues = async (req, res, next) => {
try {
const data = await service.getVariantValues(req.params.id, req.context);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
saveVariantValues = async (req, res, next) => {
try {
const data = await service.saveVariantValues(req.params.id, req.body.values, req.context);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
generateVariants = async (req, res, next) => {
try {
const result = await service.generateVariants(req.params.id, req.body, req.context);
return res.status(200).json({
success: true,
variants: result.variants,
generatedCount: result.generatedCount,
warnings: result.warnings
});
} catch (error) {
next(error);
}
}
}
export default new ProductController();
@@ -171,4 +171,27 @@ router.post(
controller.restore
);
router.get(
'/:id/variant-values',
authenticate,
authorize(['products.items']),
controller.getVariantValues
);
router.post(
'/:id/variant-values',
authenticate,
authorize(['products.items']),
controller.saveVariantValues
);
router.post(
'/:id/generate-variants',
authenticate,
authorize(['products.items']),
audit('GENERATE_VARIANTS'),
controller.generateVariants
);
export default router;
@@ -850,6 +850,67 @@ export class ProductService {
return restored;
}
async getVariantValues(productId, context = {}) {
const values = await models.ProductVariantValue.findAll({
where: { product_id: productId },
include: [
{
model: models.Attribute,
as: 'axis',
attributes: ['id', 'code', 'name', 'type']
}
],
order: [['sort_order', 'ASC'], ['created_at', 'ASC']]
});
return values;
}
async saveVariantValues(productId, values = [], context = {}) {
const transaction = await sequelize.transaction();
try {
await models.ProductVariantValue.destroy({
where: { product_id: productId },
transaction
});
const records = [];
for (let i = 0; i < values.length; i++) {
const item = values[i];
if (!item.axis_id || !item.value) continue;
const created = await models.ProductVariantValue.create({
product_id: productId,
axis_id: item.axis_id,
value: item.value,
sort_order: item.sort_order !== undefined ? item.sort_order : i
}, { transaction });
records.push(created);
}
await transaction.commit();
return records;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async generateVariants(productId, data, context = {}) {
// Import variantService dynamically to avoid circular dependencies
const variantService = (await import('../../variants/variants/variant.service.js')).default;
const result = await variantService.generateBatch({
productId,
axes: data.axes,
skuTemplate: data.skuTemplate
}, context);
return {
variants: result.variants,
generatedCount: result.created ? result.created.length : 0,
warnings: result.skipped ? result.skipped.map(s => `Skipped combination ${JSON.stringify(s.combination)}: ${s.reason}`) : []
};
}
}
export default new ProductService();
@@ -0,0 +1,50 @@
import { Model, DataTypes } from 'sequelize';
export class ProductVariantValue extends Model {
static associate(models) {
ProductVariantValue.belongsTo(models.Product, {
foreignKey: 'product_id',
as: 'product'
});
ProductVariantValue.belongsTo(models.Attribute, {
foreignKey: 'axis_id',
as: 'axis'
});
}
}
export default (sequelize) => {
ProductVariantValue.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
product_id: {
type: DataTypes.UUID,
allowNull: false
},
axis_id: {
type: DataTypes.UUID,
allowNull: false
},
value: {
type: DataTypes.TEXT,
allowNull: false
},
sort_order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
}
}, {
sequelize,
modelName: 'ProductVariantValue',
tableName: 'product_variant_values',
timestamps: true,
underscored: true
});
return ProductVariantValue;
};
@@ -0,0 +1,169 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
// 1. Extend or recreate family_variant_axes to ensure id, required, display_order, active exist
const familyAxesExists = await queryInterface.describeTable('family_variant_axes').catch(() => null);
if (familyAxesExists) {
if (!familyAxesExists.id) {
await queryInterface.addColumn('family_variant_axes', 'id', {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
allowNull: true
});
}
if (!familyAxesExists.required) {
await queryInterface.addColumn('family_variant_axes', 'required', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
});
}
if (!familyAxesExists.display_order) {
await queryInterface.addColumn('family_variant_axes', 'display_order', {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
});
}
if (!familyAxesExists.active) {
await queryInterface.addColumn('family_variant_axes', 'active', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
});
}
} else {
await queryInterface.createTable('family_variant_axes', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false
},
family_id: {
type: Sequelize.UUID,
allowNull: false,
references: { model: 'catalogs', key: 'id' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
attribute_id: {
type: Sequelize.UUID,
allowNull: false,
references: { model: 'attributes', key: 'id' },
onUpdate: 'CASCADE',
onDelete: 'RESTRICT'
},
required: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
},
display_order: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
active: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
},
created_at: {
type: Sequelize.DATE,
allowNull: false
},
updated_at: {
type: Sequelize.DATE,
allowNull: false
}
});
}
// 2. Create family_variant_axis_values table
const familyAxisValuesExists = await queryInterface.describeTable('family_variant_axis_values').catch(() => null);
if (!familyAxisValuesExists) {
await queryInterface.createTable('family_variant_axis_values', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false
},
axis_id: {
type: Sequelize.UUID,
allowNull: false
},
value: {
type: Sequelize.TEXT,
allowNull: false
},
sort_order: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
created_at: {
type: Sequelize.DATE,
allowNull: false
},
updated_at: {
type: Sequelize.DATE,
allowNull: false
}
});
await queryInterface.addIndex('family_variant_axis_values', ['axis_id'], { name: 'idx_fam_var_axis_val_axis' }).catch(() => null);
}
// 3. Create product_variant_values table
const productVariantValuesExists = await queryInterface.describeTable('product_variant_values').catch(() => null);
if (!productVariantValuesExists) {
await queryInterface.createTable('product_variant_values', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false
},
product_id: {
type: Sequelize.UUID,
allowNull: false,
references: { model: 'products', key: 'id' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
axis_id: {
type: Sequelize.UUID,
allowNull: false,
references: { model: 'attributes', key: 'id' },
onUpdate: 'CASCADE',
onDelete: 'RESTRICT'
},
value: {
type: Sequelize.TEXT,
allowNull: false
},
sort_order: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
created_at: {
type: Sequelize.DATE,
allowNull: false
},
updated_at: {
type: Sequelize.DATE,
allowNull: false
}
});
await queryInterface.addIndex('product_variant_values', ['product_id'], { name: 'idx_prod_var_val_prod' }).catch(() => null);
}
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('product_variant_values').catch(() => null);
await queryInterface.dropTable('family_variant_axis_values').catch(() => null);
}
};
+9 -3
View File
@@ -18,11 +18,17 @@ const storage = multer.diskStorage({
});
export const fileFilter = (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf', 'text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'];
if (allowedTypes.includes(file.mimetype)) {
const allowedTypes = [
'image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml', 'image/avif', 'image/bmp',
'video/mp4', 'video/webm', 'video/quicktime',
'application/pdf', 'text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
];
if (allowedTypes.includes(file.mimetype) || file.mimetype.startsWith('image/') || file.mimetype.startsWith('video/')) {
cb(null, true);
} else {
cb(new Error('Invalid file type'), false);
cb(new Error(`Invalid file type: ${file.mimetype}`), false);
}
};
+5
View File
@@ -144,6 +144,7 @@ import attributeSetGroupModelInit from '../../features/attributes/attributeSets/
// Product Family Bridge Models
import familyAttributeModelInit from '../../features/catalogs/catalogs/familyAttribute.model.js';
import familyVariantAxisModelInit from '../../features/catalogs/catalogs/familyVariantAxis.model.js';
import familyVariantAxisValueModelInit from '../../features/catalogs/catalogs/familyVariantAxisValue.model.js';
import familyAssetRequirementModelInit from '../../features/catalogs/catalogs/familyAssetRequirement.model.js';
import familyChannelModelInit from '../../features/catalogs/catalogs/familyChannel.model.js';
@@ -173,6 +174,7 @@ import channelModelInit from '../../features/channels/channels/channel.model.js'
import channelTypeModelInit from '../../features/channels/channelTypes/channelType.model.js';
import workflowModelInit from '../../features/workflows/workflow.model.js';
import productAttributeValueModelInit from '../../features/products/products/productAttributeValue.model.js';
import productVariantValueModelInit from '../../features/products/products/productVariantValue.model.js';
import productCompletenessModelInit from '../../features/products/products/productCompleteness.model.js';
export const initializeDatabaseModels = () => {
@@ -206,6 +208,7 @@ export const initializeDatabaseModels = () => {
// Product Family Bridges
registerModel('FamilyAttribute', familyAttributeModelInit);
registerModel('FamilyVariantAxis', familyVariantAxisModelInit);
registerModel('FamilyVariantAxisValue', familyVariantAxisValueModelInit);
registerModel('FamilyAssetRequirement', familyAssetRequirementModelInit);
registerModel('FamilyChannel', familyChannelModelInit);
@@ -214,6 +217,7 @@ export const initializeDatabaseModels = () => {
registerModel('ChannelType', channelTypeModelInit);
registerModel('WorkflowRegistry', workflowModelInit);
registerModel('ProductAttributeValue', productAttributeValueModelInit);
registerModel('ProductVariantValue', productVariantValueModelInit);
registerModel('ProductCompleteness', productCompletenessModelInit);
// Variants
@@ -241,3 +245,4 @@ export const initializeDatabaseModels = () => {
};
export { models, sequelize };