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);
+}