Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa9f02cd1d | ||
|
|
cf7b1e7d59 |
@@ -50,14 +50,9 @@ export class BrandRepository {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
paranoid: false,
|
||||
where: {
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ id, ...(options.where || {}) }, context)
|
||||
};
|
||||
const record = await models.Brand.findByPk(id, queryOptions);
|
||||
const record = await models.Brand.findOne(queryOptions);
|
||||
if (!record) return null;
|
||||
await record.restore();
|
||||
return record;
|
||||
|
||||
@@ -50,14 +50,9 @@ export class UnitRepository {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
paranoid: false,
|
||||
where: {
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ id, ...(options.where || {}) }, context)
|
||||
};
|
||||
const record = await models.Unit.findByPk(id, queryOptions);
|
||||
const record = await models.Unit.findOne(queryOptions);
|
||||
if (!record) return null;
|
||||
await record.restore();
|
||||
return record;
|
||||
|
||||
@@ -25,17 +25,9 @@ export class CatalogRepository {
|
||||
{
|
||||
model: models.Attribute,
|
||||
as: 'variantAxes',
|
||||
attributes: ['id', 'code', 'name', 'type', 'options'],
|
||||
attributes: ['id', 'code', 'name'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.AttributeOption,
|
||||
as: 'optionsList',
|
||||
attributes: ['id', 'code', 'label', 'sort_order'],
|
||||
required: false
|
||||
}
|
||||
]
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: models.AssetFamily,
|
||||
@@ -153,23 +145,15 @@ export class CatalogRepository {
|
||||
const queryOptions = {
|
||||
paranoid: false, // Check both active and soft-deleted records for uniqueness checks
|
||||
...options,
|
||||
where: {
|
||||
code,
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ code, ...(options.where || {}) }, context)
|
||||
};
|
||||
return await models.Catalog.findOne(queryOptions);
|
||||
}
|
||||
|
||||
async create(data, options = {}, context = {}) {
|
||||
const createData = {
|
||||
...data
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
...data,
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
};
|
||||
return await models.Catalog.create(createData, options);
|
||||
}
|
||||
@@ -198,14 +182,9 @@ export class CatalogRepository {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
paranoid: false,
|
||||
where: {
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ id, ...(options.where || {}) }, context)
|
||||
};
|
||||
const record = await models.Catalog.findByPk(id, queryOptions);
|
||||
const record = await models.Catalog.findOne(queryOptions);
|
||||
if (!record) return null;
|
||||
await record.restore();
|
||||
return record;
|
||||
|
||||
@@ -47,13 +47,7 @@ export class CategorieRepository {
|
||||
async findByCode(code, options = {}, context = {}) {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
where: {
|
||||
code,
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ code, ...(options.where || {}) }, context)
|
||||
};
|
||||
return await models.Categorie.findOne(queryOptions);
|
||||
}
|
||||
@@ -61,23 +55,15 @@ export class CategorieRepository {
|
||||
async findChildren(parentId, options = {}, context = {}) {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
where: {
|
||||
parent_id: parentId,
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ parent_id: parentId, ...(options.where || {}) }, context)
|
||||
};
|
||||
return await models.Categorie.findAll(queryOptions);
|
||||
}
|
||||
|
||||
async create(data, options = {}, context = {}) {
|
||||
const createData = {
|
||||
...data
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
...data,
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
};
|
||||
return await models.Categorie.create(createData, options);
|
||||
}
|
||||
@@ -106,14 +92,9 @@ export class CategorieRepository {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
paranoid: false,
|
||||
where: {
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ id, ...(options.where || {}) }, context)
|
||||
};
|
||||
const record = await models.Categorie.findByPk(id, queryOptions);
|
||||
const record = await models.Categorie.findOne(queryOptions);
|
||||
if (!record) return null;
|
||||
await record.restore();
|
||||
return record;
|
||||
|
||||
@@ -37,6 +37,10 @@ export default (sequelize) => {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
is_variant_eligible: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
category: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'other'
|
||||
|
||||
@@ -32,6 +32,7 @@ export class AssetTypeService {
|
||||
description: data.description || null,
|
||||
status: data.status || 'active',
|
||||
is_required: data.is_required !== undefined ? Boolean(data.is_required) : (data.isRequired !== undefined ? Boolean(data.isRequired) : false),
|
||||
is_variant_eligible: data.is_variant_eligible !== undefined ? Boolean(data.is_variant_eligible) : (data.isVariantEligible !== undefined ? Boolean(data.isVariantEligible) : false),
|
||||
category: data.category || 'other',
|
||||
validation: data.validation || {}
|
||||
};
|
||||
@@ -72,6 +73,9 @@ export class AssetTypeService {
|
||||
if (data.isRequired !== undefined && data.is_required === undefined) {
|
||||
payload.is_required = Boolean(data.isRequired);
|
||||
}
|
||||
if (data.isVariantEligible !== undefined && data.is_variant_eligible === undefined) {
|
||||
payload.is_variant_eligible = Boolean(data.isVariantEligible);
|
||||
}
|
||||
|
||||
const updatedRecord = await repository.update(id, payload);
|
||||
|
||||
|
||||
@@ -45,6 +45,23 @@ export class CompletenessService {
|
||||
as: 'attributeValues',
|
||||
include: [{ model: models.Attribute, as: 'attribute' }]
|
||||
},
|
||||
{
|
||||
model: models.Variant,
|
||||
as: 'variants',
|
||||
include: [
|
||||
{
|
||||
model: models.VariantAsset,
|
||||
as: 'variantAssets',
|
||||
include: [
|
||||
{
|
||||
model: models.Asset,
|
||||
as: 'asset',
|
||||
include: [{ model: models.AssetType, as: 'assetType' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: models.ProductAsset,
|
||||
as: 'productAssets',
|
||||
@@ -165,8 +182,11 @@ export class CompletenessService {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Validate required assets
|
||||
const assignedAssetTypeIds = (product.productAssets || []).map(pa => pa.asset?.assetType?.id).filter(Boolean);
|
||||
// 2. Validate required assets (check product assets AND variant assets)
|
||||
const productAssetTypeIds = (product.productAssets || []).map(pa => pa.asset?.assetType?.id).filter(Boolean);
|
||||
const variantAssetTypeIds = (product.variants || []).flatMap(v => (v.variantAssets || []).map(va => va.asset?.assetType?.id)).filter(Boolean);
|
||||
const assignedAssetTypeIds = Array.from(new Set([...productAssetTypeIds, ...variantAssetTypeIds]));
|
||||
|
||||
for (const assetType of uniqueRequiredAssetTypes) {
|
||||
if (assignedAssetTypeIds.includes(assetType.id)) {
|
||||
fulfilledCount++;
|
||||
@@ -175,8 +195,11 @@ export class CompletenessService {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Validate required channels
|
||||
const enabledChannels = product.metadata?.channels || [];
|
||||
// 3. Validate required channels (check family channels AND user-selected metadata channels)
|
||||
const familyChannels = product.family ? (product.family.channels || []).map(c => c.channel_code) : [];
|
||||
const metadataChannels = Array.isArray(product.metadata?.channels) ? product.metadata.channels : [];
|
||||
const enabledChannels = Array.from(new Set([...familyChannels, ...metadataChannels]));
|
||||
|
||||
for (const ch of requiredChannels) {
|
||||
if (enabledChannels.includes(ch.channel_code)) {
|
||||
fulfilledCount++;
|
||||
@@ -185,9 +208,11 @@ export class CompletenessService {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Validate General required fields (Name, Brand, Unit, Category)
|
||||
// 4. Validate General required fields (Name, SKU, Brand, Unit, Category)
|
||||
const requiredGeneral = [];
|
||||
const skuVal = product.product_type === 'simple' && product.variants?.length > 0 ? product.variants[0].sku : (product.product_type === 'variable' ? 'CONFIGURABLE' : '');
|
||||
requiredGeneral.push({ code: 'name', name: 'Product Name', value: product.name });
|
||||
requiredGeneral.push({ code: 'sku', name: 'Product SKU', value: skuVal });
|
||||
requiredGeneral.push({ code: 'category', name: 'Product Category', value: product.category_id });
|
||||
requiredGeneral.push({ code: 'brand', name: 'Brand', value: product.brand_id });
|
||||
requiredGeneral.push({ code: 'unit', name: 'Unit', value: product.unit_id });
|
||||
@@ -200,8 +225,48 @@ export class CompletenessService {
|
||||
}
|
||||
}
|
||||
|
||||
const totalCount = evaluatedAttributes.length + uniqueRequiredAssetTypes.length + requiredChannels.length + requiredGeneral.length;
|
||||
const percentage = totalCount > 0 ? Math.round((fulfilledCount / totalCount) * 100) : 100;
|
||||
let variantsTotalChecks = 0;
|
||||
let variantsFulfilledChecks = 0;
|
||||
if (product.variants && product.variants.length > 0) {
|
||||
for (const variant of product.variants) {
|
||||
// Check SKU
|
||||
variantsTotalChecks++;
|
||||
if (variant.sku && variant.sku.trim() !== '') {
|
||||
variantsFulfilledChecks++;
|
||||
} else {
|
||||
missingGeneral.push({ code: `variant_${variant.id}_sku`, name: `Variant "${variant.name.split(' - ')[1] || variant.name}" SKU` });
|
||||
}
|
||||
|
||||
// Check Price
|
||||
variantsTotalChecks++;
|
||||
if (variant.price !== undefined && variant.price !== null && Number(variant.price) > 0) {
|
||||
variantsFulfilledChecks++;
|
||||
} else {
|
||||
missingGeneral.push({ code: `variant_${variant.id}_price`, name: `Variant "${variant.name.split(' - ')[1] || variant.name}" Price` });
|
||||
}
|
||||
|
||||
// Check assets: if there are any variant-eligible asset types, they must be assigned to this variant!
|
||||
const vAssets = await models.VariantAsset.findAll({
|
||||
where: { variant_id: variant.id },
|
||||
include: [{ model: models.Asset, as: 'asset' }],
|
||||
transaction
|
||||
});
|
||||
|
||||
const requiredVariantAssetTypes = uniqueRequiredAssetTypes.filter(at => at.is_variant_eligible);
|
||||
for (const at of requiredVariantAssetTypes) {
|
||||
variantsTotalChecks++;
|
||||
const hasAsset = vAssets.some(va => va.asset && va.asset.asset_type_id === at.id);
|
||||
if (hasAsset) {
|
||||
variantsFulfilledChecks++;
|
||||
} else {
|
||||
missingGeneral.push({ code: `variant_${variant.id}_asset_${at.code}`, name: `Variant "${variant.name.split(' - ')[1] || variant.name}" ${at.name}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalCount = evaluatedAttributes.length + uniqueRequiredAssetTypes.length + requiredChannels.length + requiredGeneral.length + variantsTotalChecks;
|
||||
const percentage = totalCount > 0 ? Math.round(((fulfilledCount + variantsFulfilledChecks) / totalCount) * 100) : 100;
|
||||
|
||||
// Upsert generic default completeness
|
||||
const existingDefault = await models.ProductCompleteness.findOne({
|
||||
|
||||
@@ -82,6 +82,15 @@ export class ProductController {
|
||||
}
|
||||
}
|
||||
|
||||
bulkAssignAsset = async (req, res, next) => {
|
||||
try {
|
||||
const data = await service.bulkAssignAsset(req.params.id, req.body, req.context);
|
||||
return res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
updateAssetMapping = async (req, res, next) => {
|
||||
try {
|
||||
const data = await service.updateAssetMapping(req.params.id, req.params.assetId, req.body, req.context);
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Model, DataTypes } from 'sequelize';
|
||||
|
||||
export class Product extends Model {
|
||||
static associate(models) {
|
||||
Product.belongsTo(models.Tenant, {
|
||||
foreignKey: 'tenant_id',
|
||||
as: 'tenant'
|
||||
});
|
||||
Product.belongsTo(models.Catalog, {
|
||||
foreignKey: 'family_id',
|
||||
as: 'family'
|
||||
@@ -43,15 +47,31 @@ export default (sequelize) => {
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
allowNull: false
|
||||
},
|
||||
code: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false
|
||||
},
|
||||
product_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
unique: true
|
||||
defaultValue: 'simple'
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
short_description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
slug: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
@@ -59,6 +79,36 @@ export default (sequelize) => {
|
||||
allowNull: false,
|
||||
defaultValue: 'draft'
|
||||
},
|
||||
meta_title: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
meta_description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
meta_keywords: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
completeness_score: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0.00
|
||||
},
|
||||
is_active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
},
|
||||
approved_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
approved_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
family_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
@@ -75,9 +125,26 @@ export default (sequelize) => {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSONB,
|
||||
manufacturer_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
hsn_code_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
tax_class_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
workflow_state_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
defaultValue: {}
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
|
||||
@@ -172,6 +172,25 @@ export class ProductRepository {
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: models.VariantAsset,
|
||||
as: 'variantAssets',
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.Asset,
|
||||
as: 'asset',
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.AssetType,
|
||||
as: 'assetType',
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -218,10 +237,8 @@ export class ProductRepository {
|
||||
|
||||
async create(data, options = {}, context = {}) {
|
||||
const createData = {
|
||||
...data
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
...data,
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
};
|
||||
return await models.Product.create(createData, options);
|
||||
}
|
||||
@@ -251,10 +268,8 @@ export class ProductRepository {
|
||||
...options,
|
||||
paranoid: false,
|
||||
where: {
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
...(options.where || {}),
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
};
|
||||
const record = await models.Product.findByPk(id, queryOptions);
|
||||
|
||||
@@ -140,6 +140,13 @@ router.post(
|
||||
controller.assignAsset
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/assets/bulk-assign',
|
||||
authenticate,
|
||||
authorize(['products.items']),
|
||||
controller.bulkAssignAsset
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:id/assets/:assetId',
|
||||
authenticate,
|
||||
|
||||
@@ -14,47 +14,88 @@ export function formatProductResponse(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, 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',
|
||||
description: description !== undefined ? description : '',
|
||||
categories: Array.isArray(categories) ? categories : (json.category_id ? [json.category_id] : []),
|
||||
attributes: attributes || {},
|
||||
metadata: restMetadata
|
||||
};
|
||||
}
|
||||
// Extract master variant details for simple products
|
||||
let sku = '';
|
||||
let barcode = '';
|
||||
let price = '';
|
||||
let costPrice = '';
|
||||
let currency = 'USD';
|
||||
|
||||
const isSimple = json.product_type === 'simple';
|
||||
if (isSimple && Array.isArray(json.variants) && json.variants.length > 0) {
|
||||
const master = json.variants[0];
|
||||
sku = master.sku || '';
|
||||
barcode = master.barcode || '';
|
||||
price = master.price !== undefined && master.price !== null ? String(master.price) : '';
|
||||
costPrice = master.cost_price !== undefined && master.cost_price !== null ? String(master.cost_price) : '';
|
||||
currency = master.currency || 'USD';
|
||||
}
|
||||
|
||||
// Extract dynamic EAV attribute values
|
||||
const attributes = {};
|
||||
if (Array.isArray(json.attributeValues)) {
|
||||
for (const av of json.attributeValues) {
|
||||
const code = av.attribute ? av.attribute.code : (av.attribute_code || '');
|
||||
if (code) {
|
||||
attributes[code] = av.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format variants and extract images from variantAssets
|
||||
let formattedVariants = [];
|
||||
if (Array.isArray(json.variants)) {
|
||||
formattedVariants = json.variants.map(v => {
|
||||
const vRaw = v.toJSON ? v.toJSON() : v;
|
||||
const images = [];
|
||||
if (Array.isArray(vRaw.variantAssets)) {
|
||||
vRaw.variantAssets
|
||||
.filter(va => va.asset)
|
||||
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0))
|
||||
.forEach((va, idx) => {
|
||||
images.push({
|
||||
assetId: va.asset_id,
|
||||
url: va.asset.file_url || va.asset.url || null,
|
||||
thumbnailUrl: va.asset.thumbnail_url || va.asset.file_url || va.asset.url || null,
|
||||
name: va.asset.name || va.asset.original_name || `Asset ${idx + 1}`,
|
||||
role: va.role || (idx === 0 ? 'primary' : 'gallery'),
|
||||
isPrimary: va.is_primary || idx === 0,
|
||||
displayOrder: va.display_order || idx,
|
||||
assetType: va.asset.assetType ? {
|
||||
id: va.asset.assetType.id,
|
||||
code: va.asset.assetType.code,
|
||||
name: va.asset.assetType.name
|
||||
} : null
|
||||
});
|
||||
});
|
||||
}
|
||||
return {
|
||||
...vRaw,
|
||||
images
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Map backend 'variable' type back to frontend 'variant'
|
||||
const type = json.product_type === 'variable' ? 'variant' : 'simple';
|
||||
|
||||
return {
|
||||
...json,
|
||||
sku: '',
|
||||
price: '',
|
||||
stock: 0,
|
||||
barcode: '',
|
||||
gtin: '',
|
||||
upc: '',
|
||||
ean: '',
|
||||
country: '',
|
||||
hsn: '',
|
||||
type: 'simple',
|
||||
description: '',
|
||||
categories: json.category_id ? [json.category_id] : []
|
||||
variants: formattedVariants,
|
||||
family_id: json.family_id || json.familyId || (json.family ? json.family.id : null),
|
||||
version: json.version || 1,
|
||||
sku,
|
||||
price,
|
||||
costPrice,
|
||||
currency,
|
||||
stock: 0, // stock is managed by Inventory service, default to 0 for PIM static response
|
||||
barcode,
|
||||
type,
|
||||
description: json.description || '',
|
||||
short_description: json.short_description || '',
|
||||
slug: json.slug || '',
|
||||
categories: json.category_id ? [json.category_id] : [],
|
||||
attributes
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,6 +234,61 @@ export class ProductService {
|
||||
}
|
||||
}
|
||||
|
||||
async bulkAssignAsset(productId, data, userContext = {}) {
|
||||
const { asset_id, role, variant_ids, is_primary } = data;
|
||||
if (!asset_id) throw new Error('asset_id is required');
|
||||
if (!variant_ids || !Array.isArray(variant_ids) || variant_ids.length === 0) {
|
||||
throw new Error('variant_ids must be a non-empty array');
|
||||
}
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const product = await repository.findById(productId, { transaction });
|
||||
if (!product) throw new Error('Product not found');
|
||||
|
||||
const asset = await models.Asset.findByPk(asset_id, { transaction });
|
||||
if (!asset) throw new Error('Asset not found');
|
||||
|
||||
const results = [];
|
||||
for (const variantId of variant_ids) {
|
||||
if (is_primary) {
|
||||
await models.VariantAsset.update(
|
||||
{ is_primary: false },
|
||||
{ where: { variant_id: variantId }, transaction }
|
||||
);
|
||||
}
|
||||
|
||||
const [mapping, created] = await models.VariantAsset.findOrCreate({
|
||||
where: { variant_id: variantId, asset_id },
|
||||
defaults: {
|
||||
role: role || 'gallery_image',
|
||||
display_order: 0,
|
||||
is_primary: !!is_primary
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
await mapping.update({
|
||||
role: role || mapping.role,
|
||||
is_primary: is_primary !== undefined ? !!is_primary : mapping.is_primary
|
||||
}, { transaction });
|
||||
}
|
||||
results.push(mapping);
|
||||
}
|
||||
|
||||
await this.calculateCompleteness(productId, transaction);
|
||||
await transaction.commit();
|
||||
|
||||
SocketService.broadcast('product.updated', { id: productId });
|
||||
SocketService.broadcast('variants.updated', { productId });
|
||||
return results;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateAssetMapping(productId, assetId, data, userContext = {}) {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
@@ -264,9 +360,9 @@ export class ProductService {
|
||||
if (query.search) {
|
||||
const escapedSearch = query.search.replace(/'/g, "''");
|
||||
where[Op.or] = [
|
||||
sequelize.literal(`products.metadata->>'sku' iLike '%${escapedSearch}%'`),
|
||||
{ name: { [Op.iLike]: `%${query.search}%` } },
|
||||
{ code: { [Op.iLike]: `%${query.search}%` } }
|
||||
{ code: { [Op.iLike]: `%${query.search}%` } },
|
||||
sequelize.literal(`EXISTS (SELECT 1 FROM product_variants WHERE product_variants.product_id = "Product".id AND product_variants.sku iLike '%${escapedSearch}%')`)
|
||||
];
|
||||
}
|
||||
|
||||
@@ -410,72 +506,47 @@ export class ProductService {
|
||||
}
|
||||
data.code = finalCode;
|
||||
|
||||
// Setup default inherited channels and workflows
|
||||
const metadata = data.metadata || {};
|
||||
if (!metadata.channels) {
|
||||
metadata.channels = family ? (family.channels || []).map(c => c.channel_code) : [];
|
||||
}
|
||||
|
||||
// Fetch workflow details dynamically
|
||||
const workflowCode = family ? (family.workflow_code || 'standard') : 'standard';
|
||||
let workflowName = 'Standard Approval';
|
||||
let currentStage = 'draft';
|
||||
|
||||
const wfRegistry = await models.WorkflowRegistry.findOne({
|
||||
where: { code: workflowCode },
|
||||
transaction
|
||||
});
|
||||
if (wfRegistry) {
|
||||
workflowName = wfRegistry.name;
|
||||
const stages = wfRegistry.stages || [];
|
||||
if (stages.length > 0) {
|
||||
const sortedStages = [...stages].sort((a, b) => Number(a.order || 0) - Number(b.order || 0));
|
||||
currentStage = sortedStages[0].code || sortedStages[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
metadata.workflowCode = workflowCode;
|
||||
metadata.workflowName = workflowName;
|
||||
metadata.currentStage = currentStage;
|
||||
|
||||
// Pack general form fields into metadata since they aren't core columns
|
||||
const initialStatus = data.status || 'draft';
|
||||
if (data.sku && String(data.sku).trim() !== '') {
|
||||
metadata.sku = String(data.sku).trim();
|
||||
} else if (initialStatus === 'pending' || initialStatus === 'active') {
|
||||
metadata.sku = await this.generateSku(family, finalCode, transaction);
|
||||
} else {
|
||||
metadata.sku = null;
|
||||
}
|
||||
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.description = data.description || '';
|
||||
metadata.categories = Array.isArray(data.categories) ? data.categories : (categoryId ? [categoryId] : []);
|
||||
|
||||
// 5. Create Core Product
|
||||
const prodType = data.type === 'variant' ? 'variable' : 'simple';
|
||||
let slug = data.slug || data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-+|-+$)/g, '');
|
||||
if (!slug) slug = `product-${Date.now()}`;
|
||||
|
||||
const product = await repository.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
status: data.status || 'draft',
|
||||
product_type: prodType,
|
||||
description: data.description || '',
|
||||
short_description: data.short_description || '',
|
||||
slug: slug,
|
||||
family_id: family ? family.id : null,
|
||||
category_id: categoryId,
|
||||
brand_id: data.brand_id || data.brandId || data.brand,
|
||||
unit_id: data.unit_id || data.unitId || data.unit,
|
||||
metadata: metadata
|
||||
}, { transaction });
|
||||
metadata: data.metadata || {}
|
||||
}, { transaction }, context);
|
||||
|
||||
// 6. Save dynamic attributes from inherited Attribute Set blueprint or direct family attributes
|
||||
// Create child variant automatically for simple product type
|
||||
if (prodType === 'simple') {
|
||||
const sku = data.sku || await this.generateSku(family, finalCode, transaction);
|
||||
await models.Variant.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
product_id: product.id,
|
||||
sku: sku,
|
||||
barcode: data.barcode || null,
|
||||
name: data.name,
|
||||
price: parseFloat(data.price) || 0.00,
|
||||
cost_price: parseFloat(data.cost_price || data.costPrice) || 0.00,
|
||||
currency: data.currency || 'USD',
|
||||
status: data.status || 'draft',
|
||||
is_active: true,
|
||||
sort_order: 0
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
// 6. Save dynamic attributes
|
||||
const bodyAttributes = data.attributes || data.attributeValues || {};
|
||||
metadata.attributes = bodyAttributes;
|
||||
await product.update({ metadata }, { transaction });
|
||||
|
||||
const familyAttributesMap = new Map();
|
||||
if (family && family.attributeSet && Array.isArray(family.attributeSet.groups)) {
|
||||
for (const g of family.attributeSet.groups) {
|
||||
@@ -514,13 +585,10 @@ export class ProductService {
|
||||
|
||||
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] !== undefined ? bodyAttributes.metadata[attr.code] : bodyAttributes.metadata[attr.id];
|
||||
}
|
||||
|
||||
if (val !== undefined && val !== null) {
|
||||
await validateAttributeValue(attr, val, product.id, transaction);
|
||||
await models.ProductAttributeValue.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
product_id: product.id,
|
||||
attribute_id: attr.id,
|
||||
value: typeof val === 'object' ? JSON.stringify(val) : String(val),
|
||||
@@ -580,38 +648,41 @@ export class ProductService {
|
||||
|
||||
// Validate Family
|
||||
const familyId = data.family_id || data.familyId || record.family_id;
|
||||
const family = await models.Catalog.findByPk(familyId, {
|
||||
transaction,
|
||||
include: [
|
||||
{
|
||||
model: models.FamilyChannel,
|
||||
as: 'channels'
|
||||
},
|
||||
{
|
||||
model: models.AttributeSet,
|
||||
as: 'attributeSet',
|
||||
include: [
|
||||
{
|
||||
model: models.AttributeGroup,
|
||||
as: 'groups',
|
||||
include: [
|
||||
{
|
||||
model: models.Attribute,
|
||||
as: 'attributes'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
if (!family) {
|
||||
throw new Error('Product Family (Catalog) must exist');
|
||||
let family = null;
|
||||
if (familyId) {
|
||||
family = await models.Catalog.findByPk(familyId, {
|
||||
transaction,
|
||||
include: [
|
||||
{
|
||||
model: models.FamilyChannel,
|
||||
as: 'channels'
|
||||
},
|
||||
{
|
||||
model: models.AttributeSet,
|
||||
as: 'attributeSet',
|
||||
include: [
|
||||
{
|
||||
model: models.AttributeGroup,
|
||||
as: 'groups',
|
||||
include: [
|
||||
{
|
||||
model: models.Attribute,
|
||||
as: 'attributes'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
if (!family) {
|
||||
throw new Error('Product Family (Catalog) must exist if specified');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Brand is allowed by Family (if changed)
|
||||
const brandId = data.brand_id || data.brandId || data.brand;
|
||||
if (brandId && brandId !== record.brand_id) {
|
||||
if (brandId && brandId !== record.brand_id && family) {
|
||||
const allowedBrands = family.completeness_rules?.allowedBrands || [];
|
||||
if (allowedBrands.length > 0 && !allowedBrands.includes(brandId)) {
|
||||
throw new Error('Selected brand is not allowed for this product family');
|
||||
@@ -620,7 +691,7 @@ export class ProductService {
|
||||
|
||||
// Validate Unit is allowed by Family (if changed)
|
||||
const unitId = data.unit_id || data.unitId || data.unit;
|
||||
if (unitId && unitId !== record.unit_id) {
|
||||
if (unitId && unitId !== record.unit_id && family) {
|
||||
const allowedUnits = family.completeness_rules?.allowedUnits || [];
|
||||
if (allowedUnits.length > 0 && !allowedUnits.includes(unitId)) {
|
||||
throw new Error('Selected unit is not allowed for this product family');
|
||||
@@ -636,69 +707,66 @@ export class ProductService {
|
||||
categoryId = family ? family.category_id : null;
|
||||
}
|
||||
|
||||
// Ensure inherited channels are locked and cannot be removed
|
||||
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
|
||||
updatedChannels = [...new Set([...updatedChannels, ...familyChannels])];
|
||||
metadata.channels = updatedChannels;
|
||||
|
||||
if (!metadata.workflowCode) {
|
||||
metadata.workflowCode = family.workflow_code || record.metadata?.workflowCode || 'standard';
|
||||
}
|
||||
if (!metadata.workflowName) {
|
||||
metadata.workflowName = record.metadata?.workflowName || 'Standard Approval';
|
||||
}
|
||||
if (!metadata.currentStage) {
|
||||
metadata.currentStage = record.metadata?.currentStage || 'draft';
|
||||
// 3. Update Core Product
|
||||
const prodType = data.type === 'variant' ? 'variable' : (data.type || record.product_type || 'simple');
|
||||
let slug = data.slug || record.slug;
|
||||
if (data.name && !data.slug) {
|
||||
slug = data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-+|-+$)/g, '');
|
||||
}
|
||||
if (!slug) slug = `product-${Date.now()}`;
|
||||
|
||||
const oldStatus = record.status || record.metadata?.currentStage || 'draft';
|
||||
const newStatus = data.status || metadata.currentStage || oldStatus;
|
||||
|
||||
if (data.hasOwnProperty('sku') && data.sku && String(data.sku).trim() !== '') {
|
||||
metadata.sku = String(data.sku).trim();
|
||||
} else if (!metadata.sku && (newStatus === 'pending' || newStatus === 'active')) {
|
||||
metadata.sku = await this.generateSku(family, record.code, transaction);
|
||||
}
|
||||
|
||||
// Keep general fields inside metadata up to date
|
||||
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('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,
|
||||
status: data.status || record.status,
|
||||
product_type: prodType,
|
||||
description: data.hasOwnProperty('description') ? data.description : record.description,
|
||||
short_description: data.hasOwnProperty('short_description') ? data.short_description : record.short_description,
|
||||
slug: slug,
|
||||
brand_id: data.brand_id || data.brandId || data.brand || record.brand_id,
|
||||
unit_id: data.unit_id || data.unitId || data.unit || record.unit_id,
|
||||
category_id: categoryId,
|
||||
metadata: metadata
|
||||
metadata: data.metadata ? { ...(record.metadata || {}), ...data.metadata } : record.metadata
|
||||
}, { transaction });
|
||||
|
||||
// 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 });
|
||||
// Create/Update child variant for simple products
|
||||
if (prodType === 'simple') {
|
||||
const [variant, created] = await models.Variant.findOrCreate({
|
||||
where: { product_id: id },
|
||||
defaults: {
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
sku: data.sku || await this.generateSku(family, record.code, transaction),
|
||||
barcode: data.barcode || null,
|
||||
name: data.name || record.name,
|
||||
price: parseFloat(data.price) || 0.00,
|
||||
cost_price: parseFloat(data.cost_price || data.costPrice) || 0.00,
|
||||
currency: data.currency || 'USD',
|
||||
status: data.status || record.status || 'draft',
|
||||
is_active: true,
|
||||
sort_order: 0
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
const variantPayload = {};
|
||||
if (data.hasOwnProperty('sku')) variantPayload.sku = data.sku;
|
||||
if (data.hasOwnProperty('barcode')) variantPayload.barcode = data.barcode;
|
||||
if (data.name) variantPayload.name = data.name;
|
||||
if (data.hasOwnProperty('price')) variantPayload.price = parseFloat(data.price) || 0.00;
|
||||
if (data.hasOwnProperty('cost_price')) variantPayload.cost_price = parseFloat(data.cost_price) || 0.00;
|
||||
if (data.hasOwnProperty('costPrice')) variantPayload.cost_price = parseFloat(data.costPrice) || 0.00;
|
||||
if (data.currency) variantPayload.currency = data.currency;
|
||||
if (data.status) variantPayload.status = data.status;
|
||||
|
||||
await variant.update(variantPayload, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
// Save dynamic attributes from body
|
||||
const bodyAttributes = data.attributes || data.attributeValues || {};
|
||||
const familyAttributesMap = new Map();
|
||||
if (family.attributeSet && Array.isArray(family.attributeSet.groups)) {
|
||||
if (family && family.attributeSet && Array.isArray(family.attributeSet.groups)) {
|
||||
for (const g of family.attributeSet.groups) {
|
||||
if (Array.isArray(g.attributes)) {
|
||||
for (const a of g.attributes) {
|
||||
@@ -707,7 +775,7 @@ export class ProductService {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(family.attributes)) {
|
||||
if (family && Array.isArray(family.attributes)) {
|
||||
for (const a of family.attributes) {
|
||||
if (a && a.id) familyAttributesMap.set(a.id, a);
|
||||
}
|
||||
@@ -735,15 +803,14 @@ export class ProductService {
|
||||
|
||||
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] !== undefined ? bodyAttributes.metadata[attr.code] : bodyAttributes.metadata[attr.id];
|
||||
}
|
||||
|
||||
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: typeof val === 'object' ? JSON.stringify(val) : String(val) },
|
||||
defaults: {
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
value: typeof val === 'object' ? JSON.stringify(val) : String(val)
|
||||
},
|
||||
transaction
|
||||
});
|
||||
if (!created) {
|
||||
@@ -899,6 +966,7 @@ export class ProductService {
|
||||
const item = values[i];
|
||||
if (!item.axis_id || !item.value) continue;
|
||||
const created = await models.ProductVariantValue.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
product_id: productId,
|
||||
axis_id: item.axis_id,
|
||||
value: item.value,
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Model, DataTypes } from 'sequelize';
|
||||
|
||||
export class ProductAttributeValue extends Model {
|
||||
static associate(models) {
|
||||
ProductAttributeValue.belongsTo(models.Tenant, {
|
||||
foreignKey: 'tenant_id',
|
||||
as: 'tenant'
|
||||
});
|
||||
ProductAttributeValue.belongsTo(models.Product, {
|
||||
foreignKey: 'product_id',
|
||||
as: 'product'
|
||||
@@ -21,6 +25,10 @@ export default (sequelize) => {
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
product_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Model, DataTypes } from 'sequelize';
|
||||
|
||||
export class ProductVariantValue extends Model {
|
||||
static associate(models) {
|
||||
ProductVariantValue.belongsTo(models.Tenant, {
|
||||
foreignKey: 'tenant_id',
|
||||
as: 'tenant'
|
||||
});
|
||||
ProductVariantValue.belongsTo(models.Product, {
|
||||
foreignKey: 'product_id',
|
||||
as: 'product'
|
||||
@@ -21,6 +25,10 @@ export default (sequelize) => {
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
product_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
|
||||
@@ -14,6 +14,30 @@ export class VariantController {
|
||||
}
|
||||
}
|
||||
|
||||
// Map variantAssets → images array for frontend display
|
||||
const images = [];
|
||||
if (Array.isArray(raw.variantAssets)) {
|
||||
raw.variantAssets
|
||||
.filter(va => va.asset)
|
||||
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0))
|
||||
.forEach((va, idx) => {
|
||||
images.push({
|
||||
assetId: va.asset_id,
|
||||
url: va.asset.file_url || va.asset.url || null,
|
||||
thumbnailUrl: va.asset.thumbnail_url || va.asset.file_url || va.asset.url || null,
|
||||
name: va.asset.name || va.asset.original_name || `Asset ${idx + 1}`,
|
||||
role: va.role || (idx === 0 ? 'primary' : 'gallery'),
|
||||
isPrimary: va.is_primary || idx === 0,
|
||||
displayOrder: va.display_order || idx,
|
||||
assetType: va.asset.assetType ? {
|
||||
id: va.asset.assetType.id,
|
||||
code: va.asset.assetType.code,
|
||||
name: va.asset.assetType.name
|
||||
} : null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
sku: raw.sku,
|
||||
@@ -30,7 +54,8 @@ export class VariantController {
|
||||
reservedStock: raw.reserved_stock || 0,
|
||||
safetyStock: raw.safety_stock || 0,
|
||||
lastUpdated: raw.updated_at || raw.updatedAt,
|
||||
createdBy: raw.created_by || 'system'
|
||||
createdBy: raw.created_by || 'system',
|
||||
images
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Model, DataTypes } from 'sequelize';
|
||||
|
||||
export class Variant extends Model {
|
||||
static associate(models) {
|
||||
Variant.belongsTo(models.Tenant, {
|
||||
foreignKey: 'tenant_id',
|
||||
as: 'tenant'
|
||||
});
|
||||
// Belongs to product
|
||||
Variant.belongsTo(models.Product, {
|
||||
foreignKey: 'product_id',
|
||||
@@ -32,19 +36,22 @@ export default (sequelize) => {
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
allowNull: false
|
||||
},
|
||||
product_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
sku: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
unique: true
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
barcode: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(150),
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false
|
||||
},
|
||||
price: {
|
||||
@@ -62,30 +69,20 @@ export default (sequelize) => {
|
||||
allowNull: true,
|
||||
defaultValue: 'USD'
|
||||
},
|
||||
stock: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
available_stock: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0
|
||||
},
|
||||
reserved_stock: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0
|
||||
},
|
||||
safety_stock: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'draft'
|
||||
},
|
||||
is_active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
},
|
||||
sort_order: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
|
||||
@@ -32,7 +32,7 @@ export class VariantRepository {
|
||||
}
|
||||
|
||||
async findById(id, options = {}, context = {}) {
|
||||
const where = applyTenantScope({ id }, context);
|
||||
const where = applyTenantScope({ id, ...(options.where || {}) }, context);
|
||||
return await models.Variant.findOne({
|
||||
...options,
|
||||
where,
|
||||
@@ -58,7 +58,7 @@ export class VariantRepository {
|
||||
}
|
||||
|
||||
async findBySku(sku, options = {}, context = {}) {
|
||||
const where = applyTenantScope({ sku }, context);
|
||||
const where = applyTenantScope({ sku, ...(options.where || {}) }, context);
|
||||
return await models.Variant.findOne({
|
||||
...options,
|
||||
where,
|
||||
@@ -72,7 +72,11 @@ export class VariantRepository {
|
||||
}
|
||||
|
||||
async create(data, options = {}, context = {}) {
|
||||
return await models.Variant.create(data, options);
|
||||
const createData = {
|
||||
...data,
|
||||
tenant_id: context.tenantId
|
||||
};
|
||||
return await models.Variant.create(createData, options);
|
||||
}
|
||||
|
||||
async update(id, data, options = {}, context = {}) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import repository from './variant.repository.js';
|
||||
import { models, sequelize } from '../../../shared/database/models.js';
|
||||
import { SocketService } from '../../../shared/services/socket.service.js';
|
||||
import { AuditService } from '../../../shared/services/audit.service.js';
|
||||
import CompletenessService from '../../products/products/completeness.service.js';
|
||||
|
||||
export class VariantService {
|
||||
async getAll(query = {}, context = {}) {
|
||||
@@ -65,6 +66,26 @@ export class VariantService {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: models.VariantAsset,
|
||||
as: 'variantAssets',
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.Asset,
|
||||
as: 'asset',
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.AssetType,
|
||||
as: 'assetType',
|
||||
required: false,
|
||||
attributes: ['id', 'code', 'name', 'is_variant_eligible']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
productInclude
|
||||
]
|
||||
}, context);
|
||||
@@ -139,11 +160,9 @@ export class VariantService {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Validate Price and Stock
|
||||
// 4. Validate Price
|
||||
const price = parseFloat(data.price) ?? 0;
|
||||
const stock = parseInt(data.stock) ?? 0;
|
||||
if (price < 0) throw new Error('Price must be a positive number');
|
||||
if (stock < 0) throw new Error('Stock must be a non-negative integer');
|
||||
|
||||
// 5. Reject duplicate variant combination
|
||||
const existingVariants = await models.Variant.findAll({
|
||||
@@ -184,17 +203,17 @@ export class VariantService {
|
||||
|
||||
// 6. Create Variant
|
||||
const record = await models.Variant.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
product_id: data.parentProductId,
|
||||
sku: data.sku,
|
||||
barcode: data.barcode || null,
|
||||
name: data.name || data.variantName,
|
||||
price: price,
|
||||
cost_price: parseFloat(data.cost_price || data.costPrice) || 0.00,
|
||||
currency: data.currency || 'USD',
|
||||
stock: stock,
|
||||
available_stock: parseInt(data.available_stock || data.availableStock) || stock,
|
||||
reserved_stock: parseInt(data.reserved_stock || data.reservedStock) || 0,
|
||||
safety_stock: parseInt(data.safety_stock || data.safetyStock) || 0,
|
||||
status: data.status || 'draft'
|
||||
status: data.status || 'draft',
|
||||
is_active: data.is_active !== undefined ? !!data.is_active : true,
|
||||
sort_order: parseInt(data.sort_order || data.sortOrder) || 0
|
||||
}, { transaction });
|
||||
|
||||
// 7. Insert Variant Values
|
||||
@@ -202,6 +221,7 @@ export class VariantService {
|
||||
const attribute = family.variantAxes.find(axis => axis.code === code);
|
||||
if (attribute) {
|
||||
await models.VariantValue.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
variant_id: record.id,
|
||||
axis_id: attribute.id,
|
||||
value_text: String(val)
|
||||
@@ -209,6 +229,7 @@ export class VariantService {
|
||||
}
|
||||
}
|
||||
|
||||
await CompletenessService.calculate(record.product_id, transaction);
|
||||
await transaction.commit();
|
||||
|
||||
const fullRecord = await repository.findById(record.id, {}, context);
|
||||
@@ -328,24 +349,21 @@ export class VariantService {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Validate pricing & stock
|
||||
// 4. Validate pricing
|
||||
const price = data.hasOwnProperty('price') ? parseFloat(data.price) : record.price;
|
||||
const stock = data.hasOwnProperty('stock') ? parseInt(data.stock) : record.stock;
|
||||
if (price < 0) throw new Error('Price must be a positive number');
|
||||
if (stock < 0) throw new Error('Stock must be a non-negative integer');
|
||||
|
||||
// 5. Update Record
|
||||
const updatePayload = {
|
||||
sku: data.sku || record.sku,
|
||||
barcode: data.hasOwnProperty('barcode') ? data.barcode : record.barcode,
|
||||
name: data.name || data.variantName || record.name,
|
||||
price: price,
|
||||
cost_price: data.hasOwnProperty('cost_price') ? parseFloat(data.cost_price) : (data.hasOwnProperty('costPrice') ? parseFloat(data.costPrice) : record.cost_price),
|
||||
currency: data.currency || record.currency,
|
||||
stock: stock,
|
||||
available_stock: data.hasOwnProperty('available_stock') ? parseInt(data.available_stock) : (data.hasOwnProperty('availableStock') ? parseInt(data.availableStock) : record.available_stock),
|
||||
reserved_stock: data.hasOwnProperty('reserved_stock') ? parseInt(data.reserved_stock) : (data.hasOwnProperty('reservedStock') ? parseInt(data.reservedStock) : record.reserved_stock),
|
||||
safety_stock: data.hasOwnProperty('safety_stock') ? parseInt(data.safety_stock) : (data.hasOwnProperty('safetyStock') ? parseInt(data.safetyStock) : record.safety_stock),
|
||||
status: data.status || record.status
|
||||
status: data.status || record.status,
|
||||
is_active: data.hasOwnProperty('is_active') ? !!data.is_active : record.is_active,
|
||||
sort_order: data.hasOwnProperty('sort_order') ? parseInt(data.sort_order) : (data.hasOwnProperty('sortOrder') ? parseInt(data.sortOrder) : record.sort_order)
|
||||
};
|
||||
|
||||
await record.update(updatePayload, { transaction });
|
||||
@@ -357,6 +375,7 @@ export class VariantService {
|
||||
const attribute = family.variantAxes.find(axis => axis.code === code);
|
||||
if (attribute) {
|
||||
await models.VariantValue.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
variant_id: id,
|
||||
axis_id: attribute.id,
|
||||
value_text: String(val)
|
||||
@@ -365,6 +384,7 @@ export class VariantService {
|
||||
}
|
||||
}
|
||||
|
||||
await CompletenessService.calculate(record.product_id, transaction);
|
||||
await transaction.commit();
|
||||
|
||||
const fullRecord = await repository.findById(id, {}, context);
|
||||
@@ -401,6 +421,7 @@ export class VariantService {
|
||||
|
||||
// Hard delete variant
|
||||
await record.destroy({ force: true, transaction });
|
||||
await CompletenessService.calculate(record.product_id, transaction);
|
||||
await transaction.commit();
|
||||
|
||||
// Emit socket event
|
||||
@@ -430,6 +451,7 @@ export class VariantService {
|
||||
}
|
||||
|
||||
await record.destroy({ transaction });
|
||||
await CompletenessService.calculate(record.product_id, transaction);
|
||||
await transaction.commit();
|
||||
|
||||
SocketService.broadcast('variant.archived', { id });
|
||||
@@ -522,6 +544,7 @@ export class VariantService {
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
await CompletenessService.calculate(variant.product_id, transaction);
|
||||
await transaction.commit();
|
||||
|
||||
SocketService.broadcast('variant.updated', { id: variantId });
|
||||
@@ -535,6 +558,9 @@ export class VariantService {
|
||||
async updateAssetMapping(variantId, assetId, data, context = {}) {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const variant = await repository.findById(variantId, { transaction }, context);
|
||||
if (!variant) throw new Error('Variant not found');
|
||||
|
||||
const mapping = await models.VariantAsset.findOne({
|
||||
where: { variant_id: variantId, asset_id: assetId },
|
||||
transaction
|
||||
@@ -554,6 +580,7 @@ export class VariantService {
|
||||
is_primary: data.is_primary !== undefined ? !!data.is_primary : mapping.is_primary
|
||||
}, { transaction });
|
||||
|
||||
await CompletenessService.calculate(variant.product_id, transaction);
|
||||
await transaction.commit();
|
||||
|
||||
SocketService.broadcast('variant.updated', { id: variantId });
|
||||
@@ -565,12 +592,16 @@ export class VariantService {
|
||||
}
|
||||
|
||||
async unassignAsset(variantId, assetId, context = {}) {
|
||||
const variant = await repository.findById(variantId, {}, context);
|
||||
if (!variant) throw new Error('Variant not found');
|
||||
|
||||
const mapping = await models.VariantAsset.findOne({
|
||||
where: { variant_id: variantId, asset_id: assetId }
|
||||
});
|
||||
if (!mapping) throw new Error('Asset mapping not found');
|
||||
|
||||
await mapping.destroy();
|
||||
await CompletenessService.calculate(variant.product_id);
|
||||
SocketService.broadcast('variant.updated', { id: variantId });
|
||||
return true;
|
||||
}
|
||||
@@ -628,17 +659,23 @@ export class VariantService {
|
||||
if (!parentProduct) throw new Error('Parent product not found');
|
||||
|
||||
const family = parentProduct.family;
|
||||
if (!family) throw new Error('Parent product has no Product Family assigned');
|
||||
|
||||
const configuredAxesCodes = (family.variantAxes || []).map(a => a.code);
|
||||
|
||||
// 2. Validate all provided axis codes are configured on the family
|
||||
for (const axis of axes) {
|
||||
if (!configuredAxesCodes.includes(axis.code)) {
|
||||
throw new Error(`Axis "${axis.code}" is not a configured variant axis for family "${family.name}"`);
|
||||
// 2. Validate all provided axis codes
|
||||
if (family) {
|
||||
const configuredAxesCodes = (family.variantAxes || []).map(a => a.code);
|
||||
for (const axis of axes) {
|
||||
if (!configuredAxesCodes.includes(axis.code)) {
|
||||
throw new Error(`Axis "${axis.code}" is not a configured variant axis for family "${family.name}"`);
|
||||
}
|
||||
if (!axis.values || axis.values.length === 0) {
|
||||
throw new Error(`Axis "${axis.code}" must have at least one value`);
|
||||
}
|
||||
}
|
||||
if (!axis.values || axis.values.length === 0) {
|
||||
throw new Error(`Axis "${axis.code}" must have at least one value`);
|
||||
} else {
|
||||
for (const axis of axes) {
|
||||
if (!axis.values || axis.values.length === 0) {
|
||||
throw new Error(`Axis "${axis.code}" must have at least one value`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,24 +738,33 @@ export class VariantService {
|
||||
|
||||
// Create variant
|
||||
const variant = await models.Variant.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
product_id: productId,
|
||||
sku: finalSku,
|
||||
name: variantName,
|
||||
price: parseFloat(parentProduct.price) || 0.00,
|
||||
cost_price: 0.00,
|
||||
currency: 'USD',
|
||||
stock: 0,
|
||||
available_stock: 0,
|
||||
reserved_stock: 0,
|
||||
safety_stock: 0,
|
||||
status: 'draft'
|
||||
status: 'draft',
|
||||
is_active: true,
|
||||
sort_order: 0
|
||||
}, { transaction });
|
||||
|
||||
// Create variant values
|
||||
for (const { code, value } of combo) {
|
||||
const axisAttr = family.variantAxes.find(a => a.code === code);
|
||||
let axisAttr = null;
|
||||
if (family && Array.isArray(family.variantAxes)) {
|
||||
axisAttr = family.variantAxes.find(a => a.code === code);
|
||||
}
|
||||
if (!axisAttr) {
|
||||
axisAttr = await models.Attribute.findOne({
|
||||
where: { code },
|
||||
transaction
|
||||
});
|
||||
}
|
||||
if (axisAttr) {
|
||||
await models.VariantValue.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
variant_id: variant.id,
|
||||
axis_id: axisAttr.id,
|
||||
value_text: value
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Model, DataTypes } from 'sequelize';
|
||||
|
||||
export class VariantValue extends Model {
|
||||
static associate(models) {
|
||||
VariantValue.belongsTo(models.Tenant, {
|
||||
foreignKey: 'tenant_id',
|
||||
as: 'tenant'
|
||||
});
|
||||
// Belongs to variant
|
||||
VariantValue.belongsTo(models.Variant, {
|
||||
foreignKey: 'variant_id',
|
||||
@@ -17,6 +21,10 @@ export class VariantValue extends Model {
|
||||
|
||||
export default (sequelize) => {
|
||||
VariantValue.init({
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
variant_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
// 1. Clean up any partial state from previous run by dropping tenant_id columns if they exist
|
||||
await queryInterface.removeColumn('products', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variants', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('product_attribute_values', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('variant_values', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variant_values', 'tenant_id').catch(() => null);
|
||||
|
||||
// 2. Add tenant_id column (INTEGER, matching tenants.id) to relevant tables
|
||||
await queryInterface.addColumn('products', 'tenant_id', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('product_variants', 'tenant_id', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('product_attribute_values', 'tenant_id', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('variant_values', 'tenant_id', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('product_variant_values', 'tenant_id', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
});
|
||||
|
||||
// 3. Add barcode column to product_variants (as specified in the blueprint)
|
||||
await queryInterface.addColumn('product_variants', 'barcode', {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
// 4. Add other new relational/metadata columns to products (catch if already exists)
|
||||
await queryInterface.addColumn('products', 'product_type', {
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: false,
|
||||
defaultValue: 'simple'
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'description', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'short_description', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'slug', {
|
||||
type: Sequelize.STRING(500),
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'meta_title', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'meta_description', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'meta_keywords', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'completeness_score', {
|
||||
type: Sequelize.DECIMAL(5, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0.00
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'is_active', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'approved_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'approved_by', {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
// Add other fields from blueprint (manufacturer_id, hsn_code_id, tax_class_id, workflow_state_id)
|
||||
await queryInterface.addColumn('products', 'manufacturer_id', {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'hsn_code_id', {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'tax_class_id', {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'workflow_state_id', {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
// 5. Add other columns to product_variants
|
||||
await queryInterface.addColumn('product_variants', 'is_active', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('product_variants', 'sort_order', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
}).catch(() => null);
|
||||
|
||||
// 6. Ensure at least one tenant exists and update tenant_id fields
|
||||
const [tenants] = await queryInterface.sequelize.query('SELECT id FROM tenants LIMIT 1;');
|
||||
let defaultTenantId;
|
||||
if (tenants && tenants.length > 0) {
|
||||
defaultTenantId = tenants[0].id;
|
||||
} else {
|
||||
defaultTenantId = 1;
|
||||
await queryInterface.sequelize.query(`
|
||||
INSERT INTO tenants (id, tenant_code, tenant_name, status, created_at, updated_at)
|
||||
VALUES (${defaultTenantId}, 'default', 'Default Tenant', true, NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
}
|
||||
|
||||
await queryInterface.sequelize.query(`UPDATE products SET tenant_id = ${defaultTenantId} WHERE tenant_id IS NULL;`);
|
||||
await queryInterface.sequelize.query(`UPDATE product_variants SET tenant_id = ${defaultTenantId} WHERE tenant_id IS NULL;`);
|
||||
await queryInterface.sequelize.query(`UPDATE product_attribute_values SET tenant_id = ${defaultTenantId} WHERE tenant_id IS NULL;`);
|
||||
await queryInterface.sequelize.query(`UPDATE variant_values SET tenant_id = ${defaultTenantId} WHERE tenant_id IS NULL;`);
|
||||
await queryInterface.sequelize.query(`UPDATE product_variant_values SET tenant_id = ${defaultTenantId} WHERE tenant_id IS NULL;`);
|
||||
|
||||
// 7. Populate slug for existing products
|
||||
const [products] = await queryInterface.sequelize.query('SELECT id, name FROM products;');
|
||||
for (const prod of products) {
|
||||
const baseSlug = prod.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-+|-+$)/g, '');
|
||||
const slug = baseSlug || `product-${prod.id.slice(0, 8)}`;
|
||||
await queryInterface.sequelize.query(`UPDATE products SET slug = :slug WHERE id = :id;`, {
|
||||
replacements: { slug, id: prod.id }
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Enforce NOT NULL constraints
|
||||
await queryInterface.changeColumn('products', 'tenant_id', { type: Sequelize.INTEGER, allowNull: false });
|
||||
await queryInterface.changeColumn('products', 'slug', { type: Sequelize.STRING(500), allowNull: false });
|
||||
await queryInterface.changeColumn('product_variants', 'tenant_id', { type: Sequelize.INTEGER, allowNull: false });
|
||||
await queryInterface.changeColumn('product_attribute_values', 'tenant_id', { type: Sequelize.INTEGER, allowNull: false });
|
||||
await queryInterface.changeColumn('variant_values', 'tenant_id', { type: Sequelize.INTEGER, allowNull: false });
|
||||
|
||||
// 9. Migrate metadata details into EAV and variant tables
|
||||
const [existingProds] = await queryInterface.sequelize.query('SELECT id, name, status, tenant_id, metadata, description FROM products;');
|
||||
for (const prod of existingProds) {
|
||||
const metadata = prod.metadata || {};
|
||||
const prodType = metadata.type === 'variant' ? 'variable' : 'simple';
|
||||
const descVal = prod.description || metadata.description || '';
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE products
|
||||
SET product_type = :product_type, description = :description
|
||||
WHERE id = :id;
|
||||
`, {
|
||||
replacements: {
|
||||
product_type: prodType,
|
||||
description: descVal,
|
||||
id: prod.id
|
||||
}
|
||||
});
|
||||
|
||||
// Simple products: Migrate SKU, barcode, price to default master variant
|
||||
const [variants] = await queryInterface.sequelize.query(`SELECT id FROM product_variants WHERE product_id = '${prod.id}' LIMIT 1;`);
|
||||
if (variants.length === 0) {
|
||||
const sku = metadata.sku || `SKU-${prod.id.slice(0, 8).toUpperCase()}`;
|
||||
const barcode = metadata.barcode || null;
|
||||
const price = parseFloat(metadata.price) || 0.00;
|
||||
const costPrice = parseFloat(metadata.cost_price || metadata.costPrice) || 0.00;
|
||||
const generatedVariantId = crypto.randomUUID();
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
INSERT INTO product_variants (id, tenant_id, product_id, sku, barcode, name, price, cost_price, status, created_at, updated_at)
|
||||
VALUES (:id, :tenant_id, :product_id, :sku, :barcode, :name, :price, :cost_price, :status, NOW(), NOW());
|
||||
`, {
|
||||
replacements: {
|
||||
id: generatedVariantId,
|
||||
tenant_id: prod.tenant_id,
|
||||
product_id: prod.id,
|
||||
sku: sku,
|
||||
barcode: barcode,
|
||||
name: prod.name,
|
||||
price: price,
|
||||
cost_price: costPrice,
|
||||
status: prod.status || 'draft'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate custom metadata attributes to product_attribute_values
|
||||
const attributes = metadata.attributes || {};
|
||||
for (const [code, val] of Object.entries(attributes)) {
|
||||
const [attrs] = await queryInterface.sequelize.query('SELECT id FROM attributes WHERE code = :code LIMIT 1;', {
|
||||
replacements: { code }
|
||||
});
|
||||
if (attrs.length > 0) {
|
||||
const attrId = attrs[0].id;
|
||||
const [existingVals] = await queryInterface.sequelize.query(
|
||||
'SELECT id FROM product_attribute_values WHERE product_id = :productId AND attribute_id = :attrId LIMIT 1;',
|
||||
{ replacements: { productId: prod.id, attrId } }
|
||||
);
|
||||
if (existingVals.length === 0) {
|
||||
const valId = crypto.randomUUID();
|
||||
await queryInterface.sequelize.query(`
|
||||
INSERT INTO product_attribute_values (id, tenant_id, product_id, attribute_id, value, locale, channel, created_at, updated_at)
|
||||
VALUES (:id, :tenant_id, :product_id, :attribute_id, :value, 'en', 'default', NOW(), NOW());
|
||||
`, {
|
||||
replacements: {
|
||||
id: valId,
|
||||
tenant_id: prod.tenant_id,
|
||||
product_id: prod.id,
|
||||
attribute_id: attrId,
|
||||
value: typeof val === 'object' ? JSON.stringify(val) : String(val)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Remove metadata column from products (catch if already done)
|
||||
await queryInterface.removeColumn('products', 'metadata').catch(() => null);
|
||||
|
||||
// 11. Remove stock columns from product_variants
|
||||
await queryInterface.removeColumn('product_variants', 'stock').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variants', 'available_stock').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variants', 'reserved_stock').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variants', 'safety_stock').catch(() => null);
|
||||
|
||||
// 12. Update unique constraints
|
||||
await queryInterface.sequelize.query('ALTER TABLE products DROP CONSTRAINT IF EXISTS products_code_key;').catch(() => null);
|
||||
await queryInterface.sequelize.query('DROP INDEX IF EXISTS products_code_key;').catch(() => null);
|
||||
await queryInterface.sequelize.query('ALTER TABLE product_variants DROP CONSTRAINT IF EXISTS product_variants_sku_key;').catch(() => null);
|
||||
await queryInterface.sequelize.query('DROP INDEX IF EXISTS product_variants_sku_key;').catch(() => null);
|
||||
|
||||
await queryInterface.addIndex('products', ['tenant_id', 'code'], {
|
||||
unique: true,
|
||||
name: 'idx_products_tenant_code'
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addIndex('product_variants', ['tenant_id', 'sku'], {
|
||||
unique: true,
|
||||
name: 'idx_variants_tenant_sku'
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addIndex('product_variants', ['tenant_id', 'barcode'], {
|
||||
unique: true,
|
||||
name: 'idx_variants_tenant_barcode'
|
||||
}).catch(() => null);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
// Reverting migrations best-effort
|
||||
await queryInterface.addColumn('products', 'metadata', {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('product_variants', 'stock', { type: Sequelize.INTEGER, defaultValue: 0 }).catch(() => null);
|
||||
await queryInterface.addColumn('product_variants', 'available_stock', { type: Sequelize.INTEGER, defaultValue: 0 }).catch(() => null);
|
||||
await queryInterface.addColumn('product_variants', 'reserved_stock', { type: Sequelize.INTEGER, defaultValue: 0 }).catch(() => null);
|
||||
await queryInterface.addColumn('product_variants', 'safety_stock', { type: Sequelize.INTEGER, defaultValue: 0 }).catch(() => null);
|
||||
|
||||
await queryInterface.removeIndex('products', 'idx_products_tenant_code').catch(() => null);
|
||||
await queryInterface.removeIndex('product_variants', 'idx_variants_tenant_sku').catch(() => null);
|
||||
await queryInterface.removeIndex('product_variants', 'idx_variants_tenant_barcode').catch(() => null);
|
||||
|
||||
await queryInterface.removeColumn('products', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'product_type').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'description').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'short_description').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'slug').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'meta_title').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'meta_description').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'meta_keywords').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'completeness_score').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'is_active').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'approved_at').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'approved_by').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'manufacturer_id').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'hsn_code_id').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'tax_class_id').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'workflow_state_id').catch(() => null);
|
||||
|
||||
await queryInterface.removeColumn('product_variants', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variants', 'is_active').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variants', 'sort_order').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variants', 'barcode').catch(() => null);
|
||||
|
||||
await queryInterface.removeColumn('product_attribute_values', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('variant_values', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variant_values', 'tenant_id').catch(() => null);
|
||||
}
|
||||
};
|
||||
@@ -42,6 +42,14 @@ export const connectDatabase = async () => {
|
||||
primaryKey: true
|
||||
}).catch(() => null);
|
||||
}
|
||||
const assetTypesCols = await qi.describeTable('asset_types').catch(() => null);
|
||||
if (assetTypesCols && !assetTypesCols.is_variant_eligible) {
|
||||
await qi.addColumn('asset_types', 'is_variant_eligible', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: false
|
||||
}).catch(() => null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Schema alignment warning:', err.message);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user