1144 lines
51 KiB
C#
1144 lines
51 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Newtonsoft.Json;
|
|
using Nop.Core;
|
|
using Nop.Core.Domain.Directory;
|
|
using Nop.Core.Domain.Discounts;
|
|
using Nop.Core.Domain.Media;
|
|
using Nop.Plugin.Misc.Zettle.Domain;
|
|
using Nop.Plugin.Misc.Zettle.Domain.Api;
|
|
using Nop.Plugin.Misc.Zettle.Domain.Api.Image;
|
|
using Nop.Plugin.Misc.Zettle.Domain.Api.Inventory;
|
|
using Nop.Plugin.Misc.Zettle.Domain.Api.OAuth;
|
|
using Nop.Plugin.Misc.Zettle.Domain.Api.Product;
|
|
using Nop.Plugin.Misc.Zettle.Domain.Api.Pusher;
|
|
using Nop.Plugin.Misc.Zettle.Domain.Api.Secure;
|
|
using Nop.Services.Catalog;
|
|
using Nop.Services.Configuration;
|
|
using Nop.Services.Directory;
|
|
using Nop.Services.Discounts;
|
|
using Nop.Services.Logging;
|
|
using Nop.Services.Media;
|
|
|
|
namespace Nop.Plugin.Misc.Zettle.Services;
|
|
|
|
/// <summary>
|
|
/// Represents the plugin service
|
|
/// </summary>
|
|
public class ZettleService
|
|
{
|
|
#region Fields
|
|
|
|
protected readonly CurrencySettings _currencySettings;
|
|
protected readonly ICurrencyService _currencyService;
|
|
protected readonly IDiscountService _discountService;
|
|
protected readonly ILogger _logger;
|
|
protected readonly IPictureService _pictureService;
|
|
protected readonly IProductAttributeParser _productAttributeParser;
|
|
protected readonly IProductAttributeService _productAttributeService;
|
|
protected readonly IProductService _productService;
|
|
protected readonly ISettingService _settingService;
|
|
protected readonly IWorkContext _workContext;
|
|
protected readonly MediaSettings _mediaSettings;
|
|
protected readonly ZettleHttpClient _zettleHttpClient;
|
|
protected readonly ZettleRecordService _zettleRecordService;
|
|
protected readonly ZettleSettings _zettleSettings;
|
|
|
|
protected Dictionary<string, string> _locations = new();
|
|
|
|
#endregion
|
|
|
|
#region Ctor
|
|
|
|
public ZettleService(CurrencySettings currencySettings,
|
|
ICurrencyService currencyService,
|
|
IDiscountService discountService,
|
|
ILogger logger,
|
|
IPictureService pictureService,
|
|
IProductAttributeParser productAttributeParser,
|
|
IProductAttributeService productAttributeService,
|
|
IProductService productService,
|
|
ISettingService settingService,
|
|
IWorkContext workContext,
|
|
MediaSettings mediaSettings,
|
|
ZettleHttpClient zettleHttpClient,
|
|
ZettleRecordService zettleRecordService,
|
|
ZettleSettings zettleSettings)
|
|
{
|
|
_currencySettings = currencySettings;
|
|
_currencyService = currencyService;
|
|
_discountService = discountService;
|
|
_logger = logger;
|
|
_pictureService = pictureService;
|
|
_productAttributeParser = productAttributeParser;
|
|
_productAttributeService = productAttributeService;
|
|
_productService = productService;
|
|
_settingService = settingService;
|
|
_workContext = workContext;
|
|
_mediaSettings = mediaSettings;
|
|
_zettleHttpClient = zettleHttpClient;
|
|
_zettleRecordService = zettleRecordService;
|
|
_zettleSettings = zettleSettings;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Utilities
|
|
|
|
/// <summary>
|
|
/// Handle function and get result
|
|
/// </summary>
|
|
/// <typeparam name="TResult">Result type</typeparam>
|
|
/// <param name="function">Function</param>
|
|
/// <param name="logErrors">Whether to log errors</param>
|
|
/// <returns>
|
|
/// A task that represents the asynchronous operation
|
|
/// The task result contains the result; error if exists
|
|
/// </returns>
|
|
protected async Task<(TResult Result, string Error)> HandleFunctionAsync<TResult>(Func<Task<TResult>> function, bool logErrors = true)
|
|
{
|
|
try
|
|
{
|
|
//ensure that plugin is configured
|
|
if (!IsConfigured(_zettleSettings))
|
|
throw new NopException("Plugin not configured");
|
|
|
|
return (await function(), default);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
var errorMessage = exception.Message;
|
|
if (logErrors)
|
|
{
|
|
var logMessage = $"{ZettleDefaults.SystemName} error: {Environment.NewLine}{errorMessage}";
|
|
await _logger.ErrorAsync(logMessage, exception, await _workContext.GetCurrentCustomerAsync());
|
|
}
|
|
|
|
return (default, errorMessage);
|
|
}
|
|
}
|
|
|
|
#region Sync
|
|
|
|
/// <summary>
|
|
/// Import discounts to Zettle library
|
|
/// </summary>
|
|
/// <param name="log">Log message</param>
|
|
/// <returns>A task that represents the asynchronous operation</returns>
|
|
protected async Task ImportDiscountsAsync(StringBuilder log)
|
|
{
|
|
//if enabled
|
|
if (!_zettleSettings.DiscountSyncEnabled)
|
|
return;
|
|
|
|
log.AppendLine("Add discounts...");
|
|
|
|
var storeCurrency = await _currencyService.GetCurrencyByIdAsync(_currencySettings.PrimaryStoreCurrencyId);
|
|
|
|
//add only assigned to order subtotal discounts
|
|
var existingDiscounts = await _zettleHttpClient.RequestAsync<GetDiscountsRequest, DiscountList>(new());
|
|
var discounts = await _discountService.GetAllDiscountsAsync(DiscountType.AssignedToOrderSubTotal, showHidden: true);
|
|
var discountsToAdd = discounts
|
|
.Where(discount => !existingDiscounts.Any(existingDiscount => existingDiscount.ExternalReference == discount.Id.ToString()))
|
|
.ToList();
|
|
|
|
foreach (var discount in discountsToAdd)
|
|
{
|
|
var request = new CreateDiscountRequest
|
|
{
|
|
Uuid = GuidGenerator.GenerateTimeBasedGuid().ToString(),
|
|
Name = discount.Name,
|
|
Description = discount.Name,
|
|
ExternalReference = discount.Id.ToString()
|
|
};
|
|
if (!discount.UsePercentage)
|
|
{
|
|
request.Amount = new Domain.Api.Product.Discount.DiscountAmount
|
|
{
|
|
CurrencyId = storeCurrency.CurrencyCode.ToUpper(),
|
|
Amount = storeCurrency.CurrencyCode.ToUpper() switch
|
|
{
|
|
"JPY" or "ISK" => Convert.ToInt32(Math.Round(discount.DiscountAmount, 0)),
|
|
_ => Convert.ToInt32(Math.Round(discount.DiscountAmount * 100, 0))
|
|
}
|
|
};
|
|
}
|
|
else
|
|
request.Percentage = discount.DiscountPercentage;
|
|
|
|
log.AppendLine($"\tAdd discount '{discount.Name}'");
|
|
|
|
await _zettleHttpClient.RequestAsync<CreateDiscountRequest, ApiResponse>(request);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete products from Zettle library
|
|
/// </summary>
|
|
/// <param name="log">Log message</param>
|
|
/// <returns>A task that represents the asynchronous operation</returns>
|
|
protected async Task ImportDeletedAsync(StringBuilder log)
|
|
{
|
|
log.AppendLine("Delete products...");
|
|
|
|
//get records to delete
|
|
var records = await _zettleRecordService
|
|
.GetAllRecordsAsync(active: true, operationTypes: [OperationType.Delete]);
|
|
var idsToDelete = records
|
|
.Where(record => !string.IsNullOrEmpty(record.Uuid) && record.ProductId > 0 && record.CombinationId == 0)
|
|
.Select(record => record.Uuid)
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
if (idsToDelete.Any())
|
|
log.AppendLine($"\tDelete {idsToDelete.Count} products (#{string.Join(", #", idsToDelete)})");
|
|
|
|
//if needed, also delete all existing products
|
|
if (_zettleSettings.DeleteBeforeImport)
|
|
{
|
|
var idsToKeep = (await _zettleRecordService.GetAllRecordsAsync(productOnly: true, active: true))
|
|
.Where(record => !string.IsNullOrEmpty(record.Uuid))
|
|
.Select(record => record.Uuid)
|
|
.Distinct()
|
|
.Except(idsToDelete)
|
|
.ToList();
|
|
|
|
var products = await _zettleHttpClient.RequestAsync<GetProductsRequest, ProductList>(new());
|
|
var existingIds = products.Select(product => product.Uuid).ToList();
|
|
|
|
idsToDelete.AddRange(existingIds.Except(idsToKeep).ToList());
|
|
|
|
log.AppendLine($"\tAlso delete all existing library items before importing products");
|
|
}
|
|
|
|
idsToDelete = idsToDelete.Distinct().ToList();
|
|
if (idsToDelete.Any())
|
|
await _zettleHttpClient.RequestAsync<DeleteProductsRequest, ApiResponse>(new DeleteProductsRequest { ProductUuids = idsToDelete });
|
|
|
|
await _zettleRecordService.DeleteRecordsAsync(records.Select(record => record.Id).ToList());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Change product images in Zettle library
|
|
/// </summary>
|
|
/// <param name="log">Log message</param>
|
|
/// <returns>A task that represents the asynchronous operation</returns>
|
|
protected async Task ImportImageChangedAsync(StringBuilder log)
|
|
{
|
|
log.AppendLine("Change images...");
|
|
|
|
//upload new images
|
|
var records = (await _zettleRecordService
|
|
.GetAllRecordsAsync(active: true, operationTypes: [OperationType.ImageChanged]))
|
|
.Where(record => record.ImageSyncEnabled && !string.IsNullOrEmpty(record.Uuid))
|
|
.ToList();
|
|
await UploadImagesAsync(records, true, log);
|
|
|
|
//then update appropriate products
|
|
var products = records
|
|
.GroupBy(record => record.ProductId)
|
|
.Select(group => new
|
|
{
|
|
ProductRecord = group.FirstOrDefault(record => record.CombinationId == 0),
|
|
CombinationRecords = group.Where(record => record.CombinationId > 0 && !string.IsNullOrEmpty(record.VariantUuid)).ToList()
|
|
})
|
|
.ToList();
|
|
foreach (var product in products)
|
|
{
|
|
var existingProduct = await _zettleHttpClient
|
|
.RequestAsync<GetProductRequest, Product>(new GetProductRequest { Uuid = product.ProductRecord.Uuid });
|
|
var request = new UpdateProductRequest
|
|
{
|
|
Uuid = existingProduct.Uuid,
|
|
Name = existingProduct.Name,
|
|
ETag = $"\"{existingProduct.ETag}\""
|
|
};
|
|
if (!product.CombinationRecords.Any())
|
|
{
|
|
request.Presentation = new Product.ProductPresentation { ImageUrl = product.ProductRecord?.ImageUrl };
|
|
request.Variants =
|
|
[
|
|
new() { Uuid = product.ProductRecord.VariantUuid }
|
|
];
|
|
}
|
|
else
|
|
{
|
|
request.Variants = product.CombinationRecords.Select(record => new Product.ProductVariant
|
|
{
|
|
Uuid = record.VariantUuid,
|
|
Presentation = new Product.ProductPresentation { ImageUrl = record.ImageUrl }
|
|
}).ToList();
|
|
}
|
|
|
|
log.AppendLine($"\tAdd image to product #{product.ProductRecord.ProductId}");
|
|
|
|
await _zettleHttpClient.RequestAsync<UpdateProductRequest, ApiResponse>(request);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update inventory tracking balances in Zettle library
|
|
/// </summary>
|
|
/// <param name="log">Log message</param>
|
|
/// <returns>A task that represents the asynchronous operation</returns>
|
|
protected async Task ImportInventoryTrackingAsync(StringBuilder log)
|
|
{
|
|
log.AppendLine("Update inventory tracking...");
|
|
|
|
var records = (await _zettleRecordService
|
|
.GetAllRecordsAsync(active: true, operationTypes: [OperationType.Update]))
|
|
.Where(record => record.InventoryTrackingEnabled && !string.IsNullOrEmpty(record.Uuid))
|
|
.ToList();
|
|
if (!records.Any())
|
|
return;
|
|
|
|
var storeBalance = await _zettleHttpClient.RequestAsync<GetLocationInventoryBalanceRequest, LocationInventoryBalance>(new());
|
|
|
|
var products = records
|
|
.GroupBy(record => record.ProductId)
|
|
.Select(group => new
|
|
{
|
|
ProductRecord = group.FirstOrDefault(record => record.CombinationId == 0),
|
|
CombinationRecords = group.Where(record => record.CombinationId > 0 && !string.IsNullOrEmpty(record.VariantUuid)).ToList()
|
|
})
|
|
.Where(product => !storeBalance.TrackedProducts?.Contains(product.ProductRecord.Uuid, StringComparer.InvariantCultureIgnoreCase) ?? true)
|
|
.ToList();
|
|
if (!products.Any())
|
|
return;
|
|
|
|
var productChanges = new List<CreateTrackingRequest.ProductBalanceChange>();
|
|
var recordsToUpdate = new List<ZettleRecord>();
|
|
|
|
foreach (var product in products)
|
|
{
|
|
log.AppendLine($"\tStart inventory tracking for product #{product.ProductRecord.ProductId}");
|
|
|
|
//get current quantity if exists
|
|
var productQuantity = storeBalance.Variants
|
|
?.FirstOrDefault(balance => balance.ProductUuid == product.ProductRecord.Uuid && balance.VariantUuid == product.ProductRecord.VariantUuid)
|
|
?.Balance ?? 0;
|
|
(ZettleRecord Record, int StockQuantity, int? QuantityAdjustment) productRecordToStart = (product.ProductRecord, productQuantity, null);
|
|
var combinationRecordsToStart = new List<(ZettleRecord Record, int StockQuantity, int? QuantityAdjustment)>();
|
|
foreach (var combinationRecord in product.CombinationRecords)
|
|
{
|
|
//get current quantity if exists
|
|
var combinationQuantity = storeBalance.Variants
|
|
?.FirstOrDefault(balance => balance.ProductUuid == combinationRecord.Uuid && balance.VariantUuid == combinationRecord.VariantUuid)
|
|
?.Balance ?? 0;
|
|
combinationRecordsToStart.Add((combinationRecord, combinationQuantity, null));
|
|
}
|
|
var productChange = await PrepareInventoryBalanceChangeAsync(InventoryBalanceChangeType.StartTracking,
|
|
productRecordToStart, combinationRecordsToStart);
|
|
if (productChange is null)
|
|
continue;
|
|
|
|
productChanges.Add(productChange);
|
|
recordsToUpdate.AddRange(product.CombinationRecords.Union([product.ProductRecord]));
|
|
}
|
|
|
|
await UpdateInventoryBalanceAsync(productChanges, recordsToUpdate);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create or update products in Zettle library
|
|
/// </summary>
|
|
/// <param name="log">Log message</param>
|
|
/// <returns>
|
|
/// A task that represents the asynchronous operation
|
|
/// The task result contains the import details
|
|
/// </returns>
|
|
protected async Task<Import> ImportCreatedOrUpdatedAsync(StringBuilder log)
|
|
{
|
|
log.AppendLine("Create and update products...");
|
|
|
|
//check currency match
|
|
var storeCurrency = await _currencyService.GetCurrencyByIdAsync(_currencySettings.PrimaryStoreCurrencyId);
|
|
var (accountInfo, _) = await GetAccountInfoAsync();
|
|
var priceSyncAvailable = string.Equals(storeCurrency.CurrencyCode, accountInfo.Currency, StringComparison.InvariantCultureIgnoreCase);
|
|
|
|
//prepare price function
|
|
int preparePrice(decimal price) => storeCurrency.CurrencyCode.ToUpper() switch
|
|
{
|
|
"JPY" or "ISK" => Convert.ToInt32(Math.Round(price, 0)),
|
|
_ => Convert.ToInt32(Math.Round(price * 100, 0))
|
|
};
|
|
|
|
Import import = null;
|
|
var pageIndex = 0;
|
|
while (true)
|
|
{
|
|
//we can add up to 2000 products per request, but when uploading images, this may be too much
|
|
var records = await _zettleRecordService.GetAllRecordsAsync(active: true,
|
|
operationTypes: [OperationType.Create, OperationType.Update],
|
|
pageIndex: pageIndex++,
|
|
pageSize: _zettleSettings.ImportProductsNumber);
|
|
if (!records.Any())
|
|
return import;
|
|
|
|
log.AppendLine($"\tPrepare {records.Count} records to import");
|
|
|
|
//upload images if needed
|
|
await UploadImagesAsync(records.ToList(), false, log);
|
|
|
|
//prepare products to import
|
|
var products = await _zettleRecordService.PrepareToSyncRecords(records.ToList()).SelectAwait(async product =>
|
|
{
|
|
var request = new Product
|
|
{
|
|
Uuid = product.Uuid,
|
|
ExternalReference = product.Sku,
|
|
Name = product.Name,
|
|
Id = product.Id,
|
|
Description = product.Description,
|
|
CreateWithDefaultTax = _zettleSettings.DefaultTaxEnabled,
|
|
Category = new Product.ProductCategory
|
|
{
|
|
Name = product.CategoryName,
|
|
Uuid = GuidGenerator.GenerateTimeBasedGuid().ToString()
|
|
},
|
|
Metadata = new Product.ProductMetadata
|
|
{
|
|
InPos = true,
|
|
Source = new Product.ProductMetadata.ProductSource
|
|
{
|
|
External = true,
|
|
Name = ZettleDefaults.PartnerIdentifier
|
|
}
|
|
}
|
|
};
|
|
|
|
//set image
|
|
if (product.ImageSyncEnabled && !string.IsNullOrEmpty(product.ImageUrl))
|
|
request.Presentation = new Product.ProductPresentation { ImageUrl = product.ImageUrl };
|
|
|
|
var combinationRecords = records
|
|
.Where(record => record.ProductId == product.Id && record.CombinationId != 0)
|
|
.ToList();
|
|
if (!combinationRecords.Any())
|
|
{
|
|
//a single variant
|
|
var variant = new Product.ProductVariant
|
|
{
|
|
Uuid = product.VariantUuid,
|
|
Name = product.Name,
|
|
Sku = product.Sku,
|
|
Description = product.Description
|
|
};
|
|
|
|
//set the price if available
|
|
if (product.PriceSyncEnabled && priceSyncAvailable)
|
|
{
|
|
variant.Price = new Product.ProductVariant.ProductPrice
|
|
{
|
|
Amount = preparePrice(product.Price),
|
|
CurrencyId = accountInfo.Currency
|
|
};
|
|
variant.CostPrice = new Product.ProductVariant.ProductPrice
|
|
{
|
|
Amount = preparePrice(product.ProductCost),
|
|
CurrencyId = accountInfo.Currency
|
|
};
|
|
}
|
|
request.Variants = [variant];
|
|
}
|
|
else
|
|
{
|
|
//or multi variants
|
|
var productCombinations = await _productAttributeService.GetAllProductAttributeCombinationsAsync(product.Id);
|
|
var productAttributMappings = await _productAttributeService.GetProductAttributeMappingsByProductIdAsync(product.Id);
|
|
var productAttributes = await productAttributMappings.SelectAwait(async mapping =>
|
|
{
|
|
var productAttribute = await _productAttributeService.GetProductAttributeByIdAsync(mapping.ProductAttributeId);
|
|
var productAttributeValues = await _productAttributeService.GetProductAttributeValuesAsync(mapping.Id);
|
|
return new { Name = productAttribute.Name, Values = productAttributeValues.Select(value => value.Name).ToList() };
|
|
}).ToListAsync();
|
|
|
|
request.VariantOptionDefinitions = new Product.ProductVariantDefinitions
|
|
{
|
|
Definitions = productAttributes.Select(attribute => new Product.ProductVariantDefinitions.ProductVariantOptionDefinition
|
|
{
|
|
Name = attribute.Name,
|
|
Properties = attribute.Values.Select(value => new Product.ProductVariantDefinitions.ProductVariantOptionDefinition.ProductVariantOptionProperty
|
|
{
|
|
Value = value
|
|
}).ToList()
|
|
}).ToList()
|
|
};
|
|
|
|
var combinations = combinationRecords
|
|
.Join(productCombinations,
|
|
record => record.CombinationId,
|
|
combination => combination.Id,
|
|
(record, combination) => new { Record = record, Combination = combination })
|
|
.ToList();
|
|
request.Variants = await combinations.SelectAwait(async combination =>
|
|
{
|
|
var variant = new Product.ProductVariant
|
|
{
|
|
Uuid = combination.Record.VariantUuid,
|
|
Name = product.Name,
|
|
Sku = combination.Combination.Sku,
|
|
Description = product.Description
|
|
};
|
|
|
|
//set image
|
|
if (combination.Record.ImageSyncEnabled && !string.IsNullOrEmpty(combination.Record.ImageUrl))
|
|
variant.Presentation = new Product.ProductPresentation { ImageUrl = combination.Record.ImageUrl };
|
|
|
|
//set the price if available
|
|
if (combination.Record.PriceSyncEnabled && priceSyncAvailable)
|
|
{
|
|
variant.Price = new Product.ProductVariant.ProductPrice
|
|
{
|
|
Amount = preparePrice(combination.Combination.OverriddenPrice ?? product.Price),
|
|
CurrencyId = accountInfo.Currency
|
|
};
|
|
|
|
var attributeValues = await _productAttributeParser.ParseProductAttributeValuesAsync(combination.Combination.AttributesXml);
|
|
var attributesCost = attributeValues
|
|
.Where(value => value.AttributeValueType == Core.Domain.Catalog.AttributeValueType.Simple)
|
|
.Sum(value => value.Cost);
|
|
variant.CostPrice = new Product.ProductVariant.ProductPrice
|
|
{
|
|
Amount = preparePrice(product.ProductCost + attributesCost),
|
|
CurrencyId = accountInfo.Currency
|
|
};
|
|
}
|
|
|
|
variant.Options = await (await _productAttributeParser.ParseProductAttributeMappingsAsync(combination.Combination.AttributesXml))
|
|
.SelectAwait(async mapping =>
|
|
{
|
|
var attribute = await _productAttributeService.GetProductAttributeByIdAsync(mapping.ProductAttributeId);
|
|
var values = await _productAttributeParser.ParseProductAttributeValuesAsync(combination.Combination.AttributesXml, mapping.Id);
|
|
return new Product.ProductVariant.ProductVariantOption { Name = attribute.Name, Value = values.FirstOrDefault()?.Name };
|
|
})
|
|
.ToListAsync();
|
|
|
|
return variant;
|
|
}).ToListAsync();
|
|
}
|
|
return request;
|
|
}).ToListAsync();
|
|
|
|
log.AppendLine($"\tImport {products.Count} products (#{string.Join(", #", products.Select(product => product.Id).ToList())})");
|
|
|
|
import = await _zettleHttpClient.RequestAsync<CreateImportRequest, Import>(new CreateImportRequest { Products = products });
|
|
|
|
log.AppendLine($"\t\tImport ({import.Uuid}) created at {import.Created?.ToLongTimeString()}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Upload images for the passed records
|
|
/// </summary>
|
|
/// <param name="records">Records</param>
|
|
/// <param name="update">Whether to update existing images</param>
|
|
/// <param name="log">Log message</param>
|
|
/// <returns>A task that represents the asynchronous operation</returns>
|
|
protected async Task UploadImagesAsync(IList<ZettleRecord> records, bool update, StringBuilder log)
|
|
{
|
|
//ensure MediaSettings.UseAbsoluteImagePath is enabled (used for images uploading)
|
|
if (!_mediaSettings.UseAbsoluteImagePath)
|
|
throw new NopException("For the correct image uploading need to use absolute pictures path (MediaSettings.UseAbsoluteImagePath setting)");
|
|
|
|
//prepare images to upload
|
|
var recordsWithImages = await records
|
|
.Where(record => record.ImageSyncEnabled && (update || string.IsNullOrEmpty(record.ImageUrl)))
|
|
.SelectAwait(async record =>
|
|
{
|
|
var product = await _productService.GetProductByIdAsync(record.ProductId);
|
|
var combination = await _productAttributeService.GetProductAttributeCombinationByIdAsync(record.CombinationId);
|
|
var picture = await _pictureService.GetProductPictureAsync(product, combination?.AttributesXml);
|
|
var ext = await _pictureService.GetFileExtensionFromMimeTypeAsync(picture.MimeType);
|
|
var (url, _) = await _pictureService.GetPictureUrlAsync(picture);
|
|
return new { Record = record, Url = url, Format = ext };
|
|
})
|
|
.ToListAsync();
|
|
var imagesToUpload = recordsWithImages.Select(record => new CreateImageRequest
|
|
{
|
|
ImageFormat = record.Format?.ToUpper().Replace("JPG", "JPEG"),
|
|
ImageUrl = record.Url
|
|
}).ToList();
|
|
|
|
if (!imagesToUpload.Any())
|
|
return;
|
|
|
|
log.AppendLine($"\tUpload {recordsWithImages.Count} new images");
|
|
|
|
//upload images
|
|
var images = await _zettleHttpClient
|
|
.RequestAsync<CreateImagesRequest, ImageList>(new CreateImagesRequest { ImageUploads = imagesToUpload });
|
|
|
|
log.AppendLine($"\t{images.Uploaded?.Count ?? 0} images uploaded successfully and {images.Invalid?.Count ?? 0} failed to upload");
|
|
|
|
//set uploaded images URLs to records
|
|
var recordsToUpdate = images.Uploaded?
|
|
.SelectMany(image =>
|
|
{
|
|
var recordsWithUploadedImage = recordsWithImages
|
|
.Where(record => string.Equals(record.Url, image.Source, StringComparison.InvariantCultureIgnoreCase))
|
|
.Select(record => record.Record)
|
|
.ToList();
|
|
foreach (var record in recordsWithUploadedImage)
|
|
{
|
|
record.ImageUrl = image.ImageUrls?.FirstOrDefault();
|
|
}
|
|
return recordsWithUploadedImage;
|
|
})
|
|
.Distinct()
|
|
.ToList();
|
|
await _zettleRecordService.UpdateRecordsAsync(recordsToUpdate);
|
|
}
|
|
|
|
#region Inventory
|
|
|
|
/// <summary>
|
|
/// Prepare product inventory balance changes
|
|
/// </summary>
|
|
/// <param name="changeType">Inventory balance change type</param>
|
|
/// <param name="productRecord">Product record with initial stock quantity and qunatity adjustment</param>
|
|
/// <param name="combinationRecords">Combination records with initial stock quantity and qunatity adjustment</param>
|
|
/// <returns>
|
|
/// A task that represents the asynchronous operation
|
|
/// The task result contains list of balance changes
|
|
/// </returns>
|
|
protected async Task<CreateTrackingRequest.ProductBalanceChange> PrepareInventoryBalanceChangeAsync(InventoryBalanceChangeType changeType,
|
|
(ZettleRecord Record, int StockQuantity, int? QuantityAdjustment) productRecord,
|
|
List<(ZettleRecord Record, int StockQuantity, int? QuantityAdjustment)> combinationRecords)
|
|
{
|
|
//ensure that inventory is tracked for the product
|
|
var product = await _productService.GetProductByIdAsync(productRecord.Record?.ProductId ?? 0);
|
|
if (product is null || product.ManageInventoryMethod == Core.Domain.Catalog.ManageInventoryMethod.DontManageStock)
|
|
return null;
|
|
|
|
var productChange = new CreateTrackingRequest.ProductBalanceChange
|
|
{
|
|
ProductUuid = productRecord.Record.Uuid,
|
|
TrackingStatusChange = changeType == InventoryBalanceChangeType.StartTracking ? "START_TRACKING" : "NO_CHANGE"
|
|
};
|
|
|
|
//Zettle Inventory service keeps track of inventory balances by moving product items between so-called locations
|
|
var fromLocation = await (changeType switch
|
|
{
|
|
InventoryBalanceChangeType.StartTracking or InventoryBalanceChangeType.Restock => GetLocationAsync("SUPPLIER"),
|
|
InventoryBalanceChangeType.Purchase or InventoryBalanceChangeType.Void => GetLocationAsync("STORE"),
|
|
_ => GetLocationAsync("SUPPLIER")
|
|
});
|
|
var toLocation = await (changeType switch
|
|
{
|
|
InventoryBalanceChangeType.StartTracking or InventoryBalanceChangeType.Restock => GetLocationAsync("STORE"),
|
|
InventoryBalanceChangeType.Purchase => GetLocationAsync("SOLD"),
|
|
InventoryBalanceChangeType.Void => GetLocationAsync("BIN"),
|
|
_ => GetLocationAsync("BIN")
|
|
});
|
|
|
|
if (!combinationRecords.Any())
|
|
{
|
|
//get initial quantity
|
|
var quantity = changeType == InventoryBalanceChangeType.StartTracking
|
|
? product.StockQuantity - productRecord.StockQuantity
|
|
: productRecord.QuantityAdjustment ?? 0;
|
|
if (quantity != 0)
|
|
{
|
|
productChange.VariantChanges =
|
|
[
|
|
new()
|
|
{
|
|
FromLocationUuid = quantity > 0 ? fromLocation : toLocation,
|
|
ToLocationUuid = quantity > 0 ? toLocation : fromLocation,
|
|
VariantUuid = productRecord.Record.VariantUuid,
|
|
Change = Math.Abs(quantity)
|
|
}
|
|
];
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var combinations = await _productAttributeService.GetAllProductAttributeCombinationsAsync(product.Id);
|
|
productChange.VariantChanges = await combinationRecords.SelectAwait(async combinationRecord =>
|
|
{
|
|
var combination = await _productAttributeService.GetProductAttributeCombinationByIdAsync(combinationRecord.Record.CombinationId);
|
|
|
|
//get initial quantity
|
|
var quantity = changeType == InventoryBalanceChangeType.StartTracking
|
|
? (product.ManageInventoryMethod == Core.Domain.Catalog.ManageInventoryMethod.ManageStockByAttributes
|
|
? (combination?.StockQuantity ?? 0) - combinationRecord.StockQuantity
|
|
: (product.StockQuantity / combinations.Count) - combinationRecord.StockQuantity)
|
|
: combinationRecord.QuantityAdjustment ?? 0;
|
|
if (quantity == 0)
|
|
return null;
|
|
|
|
return new CreateTrackingRequest.VariantBalanceChange
|
|
{
|
|
FromLocationUuid = quantity > 0 ? fromLocation : toLocation,
|
|
ToLocationUuid = quantity > 0 ? toLocation : fromLocation,
|
|
VariantUuid = combinationRecord.Record.VariantUuid,
|
|
Change = Math.Abs(quantity)
|
|
};
|
|
}).Where(variantChange => variantChange is not null).ToListAsync();
|
|
}
|
|
|
|
return productChange;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update inventory balance
|
|
/// </summary>
|
|
/// <param name="productChanges">List of product changes</param>
|
|
/// <param name="records">Records</param>
|
|
/// <returns>A task that represents the asynchronous operation</returns>
|
|
protected async Task UpdateInventoryBalanceAsync(List<CreateTrackingRequest.ProductBalanceChange> productChanges, List<ZettleRecord> records)
|
|
{
|
|
if (!productChanges.Any())
|
|
return;
|
|
|
|
var inventoryRequest = new CreateTrackingRequest
|
|
{
|
|
ReturnLocationUuid = await GetLocationAsync("STORE"),
|
|
ProductChanges = productChanges,
|
|
ExternalUuid = GuidGenerator.GenerateTimeBasedGuid().ToString()
|
|
};
|
|
|
|
//save external UUID to avoid a double change, we will check it when receive a webhook event
|
|
foreach (var record in records)
|
|
{
|
|
record.ExternalUuid = inventoryRequest.ExternalUuid;
|
|
}
|
|
await _zettleRecordService.UpdateRecordsAsync(records);
|
|
|
|
//update balances
|
|
await _zettleHttpClient.RequestAsync<CreateTrackingRequest, LocationInventoryBalance>(inventoryRequest);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get location UUID by the passed type
|
|
/// </summary>
|
|
/// <param name="type">Location type</param>
|
|
/// <returns>
|
|
/// A task that represents the asynchronous operation
|
|
/// The task result contains location UUID
|
|
/// </returns>
|
|
protected async Task<string> GetLocationAsync(string type)
|
|
{
|
|
if (!_locations.TryGetValue(type, out var _))
|
|
{
|
|
var locationList = await _zettleHttpClient.RequestAsync<GetLocationsRequest, LocationList>(new());
|
|
_locations = locationList.ToDictionary(location => location.Type?.ToUpper(), location => location.Uuid);
|
|
}
|
|
|
|
return _locations[type];
|
|
}
|
|
|
|
#endregion
|
|
|
|
#endregion
|
|
|
|
#endregion
|
|
|
|
#region Methods
|
|
|
|
/// <summary>
|
|
/// Check whether the plugin is configured
|
|
/// </summary>
|
|
/// <param name="settings">Plugin settings</param>
|
|
/// <returns>Result</returns>
|
|
public static bool IsConfigured(ZettleSettings settings)
|
|
{
|
|
//Client ID and API Key are required to request services
|
|
return !string.IsNullOrEmpty(settings?.ClientId) && !string.IsNullOrEmpty(settings?.ApiKey);
|
|
}
|
|
|
|
#region Account
|
|
|
|
/// <summary>
|
|
/// Get the authenticated user info
|
|
/// </summary>
|
|
/// <returns>
|
|
/// A task that represents the asynchronous operation
|
|
/// The task result contains user details; error message if exists
|
|
/// </returns>
|
|
public async Task<(UserInfo Result, string Error)> GetUserInfoAsync()
|
|
{
|
|
return await HandleFunctionAsync(async () => await _zettleHttpClient.RequestAsync<GetUserInfoRequest, UserInfo>(new()), false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the merchant account info
|
|
/// </summary>
|
|
/// <returns>
|
|
/// A task that represents the asynchronous operation
|
|
/// The task result contains account details; error message if exists
|
|
/// </returns>
|
|
public async Task<(AccountInfo Result, string Error)> GetAccountInfoAsync()
|
|
{
|
|
return await HandleFunctionAsync(async () => await _zettleHttpClient.RequestAsync<GetAccountInfoRequest, AccountInfo>(new()), false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the default tax rate
|
|
/// </summary>
|
|
/// <returns>
|
|
/// A task that represents the asynchronous operation
|
|
/// The task result contains the default tax rate; error message if exists
|
|
/// </returns>
|
|
public async Task<(decimal? Result, string Error)> GetDefaultTaxRateAsync()
|
|
{
|
|
return await HandleFunctionAsync(async () =>
|
|
{
|
|
var taxRates = await _zettleHttpClient.RequestAsync<GetTaxRatesRequest, TaxRateList>(new());
|
|
return taxRates.TaxRates?.FirstOrDefault(rate => rate.IsDefault == true)?.Percentage;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disconnect the app from an associated Zettle organisation
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation
|
|
/// The task result contains disconnect result; error message if exists
|
|
/// </returns>
|
|
public async Task<(bool Result, string Error)> DisconnectAsync()
|
|
{
|
|
return await HandleFunctionAsync(async () =>
|
|
{
|
|
await _zettleHttpClient.RequestAsync<DeleteAppRequest, ApiResponse>(new());
|
|
return true;
|
|
}, false);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Webhooks
|
|
|
|
/// <summary>
|
|
/// Create webhook that receive events for the subscribed event types
|
|
/// </summary>
|
|
/// <param name="webhookUrl">Webhook URL</param>
|
|
/// <returns>
|
|
/// A task that represents the asynchronous operation
|
|
/// The task result contains the webhook; error message if exists
|
|
/// </returns>
|
|
public async Task<(Subscription Result, string Error)> CreateWebhookAsync(string webhookUrl)
|
|
{
|
|
return await HandleFunctionAsync(async () =>
|
|
{
|
|
//check whether the webhook already exists
|
|
var webhooks = await _zettleHttpClient.RequestAsync<GetSubscriptionsRequest, SubscriptionList>(new());
|
|
var existingWebhook = webhooks
|
|
?.FirstOrDefault(webhook => webhook.Destination?.Equals(webhookUrl, StringComparison.InvariantCultureIgnoreCase) ?? false);
|
|
if (existingWebhook is not null)
|
|
return existingWebhook;
|
|
|
|
//or try to create the new one if doesn't
|
|
var (accountInfo, _) = await GetAccountInfoAsync();
|
|
var request = new CreateSubscriptionRequest
|
|
{
|
|
Uuid = GuidGenerator.GenerateTimeBasedGuid().ToString(),
|
|
TransportName = "WEBHOOK",
|
|
EventNames = ZettleDefaults.WebhookEventNames,
|
|
Destination = webhookUrl,
|
|
ContactEmail = accountInfo?.ContactEmail
|
|
};
|
|
|
|
return await _zettleHttpClient.RequestAsync<CreateSubscriptionRequest, Subscription>(request);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete webhook
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation</returns>
|
|
public async Task DeleteWebhookAsync()
|
|
{
|
|
await HandleFunctionAsync(async () =>
|
|
{
|
|
var webhooks = await _zettleHttpClient.RequestAsync<GetSubscriptionsRequest, SubscriptionList>(new());
|
|
var existingWebhook = webhooks
|
|
?.FirstOrDefault(webhook => webhook.Destination?.Equals(_zettleSettings.WebhookUrl, StringComparison.InvariantCultureIgnoreCase) ?? false);
|
|
if (existingWebhook is null)
|
|
return false;
|
|
|
|
var request = new DeleteSubscriptionsRequest { Uuid = existingWebhook.Uuid };
|
|
await _zettleHttpClient.RequestAsync<DeleteSubscriptionsRequest, ApiResponse>(request);
|
|
|
|
return true;
|
|
}, false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle webhook request
|
|
/// </summary>
|
|
/// <param name="request">HTTP request</param>
|
|
/// <returns>A task that represents the asynchronous operation</returns>
|
|
public async Task HandleWebhookAsync(Microsoft.AspNetCore.Http.HttpRequest request)
|
|
{
|
|
await HandleFunctionAsync(async () =>
|
|
{
|
|
using var streamReader = new StreamReader(request.Body);
|
|
var requestContent = await streamReader.ReadToEndAsync();
|
|
if (string.IsNullOrEmpty(requestContent))
|
|
throw new NopException("Webhook request content is empty");
|
|
|
|
//get webhook message
|
|
var message = JsonConvert.DeserializeObject<Message>(requestContent);
|
|
|
|
//test message is sent during webhook initialization
|
|
if (message.EventName == "TestMessage")
|
|
return true;
|
|
|
|
if (string.IsNullOrEmpty(_zettleSettings.WebhookKey))
|
|
throw new NopException("Webhook is not set");
|
|
|
|
//ensure that request is signed
|
|
if (!request.Headers.TryGetValue(ZettleDefaults.SignatureHeader, out var signatures))
|
|
throw new NopException("Webhook request not signed by a signature header");
|
|
|
|
var messageBytes = Encoding.UTF8.GetBytes($"{message.Timestamp}.{message.Payload}");
|
|
var keyBytes = Encoding.UTF8.GetBytes(_zettleSettings.WebhookKey);
|
|
using var cryptographer = new HMACSHA256(keyBytes);
|
|
var hashBytes = cryptographer.ComputeHash(messageBytes);
|
|
var encryptedString = BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
|
|
if (!signatures.Any(signature => signature.Equals(encryptedString, StringComparison.InvariantCultureIgnoreCase)))
|
|
throw new NopException("Webhook request isn't valid");
|
|
|
|
switch (message.EventName)
|
|
{
|
|
case "InventoryBalanceChanged":
|
|
{
|
|
var balanceInfo = JsonConvert.DeserializeObject<InventoryBalanceUpdate>(message.Payload);
|
|
|
|
for (var i = 0; i < (balanceInfo.BalanceBefore ?? new()).Count; i++)
|
|
{
|
|
var balanceBefore = balanceInfo.BalanceBefore?.ElementAtOrDefault(i);
|
|
var balanceAfter = balanceInfo.BalanceAfter?.ElementAtOrDefault(i);
|
|
|
|
if (string.IsNullOrEmpty(balanceBefore?.ProductUuid) || string.IsNullOrEmpty(balanceAfter?.ProductUuid))
|
|
continue;
|
|
|
|
if (balanceBefore.ProductUuid != balanceAfter.ProductUuid || balanceBefore.VariantUuid != balanceAfter.VariantUuid)
|
|
continue;
|
|
|
|
if (!balanceBefore.Balance.HasValue || !balanceAfter.Balance.HasValue)
|
|
continue;
|
|
|
|
var records = await _zettleRecordService.GetAllRecordsAsync(productUuid: balanceAfter.ProductUuid);
|
|
var productRecord = records.FirstOrDefault(record => string.Equals(record.VariantUuid, balanceAfter.VariantUuid, StringComparison.InvariantCultureIgnoreCase));
|
|
if (productRecord is null || !productRecord.Active || !productRecord.InventoryTrackingEnabled)
|
|
continue;
|
|
|
|
//whether the change is initiated by the plugin (inventory balance has already been changed)
|
|
if (productRecord.ExternalUuid == balanceInfo.ExternalUuid)
|
|
{
|
|
//keep external UUID for a day in case of errors when processing webhook requests
|
|
var balanceChangeDate = balanceInfo.UpdateDetails.Timestamp ?? DateTime.UtcNow;
|
|
if (balanceChangeDate < DateTime.UtcNow.AddDays(-1))
|
|
{
|
|
productRecord.ExternalUuid = null;
|
|
await _zettleRecordService.UpdateRecordAsync(productRecord);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
//adjust inventory
|
|
var product = await _productService.GetProductByIdAsync(productRecord.ProductId);
|
|
var combination = await _productAttributeService.GetProductAttributeCombinationByIdAsync(productRecord.CombinationId);
|
|
var quantityToChange = balanceAfter.Balance.Value - balanceBefore.Balance.Value;
|
|
var logMessage = $"{ZettleDefaults.SystemName} update. Inventory balance changed at {balanceAfter.Created?.ToLongTimeString()}";
|
|
await _productService.AdjustInventoryAsync(product, quantityToChange, combination?.AttributesXml, logMessage);
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "InventoryTrackingStopped":
|
|
{
|
|
var inventoryTrackingInfo = JsonConvert.DeserializeAnonymousType(message.Payload, new { ProductUuid = string.Empty });
|
|
if (string.IsNullOrEmpty(inventoryTrackingInfo.ProductUuid))
|
|
break;
|
|
|
|
//stop tracking
|
|
var records = (await _zettleRecordService.GetAllRecordsAsync(productUuid: inventoryTrackingInfo.ProductUuid)).ToList();
|
|
foreach (var record in records)
|
|
{
|
|
record.InventoryTrackingEnabled = false;
|
|
record.UpdatedOnUtc = DateTime.UtcNow;
|
|
}
|
|
await _zettleRecordService.UpdateRecordsAsync(records);
|
|
|
|
break;
|
|
}
|
|
|
|
case "ProductCreated":
|
|
{
|
|
//use this event only to start inventory tracking for product
|
|
var productInfo = JsonConvert.DeserializeObject<Product>(message.Payload);
|
|
var records = await _zettleRecordService.GetAllRecordsAsync(productUuid: productInfo.Uuid);
|
|
var productRecord = records.FirstOrDefault(record => record.CombinationId == 0);
|
|
if (productRecord is null || !productRecord.Active || !productRecord.InventoryTrackingEnabled)
|
|
break;
|
|
|
|
var storeBalance = await _zettleHttpClient
|
|
.RequestAsync<GetLocationInventoryBalanceRequest, LocationInventoryBalance>(new());
|
|
var trackingStarted = storeBalance.TrackedProducts
|
|
?.Contains(productRecord.Uuid, StringComparer.InvariantCultureIgnoreCase);
|
|
if (trackingStarted ?? true)
|
|
break;
|
|
|
|
var combinationRecords = records.Where(record => record.CombinationId != 0).ToList();
|
|
var combinationRecordsToStart = new List<(ZettleRecord Record, int StockQuantity, int? QuantityAdjustment)>();
|
|
foreach (var combinationRecord in combinationRecords)
|
|
{
|
|
combinationRecordsToStart.Add((combinationRecord, 0, null));
|
|
}
|
|
(ZettleRecord Record, int StockQuantity, int? QuantityAdjustment) productRecordToStart = (productRecord, 0, null);
|
|
var productChange = await PrepareInventoryBalanceChangeAsync(InventoryBalanceChangeType.StartTracking,
|
|
productRecordToStart, combinationRecordsToStart);
|
|
if (productChange is null)
|
|
break;
|
|
|
|
await UpdateInventoryBalanceAsync([productChange], combinationRecords.Union([productRecord]).ToList());
|
|
|
|
break;
|
|
}
|
|
|
|
case "ApplicationConnectionRemoved":
|
|
{
|
|
var applicationInfo = JsonConvert.DeserializeAnonymousType(message.Payload, new { Type = string.Empty });
|
|
if (string.IsNullOrEmpty(applicationInfo.Type))
|
|
break;
|
|
|
|
var warning = applicationInfo.Type;
|
|
if (applicationInfo.Type.Equals("ApplicationConnectionRemoved", StringComparison.InvariantCultureIgnoreCase) ||
|
|
applicationInfo.Type.Equals("PersonalAssertionDeleted", StringComparison.InvariantCultureIgnoreCase))
|
|
{
|
|
warning = "The application was disconnected from PayPal Zettle organization. You need to reconfigure the plugin.";
|
|
|
|
_zettleSettings.ClientId = string.Empty;
|
|
_zettleSettings.ApiKey = string.Empty;
|
|
_zettleSettings.WebhookUrl = string.Empty;
|
|
_zettleSettings.WebhookKey = string.Empty;
|
|
_zettleSettings.ImportId = string.Empty;
|
|
await _settingService.SaveSettingAsync(_zettleSettings);
|
|
}
|
|
await _logger.WarningAsync($"{ZettleDefaults.SystemName}. {warning}");
|
|
|
|
break;
|
|
}
|
|
|
|
default:
|
|
throw new NopException($"Unknown webhook resource type '{message.EventName}'");
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Sync
|
|
|
|
/// <summary>
|
|
/// Get last import details
|
|
/// </summary>
|
|
/// <returns>
|
|
/// A task that represents the asynchronous operation
|
|
/// The task result contains the import details; error message if exists
|
|
/// </returns>
|
|
public async Task<(Import Result, string Error)> GetImportAsync()
|
|
{
|
|
return await HandleFunctionAsync(async () =>
|
|
{
|
|
return await _zettleHttpClient.RequestAsync<GetImportRequest, Import>(new() { ImportUuid = _zettleSettings.ImportId });
|
|
}, false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Start products import
|
|
/// </summary>
|
|
/// <returns>
|
|
/// A task that represents the asynchronous operation
|
|
/// The task result contains the import details; error message if exists
|
|
/// </returns>
|
|
public async Task<(Import Result, string Error)> ImportAsync()
|
|
{
|
|
return await HandleFunctionAsync(async () =>
|
|
{
|
|
var log = new StringBuilder($"{ZettleDefaults.SystemName} information.{Environment.NewLine}");
|
|
log.AppendLine($"Synchronization started at {DateTime.UtcNow.ToLongTimeString()} UTC");
|
|
|
|
await ImportDiscountsAsync(log);
|
|
|
|
await ImportDeletedAsync(log);
|
|
|
|
await ImportImageChangedAsync(log);
|
|
|
|
await ImportInventoryTrackingAsync(log);
|
|
|
|
var import = await ImportCreatedOrUpdatedAsync(log);
|
|
|
|
if (!string.IsNullOrEmpty(import?.Uuid))
|
|
{
|
|
//save import id for future use
|
|
await _settingService.SetSettingAsync($"{nameof(ZettleSettings)}.{nameof(ZettleSettings.ImportId)}", import?.Uuid);
|
|
|
|
//refresh records
|
|
var records = await _zettleRecordService.GetAllRecordsAsync(active: true,
|
|
operationTypes: [OperationType.Create, OperationType.Update, OperationType.ImageChanged]);
|
|
foreach (var record in records)
|
|
{
|
|
record.OperationType = OperationType.None;
|
|
record.UpdatedOnUtc = DateTime.UtcNow;
|
|
}
|
|
await _zettleRecordService.UpdateRecordsAsync(records.ToList());
|
|
}
|
|
|
|
log.AppendLine($"Synchronization finished at {DateTime.UtcNow.ToLongTimeString()} UTC");
|
|
|
|
if (_zettleSettings.LogSyncMessages)
|
|
await _logger.InformationAsync(log.ToString());
|
|
|
|
return import;
|
|
});
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Inventory
|
|
|
|
/// <summary>
|
|
/// Change inventory balance
|
|
/// </summary>
|
|
/// <param name="productId">Product identifier</param>
|
|
/// <param name="combinationId">Combination identifier</param>
|
|
/// <param name="quantityAdjustment">Stock quantity adjustment</param>
|
|
/// <returns>A task that represents the asynchronous operation</returns>
|
|
public async Task ChangeInventoryBalanceAsync(int productId, int combinationId, int quantityAdjustment)
|
|
{
|
|
var records = (await _zettleRecordService.GetAllRecordsAsync(active: true))
|
|
.Where(record => record.ProductId == productId && record.InventoryTrackingEnabled && !string.IsNullOrEmpty(record.Uuid))
|
|
.ToList();
|
|
if (!records.Any())
|
|
return;
|
|
|
|
var productRecord = records.FirstOrDefault(record => record.CombinationId == 0);
|
|
var combinationRecords = combinationId > 0
|
|
? records.Where(record => record.CombinationId == combinationId && record.InventoryTrackingEnabled && !string.IsNullOrEmpty(record.VariantUuid)).ToList()
|
|
: new List<ZettleRecord>();
|
|
|
|
//we cannot know the exact reason of the change, so we will use Purchase for negative adjustments and Re-stock for positive ones
|
|
var changeType = quantityAdjustment < 0 ? InventoryBalanceChangeType.Purchase : InventoryBalanceChangeType.Restock;
|
|
var combinationRecordsToUpdate = new List<(ZettleRecord Record, int StockQuantity, int? QuantityAdjustment)>();
|
|
foreach (var combinationRecord in combinationRecords)
|
|
{
|
|
combinationRecordsToUpdate.Add((combinationRecord, 0, Math.Abs(quantityAdjustment)));
|
|
}
|
|
(ZettleRecord Record, int StockQuantity, int? QuantityAdjustment) productRecordToUpdate = (productRecord, 0, Math.Abs(quantityAdjustment));
|
|
var productChange = await PrepareInventoryBalanceChangeAsync(changeType, productRecordToUpdate, combinationRecordsToUpdate);
|
|
if (productChange is null)
|
|
return;
|
|
|
|
await UpdateInventoryBalanceAsync([productChange], combinationRecords.Union([productRecord]).ToList());
|
|
}
|
|
|
|
#endregion
|
|
|
|
#endregion
|
|
} |