diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductBatchToolContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductBatchToolContracts.cs new file mode 100644 index 0000000..647dcd5 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductBatchToolContracts.cs @@ -0,0 +1,362 @@ +using Microsoft.AspNetCore.Http; + +namespace TakeoutSaaS.TenantApi.Contracts.Product; + +/// +/// 批量范围请求。 +/// +public sealed class ProductBatchScopeRequest +{ + /// + /// 范围类型(all/category/selected/manual)。 + /// + public string Type { get; set; } = "all"; + + /// + /// 单个分类 ID(兼容字段)。 + /// + public string? CategoryId { get; set; } + + /// + /// 分类 ID 列表(按分类时)。 + /// + public List CategoryIds { get; set; } = []; + + /// + /// 商品 ID 列表(手动选择时)。 + /// + public List ProductIds { get; set; } = []; +} + +/// +/// 批量调价预览请求。 +/// +public sealed class BatchPriceAdjustPreviewRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 批量范围。 + /// + public ProductBatchScopeRequest Scope { get; set; } = new(); + + /// + /// 调价方向(up/down)。 + /// + public string Direction { get; set; } = "up"; + + /// + /// 调价方式(fixed/percent)。 + /// + public string AmountType { get; set; } = "fixed"; + + /// + /// 调价数值。 + /// + public decimal Amount { get; set; } +} + +/// +/// 批量调价请求。 +/// +public sealed class BatchPriceAdjustRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 批量范围。 + /// + public ProductBatchScopeRequest Scope { get; set; } = new(); + + /// + /// 调价方向(up/down)。 + /// + public string Direction { get; set; } = "up"; + + /// + /// 调价方式(fixed/percent)。 + /// + public string AmountType { get; set; } = "fixed"; + + /// + /// 调价数值。 + /// + public decimal Amount { get; set; } +} + +/// +/// 批量上下架请求。 +/// +public sealed class BatchSaleSwitchRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 批量范围。 + /// + public ProductBatchScopeRequest Scope { get; set; } = new(); + + /// + /// 动作(on/off)。 + /// + public string Action { get; set; } = "off"; +} + +/// +/// 批量移动分类请求。 +/// +public sealed class BatchMoveCategoryRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 源分类 ID(可选)。 + /// + public string? SourceCategoryId { get; set; } + + /// + /// 目标分类 ID。 + /// + public string TargetCategoryId { get; set; } = string.Empty; + + /// + /// 批量范围。 + /// + public ProductBatchScopeRequest Scope { get; set; } = new(); +} + +/// +/// 批量同步门店请求。 +/// +public sealed class BatchSyncStoreRequest +{ + /// + /// 源门店 ID。 + /// + public string SourceStoreId { get; set; } = string.Empty; + + /// + /// 目标门店 ID 列表。 + /// + public List TargetStoreIds { get; set; } = []; + + /// + /// 商品 ID 列表。 + /// + public List ProductIds { get; set; } = []; + + /// + /// 是否同步价格。 + /// + public bool SyncPrice { get; set; } = true; + + /// + /// 是否同步库存。 + /// + public bool SyncStock { get; set; } = true; + + /// + /// 是否同步状态。 + /// + public bool SyncStatus { get; set; } +} + +/// +/// 批量导出请求。 +/// +public sealed class BatchExportRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 批量范围。 + /// + public ProductBatchScopeRequest Scope { get; set; } = new(); +} + +/// +/// 批量导入请求(表单)。 +/// +public sealed class BatchImportRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 导入文件。 + /// + public IFormFile? File { get; set; } +} + +/// +/// 批量工具通用结果。 +/// +public sealed class BatchToolResultResponse +{ + /// + /// 总条数。 + /// + public int TotalCount { get; set; } + + /// + /// 成功条数。 + /// + public int SuccessCount { get; set; } + + /// + /// 失败条数。 + /// + public int FailedCount { get; set; } + + /// + /// 跳过条数。 + /// + public int SkippedCount { get; set; } +} + +/// +/// 调价预览项。 +/// +public sealed class BatchPricePreviewItemResponse +{ + /// + /// 商品 ID。 + /// + public string ProductId { get; set; } = string.Empty; + + /// + /// 商品名称。 + /// + public string ProductName { get; set; } = string.Empty; + + /// + /// 原价。 + /// + public decimal OriginalPrice { get; set; } + + /// + /// 新价。 + /// + public decimal NewPrice { get; set; } + + /// + /// 变动值。 + /// + public decimal DeltaPrice { get; set; } +} + +/// +/// 调价预览结果。 +/// +public sealed class BatchPricePreviewResponse +{ + /// + /// 预览项。 + /// + public List Items { get; set; } = []; + + /// + /// 总影响商品数。 + /// + public int TotalCount { get; set; } +} + +/// +/// Excel 文件响应。 +/// +public sealed class BatchExcelFileResponse +{ + /// + /// 文件名。 + /// + public string FileName { get; set; } = string.Empty; + + /// + /// Base64 文件内容。 + /// + public string FileContentBase64 { get; set; } = string.Empty; + + /// + /// 导出总条数。 + /// + public int TotalCount { get; set; } + + /// + /// 成功条数。 + /// + public int SuccessCount { get; set; } + + /// + /// 失败条数。 + /// + public int FailedCount { get; set; } +} + +/// +/// 导入错误项。 +/// +public sealed class BatchImportErrorItemResponse +{ + /// + /// 行号。 + /// + public int RowNo { get; set; } + + /// + /// 错误说明。 + /// + public string Message { get; set; } = string.Empty; +} + +/// +/// 批量导入结果。 +/// +public sealed class BatchImportResultResponse +{ + /// + /// 总条数。 + /// + public int TotalCount { get; set; } + + /// + /// 成功条数。 + /// + public int SuccessCount { get; set; } + + /// + /// 失败条数。 + /// + public int FailedCount { get; set; } + + /// + /// 跳过条数。 + /// + public int SkippedCount { get; set; } + + /// + /// 文件名。 + /// + public string FileName { get; set; } = string.Empty; + + /// + /// 错误明细。 + /// + public List Errors { get; set; } = []; +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductBatchToolController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductBatchToolController.cs new file mode 100644 index 0000000..1f3c95f --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductBatchToolController.cs @@ -0,0 +1,1168 @@ +using System.Globalization; +using ClosedXML.Excel; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Infrastructure.App.Persistence; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; +using TakeoutSaaS.TenantApi.Contracts.Product; + +namespace TakeoutSaaS.TenantApi.Controllers; + +[ApiVersion("1.0")] +[Authorize] +[Route("api/tenant/v{version:apiVersion}/product/batch")] +public sealed class ProductBatchToolController( + TakeoutAppDbContext dbContext, + StoreContextService storeContextService, + IIdGenerator idGenerator) : BaseApiController +{ + private const int MaxImportFileSize = 10 * 1024 * 1024; + + private static readonly string[] ImportHeaders = + [ + "SPU编码", + "商品名称", + "副标题", + "分类名称", + "商品类型", + "售价", + "划线价", + "库存", + "状态" + ]; + + [HttpPost("price-adjust/preview")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> PreviewPriceAdjust( + [FromBody] BatchPriceAdjustPreviewRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + var (tenantId, _) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + ValidatePriceAdjustRequest(request.Direction, request.AmountType, request.Amount); + var scopedQuery = BuildScopedProductQuery(tenantId, storeId, request.Scope); + var totalCount = await scopedQuery.CountAsync(cancellationToken); + var previewRows = await scopedQuery + .OrderBy(x => x.Name) + .ThenBy(x => x.Id) + .Take(50) + .Select(x => new { x.Id, x.Name, x.Price }) + .ToListAsync(cancellationToken); + + var items = previewRows + .Select(row => + { + var newPrice = CalculateAdjustedPrice(row.Price, request.Direction, request.AmountType, request.Amount); + return new BatchPricePreviewItemResponse + { + ProductId = row.Id.ToString(), + ProductName = row.Name, + OriginalPrice = row.Price, + NewPrice = newPrice, + DeltaPrice = decimal.Round(newPrice - row.Price, 2, MidpointRounding.AwayFromZero) + }; + }) + .ToList(); + + return ApiResponse.Ok(new BatchPricePreviewResponse + { + TotalCount = totalCount, + Items = items + }); + } + + [HttpPost("price-adjust")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> PriceAdjust( + [FromBody] BatchPriceAdjustRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + var (tenantId, _) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + ValidatePriceAdjustRequest(request.Direction, request.AmountType, request.Amount); + var products = await BuildScopedProductQuery(tenantId, storeId, request.Scope).ToListAsync(cancellationToken); + + var successCount = 0; + var skippedCount = 0; + foreach (var product in products) + { + var newPrice = CalculateAdjustedPrice(product.Price, request.Direction, request.AmountType, request.Amount); + if (newPrice == product.Price) + { + skippedCount++; + continue; + } + + product.Price = newPrice; + successCount++; + } + + if (successCount > 0) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + + return ApiResponse.Ok(new BatchToolResultResponse + { + TotalCount = products.Count, + SuccessCount = successCount, + FailedCount = 0, + SkippedCount = skippedCount + }); + } + + [HttpPost("sale-switch")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> SaleSwitch( + [FromBody] BatchSaleSwitchRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + var (tenantId, _) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var action = NormalizeName(request.Action); + var targetStatus = action switch + { + "on" => ProductStatus.OnSale, + "off" => ProductStatus.OffShelf, + _ => throw new BusinessException(ErrorCodes.BadRequest, "action 非法,仅支持 on/off") + }; + + var products = await BuildScopedProductQuery(tenantId, storeId, request.Scope).ToListAsync(cancellationToken); + var successCount = 0; + var skippedCount = 0; + foreach (var product in products) + { + var changed = false; + if (product.Status != targetStatus) + { + product.Status = targetStatus; + changed = true; + } + + if (product.SoldoutMode.HasValue || product.SoldoutReason is not null || product.RecoverAt.HasValue || product.RemainStock.HasValue) + { + product.SoldoutMode = null; + product.SoldoutReason = null; + product.RecoverAt = null; + product.RemainStock = null; + changed = true; + } + + if (changed) + { + successCount++; + } + else + { + skippedCount++; + } + } + + if (successCount > 0) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + + return ApiResponse.Ok(new BatchToolResultResponse + { + TotalCount = products.Count, + SuccessCount = successCount, + FailedCount = 0, + SkippedCount = skippedCount + }); + } + + [HttpPost("move-category")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> MoveCategory( + [FromBody] BatchMoveCategoryRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + var (tenantId, _) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var targetCategoryId = StoreApiHelpers.ParseRequiredSnowflake(request.TargetCategoryId, nameof(request.TargetCategoryId)); + var sourceCategoryId = StoreApiHelpers.ParseSnowflakeOrNull(request.SourceCategoryId); + + var categoryIdsToCheck = new List { targetCategoryId }; + if (sourceCategoryId.HasValue) + { + categoryIdsToCheck.Add(sourceCategoryId.Value); + } + + var categorySet = await dbContext.ProductCategories + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId && categoryIdsToCheck.Contains(x.Id)) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + + if (!categorySet.Contains(targetCategoryId)) + { + throw new BusinessException(ErrorCodes.BadRequest, "目标分类不存在"); + } + + if (sourceCategoryId.HasValue && !categorySet.Contains(sourceCategoryId.Value)) + { + throw new BusinessException(ErrorCodes.BadRequest, "源分类不存在"); + } + + var scopedQuery = BuildScopedProductQuery(tenantId, storeId, request.Scope); + if (sourceCategoryId.HasValue) + { + scopedQuery = scopedQuery.Where(x => x.CategoryId == sourceCategoryId.Value); + } + + var products = await scopedQuery.ToListAsync(cancellationToken); + var successCount = 0; + var skippedCount = 0; + foreach (var product in products) + { + if (product.CategoryId == targetCategoryId) + { + skippedCount++; + continue; + } + + product.CategoryId = targetCategoryId; + successCount++; + } + + if (successCount > 0) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + + return ApiResponse.Ok(new BatchToolResultResponse + { + TotalCount = products.Count, + SuccessCount = successCount, + FailedCount = 0, + SkippedCount = skippedCount + }); + } + + [HttpPost("store-sync")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> SyncStore( + [FromBody] BatchSyncStoreRequest request, + CancellationToken cancellationToken) + { + var sourceStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.SourceStoreId, nameof(request.SourceStoreId)); + var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + await EnsureStoreAccessibleAsync(sourceStoreId, cancellationToken); + + var targetStoreIds = StoreApiHelpers.ParseSnowflakeList(request.TargetStoreIds) + .Where(id => id != sourceStoreId) + .Distinct() + .ToList(); + if (targetStoreIds.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "targetStoreIds 不能为空"); + } + + var accessibleTargetStoreIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync( + dbContext, + tenantId, + merchantId, + targetStoreIds, + cancellationToken); + targetStoreIds = targetStoreIds.Where(accessibleTargetStoreIds.Contains).ToList(); + if (targetStoreIds.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "目标门店不可访问"); + } + + var productIds = StoreApiHelpers.ParseSnowflakeList(request.ProductIds); + if (productIds.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "productIds 不能为空"); + } + + var sourceProducts = await dbContext.Products + .Where(x => + x.TenantId == tenantId && + x.StoreId == sourceStoreId && + productIds.Contains(x.Id)) + .OrderBy(x => x.Name) + .ThenBy(x => x.Id) + .ToListAsync(cancellationToken); + + if (sourceProducts.Count == 0) + { + return ApiResponse.Ok(new BatchToolResultResponse()); + } + + var sourceCategories = await dbContext.ProductCategories + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId) + .ToDictionaryAsync(x => x.Id, x => x.Name, cancellationToken); + + var totalCount = sourceProducts.Count * targetStoreIds.Count; + var successCount = 0; + var failedCount = 0; + var skippedCount = 0; + + foreach (var targetStoreId in targetStoreIds) + { + var targetCategories = await dbContext.ProductCategories + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == targetStoreId) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .ToListAsync(cancellationToken); + if (targetCategories.Count == 0) + { + failedCount += sourceProducts.Count; + continue; + } + + var targetCategoryByName = targetCategories + .GroupBy(x => NormalizeName(x.Name)) + .ToDictionary(group => group.Key, group => group.First().Id); + var fallbackCategoryId = targetCategories[0].Id; + + var sourceNames = sourceProducts.Select(x => x.Name).Distinct().ToList(); + var existingTargetProducts = await dbContext.Products + .Where(x => + x.TenantId == tenantId && + x.StoreId == targetStoreId && + sourceNames.Contains(x.Name)) + .ToListAsync(cancellationToken); + var existingLookup = existingTargetProducts + .GroupBy(x => BuildSyncProductKey(x.Name, x.Kind)) + .ToDictionary(group => group.Key, group => group.First()); + + foreach (var source in sourceProducts) + { + var sourceCategoryName = sourceCategories.GetValueOrDefault(source.CategoryId, string.Empty); + var targetCategoryId = targetCategoryByName.GetValueOrDefault(NormalizeName(sourceCategoryName), fallbackCategoryId); + if (targetCategoryId <= 0) + { + failedCount++; + continue; + } + + var key = BuildSyncProductKey(source.Name, source.Kind); + if (existingLookup.TryGetValue(key, out var existing)) + { + var changed = false; + if (existing.CategoryId != targetCategoryId) + { + existing.CategoryId = targetCategoryId; + changed = true; + } + + if (request.SyncPrice) + { + if (existing.Price != source.Price) + { + existing.Price = source.Price; + changed = true; + } + + if (existing.OriginalPrice != source.OriginalPrice) + { + existing.OriginalPrice = source.OriginalPrice; + changed = true; + } + } + + if (request.SyncStock && existing.StockQuantity != source.StockQuantity) + { + existing.StockQuantity = source.StockQuantity; + changed = true; + } + + if (request.SyncStatus) + { + if (existing.Status != source.Status) + { + existing.Status = source.Status; + changed = true; + } + + if (existing.SoldoutMode != source.SoldoutMode || + existing.SoldoutReason != source.SoldoutReason || + existing.RecoverAt != source.RecoverAt || + existing.RemainStock != source.RemainStock) + { + existing.SoldoutMode = source.SoldoutMode; + existing.SoldoutReason = source.SoldoutReason; + existing.RecoverAt = source.RecoverAt; + existing.RemainStock = source.RemainStock; + changed = true; + } + } + + if (changed) + { + successCount++; + } + else + { + skippedCount++; + } + } + else + { + var nextId = idGenerator.NextId(); + var copied = new Product + { + Id = nextId, + TenantId = tenantId, + StoreId = targetStoreId, + CategoryId = targetCategoryId, + SpuCode = GenerateSpuCode(nextId), + Name = source.Name, + Subtitle = source.Subtitle, + Unit = source.Unit, + Price = source.Price, + OriginalPrice = source.OriginalPrice, + StockQuantity = source.StockQuantity, + MaxQuantityPerOrder = source.MaxQuantityPerOrder, + Status = request.SyncStatus ? source.Status : ProductStatus.OffShelf, + Kind = source.Kind, + SalesMonthly = source.SalesMonthly, + TagsJson = source.TagsJson, + SoldoutMode = request.SyncStatus ? source.SoldoutMode : null, + RecoverAt = request.SyncStatus ? source.RecoverAt : null, + RemainStock = request.SyncStatus ? source.RemainStock : null, + SoldoutReason = request.SyncStatus ? source.SoldoutReason : null, + SyncToPlatform = source.SyncToPlatform, + NotifyManager = source.NotifyManager, + TimedOnShelfAt = source.TimedOnShelfAt, + CoverImage = source.CoverImage, + GalleryImages = source.GalleryImages, + Description = source.Description, + SortWeight = source.SortWeight, + WarningStock = source.WarningStock, + PackingFee = source.PackingFee, + EnableDineIn = source.EnableDineIn, + EnablePickup = source.EnablePickup, + EnableDelivery = source.EnableDelivery, + IsFeatured = source.IsFeatured + }; + + dbContext.Products.Add(copied); + existingLookup[key] = copied; + successCount++; + } + } + } + + if (successCount > 0) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + + return ApiResponse.Ok(new BatchToolResultResponse + { + TotalCount = totalCount, + SuccessCount = successCount, + FailedCount = failedCount, + SkippedCount = skippedCount + }); + } + + [HttpGet("import/template")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> DownloadImportTemplate( + [FromQuery] string storeId, + CancellationToken cancellationToken) + { + var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId)); + await EnsureStoreAccessibleAsync(parsedStoreId, cancellationToken); + + using var workbook = new XLWorkbook(); + var sheet = workbook.Worksheets.Add("商品导入模板"); + for (var index = 0; index < ImportHeaders.Length; index++) + { + var cell = sheet.Cell(1, index + 1); + cell.Value = ImportHeaders[index]; + cell.Style.Font.Bold = true; + cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#F5F7FA"); + } + + sheet.Cell(2, 1).Value = "SPU202602260001"; + sheet.Cell(2, 2).Value = "经典宫保鸡丁"; + sheet.Cell(2, 3).Value = "下饭推荐"; + sheet.Cell(2, 4).Value = "热销菜品"; + sheet.Cell(2, 5).Value = "单品"; + sheet.Cell(2, 6).Value = 28.00m; + sheet.Cell(2, 7).Value = 32.00m; + sheet.Cell(2, 8).Value = 99; + sheet.Cell(2, 9).Value = "上架"; + sheet.Columns(1, ImportHeaders.Length).AdjustToContents(); + + return ApiResponse.Ok(new BatchExcelFileResponse + { + FileName = $"商品导入模板_{DateTime.UtcNow:yyyyMMddHHmmss}.xlsx", + FileContentBase64 = EncodeWorkbookToBase64(workbook), + TotalCount = 0, + SuccessCount = 0, + FailedCount = 0 + }); + } + + [HttpPost("import")] + [Consumes("multipart/form-data")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Import( + [FromForm] BatchImportRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + var (tenantId, _) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + if (request.File is null || request.File.Length <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "请上传 Excel 文件"); + } + + if (request.File.Length > MaxImportFileSize) + { + throw new BusinessException(ErrorCodes.BadRequest, "导入文件不能超过 10MB"); + } + + var extension = Path.GetExtension(request.File.FileName); + if (!string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException(ErrorCodes.BadRequest, "仅支持 .xlsx 文件"); + } + + using var stream = request.File.OpenReadStream(); + using var workbook = new XLWorkbook(stream); + var sheet = workbook.Worksheets.FirstOrDefault(); + if (sheet is null) + { + throw new BusinessException(ErrorCodes.BadRequest, "导入文件为空"); + } + + for (var index = 0; index < ImportHeaders.Length; index++) + { + var actual = NormalizeName(ReadCellText(sheet.Cell(1, index + 1))); + var expected = NormalizeName(ImportHeaders[index]); + if (!string.Equals(actual, expected, StringComparison.Ordinal)) + { + throw new BusinessException(ErrorCodes.BadRequest, $"导入模板不正确,第 {index + 1} 列应为 {ImportHeaders[index]}"); + } + } + + var categories = await dbContext.ProductCategories + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .ToListAsync(cancellationToken); + if (categories.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "当前门店没有商品分类,无法导入"); + } + + var categoryLookup = categories + .GroupBy(x => NormalizeName(x.Name)) + .ToDictionary(group => group.Key, group => group.First().Id); + + var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1; + var importRows = new List(); + var errors = new List(); + var seenSpuCodes = new HashSet(StringComparer.OrdinalIgnoreCase); + var totalCount = 0; + + for (var rowNo = 2; rowNo <= lastRow; rowNo++) + { + var row = sheet.Row(rowNo); + var spuCodeRaw = ReadCellText(row.Cell(1)); + var nameRaw = ReadCellText(row.Cell(2)); + var subtitleRaw = ReadCellText(row.Cell(3)); + var categoryNameRaw = ReadCellText(row.Cell(4)); + var kindRaw = ReadCellText(row.Cell(5)); + var priceRaw = ReadCellText(row.Cell(6)); + var originalPriceRaw = ReadCellText(row.Cell(7)); + var stockRaw = ReadCellText(row.Cell(8)); + var statusRaw = ReadCellText(row.Cell(9)); + + if (string.IsNullOrWhiteSpace(spuCodeRaw) && + string.IsNullOrWhiteSpace(nameRaw) && + string.IsNullOrWhiteSpace(subtitleRaw) && + string.IsNullOrWhiteSpace(categoryNameRaw) && + string.IsNullOrWhiteSpace(kindRaw) && + string.IsNullOrWhiteSpace(priceRaw) && + string.IsNullOrWhiteSpace(originalPriceRaw) && + string.IsNullOrWhiteSpace(stockRaw) && + string.IsNullOrWhiteSpace(statusRaw)) + { + continue; + } + + totalCount++; + var rowErrors = new List(); + + var name = (nameRaw ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + rowErrors.Add("商品名称不能为空"); + } + else if (name.Length > 128) + { + rowErrors.Add("商品名称长度不能超过 128"); + } + + var subtitle = TrimOrNull(subtitleRaw); + if (!string.IsNullOrWhiteSpace(subtitle) && subtitle.Length > 256) + { + rowErrors.Add("副标题长度不能超过 256"); + } + + var categoryKey = NormalizeName(categoryNameRaw); + if (!categoryLookup.TryGetValue(categoryKey, out var categoryId)) + { + rowErrors.Add("分类不存在"); + } + + var kind = ProductKind.Single; + try + { + kind = ParseImportKind(kindRaw); + } + catch (BusinessException ex) + { + rowErrors.Add(ex.Message); + } + + if (!TryParseDecimal(priceRaw, out var price) || price < 0) + { + rowErrors.Add("售价必须为大于等于 0 的数字"); + } + else + { + price = decimal.Round(price, 2, MidpointRounding.AwayFromZero); + } + + decimal? originalPrice = null; + if (!string.IsNullOrWhiteSpace(originalPriceRaw)) + { + if (!TryParseDecimal(originalPriceRaw, out var parsedOriginalPrice) || parsedOriginalPrice < 0) + { + rowErrors.Add("划线价必须为大于等于 0 的数字"); + } + else + { + originalPrice = decimal.Round(parsedOriginalPrice, 2, MidpointRounding.AwayFromZero); + } + } + + if (!TryParseInt(stockRaw, out var stock) || stock < 0) + { + rowErrors.Add("库存必须为大于等于 0 的整数"); + } + + var status = ProductStatus.OffShelf; + try + { + status = ParseImportStatus(statusRaw); + } + catch (BusinessException ex) + { + rowErrors.Add(ex.Message); + } + + var spuCode = TrimOrNull(spuCodeRaw); + if (string.IsNullOrWhiteSpace(spuCode)) + { + spuCode = GenerateSpuCode(idGenerator.NextId()); + } + else if (spuCode.Length > 32) + { + rowErrors.Add("SPU编码长度不能超过 32"); + } + + if (!string.IsNullOrWhiteSpace(spuCode) && !seenSpuCodes.Add(spuCode)) + { + rowErrors.Add("导入文件内 SPU 编码重复"); + } + + if (rowErrors.Count > 0) + { + errors.Add(new BatchImportErrorItemResponse + { + RowNo = rowNo, + Message = string.Join(";", rowErrors) + }); + continue; + } + + importRows.Add(new ImportRowInput( + rowNo, + spuCode!, + name, + subtitle, + categoryId, + kind, + price, + originalPrice, + stock, + status)); + } + + var skippedCount = 0; + var successCount = 0; + + if (importRows.Count > 0) + { + var spuCodes = importRows.Select(x => x.SpuCode).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var existingBySpu = await dbContext.Products + .Where(x => x.TenantId == tenantId && spuCodes.Contains(x.SpuCode)) + .ToDictionaryAsync(x => x.SpuCode, x => x, StringComparer.OrdinalIgnoreCase, cancellationToken); + + foreach (var row in importRows) + { + if (existingBySpu.TryGetValue(row.SpuCode, out var existing)) + { + if (existing.StoreId != storeId) + { + errors.Add(new BatchImportErrorItemResponse + { + RowNo = row.RowNo, + Message = "SPU编码已被其他门店占用" + }); + continue; + } + + var changed = false; + if (existing.Name != row.Name) + { + existing.Name = row.Name; + changed = true; + } + + if (existing.Subtitle != row.Subtitle) + { + existing.Subtitle = row.Subtitle; + changed = true; + } + + if (existing.CategoryId != row.CategoryId) + { + existing.CategoryId = row.CategoryId; + changed = true; + } + + if (existing.Kind != row.Kind) + { + existing.Kind = row.Kind; + changed = true; + } + + if (existing.Price != row.Price) + { + existing.Price = row.Price; + changed = true; + } + + if (existing.OriginalPrice != row.OriginalPrice) + { + existing.OriginalPrice = row.OriginalPrice; + changed = true; + } + + if (existing.StockQuantity != row.Stock) + { + existing.StockQuantity = row.Stock; + changed = true; + } + + if (existing.Status != row.Status) + { + existing.Status = row.Status; + changed = true; + } + + if (existing.SoldoutMode.HasValue || existing.SoldoutReason is not null || existing.RecoverAt.HasValue || existing.RemainStock.HasValue) + { + existing.SoldoutMode = null; + existing.SoldoutReason = null; + existing.RecoverAt = null; + existing.RemainStock = null; + changed = true; + } + + if (changed) + { + successCount++; + } + else + { + skippedCount++; + } + } + else + { + var nextId = idGenerator.NextId(); + dbContext.Products.Add(new Product + { + Id = nextId, + TenantId = tenantId, + StoreId = storeId, + CategoryId = row.CategoryId, + SpuCode = row.SpuCode, + Name = row.Name, + Subtitle = row.Subtitle, + Price = row.Price, + OriginalPrice = row.OriginalPrice, + StockQuantity = row.Stock, + Status = row.Status, + Kind = row.Kind, + SalesMonthly = 0, + SyncToPlatform = true, + NotifyManager = false, + SortWeight = 0, + EnableDineIn = true, + EnablePickup = true, + EnableDelivery = true + }); + successCount++; + } + } + + if (successCount > 0) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + return ApiResponse.Ok(new BatchImportResultResponse + { + FileName = request.File.FileName, + TotalCount = totalCount, + SuccessCount = successCount, + FailedCount = errors.Count, + SkippedCount = skippedCount, + Errors = errors + }); + } + + [HttpPost("export")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Export( + [FromBody] BatchExportRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + var (tenantId, _) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var products = await BuildScopedProductQuery(tenantId, storeId, request.Scope) + .OrderBy(x => x.Name) + .ThenBy(x => x.Id) + .ToListAsync(cancellationToken); + + var categoryLookup = await dbContext.ProductCategories + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .ToDictionaryAsync(x => x.Id, x => x.Name, cancellationToken); + + using var workbook = new XLWorkbook(); + var sheet = workbook.Worksheets.Add("商品导出"); + for (var index = 0; index < ImportHeaders.Length; index++) + { + var cell = sheet.Cell(1, index + 1); + cell.Value = ImportHeaders[index]; + cell.Style.Font.Bold = true; + cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#F5F7FA"); + } + + for (var rowIndex = 0; rowIndex < products.Count; rowIndex++) + { + var product = products[rowIndex]; + var rowNo = rowIndex + 2; + sheet.Cell(rowNo, 1).Value = product.SpuCode; + sheet.Cell(rowNo, 2).Value = product.Name; + sheet.Cell(rowNo, 3).Value = product.Subtitle ?? string.Empty; + sheet.Cell(rowNo, 4).Value = categoryLookup.GetValueOrDefault(product.CategoryId, string.Empty); + sheet.Cell(rowNo, 5).Value = ToImportKindText(product.Kind); + sheet.Cell(rowNo, 6).Value = decimal.Round(product.Price, 2, MidpointRounding.AwayFromZero); + sheet.Cell(rowNo, 7).Value = product.OriginalPrice.HasValue + ? decimal.Round(product.OriginalPrice.Value, 2, MidpointRounding.AwayFromZero) + : string.Empty; + sheet.Cell(rowNo, 8).Value = Math.Max(0, product.StockQuantity ?? 0); + sheet.Cell(rowNo, 9).Value = ToImportStatusText(product.Status); + } + + sheet.Columns(1, ImportHeaders.Length).AdjustToContents(); + + return ApiResponse.Ok(new BatchExcelFileResponse + { + FileName = $"商品导出_{DateTime.UtcNow:yyyyMMddHHmmss}.xlsx", + FileContentBase64 = EncodeWorkbookToBase64(workbook), + TotalCount = products.Count, + SuccessCount = products.Count, + FailedCount = 0 + }); + } + + private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken) + { + var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken); + } + + private IQueryable BuildScopedProductQuery(long tenantId, long storeId, ProductBatchScopeRequest? scope) + { + var query = dbContext.Products + .Where(x => x.TenantId == tenantId && x.StoreId == storeId); + + if (scope is null) + { + return query; + } + + var type = NormalizeName(scope.Type); + if (string.IsNullOrWhiteSpace(type) || type == "all") + { + return query; + } + + if (type == "category") + { + var rawCategoryIds = new List(scope.CategoryIds); + if (!string.IsNullOrWhiteSpace(scope.CategoryId)) + { + rawCategoryIds.Add(scope.CategoryId); + } + + var categoryIds = StoreApiHelpers.ParseSnowflakeList(rawCategoryIds); + if (categoryIds.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "按分类范围必须至少选择一个分类"); + } + + return query.Where(x => categoryIds.Contains(x.CategoryId)); + } + + if (type is "selected" or "manual") + { + var productIds = StoreApiHelpers.ParseSnowflakeList(scope.ProductIds); + if (productIds.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "手动范围必须至少选择一个商品"); + } + + return query.Where(x => productIds.Contains(x.Id)); + } + + throw new BusinessException(ErrorCodes.BadRequest, "scope.type 非法"); + } + + private static void ValidatePriceAdjustRequest(string? direction, string? amountType, decimal amount) + { + var normalizedDirection = NormalizeName(direction); + if (normalizedDirection is not ("up" or "down")) + { + throw new BusinessException(ErrorCodes.BadRequest, "direction 非法,仅支持 up/down"); + } + + var normalizedAmountType = NormalizeName(amountType); + if (normalizedAmountType is not ("fixed" or "percent")) + { + throw new BusinessException(ErrorCodes.BadRequest, "amountType 非法,仅支持 fixed/percent"); + } + + if (amount < 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "amount 不能小于 0"); + } + + if (normalizedAmountType == "percent" && amount > 100) + { + throw new BusinessException(ErrorCodes.BadRequest, "百分比调价 amount 不能大于 100"); + } + } + + private static decimal CalculateAdjustedPrice(decimal currentPrice, string? direction, string? amountType, decimal amount) + { + var normalizedDirection = NormalizeName(direction); + var normalizedAmountType = NormalizeName(amountType); + + decimal delta; + if (normalizedAmountType == "percent") + { + delta = decimal.Round(currentPrice * (amount / 100m), 2, MidpointRounding.AwayFromZero); + } + else + { + delta = decimal.Round(amount, 2, MidpointRounding.AwayFromZero); + } + + var next = normalizedDirection == "down" + ? currentPrice - delta + : currentPrice + delta; + + if (next < 0) + { + next = 0; + } + + return decimal.Round(next, 2, MidpointRounding.AwayFromZero); + } + + private static ProductKind ParseImportKind(string? value) + { + var normalized = NormalizeName(value); + return normalized switch + { + "" => ProductKind.Single, + "single" => ProductKind.Single, + "单品" => ProductKind.Single, + "0" => ProductKind.Single, + "combo" => ProductKind.Combo, + "套餐" => ProductKind.Combo, + "1" => ProductKind.Combo, + _ => throw new BusinessException(ErrorCodes.BadRequest, "商品类型仅支持 单品/套餐") + }; + } + + private static ProductStatus ParseImportStatus(string? value) + { + var normalized = NormalizeName(value); + return normalized switch + { + "" => ProductStatus.OffShelf, + "onsale" => ProductStatus.OnSale, + "on_sale" => ProductStatus.OnSale, + "on" => ProductStatus.OnSale, + "上架" => ProductStatus.OnSale, + "在售" => ProductStatus.OnSale, + "1" => ProductStatus.OnSale, + "offshelf" => ProductStatus.OffShelf, + "off_shelf" => ProductStatus.OffShelf, + "off" => ProductStatus.OffShelf, + "下架" => ProductStatus.OffShelf, + "停售" => ProductStatus.OffShelf, + "2" => ProductStatus.OffShelf, + "draft" => ProductStatus.Draft, + "草稿" => ProductStatus.Draft, + "0" => ProductStatus.Draft, + _ => throw new BusinessException(ErrorCodes.BadRequest, "状态仅支持 上架/下架/草稿") + }; + } + + private static string ToImportKindText(ProductKind kind) + { + return kind switch + { + ProductKind.Combo => "套餐", + _ => "单品" + }; + } + + private static string ToImportStatusText(ProductStatus status) + { + return status switch + { + ProductStatus.OnSale => "上架", + ProductStatus.Draft => "草稿", + _ => "下架" + }; + } + + private static string ReadCellText(IXLCell cell) + { + return cell.GetFormattedString().Trim(); + } + + private static bool TryParseDecimal(string? input, out decimal value) + { + var text = (input ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(text)) + { + value = 0m; + return false; + } + + if (decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out value)) + { + return true; + } + + return decimal.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out value); + } + + private static bool TryParseInt(string? input, out int value) + { + var text = (input ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(text)) + { + value = 0; + return false; + } + + if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value)) + { + return true; + } + + return int.TryParse(text, NumberStyles.Integer, CultureInfo.CurrentCulture, out value); + } + + private static string? TrimOrNull(string? value) + { + var trimmed = (value ?? string.Empty).Trim(); + return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; + } + + private static string NormalizeName(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + return string.Concat(value.Trim().ToLowerInvariant().Where(ch => !char.IsWhiteSpace(ch))); + } + + private static string BuildSyncProductKey(string? productName, ProductKind kind) + { + return $"{NormalizeName(productName)}|{(int)kind}"; + } + + private static string GenerateSpuCode(long seed) + { + return $"SPU{DateTime.UtcNow:yyyyMMddHHmmss}{Math.Abs(seed % 10_000):D4}"; + } + + private static string EncodeWorkbookToBase64(XLWorkbook workbook) + { + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return Convert.ToBase64String(stream.ToArray()); + } + + private sealed record ImportRowInput( + int RowNo, + string SpuCode, + string Name, + string? Subtitle, + long CategoryId, + ProductKind Kind, + decimal Price, + decimal? OriginalPrice, + int Stock, + ProductStatus Status); +}