UNPKG

@vendure/core

Version:

A modern, headless ecommerce framework

824 lines 43.3 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ProductVariantService = void 0; const common_1 = require("@nestjs/common"); const generated_types_1 = require("@vendure/common/lib/generated-types"); const unique_1 = require("@vendure/common/lib/unique"); const typeorm_1 = require("typeorm"); const request_context_cache_service_1 = require("../../cache/request-context-cache.service"); const errors_1 = require("../../common/error/errors"); const instrument_decorator_1 = require("../../common/instrument-decorator"); const round_money_1 = require("../../common/round-money"); const utils_1 = require("../../common/utils"); const config_service_1 = require("../../config/config.service"); const transactional_connection_1 = require("../../connection/transactional-connection"); const entity_1 = require("../../entity"); const product_option_entity_1 = require("../../entity/product-option/product-option.entity"); const product_variant_translation_entity_1 = require("../../entity/product-variant/product-variant-translation.entity"); const product_variant_entity_1 = require("../../entity/product-variant/product-variant.entity"); const product_entity_1 = require("../../entity/product/product.entity"); const event_bus_1 = require("../../event-bus/event-bus"); const product_variant_channel_event_1 = require("../../event-bus/events/product-variant-channel-event"); const product_variant_event_1 = require("../../event-bus/events/product-variant-event"); const product_variant_price_event_1 = require("../../event-bus/events/product-variant-price-event"); const custom_field_relation_service_1 = require("../helpers/custom-field-relation/custom-field-relation.service"); const list_query_builder_1 = require("../helpers/list-query-builder/list-query-builder"); const product_price_applicator_1 = require("../helpers/product-price-applicator/product-price-applicator"); const translatable_saver_1 = require("../helpers/translatable-saver/translatable-saver"); const translator_service_1 = require("../helpers/translator/translator.service"); const patch_entity_1 = require("../helpers/utils/patch-entity"); const samples_each_1 = require("../helpers/utils/samples-each"); const asset_service_1 = require("./asset.service"); const channel_service_1 = require("./channel.service"); const facet_value_service_1 = require("./facet-value.service"); const global_settings_service_1 = require("./global-settings.service"); const role_service_1 = require("./role.service"); const stock_level_service_1 = require("./stock-level.service"); const stock_movement_service_1 = require("./stock-movement.service"); const tax_category_service_1 = require("./tax-category.service"); /** * @description * Contains methods relating to {@link ProductVariant} entities. * * @docsCategory services */ let ProductVariantService = class ProductVariantService { constructor(connection, configService, taxCategoryService, facetValueService, assetService, translatableSaver, eventBus, listQueryBuilder, globalSettingsService, stockMovementService, stockLevelService, channelService, roleService, customFieldRelationService, requestCache, productPriceApplicator, translator) { this.connection = connection; this.configService = configService; this.taxCategoryService = taxCategoryService; this.facetValueService = facetValueService; this.assetService = assetService; this.translatableSaver = translatableSaver; this.eventBus = eventBus; this.listQueryBuilder = listQueryBuilder; this.globalSettingsService = globalSettingsService; this.stockMovementService = stockMovementService; this.stockLevelService = stockLevelService; this.channelService = channelService; this.roleService = roleService; this.customFieldRelationService = customFieldRelationService; this.requestCache = requestCache; this.productPriceApplicator = productPriceApplicator; this.translator = translator; } async findAll(ctx, options) { const relations = ['featuredAsset', 'taxCategory', 'channels']; const customPropertyMap = {}; const hasFacetValueIdFilter = this.listQueryBuilder.filterObjectHasProperty(options === null || options === void 0 ? void 0 : options.filter, 'facetValueId'); if (hasFacetValueIdFilter) { relations.push('facetValues'); customPropertyMap.facetValueId = 'facetValues.id'; } return this.listQueryBuilder .build(product_variant_entity_1.ProductVariant, options, { relations, channelId: ctx.channelId, where: { deletedAt: (0, typeorm_1.IsNull)() }, ctx, customPropertyMap, }) .getManyAndCount() .then(async ([variants, totalItems]) => { const items = await this.applyPricesAndTranslateVariants(ctx, variants); return { items, totalItems, }; }); } findOne(ctx, productVariantId, relations) { return this.connection .findOneInChannel(ctx, product_variant_entity_1.ProductVariant, productVariantId, ctx.channelId, { relations: [ ...(relations || ['product', 'featuredAsset', 'product.featuredAsset']), 'taxCategory', ], where: { deletedAt: (0, typeorm_1.IsNull)() }, }) .then(async (result) => { if (result) { return this.translator.translate(await this.applyChannelPriceAndTax(result, ctx), ctx, [ 'product', ]); } }); } findByIds(ctx, ids) { return this.connection .findByIdsInChannel(ctx, product_variant_entity_1.ProductVariant, ids, ctx.channelId, { relations: [ 'options', 'facetValues', 'facetValues.facet', 'taxCategory', 'assets', 'featuredAsset', ], }) .then(variants => this.applyPricesAndTranslateVariants(ctx, variants)); } getVariantsByProductId(ctx, productId, options = {}, relations) { const qb = this.listQueryBuilder .build(product_variant_entity_1.ProductVariant, options, { relations: [ ...(relations || [ 'options', 'facetValues', 'facetValues.facet', 'assets', 'featuredAsset', ]), 'taxCategory', ], orderBy: { id: 'ASC' }, where: { deletedAt: (0, typeorm_1.IsNull)() }, ctx, }) .innerJoinAndSelect('productvariant.channels', 'channel', 'channel.id = :channelId', { channelId: ctx.channelId, }) .innerJoinAndSelect('productvariant.product', 'product', 'product.id = :productId', { productId, }); if (ctx.apiType === 'shop') { qb.andWhere('productvariant.enabled = :enabled', { enabled: true }); } return qb.getManyAndCount().then(async ([variants, totalItems]) => { const items = await this.applyPricesAndTranslateVariants(ctx, variants); return { items, totalItems, }; }); } /** * @description * Returns a {@link PaginatedList} of all ProductVariants associated with the given Collection. */ getVariantsByCollectionId(ctx, collectionId, options, relations = []) { const qb = this.listQueryBuilder .build(product_variant_entity_1.ProductVariant, options, { relations: (0, unique_1.unique)([...relations, 'taxCategory']), channelId: ctx.channelId, ctx, }) .leftJoin('productvariant.collections', 'collection') .leftJoin('productvariant.product', 'product') .andWhere('product.deletedAt IS NULL') .andWhere('productvariant.deletedAt IS NULL') .andWhere('collection.id = :collectionId', { collectionId }); if (options && options.filter && options.filter.enabled && options.filter.enabled.eq === true) { qb.andWhere('product.enabled = :enabled', { enabled: true }); } return qb.getManyAndCount().then(async ([variants, totalItems]) => { const items = await this.applyPricesAndTranslateVariants(ctx, variants); return { items, totalItems, }; }); } /** * @description * Returns all Channels to which the ProductVariant is assigned. */ async getProductVariantChannels(ctx, productVariantId) { const variant = await this.connection.getEntityOrThrow(ctx, product_variant_entity_1.ProductVariant, productVariantId, { relations: ['channels'], channelId: ctx.channelId, }); return variant.channels; } async getProductVariantPrices(ctx, productVariantId) { return this.connection .getRepository(ctx, entity_1.ProductVariantPrice) .createQueryBuilder('pvp') .where('pvp.variant = :productVariantId', { productVariantId }) .andWhere('pvp.channelId = :channelId', { channelId: ctx.channelId }) .getMany(); } /** * @description * Returns the ProductVariant associated with the given {@link OrderLine}. */ async getVariantByOrderLineId(ctx, orderLineId) { const { productVariant } = await this.connection.getEntityOrThrow(ctx, entity_1.OrderLine, orderLineId, { relations: ['productVariant', 'productVariant.taxCategory'], includeSoftDeleted: true, }); return this.translator.translate(await this.applyChannelPriceAndTax(productVariant, ctx), ctx); } /** * @description * Returns the {@link ProductOption}s for the given ProductVariant. */ getOptionsForVariant(ctx, variantId) { return this.connection .findOneInChannel(ctx, product_variant_entity_1.ProductVariant, variantId, ctx.channelId, { relations: ['options'], }) .then(variant => (!variant ? [] : variant.options.map(o => this.translator.translate(o, ctx)))); } getFacetValuesForVariant(ctx, variantId) { return this.connection .findOneInChannel(ctx, product_variant_entity_1.ProductVariant, variantId, ctx.channelId, { relations: ['facetValues', 'facetValues.facet', 'facetValues.channels'], }) .then(variant => !variant ? [] : variant.facetValues.map(o => this.translator.translate(o, ctx, ['facet']))); } /** * @description * Returns the Product associated with the ProductVariant. Whereas the `ProductService.findOne()` * method performs a large multi-table join with all the typical data needed for a "product detail" * page, this method returns only the Product itself. */ async getProductForVariant(ctx, variant) { let product; if (!variant.product) { product = await this.connection.getEntityOrThrow(ctx, product_entity_1.Product, variant.productId, { includeSoftDeleted: true, }); } else { product = variant.product; } return this.translator.translate(product, ctx); } /** * @description * Returns the number of saleable units of the ProductVariant, i.e. how many are available * for purchase by Customers. This is determined by the ProductVariant's `stockOnHand` value, * as well as the local and global `outOfStockThreshold` settings. */ async getSaleableStockLevel(ctx, variant) { const { outOfStockThreshold, trackInventory } = await this.globalSettingsService.getSettings(ctx); const inventoryNotTracked = variant.trackInventory === generated_types_1.GlobalFlag.FALSE || (variant.trackInventory === generated_types_1.GlobalFlag.INHERIT && trackInventory === false); if (inventoryNotTracked) { return Number.MAX_SAFE_INTEGER; } const { stockOnHand, stockAllocated } = await this.stockLevelService.getAvailableStock(ctx, variant.id); const effectiveOutOfStockThreshold = variant.useGlobalOutOfStockThreshold ? outOfStockThreshold : variant.outOfStockThreshold; return stockOnHand - stockAllocated - effectiveOutOfStockThreshold; } async getOutOfStockThreshold(ctx, variant) { const { outOfStockThreshold, trackInventory } = await this.globalSettingsService.getSettings(ctx); const inventoryNotTracked = variant.trackInventory === generated_types_1.GlobalFlag.FALSE || (variant.trackInventory === generated_types_1.GlobalFlag.INHERIT && trackInventory === false); if (inventoryNotTracked) { return 0; } else { return variant.useGlobalOutOfStockThreshold ? outOfStockThreshold : variant.outOfStockThreshold; } } /** * @description * Returns the stockLevel to display to the customer, as specified by the configured * {@link StockDisplayStrategy}. */ async getDisplayStockLevel(ctx, variant) { const { stockDisplayStrategy } = this.configService.catalogOptions; const saleableStockLevel = await this.getSaleableStockLevel(ctx, variant); return stockDisplayStrategy.getStockLevel(ctx, variant, saleableStockLevel); } /** * @description * Returns the number of fulfillable units of the ProductVariant, equivalent to stockOnHand * for those variants which are tracking inventory. */ async getFulfillableStockLevel(ctx, variant) { const { outOfStockThreshold, trackInventory } = await this.globalSettingsService.getSettings(ctx); const inventoryNotTracked = variant.trackInventory === generated_types_1.GlobalFlag.FALSE || (variant.trackInventory === generated_types_1.GlobalFlag.INHERIT && trackInventory === false); if (inventoryNotTracked) { return Number.MAX_SAFE_INTEGER; } const { stockOnHand } = await this.stockLevelService.getAvailableStock(ctx, variant.id); return stockOnHand; } async create(ctx, input) { const ids = []; for (const productInput of input) { const id = await this.createSingle(ctx, productInput); ids.push(id); } const createdVariants = await this.findByIds(ctx, ids); await this.eventBus.publish(new product_variant_event_1.ProductVariantEvent(ctx, createdVariants, 'created', input)); return createdVariants; } async update(ctx, input) { for (const productInput of input) { await this.updateSingle(ctx, productInput); } const updatedVariants = await this.findByIds(ctx, input.map(i => i.id)); await this.eventBus.publish(new product_variant_event_1.ProductVariantEvent(ctx, updatedVariants, 'updated', input)); return updatedVariants; } async createSingle(ctx, input) { await this.validateVariantOptionIds(ctx, input.productId, input.optionIds); if (!input.optionIds) { input.optionIds = []; } if (input.price == null) { input.price = 0; } input.taxCategoryId = (await this.getTaxCategoryForNewVariant(ctx, input.taxCategoryId)).id; const inputWithoutPrice = Object.assign({}, input); delete inputWithoutPrice.price; const createdVariant = await this.translatableSaver.create({ ctx, input: inputWithoutPrice, entityType: product_variant_entity_1.ProductVariant, translationType: product_variant_translation_entity_1.ProductVariantTranslation, beforeSave: async (variant) => { const { optionIds } = input; if (optionIds && optionIds.length) { const selectedOptions = await this.connection .getRepository(ctx, product_option_entity_1.ProductOption) .find({ where: { id: (0, typeorm_1.In)(optionIds) } }); variant.options = selectedOptions; } if (input.facetValueIds) { variant.facetValues = await this.facetValueService.findByIds(ctx, input.facetValueIds); } variant.product = { id: input.productId }; variant.taxCategory = { id: input.taxCategoryId }; await this.assetService.updateFeaturedAsset(ctx, variant, input); await this.channelService.assignToCurrentChannel(variant, ctx); }, typeOrmSubscriberData: { channelId: ctx.channelId, taxCategoryId: input.taxCategoryId, }, }); await this.customFieldRelationService.updateRelations(ctx, product_variant_entity_1.ProductVariant, input, createdVariant); await this.assetService.updateEntityAssets(ctx, createdVariant, input); if (input.stockOnHand != null || input.stockLevels) { await this.stockMovementService.adjustProductVariantStock(ctx, createdVariant.id, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion input.stockLevels || input.stockOnHand); } const defaultChannel = await this.channelService.getDefaultChannel(ctx); await this.createOrUpdateProductVariantPrice(ctx, createdVariant.id, input.price, ctx.channelId); if (!(0, utils_1.idsAreEqual)(ctx.channelId, defaultChannel.id)) { // When creating a ProductVariant _not_ in the default Channel, we still need to // create a ProductVariantPrice for it in the default Channel, otherwise errors will // result when trying to query it there. await this.createOrUpdateProductVariantPrice(ctx, createdVariant.id, input.price, defaultChannel.id, defaultChannel.defaultCurrencyCode); } // Seed a StockLevel for the current channel's stock locations (idempotent), so that for a // variant created directly within a non-default channel the channel-filtered `stockLevels` // field resolves to a real entry rather than an empty array until stock is first adjusted. await this.ensureStockLevelsForChannel(ctx, [createdVariant.id], ctx.channelId); return createdVariant.id; } async updateSingle(ctx, input) { const existingVariant = await this.connection.getEntityOrThrow(ctx, product_variant_entity_1.ProductVariant, input.id, { channelId: ctx.channelId, relations: ['facetValues', 'facetValues.channels'], }); const outOfStockThreshold = await this.getOutOfStockThreshold(ctx, existingVariant); if (input.stockOnHand && input.stockOnHand < outOfStockThreshold) { throw new errors_1.UserInputError('error.stockonhand-cannot-be-negative'); } if (input.optionIds) { await this.validateVariantOptionIds(ctx, existingVariant.productId, input.optionIds, true); } const inputWithoutPriceAndStockLevels = Object.assign({}, input); delete inputWithoutPriceAndStockLevels.price; delete inputWithoutPriceAndStockLevels.stockLevels; const updatedVariant = await this.translatableSaver.update({ ctx, input: inputWithoutPriceAndStockLevels, entityType: product_variant_entity_1.ProductVariant, translationType: product_variant_translation_entity_1.ProductVariantTranslation, beforeSave: async (v) => { if (input.taxCategoryId) { const taxCategory = await this.taxCategoryService.findOne(ctx, input.taxCategoryId); if (taxCategory) { v.taxCategory = taxCategory; } } if (input.optionIds && input.optionIds.length) { const selectedOptions = await this.connection .getRepository(ctx, product_option_entity_1.ProductOption) .find({ where: { id: (0, typeorm_1.In)(input.optionIds) } }); v.options = selectedOptions; } if (input.facetValueIds) { const facetValuesInOtherChannels = existingVariant.facetValues.filter(fv => fv.channels.every(channel => !(0, utils_1.idsAreEqual)(channel.id, ctx.channelId))); v.facetValues = [ ...facetValuesInOtherChannels, ...(await this.facetValueService.findByIds(ctx, input.facetValueIds)), ]; } if (input.stockOnHand != null || input.stockLevels) { await this.stockMovementService.adjustProductVariantStock(ctx, existingVariant.id, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion input.stockLevels || input.stockOnHand); } await this.assetService.updateFeaturedAsset(ctx, v, input); await this.assetService.updateEntityAssets(ctx, v, input); }, typeOrmSubscriberData: { channelId: ctx.channelId, taxCategoryId: input.taxCategoryId, }, }); await this.customFieldRelationService.updateRelations(ctx, product_variant_entity_1.ProductVariant, input, updatedVariant); if (input.price != null) { await this.createOrUpdateProductVariantPrice(ctx, input.id, input.price, ctx.channelId); } if (input.prices) { for (const priceInput of input.prices) { if (priceInput.delete === true) { await this.deleteProductVariantPrice(ctx, input.id, ctx.channelId, priceInput.currencyCode); } else { await this.createOrUpdateProductVariantPrice(ctx, input.id, priceInput.price, ctx.channelId, priceInput.currencyCode, priceInput.customFields); } } } return updatedVariant.id; } /** * @description * Creates a {@link ProductVariantPrice} for the given ProductVariant/Channel combination. * If the `currencyCode` is not specified, the default currency of the Channel will be used. */ async createOrUpdateProductVariantPrice(ctx, productVariantId, price, channelId, currencyCode, customFields) { const { productVariantPriceUpdateStrategy } = this.configService.catalogOptions; const allPrices = await this.connection.getRepository(ctx, entity_1.ProductVariantPrice).find({ where: { variant: { id: productVariantId }, }, }); let targetPrice = allPrices.find(p => (0, utils_1.idsAreEqual)(p.channelId, channelId) && p.currencyCode === (currencyCode !== null && currencyCode !== void 0 ? currencyCode : ctx.channel.defaultCurrencyCode)); if (currencyCode) { const channel = await this.channelService.findOne(ctx, channelId); if (!(channel === null || channel === void 0 ? void 0 : channel.availableCurrencyCodes.includes(currencyCode))) { throw new errors_1.UserInputError('error.currency-not-available-in-channel', { currencyCode, }); } } let additionalPricesToUpdate = []; if (!targetPrice) { const createdPrice = await this.connection.getRepository(ctx, entity_1.ProductVariantPrice).save(new entity_1.ProductVariantPrice({ channelId, price, variant: new product_variant_entity_1.ProductVariant({ id: productVariantId }), currencyCode: currencyCode !== null && currencyCode !== void 0 ? currencyCode : ctx.channel.defaultCurrencyCode, customFields, })); if (customFields) { await this.customFieldRelationService.updateRelations(ctx, entity_1.ProductVariantPrice, customFields, createdPrice); } await this.eventBus.publish(new product_variant_price_event_1.ProductVariantPriceEvent(ctx, [createdPrice], 'created')); additionalPricesToUpdate = await productVariantPriceUpdateStrategy.onPriceCreated(ctx, createdPrice, allPrices); targetPrice = createdPrice; } else { (0, patch_entity_1.patchEntity)(targetPrice, { price, customFields: customFields || targetPrice.customFields, }); const updatedPrice = await this.connection .getRepository(ctx, entity_1.ProductVariantPrice) .save(targetPrice); if (customFields) { await this.customFieldRelationService.updateRelations(ctx, entity_1.ProductVariantPrice, customFields, updatedPrice); } await this.eventBus.publish(new product_variant_price_event_1.ProductVariantPriceEvent(ctx, [updatedPrice], 'updated')); additionalPricesToUpdate = await productVariantPriceUpdateStrategy.onPriceUpdated(ctx, updatedPrice, allPrices); } const uniqueAdditionalPricesToUpdate = (0, unique_1.unique)(additionalPricesToUpdate, 'id').filter(p => // We don't save the targetPrice again unless it has been assigned // a different price by the ProductVariantPriceUpdateStrategy. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion !((0, utils_1.idsAreEqual)(p.id, targetPrice.id) && p.price === targetPrice.price)); if (uniqueAdditionalPricesToUpdate.length) { const updatedAdditionalPrices = await this.connection .getRepository(ctx, entity_1.ProductVariantPrice) .save(uniqueAdditionalPricesToUpdate); await this.eventBus.publish(new product_variant_price_event_1.ProductVariantPriceEvent(ctx, updatedAdditionalPrices, 'updated')); } return targetPrice; } async deleteProductVariantPrice(ctx, variantId, channelId, currencyCode) { const variantPrice = await this.connection.getRepository(ctx, entity_1.ProductVariantPrice).findOne({ where: { variant: { id: variantId }, channelId, currencyCode, }, }); if (variantPrice) { await this.connection.getRepository(ctx, entity_1.ProductVariantPrice).remove(variantPrice); await this.eventBus.publish(new product_variant_price_event_1.ProductVariantPriceEvent(ctx, [variantPrice], 'deleted')); const { productVariantPriceUpdateStrategy } = this.configService.catalogOptions; const allPrices = await this.connection.getRepository(ctx, entity_1.ProductVariantPrice).find({ where: { variant: { id: variantId }, }, }); const additionalPricesToUpdate = await productVariantPriceUpdateStrategy.onPriceDeleted(ctx, variantPrice, allPrices); if (additionalPricesToUpdate.length) { const updatedAdditionalPrices = await this.connection .getRepository(ctx, entity_1.ProductVariantPrice) .save(additionalPricesToUpdate); await this.eventBus.publish(new product_variant_price_event_1.ProductVariantPriceEvent(ctx, updatedAdditionalPrices, 'updated')); } } } async softDelete(ctx, id) { const ids = Array.isArray(id) ? id : [id]; const variants = await this.connection .getRepository(ctx, product_variant_entity_1.ProductVariant) .find({ where: { id: (0, typeorm_1.In)(ids) } }); for (const variant of variants) { variant.deletedAt = new Date(); } await this.connection.getRepository(ctx, product_variant_entity_1.ProductVariant).save(variants, { reload: false }); await this.eventBus.publish(new product_variant_event_1.ProductVariantEvent(ctx, variants, 'deleted', id)); return { result: generated_types_1.DeletionResult.DELETED, }; } /** * @description * This method is intended to be used by the ProductVariant GraphQL entity resolver to resolve the * price-related fields which need to be populated at run-time using the `applyChannelPriceAndTax` * method. * * Is optimized to make as few DB calls as possible using caching based on the open request. */ async hydratePriceFields(ctx, variant, priceField) { const cacheKey = `hydrate-variant-price-fields-${variant.id}`; let populatePricesPromise = this.requestCache.get(ctx, cacheKey); if (!populatePricesPromise) { // eslint-disable-next-line @typescript-eslint/no-misused-promises populatePricesPromise = new Promise(async (resolve, reject) => { var _a; try { if (!((_a = variant.productVariantPrices) === null || _a === void 0 ? void 0 : _a.length)) { const variantWithPrices = await this.connection.getEntityOrThrow(ctx, product_variant_entity_1.ProductVariant, variant.id, { relations: ['productVariantPrices'], includeSoftDeleted: true }); variant.productVariantPrices = variantWithPrices.productVariantPrices; } if (!variant.taxCategory) { const variantWithTaxCategory = await this.connection.getEntityOrThrow(ctx, product_variant_entity_1.ProductVariant, variant.id, { relations: ['taxCategory'], includeSoftDeleted: true }); variant.taxCategory = variantWithTaxCategory.taxCategory; } resolve(await this.applyChannelPriceAndTax(variant, ctx, undefined, true)); } catch (e) { reject(e); } }); this.requestCache.set(ctx, cacheKey, populatePricesPromise); } const hydratedVariant = await populatePricesPromise; return hydratedVariant[priceField]; } /** * @description * Given an array of ProductVariants from the database, this method will apply the correct price and tax * and translate each item. */ async applyPricesAndTranslateVariants(ctx, variants) { return await Promise.all(variants.map(async (variant) => { const variantWithPrices = await this.applyChannelPriceAndTax(variant, ctx); return this.translator.translate(variantWithPrices, ctx, [ 'options', 'facetValues', ['facetValues', 'facet'], ]); })); } /** * @description * Populates the `price` field with the price for the specified channel. */ async applyChannelPriceAndTax(variant, ctx, order, throwIfNoPriceFound = false) { return this.productPriceApplicator.applyChannelPriceAndTax(variant, ctx, order, throwIfNoPriceFound); } /** * @description * Assigns the specified ProductVariants to the specified Channel. In doing so, it will create a new * {@link ProductVariantPrice} and also assign the associated Product and any Assets to the Channel too. */ async assignProductVariantsToChannel(ctx, input) { var _a; const hasPermission = await this.roleService.userHasPermissionOnChannel(ctx, input.channelId, generated_types_1.Permission.UpdateCatalog); if (!hasPermission) { throw new errors_1.ForbiddenError(); } const variants = await this.connection.getRepository(ctx, product_variant_entity_1.ProductVariant).find({ where: { id: (0, typeorm_1.In)(input.productVariantIds), }, relations: ['taxCategory', 'assets'], }); const priceFactor = input.priceFactor != null ? input.priceFactor : 1; const targetChannel = await this.connection.getEntityOrThrow(ctx, entity_1.Channel, input.channelId); const assignedVariantIds = []; for (const variant of variants) { if (variant.deletedAt) { continue; } await this.applyChannelPriceAndTax(variant, ctx); await this.channelService.assignToChannels(ctx, product_entity_1.Product, variant.productId, [input.channelId]); await this.channelService.assignToChannels(ctx, product_variant_entity_1.ProductVariant, variant.id, [input.channelId]); const price = targetChannel.pricesIncludeTax ? variant.priceWithTax : variant.price; await this.createOrUpdateProductVariantPrice(ctx, variant.id, (0, round_money_1.roundMoney)(price * priceFactor), input.channelId, targetChannel.defaultCurrencyCode); const assetIds = ((_a = variant.assets) === null || _a === void 0 ? void 0 : _a.map(a => a.assetId)) || []; await this.assetService.assignToChannel(ctx, { channelId: input.channelId, assetIds }); assignedVariantIds.push(variant.id); } // Seed a StockLevel for each of the target channel's stock locations so that per-channel // inventory is usable immediately; otherwise the channel-filtered `stockLevels` field // resolves to `[]` in the newly-assigned channel until stock is first adjusted there. await this.ensureStockLevelsForChannel(ctx, assignedVariantIds, input.channelId); const result = await this.findByIds(ctx, variants.map(v => v.id)); for (const variant of variants) { await this.eventBus.publish(new product_variant_channel_event_1.ProductVariantChannelEvent(ctx, variant, input.channelId, 'assigned')); } return result; } /** * Ensures a `stockOnHand: 0` StockLevel exists for each given variant at every StockLocation of * the given channel. Idempotent, batched and concurrency-safe: it issues a single bulk insert * with `orIgnore()`, so rows that already exist (unique productVariantId + stockLocationId) are * skipped at the DB level and never overwritten — even under concurrent assignment/create requests. */ async ensureStockLevelsForChannel(ctx, variantIds, channelId) { if (variantIds.length === 0) { return; } const stockLocations = await this.connection .getRepository(ctx, entity_1.StockLocation) .createQueryBuilder('stockLocation') .innerJoin('stockLocation.channels', 'channel') .where('channel.id = :channelId', { channelId }) .getMany(); if (stockLocations.length === 0) { return; } const newStockLevels = variantIds.flatMap(productVariantId => stockLocations.map(stockLocation => ({ productVariantId, stockLocationId: stockLocation.id, stockOnHand: 0, stockAllocated: 0, }))); await this.connection .getRepository(ctx, entity_1.StockLevel) .createQueryBuilder() .insert() .values(newStockLevels) // Fix for MySQL and MariaDB < 10.5: updateEntity(false) prevents TypeORM from using the // RETURNING clause after the INSERT IGNORE, where an ignored-duplicate row reports // insertId 0 and the re-select would otherwise throw. The seeded ids are never used here. .updateEntity(false) .orIgnore() .execute(); } async removeProductVariantsFromChannel(ctx, input) { const hasPermission = await this.roleService.userHasPermissionOnChannel(ctx, input.channelId, generated_types_1.Permission.UpdateCatalog); if (!hasPermission) { throw new errors_1.ForbiddenError(); } const defaultChannel = await this.channelService.getDefaultChannel(ctx); if ((0, utils_1.idsAreEqual)(input.channelId, defaultChannel.id)) { throw new errors_1.UserInputError('error.items-cannot-be-removed-from-default-channel'); } const variants = await this.connection .getRepository(ctx, product_variant_entity_1.ProductVariant) .find({ where: { id: (0, typeorm_1.In)(input.productVariantIds) } }); for (const variant of variants) { await this.channelService.removeFromChannels(ctx, product_variant_entity_1.ProductVariant, variant.id, [input.channelId]); await this.connection.getRepository(ctx, entity_1.ProductVariantPrice).delete({ channelId: input.channelId, variant: { id: variant.id }, }); // If none of the ProductVariants is assigned to the Channel, remove the Channel from Product const productVariants = await this.connection.getRepository(ctx, product_variant_entity_1.ProductVariant).find({ where: { productId: variant.productId, }, relations: ['channels'], }); const productChannelsFromVariants = [].concat(...productVariants.map(pv => pv.channels)); if (!productChannelsFromVariants.find(c => c.id === input.channelId)) { await this.channelService.removeFromChannels(ctx, product_entity_1.Product, variant.productId, [ input.channelId, ]); } } const result = await this.findByIds(ctx, variants.map(v => v.id)); // Publish the events at the latest possible stage to decrease the chance of race conditions // whereby an event listener triggers a query which does not yet have access to the changes // within the current transaction. for (const variant of variants) { await this.eventBus.publish(new product_variant_channel_event_1.ProductVariantChannelEvent(ctx, variant, input.channelId, 'removed')); } return result; } async validateVariantOptionIds(ctx, productId, optionIds = [], isUpdateOperation) { // this could be done with fewer queries but depending on the data, node will crash // https://github.com/vendurehq/vendure/issues/328 const optionGroups = (await this.connection.getEntityOrThrow(ctx, product_entity_1.Product, productId, { channelId: ctx.channelId, relations: ['optionGroups', 'optionGroups.options'], loadEagerRelations: false, })).optionGroups; const activeOptions = optionGroups && optionGroups.filter(group => !group.deletedAt); if (optionIds.length !== activeOptions.length) { this.throwIncompatibleOptionsError(optionGroups); } if (!(0, samples_each_1.samplesEach)(optionIds, activeOptions.map(g => g.options.map(o => o.id)))) { this.throwIncompatibleOptionsError(optionGroups); } const product = await this.connection.getEntityOrThrow(ctx, product_entity_1.Product, productId, { channelId: ctx.channelId, relations: ['variants', 'variants.options'], loadEagerRelations: true, }); const inputOptionIds = this.sortJoin(optionIds, ','); product.variants .filter(v => !v.deletedAt) .forEach(variant => { const variantOptionIds = this.sortJoin(variant.options, ',', 'id'); if (isUpdateOperation) return; if (variantOptionIds === inputOptionIds) { throw new errors_1.UserInputError('error.product-variant-options-combination-already-exists', { variantName: this.translator.translate(variant, ctx).name, }); } }); } throwIncompatibleOptionsError(optionGroups) { throw new errors_1.UserInputError('error.product-variant-option-ids-not-compatible', { groupNames: this.sortJoin(optionGroups, ', ', 'code'), }); } sortJoin(arr, glue, prop) { return arr .map(x => (prop ? x[prop] : x)) .sort() .join(glue); } async getTaxCategoryForNewVariant(ctx, taxCategoryId) { var _a; let taxCategory; if (taxCategoryId) { taxCategory = await this.connection.getEntityOrThrow(ctx, entity_1.TaxCategory, taxCategoryId); } else { const taxCategories = await this.taxCategoryService.findAll(ctx); taxCategory = (_a = taxCategories.items.find(t => t.isDefault === true)) !== null && _a !== void 0 ? _a : taxCategories.items[0]; } if (!taxCategory) { // there is no TaxCategory set up, so create a default taxCategory = await this.taxCategoryService.create(ctx, { name: 'Standard Tax' }); } return taxCategory; } }; exports.ProductVariantService = ProductVariantService; exports.ProductVariantService = ProductVariantService = __decorate([ (0, common_1.Injectable)(), (0, instrument_decorator_1.Instrument)(), __metadata("design:paramtypes", [transactional_connection_1.TransactionalConnection, config_service_1.ConfigService, tax_category_service_1.TaxCategoryService, facet_value_service_1.FacetValueService, asset_service_1.AssetService, translatable_saver_1.TranslatableSaver, event_bus_1.EventBus, list_query_builder_1.ListQueryBuilder, global_settings_service_1.GlobalSettingsService, stock_movement_service_1.StockMovementService, stock_level_service_1.StockLevelService, channel_service_1.ChannelService, role_service_1.RoleService, custom_field_relation_service_1.CustomFieldRelationService, request_context_cache_service_1.RequestContextCacheService, product_price_applicator_1.ProductPriceApplicator, translator_service_1.TranslatorService]) ], ProductVariantService); //# sourceMappingURL=product-variant.service.js.map