From d66879f5cf11687dc5286e5f23ad643cc2144392 Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Sun, 22 Feb 2026 09:35:57 +0800
Subject: [PATCH] feat(product): complete combo and detail editing data model
---
.../Contracts/Product/ProductContracts.cs | 318 +
.../Controllers/ProductController.cs | 1055 +-
.../Products/Commands/CreateProductCommand.cs | 15 +
.../Products/Commands/UpdateProductCommand.cs | 15 +
.../App/Products/Dto/ProductDto.cs | 15 +
.../App/Products/Dto/ProductSkuDto.cs | 5 +
.../Handlers/CreateProductCommandHandler.cs | 6 +
.../Handlers/SearchProductsQueryHandler.cs | 3 +
.../Handlers/UpdateProductCommandHandler.cs | 6 +
.../App/Products/ProductMapping.cs | 6 +-
.../Products/Entities/Product.cs | 15 +
.../Products/Entities/ProductComboGroup.cs | 34 +
.../Entities/ProductComboGroupItem.cs | 29 +
.../Products/Entities/ProductSku.cs | 5 +
.../Repositories/IProductRepository.cs | 38 +
.../App/Persistence/TakeoutAppDbContext.cs | 38 +
.../App/Repositories/EfProductRepository.cs | 112 +
...22000604_AddProductComboGroups.Designer.cs | 8559 ++++++++++++++++
.../20260222000604_AddProductComboGroups.cs | 96 +
...354_AddProductDetailRichFields.Designer.cs | 8580 +++++++++++++++++
...260222010354_AddProductDetailRichFields.cs | 66 +
.../TakeoutAppDbContextModelSnapshot.cs | 158 +
22 files changed, 19099 insertions(+), 75 deletions(-)
create mode 100644 src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductComboGroup.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductComboGroupItem.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260222000604_AddProductComboGroups.Designer.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260222000604_AddProductComboGroups.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260222010354_AddProductDetailRichFields.Designer.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260222010354_AddProductDetailRichFields.cs
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs
index 8243d2c..647d910 100644
--- a/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs
@@ -112,6 +112,21 @@ public sealed class SaveProductRequest
///
public int Stock { get; set; }
+ ///
+ /// 排序权重。
+ ///
+ public int? SortWeight { get; set; }
+
+ ///
+ /// 库存预警值。
+ ///
+ public int? WarningStock { get; set; }
+
+ ///
+ /// 打包费。
+ ///
+ public decimal? PackingFee { get; set; }
+
///
/// 标签。
///
@@ -141,6 +156,140 @@ public sealed class SaveProductRequest
/// 商品图片地址列表。
///
public List ImageUrls { get; set; } = [];
+
+ ///
+ /// 关联规格模板 ID。
+ ///
+ public List? SpecTemplateIds { get; set; }
+
+ ///
+ /// 关联加料组模板 ID。
+ ///
+ public List? AddonGroupIds { get; set; }
+
+ ///
+ /// 关联标签 ID。
+ ///
+ public List? LabelIds { get; set; }
+
+ ///
+ /// SKU 列表。
+ ///
+ public List? Skus { get; set; }
+
+ ///
+ /// 套餐分组。
+ ///
+ public List ComboGroups { get; set; } = [];
+}
+
+///
+/// 保存商品套餐分组请求。
+///
+public sealed class SaveProductComboGroupRequest
+{
+ ///
+ /// 分组名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 最小选择数。
+ ///
+ public int MinSelect { get; set; } = 1;
+
+ ///
+ /// 最大选择数。
+ ///
+ public int MaxSelect { get; set; } = 1;
+
+ ///
+ /// 分组排序。
+ ///
+ public int SortOrder { get; set; }
+
+ ///
+ /// 分组内商品。
+ ///
+ public List Items { get; set; } = [];
+}
+
+///
+/// 保存商品套餐分组商品请求。
+///
+public sealed class SaveProductComboGroupItemRequest
+{
+ ///
+ /// 商品 ID。
+ ///
+ public string ProductId { get; set; } = string.Empty;
+
+ ///
+ /// 数量。
+ ///
+ public int Quantity { get; set; } = 1;
+
+ ///
+ /// 排序。
+ ///
+ public int SortOrder { get; set; }
+}
+
+///
+/// 保存商品 SKU 请求。
+///
+public sealed class SaveProductSkuRequest
+{
+ ///
+ /// SKU 编码(可选)。
+ ///
+ public string? SkuCode { get; set; }
+
+ ///
+ /// 售价。
+ ///
+ public decimal Price { get; set; }
+
+ ///
+ /// 划线价。
+ ///
+ public decimal? OriginalPrice { get; set; }
+
+ ///
+ /// 库存。
+ ///
+ public int Stock { get; set; }
+
+ ///
+ /// 是否启用。
+ ///
+ public bool IsEnabled { get; set; } = true;
+
+ ///
+ /// 排序。
+ ///
+ public int SortOrder { get; set; }
+
+ ///
+ /// 规格属性组合。
+ ///
+ public List Attributes { get; set; } = [];
+}
+
+///
+/// SKU 规格属性请求。
+///
+public sealed class SaveProductSkuAttributeRequest
+{
+ ///
+ /// 模板 ID。
+ ///
+ public string TemplateId { get; set; } = string.Empty;
+
+ ///
+ /// 选项 ID。
+ ///
+ public string OptionId { get; set; } = string.Empty;
}
///
@@ -384,6 +533,26 @@ public class ProductListItemResponse
///
public sealed class ProductDetailResponse : ProductListItemResponse
{
+ ///
+ /// 排序权重。
+ ///
+ public int SortWeight { get; set; }
+
+ ///
+ /// 库存预警值。
+ ///
+ public int? WarningStock { get; set; }
+
+ ///
+ /// 打包费。
+ ///
+ public decimal? PackingFee { get; set; }
+
+ ///
+ /// 套餐分组。
+ ///
+ public List ComboGroups { get; set; } = [];
+
///
/// 商品图片列表。
///
@@ -394,6 +563,31 @@ public sealed class ProductDetailResponse : ProductListItemResponse
///
public string Description { get; set; } = string.Empty;
+ ///
+ /// 关联规格模板 ID。
+ ///
+ public List SpecTemplateIds { get; set; } = [];
+
+ ///
+ /// 关联加料组模板 ID。
+ ///
+ public List AddonGroupIds { get; set; } = [];
+
+ ///
+ /// 关联标签 ID。
+ ///
+ public List LabelIds { get; set; } = [];
+
+ ///
+ /// SKU 列表。
+ ///
+ public List Skus { get; set; } = [];
+
+ ///
+ /// 定时上架时间。
+ ///
+ public string? TimedOnShelfAt { get; set; }
+
///
/// 是否通知店长。
///
@@ -420,6 +614,130 @@ public sealed class ProductDetailResponse : ProductListItemResponse
public bool SyncToPlatform { get; set; }
}
+///
+/// 套餐分组响应。
+///
+public sealed class ProductComboGroupResponse
+{
+ ///
+ /// 分组 ID。
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// 分组名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 最小选择数。
+ ///
+ public int MinSelect { get; set; }
+
+ ///
+ /// 最大选择数。
+ ///
+ public int MaxSelect { get; set; }
+
+ ///
+ /// 排序。
+ ///
+ public int SortOrder { get; set; }
+
+ ///
+ /// 分组商品列表。
+ ///
+ public List Items { get; set; } = [];
+}
+
+///
+/// 套餐分组商品响应。
+///
+public sealed class ProductComboGroupItemResponse
+{
+ ///
+ /// 商品 ID。
+ ///
+ public string ProductId { get; set; } = string.Empty;
+
+ ///
+ /// 商品名称。
+ ///
+ public string ProductName { get; set; } = string.Empty;
+
+ ///
+ /// 数量。
+ ///
+ public int Quantity { get; set; }
+
+ ///
+ /// 排序。
+ ///
+ public int SortOrder { get; set; }
+}
+
+///
+/// 商品 SKU 响应。
+///
+public sealed class ProductSkuResponse
+{
+ ///
+ /// SKU ID。
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// SKU 编码。
+ ///
+ public string SkuCode { get; set; } = string.Empty;
+
+ ///
+ /// 售价。
+ ///
+ public decimal Price { get; set; }
+
+ ///
+ /// 划线价。
+ ///
+ public decimal? OriginalPrice { get; set; }
+
+ ///
+ /// 库存。
+ ///
+ public int Stock { get; set; }
+
+ ///
+ /// 是否启用。
+ ///
+ public bool IsEnabled { get; set; }
+
+ ///
+ /// 排序。
+ ///
+ public int SortOrder { get; set; }
+
+ ///
+ /// 规格属性。
+ ///
+ public List Attributes { get; set; } = [];
+}
+
+///
+/// SKU 规格属性响应。
+///
+public sealed class ProductSkuAttributeResponse
+{
+ ///
+ /// 模板 ID。
+ ///
+ public string TemplateId { get; set; } = string.Empty;
+
+ ///
+ /// 选项 ID。
+ ///
+ public string OptionId { get; set; } = string.Empty;
+}
+
///
/// 批量操作响应。
///
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs
index 4ff31ae..ddfdc14 100644
--- a/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs
@@ -3,10 +3,12 @@ using System.Text.Json;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Application.App.Products.Queries;
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;
@@ -55,10 +57,18 @@ public sealed class ProductController(
PageSize = Math.Clamp(request.PageSize, 1, 200)
}, cancellationToken);
+ var products = result.Items.ToList();
var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
+ var labelNameMap = await BuildProductLabelNameMapAsync(
+ products.Select(item => item.Id),
+ storeId,
+ cancellationToken);
+
return ApiResponse.Ok(new ProductListResultResponse
{
- Items = result.Items.Select(item => MapListItem(item, categoryNameLookup)).ToList(),
+ Items = products
+ .Select(item => MapListItem(item, categoryNameLookup, labelNameMap.GetValueOrDefault(item.Id, [])))
+ .ToList(),
Total = result.TotalCount,
Page = result.Page,
PageSize = result.PageSize
@@ -89,7 +99,18 @@ public sealed class ProductController(
}
var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
- return ApiResponse.Ok(MapDetailItem(product, categoryNameLookup));
+ var labelNameMap = await BuildProductLabelNameMapAsync([product.Id], storeId, cancellationToken);
+ var comboGroups = await BuildComboGroupResponsesAsync(product.Id, storeId, cancellationToken);
+ var relationState = await LoadProductRelationStateAsync(product.Id, storeId, cancellationToken);
+ return ApiResponse.Ok(MapDetailItem(
+ product,
+ categoryNameLookup,
+ labelNameMap.GetValueOrDefault(product.Id, []),
+ comboGroups,
+ relationState.SpecTemplateIds,
+ relationState.AddonGroupIds,
+ relationState.LabelIds,
+ relationState.Skus));
}
///
@@ -110,6 +131,7 @@ public sealed class ProductController(
var timedOnShelfAt = ParseTimedOnShelfAt(request.ShelfMode, request.TimedOnShelfAt);
var normalizedStatus = ResolveStatusByShelfMode(request.ShelfMode, request.Status);
var imageUrls = NormalizeImageUrls(request.ImageUrls);
+ var comboGroups = NormalizeComboGroups(request.ComboGroups, kind);
ProductDto? existing = null;
if (productId.HasValue)
@@ -125,6 +147,40 @@ public sealed class ProductController(
}
}
+ var shouldReplaceSpecAddon = existing is null || request.SpecTemplateIds is not null || request.AddonGroupIds is not null;
+ var shouldReplaceLabels = existing is null || request.LabelIds is not null;
+ var shouldReplaceSkus = existing is null || request.Skus is not null;
+
+ var specTemplateIds = shouldReplaceSpecAddon
+ ? ParseSnowflakeListStrict(request.SpecTemplateIds, nameof(request.SpecTemplateIds))
+ : [];
+ var addonGroupIds = shouldReplaceSpecAddon
+ ? ParseSnowflakeListStrict(request.AddonGroupIds, nameof(request.AddonGroupIds))
+ : [];
+
+ if (existing is not null &&
+ shouldReplaceSpecAddon &&
+ (request.SpecTemplateIds is null || request.AddonGroupIds is null))
+ {
+ var currentRelationState = await LoadProductRelationStateAsync(existing.Id, storeId, cancellationToken);
+ if (request.SpecTemplateIds is null)
+ {
+ specTemplateIds = currentRelationState.SpecTemplateIds.ToList();
+ }
+
+ if (request.AddonGroupIds is null)
+ {
+ addonGroupIds = currentRelationState.AddonGroupIds.ToList();
+ }
+ }
+
+ var labelIds = shouldReplaceLabels
+ ? ParseSnowflakeListStrict(request.LabelIds, nameof(request.LabelIds))
+ : [];
+ var normalizedSkus = shouldReplaceSkus
+ ? NormalizeSkus(request.Skus, request.Price, request.OriginalPrice, request.Stock)
+ : [];
+
var tagsJson = SerializeTagsJson(request.Tags);
var soldoutMode = ResolveSoldoutModeForSave(existing, normalizedStatus, request.Status);
var recoverAt = soldoutMode == ProductSoldoutMode.Timed ? existing?.RecoverAt : null;
@@ -132,76 +188,115 @@ public sealed class ProductController(
var soldoutReason = soldoutMode.HasValue ? existing?.SoldoutReason : null;
ProductDto? saved;
- if (existing is null)
+ await using (var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken))
{
- saved = await mediator.Send(new CreateProductCommand
+ if (existing is null)
{
- StoreId = storeId,
- CategoryId = categoryId,
- SpuCode = string.IsNullOrWhiteSpace(request.SpuCode) ? GenerateSpuCode() : request.SpuCode.Trim(),
- Name = request.Name.Trim(),
- Subtitle = request.Subtitle?.Trim(),
- Description = request.Description?.Trim(),
- Price = request.Price,
- OriginalPrice = request.OriginalPrice.HasValue && request.OriginalPrice.Value > 0 ? request.OriginalPrice : null,
- StockQuantity = Math.Max(0, request.Stock),
- Status = normalizedStatus,
- Kind = kind,
- TagsJson = tagsJson,
- TimedOnShelfAt = timedOnShelfAt,
- SoldoutMode = soldoutMode,
- RecoverAt = recoverAt,
- RemainStock = remainStock,
- SoldoutReason = soldoutReason,
- SyncToPlatform = existing?.SyncToPlatform ?? true,
- NotifyManager = existing?.NotifyManager ?? false,
- CoverImage = imageUrls.FirstOrDefault(),
- GalleryImages = string.Join(',', imageUrls)
- }, cancellationToken);
- }
- else
- {
- saved = await mediator.Send(new UpdateProductCommand
+ saved = await mediator.Send(new CreateProductCommand
+ {
+ StoreId = storeId,
+ CategoryId = categoryId,
+ SpuCode = string.IsNullOrWhiteSpace(request.SpuCode) ? GenerateSpuCode() : request.SpuCode.Trim(),
+ Name = request.Name.Trim(),
+ Subtitle = request.Subtitle?.Trim(),
+ Description = request.Description?.Trim(),
+ Price = request.Price,
+ OriginalPrice = request.OriginalPrice.HasValue && request.OriginalPrice.Value > 0 ? request.OriginalPrice : null,
+ StockQuantity = Math.Max(0, request.Stock),
+ Status = normalizedStatus,
+ Kind = kind,
+ TagsJson = tagsJson,
+ TimedOnShelfAt = timedOnShelfAt,
+ SoldoutMode = soldoutMode,
+ RecoverAt = recoverAt,
+ RemainStock = remainStock,
+ SoldoutReason = soldoutReason,
+ SyncToPlatform = true,
+ NotifyManager = false,
+ CoverImage = imageUrls.FirstOrDefault(),
+ GalleryImages = string.Join(',', imageUrls),
+ SortWeight = Math.Max(0, request.SortWeight ?? 0),
+ WarningStock = request.WarningStock.HasValue ? Math.Max(0, request.WarningStock.Value) : null,
+ PackingFee = NormalizePackingFee(request.PackingFee)
+ }, cancellationToken);
+ }
+ else
{
- ProductId = existing.Id,
- StoreId = storeId,
- CategoryId = categoryId,
- SpuCode = string.IsNullOrWhiteSpace(request.SpuCode) ? existing.SpuCode : request.SpuCode.Trim(),
- Name = request.Name.Trim(),
- Subtitle = request.Subtitle?.Trim(),
- Unit = existing.Unit,
- Price = request.Price,
- OriginalPrice = request.OriginalPrice.HasValue && request.OriginalPrice.Value > 0 ? request.OriginalPrice : null,
- StockQuantity = Math.Max(0, request.Stock),
- MaxQuantityPerOrder = existing.MaxQuantityPerOrder,
- Status = normalizedStatus,
- Kind = kind,
- SalesMonthly = existing.SalesMonthly,
- TagsJson = tagsJson,
- SoldoutMode = soldoutMode,
- RecoverAt = recoverAt,
- RemainStock = remainStock,
- SoldoutReason = soldoutReason,
- SyncToPlatform = existing.SyncToPlatform,
- NotifyManager = existing.NotifyManager,
- TimedOnShelfAt = timedOnShelfAt,
- CoverImage = imageUrls.FirstOrDefault() ?? existing.CoverImage,
- GalleryImages = imageUrls.Count > 0 ? string.Join(',', imageUrls) : existing.GalleryImages,
- Description = request.Description?.Trim(),
- EnableDineIn = existing.EnableDineIn,
- EnablePickup = existing.EnablePickup,
- EnableDelivery = existing.EnableDelivery,
- IsFeatured = existing.IsFeatured
- }, cancellationToken);
- }
-
- if (saved is null)
- {
- return ApiResponse.Error(ErrorCodes.NotFound, "商品不存在");
+ saved = await mediator.Send(new UpdateProductCommand
+ {
+ ProductId = existing.Id,
+ StoreId = storeId,
+ CategoryId = categoryId,
+ SpuCode = string.IsNullOrWhiteSpace(request.SpuCode) ? existing.SpuCode : request.SpuCode.Trim(),
+ Name = request.Name.Trim(),
+ Subtitle = request.Subtitle?.Trim(),
+ Unit = existing.Unit,
+ Price = request.Price,
+ OriginalPrice = request.OriginalPrice.HasValue && request.OriginalPrice.Value > 0 ? request.OriginalPrice : null,
+ StockQuantity = Math.Max(0, request.Stock),
+ MaxQuantityPerOrder = existing.MaxQuantityPerOrder,
+ Status = normalizedStatus,
+ Kind = kind,
+ SalesMonthly = existing.SalesMonthly,
+ TagsJson = tagsJson,
+ SoldoutMode = soldoutMode,
+ RecoverAt = recoverAt,
+ RemainStock = remainStock,
+ SoldoutReason = soldoutReason,
+ SyncToPlatform = existing.SyncToPlatform,
+ NotifyManager = existing.NotifyManager,
+ TimedOnShelfAt = timedOnShelfAt,
+ CoverImage = imageUrls.FirstOrDefault() ?? existing.CoverImage,
+ GalleryImages = imageUrls.Count > 0 ? string.Join(',', imageUrls) : existing.GalleryImages,
+ Description = request.Description?.Trim(),
+ EnableDineIn = existing.EnableDineIn,
+ EnablePickup = existing.EnablePickup,
+ EnableDelivery = existing.EnableDelivery,
+ IsFeatured = existing.IsFeatured,
+ SortWeight = Math.Max(0, request.SortWeight ?? 0),
+ WarningStock = request.WarningStock.HasValue ? Math.Max(0, request.WarningStock.Value) : null,
+ PackingFee = NormalizePackingFee(request.PackingFee)
+ }, cancellationToken);
+ }
+
+ if (saved is null)
+ {
+ return ApiResponse.Error(ErrorCodes.NotFound, "商品不存在");
+ }
+
+ if (shouldReplaceSpecAddon)
+ {
+ await ReplaceSpecTemplateRelationsAsync(saved.Id, storeId, specTemplateIds, addonGroupIds, cancellationToken);
+ }
+
+ if (shouldReplaceLabels)
+ {
+ await ReplaceLabelRelationsAsync(saved.Id, storeId, labelIds, cancellationToken);
+ }
+
+ if (shouldReplaceSkus)
+ {
+ await ReplaceSkusAsync(saved.Id, storeId, normalizedSkus, specTemplateIds, cancellationToken);
+ }
+
+ await ReplaceComboGroupsAsync(saved.Id, storeId, kind, comboGroups, cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
}
+ var savedProduct = saved!;
+ var detailComboGroups = await BuildComboGroupResponsesAsync(savedProduct.Id, storeId, cancellationToken);
var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
- return ApiResponse.Ok(MapDetailItem(saved, categoryNameLookup));
+ var savedRelationState = await LoadProductRelationStateAsync(savedProduct.Id, storeId, cancellationToken);
+ var labelNameMap = await BuildProductLabelNameMapAsync([savedProduct.Id], storeId, cancellationToken);
+ return ApiResponse.Ok(MapDetailItem(
+ savedProduct,
+ categoryNameLookup,
+ labelNameMap.GetValueOrDefault(savedProduct.Id, []),
+ detailComboGroups,
+ savedRelationState.SpecTemplateIds,
+ savedRelationState.AddonGroupIds,
+ savedRelationState.LabelIds,
+ savedRelationState.Skus));
}
///
@@ -270,7 +365,18 @@ public sealed class ProductController(
cancellationToken);
var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
- return ApiResponse.Ok(MapDetailItem(updated, categoryNameLookup));
+ var labelNameMap = await BuildProductLabelNameMapAsync([updated.Id], storeId, cancellationToken);
+ var comboGroups = await BuildComboGroupResponsesAsync(updated.Id, storeId, cancellationToken);
+ var relationState = await LoadProductRelationStateAsync(updated.Id, storeId, cancellationToken);
+ return ApiResponse.Ok(MapDetailItem(
+ updated,
+ categoryNameLookup,
+ labelNameMap.GetValueOrDefault(updated.Id, []),
+ comboGroups,
+ relationState.SpecTemplateIds,
+ relationState.AddonGroupIds,
+ relationState.LabelIds,
+ relationState.Skus));
}
///
@@ -315,7 +421,18 @@ public sealed class ProductController(
}
var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
- return ApiResponse.Ok(MapDetailItem(result, categoryNameLookup));
+ var labelNameMap = await BuildProductLabelNameMapAsync([result.Id], storeId, cancellationToken);
+ var comboGroups = await BuildComboGroupResponsesAsync(result.Id, storeId, cancellationToken);
+ var relationState = await LoadProductRelationStateAsync(result.Id, storeId, cancellationToken);
+ return ApiResponse.Ok(MapDetailItem(
+ result,
+ categoryNameLookup,
+ labelNameMap.GetValueOrDefault(result.Id, []),
+ comboGroups,
+ relationState.SpecTemplateIds,
+ relationState.AddonGroupIds,
+ relationState.LabelIds,
+ relationState.Skus));
}
///
@@ -457,7 +574,10 @@ public sealed class ProductController(
EnableDineIn = existing.EnableDineIn,
EnablePickup = existing.EnablePickup,
EnableDelivery = existing.EnableDelivery,
- IsFeatured = existing.IsFeatured
+ IsFeatured = existing.IsFeatured,
+ SortWeight = existing.SortWeight,
+ WarningStock = existing.WarningStock,
+ PackingFee = existing.PackingFee
}, cancellationToken);
if (updated is null)
@@ -483,10 +603,53 @@ public sealed class ProductController(
return categories.ToDictionary(item => item.Id, item => item.Name);
}
- private static ProductListItemResponse MapListItem(ProductDto source, IReadOnlyDictionary categoryNameLookup)
+ private async Task>> BuildProductLabelNameMapAsync(
+ IEnumerable productIds,
+ long storeId,
+ CancellationToken cancellationToken)
+ {
+ var normalizedProductIds = productIds
+ .Where(item => item > 0)
+ .Distinct()
+ .ToList();
+
+ if (normalizedProductIds.Count == 0)
+ {
+ return [];
+ }
+
+ var rows = await (
+ from relation in dbContext.ProductLabelProducts.AsNoTracking()
+ join label in dbContext.ProductLabels.AsNoTracking() on relation.LabelId equals label.Id
+ where relation.StoreId == storeId &&
+ normalizedProductIds.Contains(relation.ProductId)
+ orderby label.SortOrder, label.Id
+ select new
+ {
+ relation.ProductId,
+ LabelName = label.Name
+ }).ToListAsync(cancellationToken);
+
+ var result = normalizedProductIds.ToDictionary(item => item, _ => new List());
+ foreach (var group in rows.GroupBy(item => item.ProductId))
+ {
+ result[group.Key] = group
+ .Select(item => item.LabelName?.Trim() ?? string.Empty)
+ .Where(item => !string.IsNullOrWhiteSpace(item))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Take(8)
+ .ToList();
+ }
+
+ return result;
+ }
+
+ private static ProductListItemResponse MapListItem(
+ ProductDto source,
+ IReadOnlyDictionary categoryNameLookup,
+ IReadOnlyList tags)
{
var stock = source.StockQuantity ?? 0;
- var tags = ParseTagsJson(source.TagsJson);
return new ProductListItemResponse
{
Id = source.Id.ToString(),
@@ -503,13 +666,21 @@ public sealed class ProductController(
Status = ToUiStatus(source.Status, source.SoldoutMode),
Stock = stock,
Subtitle = source.Subtitle ?? string.Empty,
- Tags = tags
+ Tags = tags.ToList()
};
}
- private static ProductDetailResponse MapDetailItem(ProductDto source, IReadOnlyDictionary categoryNameLookup)
+ private static ProductDetailResponse MapDetailItem(
+ ProductDto source,
+ IReadOnlyDictionary categoryNameLookup,
+ IReadOnlyList tags,
+ IReadOnlyList? comboGroups = null,
+ IReadOnlyList? specTemplateIds = null,
+ IReadOnlyList? addonGroupIds = null,
+ IReadOnlyList? labelIds = null,
+ IReadOnlyList? skus = null)
{
- var listItem = MapListItem(source, categoryNameLookup);
+ var listItem = MapListItem(source, categoryNameLookup, tags);
return new ProductDetailResponse
{
Id = listItem.Id,
@@ -527,8 +698,17 @@ public sealed class ProductController(
Stock = listItem.Stock,
Subtitle = listItem.Subtitle,
Tags = listItem.Tags,
+ SortWeight = source.SortWeight,
+ WarningStock = source.WarningStock,
+ PackingFee = source.PackingFee,
+ ComboGroups = comboGroups?.ToList() ?? [],
ImageUrls = BuildImageUrls(source),
Description = source.Description ?? string.Empty,
+ SpecTemplateIds = (specTemplateIds ?? []).Select(item => item.ToString()).ToList(),
+ AddonGroupIds = (addonGroupIds ?? []).Select(item => item.ToString()).ToList(),
+ LabelIds = (labelIds ?? []).Select(item => item.ToString()).ToList(),
+ Skus = skus?.ToList() ?? [],
+ TimedOnShelfAt = source.TimedOnShelfAt?.ToString("yyyy-MM-dd HH:mm:ss"),
NotifyManager = source.NotifyManager,
RecoverAt = source.RecoverAt?.ToString("yyyy-MM-dd HH:mm:ss"),
RemainStock = Math.Max(0, source.RemainStock ?? source.StockQuantity ?? 0),
@@ -719,6 +899,728 @@ public sealed class ProductController(
.ToList();
}
+ private static List ParseSnowflakeListStrict(IEnumerable? values, string fieldName)
+ {
+ if (values is null)
+ {
+ return [];
+ }
+
+ var result = new List();
+ var dedup = new HashSet();
+ var index = 0;
+ foreach (var value in values)
+ {
+ var raw = (value ?? string.Empty).Trim();
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ index++;
+ continue;
+ }
+
+ var id = StoreApiHelpers.ParseRequiredSnowflake(raw, $"{fieldName}[{index}]");
+ if (dedup.Add(id))
+ {
+ result.Add(id);
+ }
+
+ index++;
+ }
+
+ return result;
+ }
+
+ private static decimal? NormalizePackingFee(decimal? packingFee)
+ {
+ if (!packingFee.HasValue)
+ {
+ return null;
+ }
+
+ if (packingFee.Value < 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "packingFee 不能小于 0");
+ }
+
+ return decimal.Round(packingFee.Value, 2, MidpointRounding.AwayFromZero);
+ }
+
+ private static IReadOnlyList NormalizeSkus(
+ IEnumerable? source,
+ decimal defaultPrice,
+ decimal? defaultOriginalPrice,
+ int defaultStock)
+ {
+ var skus = (source ?? []).ToList();
+ if (skus.Count == 0)
+ {
+ return
+ [
+ new NormalizedSkuRequest(
+ null,
+ Math.Max(0, defaultPrice),
+ defaultOriginalPrice.HasValue && defaultOriginalPrice.Value > 0
+ ? defaultOriginalPrice.Value
+ : null,
+ Math.Max(0, defaultStock),
+ true,
+ 1,
+ [])
+ ];
+ }
+
+ var normalized = new List(skus.Count);
+ var combinationSet = new HashSet(StringComparer.Ordinal);
+ for (var skuIndex = 0; skuIndex < skus.Count; skuIndex++)
+ {
+ var sku = skus[skuIndex];
+ if (sku.Price < 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"skus[{skuIndex}].price 不能小于 0");
+ }
+
+ if (sku.Stock < 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"skus[{skuIndex}].stock 不能小于 0");
+ }
+
+ var skuCode = (sku.SkuCode ?? string.Empty).Trim();
+ if (skuCode.Length > 32)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"skus[{skuIndex}].skuCode 长度不能超过 32");
+ }
+
+ var attributes = new List();
+ var templateDedup = new HashSet();
+ var attrList = sku.Attributes ?? [];
+ for (var attrIndex = 0; attrIndex < attrList.Count; attrIndex++)
+ {
+ var attr = attrList[attrIndex];
+ var templateId = StoreApiHelpers.ParseRequiredSnowflake(
+ attr.TemplateId,
+ $"skus[{skuIndex}].attributes[{attrIndex}].templateId");
+ var optionId = StoreApiHelpers.ParseRequiredSnowflake(
+ attr.OptionId,
+ $"skus[{skuIndex}].attributes[{attrIndex}].optionId");
+
+ if (!templateDedup.Add(templateId))
+ {
+ throw new BusinessException(
+ ErrorCodes.BadRequest,
+ $"skus[{skuIndex}] 存在重复模板属性");
+ }
+
+ attributes.Add(new NormalizedSkuAttributeRequest(templateId, optionId));
+ }
+
+ var combinationKey = BuildSkuAttributeKey(attributes);
+ if (!combinationSet.Add(combinationKey))
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "SKU 规格组合重复");
+ }
+
+ normalized.Add(new NormalizedSkuRequest(
+ string.IsNullOrWhiteSpace(skuCode) ? null : skuCode,
+ decimal.Round(sku.Price, 2, MidpointRounding.AwayFromZero),
+ sku.OriginalPrice.HasValue && sku.OriginalPrice.Value > 0
+ ? decimal.Round(sku.OriginalPrice.Value, 2, MidpointRounding.AwayFromZero)
+ : null,
+ sku.Stock,
+ sku.IsEnabled,
+ sku.SortOrder > 0 ? sku.SortOrder : skuIndex + 1,
+ attributes
+ .OrderBy(item => item.TemplateId)
+ .ThenBy(item => item.OptionId)
+ .ToList()));
+ }
+
+ return normalized;
+ }
+
+ private static string BuildSkuAttributeKey(IReadOnlyList attributes)
+ {
+ if (attributes.Count == 0)
+ {
+ return "default";
+ }
+
+ return string.Join('|', attributes
+ .OrderBy(item => item.TemplateId)
+ .ThenBy(item => item.OptionId)
+ .Select(item => $"{item.TemplateId}:{item.OptionId}"));
+ }
+
+ private async Task ReplaceSpecTemplateRelationsAsync(
+ long productId,
+ long storeId,
+ IReadOnlyCollection specTemplateIds,
+ IReadOnlyCollection addonGroupIds,
+ CancellationToken cancellationToken)
+ {
+ var relationTemplateIds = specTemplateIds
+ .Concat(addonGroupIds)
+ .Distinct()
+ .ToList();
+
+ var templateLookup = relationTemplateIds.Count == 0
+ ? new Dictionary()
+ : await dbContext.ProductSpecTemplates
+ .AsNoTracking()
+ .Where(item => item.StoreId == storeId && relationTemplateIds.Contains(item.Id))
+ .ToDictionaryAsync(item => item.Id, item => item.TemplateType, cancellationToken);
+
+ var missingTemplateId = relationTemplateIds.FirstOrDefault(item => !templateLookup.ContainsKey(item));
+ if (missingTemplateId > 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"规格模板不存在: {missingTemplateId}");
+ }
+
+ var invalidSpecTemplateId = specTemplateIds.FirstOrDefault(item => templateLookup[item] == ProductSpecTemplateType.Addon);
+ if (invalidSpecTemplateId > 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"规格模板类型错误: {invalidSpecTemplateId}");
+ }
+
+ var invalidAddonTemplateId = addonGroupIds.FirstOrDefault(item => templateLookup[item] != ProductSpecTemplateType.Addon);
+ if (invalidAddonTemplateId > 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"加料模板类型错误: {invalidAddonTemplateId}");
+ }
+
+ await dbContext.ProductSpecTemplateProducts
+ .Where(item => item.StoreId == storeId && item.ProductId == productId)
+ .ExecuteDeleteAsync(cancellationToken);
+
+ if (relationTemplateIds.Count == 0)
+ {
+ return;
+ }
+
+ var entities = relationTemplateIds
+ .Select(item => new ProductSpecTemplateProduct
+ {
+ StoreId = storeId,
+ TemplateId = item,
+ ProductId = productId
+ })
+ .ToList();
+
+ await dbContext.ProductSpecTemplateProducts.AddRangeAsync(entities, cancellationToken);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+
+ private async Task ReplaceLabelRelationsAsync(
+ long productId,
+ long storeId,
+ IReadOnlyCollection labelIds,
+ CancellationToken cancellationToken)
+ {
+ var existingLabelIds = labelIds.Count == 0
+ ? []
+ : await dbContext.ProductLabels
+ .AsNoTracking()
+ .Where(item => item.StoreId == storeId && labelIds.Contains(item.Id))
+ .Select(item => item.Id)
+ .ToListAsync(cancellationToken);
+
+ var missingLabelId = labelIds.FirstOrDefault(item => !existingLabelIds.Contains(item));
+ if (missingLabelId > 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"标签不存在: {missingLabelId}");
+ }
+
+ await dbContext.ProductLabelProducts
+ .Where(item => item.StoreId == storeId && item.ProductId == productId)
+ .ExecuteDeleteAsync(cancellationToken);
+
+ if (labelIds.Count == 0)
+ {
+ return;
+ }
+
+ var entities = labelIds
+ .Select(item => new ProductLabelProduct
+ {
+ StoreId = storeId,
+ LabelId = item,
+ ProductId = productId
+ })
+ .ToList();
+
+ await dbContext.ProductLabelProducts.AddRangeAsync(entities, cancellationToken);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+
+ private async Task ReplaceSkusAsync(
+ long productId,
+ long storeId,
+ IReadOnlyList skus,
+ IReadOnlyCollection specTemplateIds,
+ CancellationToken cancellationToken)
+ {
+ var allowedSpecTemplateIds = specTemplateIds.ToHashSet();
+ var templateIdsInSkus = skus
+ .SelectMany(item => item.Attributes)
+ .Select(item => item.TemplateId)
+ .Distinct()
+ .ToList();
+
+ var outOfSelectedTemplateId = templateIdsInSkus.FirstOrDefault(item => !allowedSpecTemplateIds.Contains(item));
+ if (outOfSelectedTemplateId > 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"SKU 使用了未关联的规格模板: {outOfSelectedTemplateId}");
+ }
+
+ if (templateIdsInSkus.Count > 0)
+ {
+ var templateTypeLookup = await dbContext.ProductSpecTemplates
+ .AsNoTracking()
+ .Where(item => item.StoreId == storeId && templateIdsInSkus.Contains(item.Id))
+ .ToDictionaryAsync(item => item.Id, item => item.TemplateType, cancellationToken);
+
+ var missingTemplateId = templateIdsInSkus.FirstOrDefault(item => !templateTypeLookup.ContainsKey(item));
+ if (missingTemplateId > 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"SKU 规格模板不存在: {missingTemplateId}");
+ }
+
+ var invalidTemplateId = templateTypeLookup
+ .FirstOrDefault(item => item.Value == ProductSpecTemplateType.Addon)
+ .Key;
+ if (invalidTemplateId > 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"SKU 规格模板类型错误: {invalidTemplateId}");
+ }
+ }
+
+ var optionIdsInSkus = skus
+ .SelectMany(item => item.Attributes)
+ .Select(item => item.OptionId)
+ .Distinct()
+ .ToList();
+
+ var optionTemplateLookup = optionIdsInSkus.Count == 0
+ ? new Dictionary()
+ : await dbContext.ProductSpecTemplateOptions
+ .AsNoTracking()
+ .Where(item => optionIdsInSkus.Contains(item.Id))
+ .ToDictionaryAsync(item => item.Id, item => item.TemplateId, cancellationToken);
+
+ var missingOptionId = optionIdsInSkus.FirstOrDefault(item => !optionTemplateLookup.ContainsKey(item));
+ if (missingOptionId > 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"SKU 规格选项不存在: {missingOptionId}");
+ }
+
+ foreach (var sku in skus)
+ {
+ foreach (var attr in sku.Attributes)
+ {
+ if (optionTemplateLookup[attr.OptionId] != attr.TemplateId)
+ {
+ throw new BusinessException(
+ ErrorCodes.BadRequest,
+ $"SKU 规格选项与模板不匹配: templateId={attr.TemplateId}, optionId={attr.OptionId}");
+ }
+ }
+ }
+
+ await dbContext.ProductSkus
+ .Where(item => item.ProductId == productId)
+ .ExecuteDeleteAsync(cancellationToken);
+
+ var skuEntities = skus
+ .Select((item, index) => new ProductSku
+ {
+ ProductId = productId,
+ SkuCode = item.SkuCode ?? GenerateSkuCode(productId, index + 1),
+ Price = item.Price,
+ OriginalPrice = item.OriginalPrice,
+ StockQuantity = item.Stock,
+ AttributesJson = SerializeSkuAttributes(item.Attributes),
+ SortOrder = item.SortOrder,
+ IsEnabled = item.IsEnabled
+ })
+ .ToList();
+
+ await dbContext.ProductSkus.AddRangeAsync(skuEntities, cancellationToken);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+
+ private async Task LoadProductRelationStateAsync(
+ long productId,
+ long storeId,
+ CancellationToken cancellationToken)
+ {
+ var templateIds = await dbContext.ProductSpecTemplateProducts
+ .AsNoTracking()
+ .Where(item => item.StoreId == storeId && item.ProductId == productId)
+ .Select(item => item.TemplateId)
+ .Distinct()
+ .ToListAsync(cancellationToken);
+
+ var specTemplateIds = new List();
+ var addonGroupIds = new List();
+ if (templateIds.Count > 0)
+ {
+ var templates = await dbContext.ProductSpecTemplates
+ .AsNoTracking()
+ .Where(item => item.StoreId == storeId && templateIds.Contains(item.Id))
+ .Select(item => new { item.Id, item.TemplateType })
+ .ToListAsync(cancellationToken);
+
+ specTemplateIds = templates
+ .Where(item => item.TemplateType != ProductSpecTemplateType.Addon)
+ .Select(item => item.Id)
+ .Distinct()
+ .OrderBy(item => item)
+ .ToList();
+
+ addonGroupIds = templates
+ .Where(item => item.TemplateType == ProductSpecTemplateType.Addon)
+ .Select(item => item.Id)
+ .Distinct()
+ .OrderBy(item => item)
+ .ToList();
+ }
+
+ var labelIds = await dbContext.ProductLabelProducts
+ .AsNoTracking()
+ .Where(item => item.StoreId == storeId && item.ProductId == productId)
+ .Select(item => item.LabelId)
+ .Distinct()
+ .OrderBy(item => item)
+ .ToListAsync(cancellationToken);
+
+ var skus = await BuildProductSkuResponsesAsync(productId, cancellationToken);
+ return new ProductRelationState(specTemplateIds, addonGroupIds, labelIds, skus);
+ }
+
+ private async Task> BuildProductSkuResponsesAsync(long productId, CancellationToken cancellationToken)
+ {
+ var skus = await dbContext.ProductSkus
+ .AsNoTracking()
+ .Where(item => item.ProductId == productId)
+ .OrderBy(item => item.SortOrder)
+ .ThenBy(item => item.Id)
+ .ToListAsync(cancellationToken);
+
+ return skus.Select(item => new ProductSkuResponse
+ {
+ Id = item.Id.ToString(),
+ SkuCode = item.SkuCode,
+ Price = item.Price,
+ OriginalPrice = item.OriginalPrice,
+ Stock = Math.Max(0, item.StockQuantity ?? 0),
+ IsEnabled = item.IsEnabled,
+ SortOrder = item.SortOrder,
+ Attributes = ParseSkuAttributes(item.AttributesJson)
+ }).ToList();
+ }
+
+ private static List ParseSkuAttributes(string? attributesJson)
+ {
+ if (string.IsNullOrWhiteSpace(attributesJson))
+ {
+ return [];
+ }
+
+ try
+ {
+ var parsed = JsonSerializer.Deserialize>(attributesJson, StoreApiHelpers.JsonOptions) ?? [];
+ var dedup = new HashSet(StringComparer.Ordinal);
+ var result = new List();
+ foreach (var item in parsed)
+ {
+ if (item.TemplateId <= 0 || item.OptionId <= 0)
+ {
+ continue;
+ }
+
+ var key = $"{item.TemplateId}:{item.OptionId}";
+ if (!dedup.Add(key))
+ {
+ continue;
+ }
+
+ result.Add(new ProductSkuAttributeResponse
+ {
+ TemplateId = item.TemplateId.ToString(),
+ OptionId = item.OptionId.ToString()
+ });
+ }
+
+ return result;
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ private static string SerializeSkuAttributes(IReadOnlyList attributes)
+ {
+ if (attributes.Count == 0)
+ {
+ return "[]";
+ }
+
+ var payload = attributes
+ .OrderBy(item => item.TemplateId)
+ .ThenBy(item => item.OptionId)
+ .Select(item => new SkuAttributePayload(item.TemplateId, item.OptionId))
+ .ToList();
+ return JsonSerializer.Serialize(payload);
+ }
+
+ private async Task> BuildComboGroupResponsesAsync(long productId, long storeId, CancellationToken cancellationToken)
+ {
+ var groups = await dbContext.ProductComboGroups
+ .AsNoTracking()
+ .Where(x => x.ProductId == productId)
+ .OrderBy(x => x.SortOrder)
+ .ThenBy(x => x.Id)
+ .ToListAsync(cancellationToken);
+
+ if (groups.Count == 0)
+ {
+ return [];
+ }
+
+ var groupIds = groups.Select(x => x.Id).ToList();
+ var items = await dbContext.ProductComboGroupItems
+ .AsNoTracking()
+ .Where(x => groupIds.Contains(x.ComboGroupId))
+ .OrderBy(x => x.SortOrder)
+ .ThenBy(x => x.Id)
+ .ToListAsync(cancellationToken);
+
+ var productIds = items.Select(x => x.ProductId).Distinct().ToList();
+ var productNameLookup = productIds.Count == 0
+ ? new Dictionary()
+ : await dbContext.Products
+ .AsNoTracking()
+ .Where(x => x.StoreId == storeId && productIds.Contains(x.Id))
+ .ToDictionaryAsync(x => x.Id, x => x.Name, cancellationToken);
+
+ var itemsLookup = items.ToLookup(x => x.ComboGroupId);
+ return groups.Select(group => new ProductComboGroupResponse
+ {
+ Id = group.Id.ToString(),
+ Name = group.Name,
+ MinSelect = group.MinSelect,
+ MaxSelect = group.MaxSelect,
+ SortOrder = group.SortOrder,
+ Items = itemsLookup[group.Id]
+ .Select(item => new ProductComboGroupItemResponse
+ {
+ ProductId = item.ProductId.ToString(),
+ ProductName = productNameLookup.GetValueOrDefault(item.ProductId, string.Empty),
+ Quantity = item.Quantity,
+ SortOrder = item.SortOrder
+ })
+ .ToList()
+ }).ToList();
+ }
+
+ private async Task ReplaceComboGroupsAsync(
+ long productId,
+ long storeId,
+ ProductKind kind,
+ IReadOnlyList comboGroups,
+ CancellationToken cancellationToken)
+ {
+ await RemoveComboGroupsAsync(productId, cancellationToken);
+ if (kind != ProductKind.Combo)
+ {
+ return;
+ }
+
+ var selectedProductIds = comboGroups
+ .SelectMany(group => group.Items)
+ .Select(item => item.ProductId)
+ .Distinct()
+ .ToList();
+
+ if (selectedProductIds.Contains(productId))
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "套餐分组商品不能包含当前套餐");
+ }
+
+ var existingProductIds = selectedProductIds.Count == 0
+ ? []
+ : await dbContext.Products
+ .AsNoTracking()
+ .Where(x => x.StoreId == storeId && selectedProductIds.Contains(x.Id))
+ .Select(x => x.Id)
+ .ToListAsync(cancellationToken);
+
+ var missingProductId = selectedProductIds
+ .Except(existingProductIds)
+ .FirstOrDefault();
+ if (missingProductId > 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组商品不存在: {missingProductId}");
+ }
+
+ var groupEntities = comboGroups
+ .Select(group => new ProductComboGroup
+ {
+ ProductId = productId,
+ Name = group.Name,
+ MinSelect = group.MinSelect,
+ MaxSelect = group.MaxSelect,
+ SortOrder = group.SortOrder
+ })
+ .ToList();
+ await dbContext.ProductComboGroups.AddRangeAsync(groupEntities, cancellationToken);
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ var itemEntities = comboGroups
+ .SelectMany((group, groupIndex) => group.Items.Select(item => new ProductComboGroupItem
+ {
+ ComboGroupId = groupEntities[groupIndex].Id,
+ ProductId = item.ProductId,
+ Quantity = item.Quantity,
+ SortOrder = item.SortOrder
+ }))
+ .ToList();
+ await dbContext.ProductComboGroupItems.AddRangeAsync(itemEntities, cancellationToken);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+
+ private async Task RemoveComboGroupsAsync(long productId, CancellationToken cancellationToken)
+ {
+ var groupIds = await dbContext.ProductComboGroups
+ .Where(x => x.ProductId == productId)
+ .Select(x => x.Id)
+ .ToListAsync(cancellationToken);
+
+ if (groupIds.Count == 0)
+ {
+ return;
+ }
+
+ await dbContext.ProductComboGroupItems
+ .Where(x => groupIds.Contains(x.ComboGroupId))
+ .ExecuteDeleteAsync(cancellationToken);
+
+ await dbContext.ProductComboGroups
+ .Where(x => x.ProductId == productId)
+ .ExecuteDeleteAsync(cancellationToken);
+ }
+
+ private static IReadOnlyList NormalizeComboGroups(
+ IEnumerable? groups,
+ ProductKind kind)
+ {
+ if (kind != ProductKind.Combo)
+ {
+ return [];
+ }
+
+ var source = (groups ?? []).ToList();
+ if (source.Count == 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "套餐商品必须至少配置一个套餐分组");
+ }
+
+ var groupNameSet = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var normalized = new List(source.Count);
+ for (var groupIndex = 0; groupIndex < source.Count; groupIndex++)
+ {
+ var group = source[groupIndex];
+ var name = (group.Name ?? string.Empty).Trim();
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组第 {groupIndex + 1} 项名称不能为空");
+ }
+
+ if (!groupNameSet.Add(name))
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组名称重复: {name}");
+ }
+
+ if (group.MinSelect <= 0 || group.MaxSelect <= 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组 {name} 的最小/最大选择数必须大于 0");
+ }
+
+ if (group.MaxSelect < group.MinSelect)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组 {name} 的最大选择数不能小于最小选择数");
+ }
+
+ var sourceItems = group.Items ?? [];
+ if (sourceItems.Count == 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组 {name} 至少需要一个商品");
+ }
+
+ var itemProductSet = new HashSet();
+ var normalizedItems = new List(sourceItems.Count);
+ for (var itemIndex = 0; itemIndex < sourceItems.Count; itemIndex++)
+ {
+ var item = sourceItems[itemIndex];
+ var productId = StoreApiHelpers.ParseRequiredSnowflake(
+ item.ProductId,
+ $"comboGroups[{groupIndex}].items[{itemIndex}].productId");
+
+ if (!itemProductSet.Add(productId))
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组 {name} 存在重复商品");
+ }
+
+ if (item.Quantity <= 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组 {name} 的商品数量必须大于 0");
+ }
+
+ normalizedItems.Add(new NormalizedComboGroupItemRequest(
+ productId,
+ item.Quantity,
+ item.SortOrder > 0 ? item.SortOrder : itemIndex + 1));
+ }
+
+ normalized.Add(new NormalizedComboGroupRequest(
+ name,
+ group.MinSelect,
+ group.MaxSelect,
+ group.SortOrder > 0 ? group.SortOrder : groupIndex + 1,
+ normalizedItems));
+ }
+
+ return normalized;
+ }
+
+ private sealed record NormalizedComboGroupRequest(
+ string Name,
+ int MinSelect,
+ int MaxSelect,
+ int SortOrder,
+ IReadOnlyList Items);
+
+ private sealed record NormalizedComboGroupItemRequest(long ProductId, int Quantity, int SortOrder);
+
+ private sealed record NormalizedSkuRequest(
+ string? SkuCode,
+ decimal Price,
+ decimal? OriginalPrice,
+ int Stock,
+ bool IsEnabled,
+ int SortOrder,
+ IReadOnlyList Attributes);
+
+ private sealed record NormalizedSkuAttributeRequest(long TemplateId, long OptionId);
+
+ private sealed record ProductRelationState(
+ IReadOnlyList SpecTemplateIds,
+ IReadOnlyList AddonGroupIds,
+ IReadOnlyList LabelIds,
+ IReadOnlyList Skus);
+
+ private sealed record SkuAttributePayload(long TemplateId, long OptionId);
+
private static string ToKindText(ProductKind kind)
{
return kind == ProductKind.Combo ? "combo" : "single";
@@ -755,4 +1657,9 @@ public sealed class ProductController(
var seed = (int)(now.Ticks % 10_000);
return $"SPU{now:yyyyMMddHHmmss}{seed:D4}";
}
+
+ private static string GenerateSkuCode(long productId, int sequence)
+ {
+ return $"SKU{productId}{sequence:D2}";
+ }
}
diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/CreateProductCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/CreateProductCommand.cs
index ea3737d..ae63d98 100644
--- a/src/Application/TakeoutSaaS.Application/App/Products/Commands/CreateProductCommand.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/CreateProductCommand.cs
@@ -129,6 +129,21 @@ public sealed class CreateProductCommand : IRequest
///
public string? Description { get; set; }
+ ///
+ /// 排序权重。
+ ///
+ public int SortWeight { get; set; }
+
+ ///
+ /// 库存预警值。
+ ///
+ public int? WarningStock { get; set; }
+
+ ///
+ /// 打包费。
+ ///
+ public decimal? PackingFee { get; set; }
+
///
/// 支持堂食。
///
diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/UpdateProductCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/UpdateProductCommand.cs
index f24eea2..0f24a7d 100644
--- a/src/Application/TakeoutSaaS.Application/App/Products/Commands/UpdateProductCommand.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/UpdateProductCommand.cs
@@ -134,6 +134,21 @@ public sealed record UpdateProductCommand : IRequest
///
public string? Description { get; init; }
+ ///
+ /// 排序权重。
+ ///
+ public int SortWeight { get; init; }
+
+ ///
+ /// 库存预警值。
+ ///
+ public int? WarningStock { get; init; }
+
+ ///
+ /// 打包费。
+ ///
+ public decimal? PackingFee { get; init; }
+
///
/// 支持堂食。
///
diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs
index ac2aef6..0f04c95 100644
--- a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs
@@ -143,6 +143,21 @@ public sealed class ProductDto
///
public string? Description { get; init; }
+ ///
+ /// 排序权重。
+ ///
+ public int SortWeight { get; init; }
+
+ ///
+ /// 库存预警值。
+ ///
+ public int? WarningStock { get; init; }
+
+ ///
+ /// 打包费。
+ ///
+ public decimal? PackingFee { get; init; }
+
///
/// 支持堂食。
///
diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductSkuDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductSkuDto.cs
index 4b76869..a015297 100644
--- a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductSkuDto.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductSkuDto.cs
@@ -59,4 +59,9 @@ public sealed record ProductSkuDto
/// 排序。
///
public int SortOrder { get; init; }
+
+ ///
+ /// 是否启用。
+ ///
+ public bool IsEnabled { get; init; }
}
diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/CreateProductCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/CreateProductCommandHandler.cs
index aef223c..9112147 100644
--- a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/CreateProductCommandHandler.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/CreateProductCommandHandler.cs
@@ -46,6 +46,9 @@ public sealed class CreateProductCommandHandler(IProductRepository productReposi
CoverImage = request.CoverImage?.Trim(),
GalleryImages = request.GalleryImages?.Trim(),
Description = request.Description?.Trim(),
+ SortWeight = request.SortWeight,
+ WarningStock = request.WarningStock,
+ PackingFee = request.PackingFee,
EnableDineIn = request.EnableDineIn,
EnablePickup = request.EnablePickup,
EnableDelivery = request.EnableDelivery,
@@ -89,6 +92,9 @@ public sealed class CreateProductCommandHandler(IProductRepository productReposi
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,
+ SortWeight = product.SortWeight,
+ WarningStock = product.WarningStock,
+ PackingFee = product.PackingFee,
EnableDineIn = product.EnableDineIn,
EnablePickup = product.EnablePickup,
EnableDelivery = product.EnableDelivery,
diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs
index c4c2773..8e7fa3c 100644
--- a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs
@@ -109,6 +109,9 @@ public sealed class SearchProductsQueryHandler(
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,
+ SortWeight = product.SortWeight,
+ WarningStock = product.WarningStock,
+ PackingFee = product.PackingFee,
EnableDineIn = product.EnableDineIn,
EnablePickup = product.EnablePickup,
EnableDelivery = product.EnableDelivery,
diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UpdateProductCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UpdateProductCommandHandler.cs
index d7df44e..a048b54 100644
--- a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UpdateProductCommandHandler.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UpdateProductCommandHandler.cs
@@ -57,6 +57,9 @@ public sealed class UpdateProductCommandHandler(
existing.CoverImage = request.CoverImage?.Trim();
existing.GalleryImages = request.GalleryImages?.Trim();
existing.Description = request.Description?.Trim();
+ existing.SortWeight = request.SortWeight;
+ existing.WarningStock = request.WarningStock;
+ existing.PackingFee = request.PackingFee;
existing.EnableDineIn = request.EnableDineIn;
existing.EnablePickup = request.EnablePickup;
existing.EnableDelivery = request.EnableDelivery;
@@ -99,6 +102,9 @@ public sealed class UpdateProductCommandHandler(
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,
+ SortWeight = product.SortWeight,
+ WarningStock = product.WarningStock,
+ PackingFee = product.PackingFee,
EnableDineIn = product.EnableDineIn,
EnablePickup = product.EnablePickup,
EnableDelivery = product.EnableDelivery,
diff --git a/src/Application/TakeoutSaaS.Application/App/Products/ProductMapping.cs b/src/Application/TakeoutSaaS.Application/App/Products/ProductMapping.cs
index 764a40b..5b0befe 100644
--- a/src/Application/TakeoutSaaS.Application/App/Products/ProductMapping.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Products/ProductMapping.cs
@@ -41,6 +41,9 @@ public static class ProductMapping
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,
+ SortWeight = product.SortWeight,
+ WarningStock = product.WarningStock,
+ PackingFee = product.PackingFee,
EnableDineIn = product.EnableDineIn,
EnablePickup = product.EnablePickup,
EnableDelivery = product.EnableDelivery,
@@ -62,7 +65,8 @@ public static class ProductMapping
StockQuantity = sku.StockQuantity,
Weight = sku.Weight,
AttributesJson = sku.AttributesJson,
- SortOrder = sku.SortOrder
+ SortOrder = sku.SortOrder,
+ IsEnabled = sku.IsEnabled
};
///
diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/Product.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/Product.cs
index e44bb07..193985c 100644
--- a/src/Domain/TakeoutSaaS.Domain/Products/Entities/Product.cs
+++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/Product.cs
@@ -128,6 +128,21 @@ public sealed class Product : MultiTenantEntityBase
///
public string? Description { get; set; }
+ ///
+ /// 排序权重,越大越靠前。
+ ///
+ public int SortWeight { get; set; }
+
+ ///
+ /// 库存预警值。
+ ///
+ public int? WarningStock { get; set; }
+
+ ///
+ /// 打包费(元/份)。
+ ///
+ public decimal? PackingFee { get; set; }
+
///
/// 支持堂食。
///
diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductComboGroup.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductComboGroup.cs
new file mode 100644
index 0000000..58ee119
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductComboGroup.cs
@@ -0,0 +1,34 @@
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Products.Entities;
+
+///
+/// 套餐分组。
+///
+public sealed class ProductComboGroup : MultiTenantEntityBase
+{
+ ///
+ /// 套餐商品 ID。
+ ///
+ public long ProductId { get; set; }
+
+ ///
+ /// 分组名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 最小选择数。
+ ///
+ public int MinSelect { get; set; } = 1;
+
+ ///
+ /// 最大选择数。
+ ///
+ public int MaxSelect { get; set; } = 1;
+
+ ///
+ /// 排序值。
+ ///
+ public int SortOrder { get; set; } = 100;
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductComboGroupItem.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductComboGroupItem.cs
new file mode 100644
index 0000000..7823cd4
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductComboGroupItem.cs
@@ -0,0 +1,29 @@
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Products.Entities;
+
+///
+/// 套餐分组内商品。
+///
+public sealed class ProductComboGroupItem : MultiTenantEntityBase
+{
+ ///
+ /// 所属套餐分组 ID。
+ ///
+ public long ComboGroupId { get; set; }
+
+ ///
+ /// 商品 ID。
+ ///
+ public long ProductId { get; set; }
+
+ ///
+ /// 数量。
+ ///
+ public int Quantity { get; set; } = 1;
+
+ ///
+ /// 排序值。
+ ///
+ public int SortOrder { get; set; } = 100;
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSku.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSku.cs
index f20fc17..7b16ed6 100644
--- a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSku.cs
+++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSku.cs
@@ -51,4 +51,9 @@ public sealed class ProductSku : MultiTenantEntityBase
/// 排序值。
///
public int SortOrder { get; set; } = 100;
+
+ ///
+ /// 是否启用。
+ ///
+ public bool IsEnabled { get; set; } = true;
}
diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs
index e789e0d..3271639 100644
--- a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs
+++ b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs
@@ -153,6 +153,26 @@ public interface IProductRepository
///
Task> GetSkusByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default);
+ ///
+ /// 获取商品套餐分组。
+ ///
+ Task> GetComboGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 批量获取商品套餐分组。
+ ///
+ Task> GetComboGroupsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 获取套餐分组内商品。
+ ///
+ Task> GetComboGroupItemsAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 批量获取套餐分组内商品。
+ ///
+ Task> GetComboGroupItemsByGroupIdsAsync(IReadOnlyCollection comboGroupIds, long tenantId, CancellationToken cancellationToken = default);
+
///
/// 获取商品加料组与选项。
///
@@ -291,6 +311,15 @@ public interface IProductRepository
/// 异步任务。
Task AddSkusAsync(IEnumerable skus, CancellationToken cancellationToken = default);
+ ///
+ /// 新增套餐分组与分组商品。
+ ///
+ /// 套餐分组集合。
+ /// 分组商品集合。
+ /// 取消标记。
+ /// 异步任务。
+ Task AddComboGroupsAsync(IEnumerable groups, IEnumerable items, CancellationToken cancellationToken = default);
+
///
/// 新增加料组与选项。
///
@@ -405,6 +434,15 @@ public interface IProductRepository
/// 异步任务。
Task RemoveSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
+ ///
+ /// 删除商品下的套餐分组及分组商品。
+ ///
+ /// 商品 ID。
+ /// 租户 ID。
+ /// 取消标记。
+ /// 异步任务。
+ Task RemoveComboGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
+
///
/// 删除模板下的规格做法选项。
///
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
index 0cd1a45..adc8c97 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
@@ -233,6 +233,14 @@ public sealed class TakeoutAppDbContext(
///
public DbSet ProductSkus => Set();
///
+ /// 套餐分组。
+ ///
+ public DbSet ProductComboGroups => Set();
+ ///
+ /// 套餐分组商品。
+ ///
+ public DbSet ProductComboGroupItems => Set();
+ ///
/// 加料分组。
///
public DbSet ProductAddonGroups => Set();
@@ -477,6 +485,8 @@ public sealed class TakeoutAppDbContext(
ConfigureProductSchedule(modelBuilder.Entity());
ConfigureProductScheduleProduct(modelBuilder.Entity());
ConfigureProductSku(modelBuilder.Entity());
+ ConfigureProductComboGroup(modelBuilder.Entity());
+ ConfigureProductComboGroupItem(modelBuilder.Entity());
ConfigureProductAddonGroup(modelBuilder.Entity());
ConfigureProductAddonOption(modelBuilder.Entity());
ConfigureProductPricingRule(modelBuilder.Entity());
@@ -747,6 +757,8 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.CoverImage).HasMaxLength(256);
builder.Property(x => x.GalleryImages).HasMaxLength(1024);
builder.Property(x => x.Description).HasColumnType("text");
+ builder.Property(x => x.SortWeight).HasDefaultValue(0);
+ builder.Property(x => x.PackingFee).HasPrecision(18, 2);
builder.HasIndex(x => new { x.TenantId, x.StoreId });
builder.HasIndex(x => new { x.TenantId, x.SpuCode }).IsUnique();
}
@@ -1308,9 +1320,35 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.OriginalPrice).HasPrecision(18, 2);
builder.Property(x => x.Weight).HasPrecision(10, 3);
builder.Property(x => x.AttributesJson).HasColumnType("text");
+ builder.Property(x => x.IsEnabled).HasDefaultValue(true);
builder.HasIndex(x => new { x.TenantId, x.SkuCode }).IsUnique();
}
+ private static void ConfigureProductComboGroup(EntityTypeBuilder builder)
+ {
+ builder.ToTable("product_combo_groups");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.ProductId).IsRequired();
+ builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
+ builder.Property(x => x.MinSelect).IsRequired();
+ builder.Property(x => x.MaxSelect).IsRequired();
+ builder.Property(x => x.SortOrder).IsRequired();
+ builder.HasIndex(x => new { x.TenantId, x.ProductId, x.SortOrder });
+ builder.HasIndex(x => new { x.TenantId, x.ProductId, x.Name });
+ }
+
+ private static void ConfigureProductComboGroupItem(EntityTypeBuilder builder)
+ {
+ builder.ToTable("product_combo_group_items");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.ComboGroupId).IsRequired();
+ builder.Property(x => x.ProductId).IsRequired();
+ builder.Property(x => x.Quantity).IsRequired();
+ builder.Property(x => x.SortOrder).IsRequired();
+ builder.HasIndex(x => new { x.TenantId, x.ComboGroupId, x.SortOrder });
+ builder.HasIndex(x => new { x.TenantId, x.ComboGroupId, x.ProductId }).IsUnique();
+ }
+
private static void ConfigureProductAddonGroup(EntityTypeBuilder builder)
{
builder.ToTable("product_addon_groups");
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs
index 4f9b97c..cdcbf31 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs
@@ -493,6 +493,77 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
return skus;
}
+ ///
+ public async Task> GetComboGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
+ {
+ var groups = await context.ProductComboGroups
+ .AsNoTracking()
+ .Where(x => x.TenantId == tenantId && x.ProductId == productId)
+ .OrderBy(x => x.SortOrder)
+ .ThenBy(x => x.Id)
+ .ToListAsync(cancellationToken);
+
+ return groups;
+ }
+
+ ///
+ public async Task> GetComboGroupsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default)
+ {
+ if (productIds.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ var groups = await context.ProductComboGroups
+ .AsNoTracking()
+ .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
+ .OrderBy(x => x.SortOrder)
+ .ThenBy(x => x.Id)
+ .ToListAsync(cancellationToken);
+ return groups;
+ }
+
+ ///
+ public async Task> GetComboGroupItemsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
+ {
+ var groupIds = await context.ProductComboGroups
+ .AsNoTracking()
+ .Where(x => x.TenantId == tenantId && x.ProductId == productId)
+ .Select(x => x.Id)
+ .ToListAsync(cancellationToken);
+
+ if (groupIds.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ var items = await context.ProductComboGroupItems
+ .AsNoTracking()
+ .Where(x => x.TenantId == tenantId && groupIds.Contains(x.ComboGroupId))
+ .OrderBy(x => x.SortOrder)
+ .ThenBy(x => x.Id)
+ .ToListAsync(cancellationToken);
+
+ return items;
+ }
+
+ ///
+ public async Task> GetComboGroupItemsByGroupIdsAsync(IReadOnlyCollection comboGroupIds, long tenantId, CancellationToken cancellationToken = default)
+ {
+ if (comboGroupIds.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ var items = await context.ProductComboGroupItems
+ .AsNoTracking()
+ .Where(x => x.TenantId == tenantId && comboGroupIds.Contains(x.ComboGroupId))
+ .OrderBy(x => x.SortOrder)
+ .ThenBy(x => x.Id)
+ .ToListAsync(cancellationToken);
+ return items;
+ }
+
///
public async Task> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
@@ -731,6 +802,14 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
return context.ProductSkus.AddRangeAsync(skus, cancellationToken);
}
+ ///
+ public Task AddComboGroupsAsync(IEnumerable groups, IEnumerable items, CancellationToken cancellationToken = default)
+ {
+ var addGroupsTask = context.ProductComboGroups.AddRangeAsync(groups, cancellationToken);
+ var addItemsTask = context.ProductComboGroupItems.AddRangeAsync(items, cancellationToken);
+ return Task.WhenAll(addGroupsTask, addItemsTask);
+ }
+
///
public Task AddAddonGroupsAsync(IEnumerable groups, IEnumerable options, CancellationToken cancellationToken = default)
{
@@ -779,6 +858,7 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
await RemoveMediaAssetsAsync(productId, tenantId, cancellationToken);
await RemoveAttributeGroupsAsync(productId, tenantId, cancellationToken);
await RemoveAddonGroupsAsync(productId, tenantId, cancellationToken);
+ await RemoveComboGroupsAsync(productId, tenantId, cancellationToken);
await RemoveSkusAsync(productId, tenantId, cancellationToken);
var existing = await context.Products
@@ -902,6 +982,38 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
context.ProductSkus.RemoveRange(skus);
}
+ ///
+ public async Task RemoveComboGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
+ {
+ var groupIds = await context.ProductComboGroups
+ .Where(x => x.TenantId == tenantId && x.ProductId == productId)
+ .Select(x => x.Id)
+ .ToListAsync(cancellationToken);
+
+ if (groupIds.Count == 0)
+ {
+ return;
+ }
+
+ var items = await context.ProductComboGroupItems
+ .Where(x => x.TenantId == tenantId && groupIds.Contains(x.ComboGroupId))
+ .ToListAsync(cancellationToken);
+
+ if (items.Count > 0)
+ {
+ context.ProductComboGroupItems.RemoveRange(items);
+ }
+
+ var groups = await context.ProductComboGroups
+ .Where(x => groupIds.Contains(x.Id))
+ .ToListAsync(cancellationToken);
+
+ if (groups.Count > 0)
+ {
+ context.ProductComboGroups.RemoveRange(groups);
+ }
+ }
+
///
public async Task RemoveSpecTemplateOptionsAsync(long templateId, long tenantId, CancellationToken cancellationToken = default)
{
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260222000604_AddProductComboGroups.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260222000604_AddProductComboGroups.Designer.cs
new file mode 100644
index 0000000..26c28bd
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260222000604_AddProductComboGroups.Designer.cs
@@ -0,0 +1,8559 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+
+#nullable disable
+
+namespace TakeoutSaaS.Infrastructure.Migrations
+{
+ [DbContext(typeof(TakeoutAppDbContext))]
+ [Migration("20260222000604_AddProductComboGroups")]
+ partial class AddProductComboGroups
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.1")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricAlertRule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionJson")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasComment("触发条件 JSON。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean")
+ .HasComment("是否启用。");
+
+ b.Property("MetricDefinitionId")
+ .HasColumnType("bigint")
+ .HasComment("关联指标。");
+
+ b.Property("NotificationChannels")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("通知渠道。");
+
+ b.Property("Severity")
+ .HasColumnType("integer")
+ .HasComment("告警级别。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "MetricDefinitionId", "Severity");
+
+ b.ToTable("metric_alert_rules", null, t =>
+ {
+ t.HasComment("指标告警规则。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasComment("指标编码。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DefaultAggregation")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("默认聚合方式。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Description")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("说明。");
+
+ b.Property("DimensionsJson")
+ .HasColumnType("text")
+ .HasComment("维度描述 JSON。");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("指标名称。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Code")
+ .IsUnique();
+
+ b.ToTable("metric_definitions", null, t =>
+ {
+ t.HasComment("指标定义,描述可观测的数据点。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricSnapshot", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("DimensionKey")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("维度键(JSON)。");
+
+ b.Property("MetricDefinitionId")
+ .HasColumnType("bigint")
+ .HasComment("指标定义 ID。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("Value")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)")
+ .HasComment("数值。");
+
+ b.Property("WindowEnd")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("统计时间窗口结束。");
+
+ b.Property("WindowStart")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("统计时间窗口开始。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "MetricDefinitionId", "DimensionKey", "WindowStart", "WindowEnd")
+ .IsUnique();
+
+ b.ToTable("metric_snapshots", null, t =>
+ {
+ t.HasComment("指标快照,用于大盘展示。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.Coupon", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("券码或序列号。");
+
+ b.Property("CouponTemplateId")
+ .HasColumnType("bigint")
+ .HasComment("模板标识。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("ExpireAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("到期时间。");
+
+ b.Property("IssuedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("发放时间。");
+
+ b.Property("OrderId")
+ .HasColumnType("bigint")
+ .HasComment("订单 ID(已使用时记录)。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("状态。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("使用时间。");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasComment("归属用户。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Code")
+ .IsUnique();
+
+ b.ToTable("coupons", null, t =>
+ {
+ t.HasComment("用户领取的券。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.CouponTemplate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AllowStack")
+ .HasColumnType("boolean")
+ .HasComment("是否允许叠加其他优惠。");
+
+ b.Property("ChannelsJson")
+ .HasColumnType("text")
+ .HasComment("发放渠道(JSON)。");
+
+ b.Property("ClaimedQuantity")
+ .HasColumnType("integer")
+ .HasComment("已领取数量。");
+
+ b.Property("CouponType")
+ .HasColumnType("integer")
+ .HasComment("券类型。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Description")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("备注。");
+
+ b.Property("DiscountCap")
+ .HasColumnType("numeric")
+ .HasComment("折扣上限(针对折扣券)。");
+
+ b.Property("MinimumSpend")
+ .HasColumnType("numeric")
+ .HasComment("最低消费门槛。");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("模板名称。");
+
+ b.Property("ProductScopeJson")
+ .HasColumnType("text")
+ .HasComment("适用品类或商品范围(JSON)。");
+
+ b.Property("RelativeValidDays")
+ .HasColumnType("integer")
+ .HasComment("有效天数(相对发放时间)。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("状态。");
+
+ b.Property("StoreScopeJson")
+ .HasColumnType("text")
+ .HasComment("适用门店 ID 集合(JSON)。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("TotalQuantity")
+ .HasColumnType("integer")
+ .HasComment("总发放数量上限。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("ValidFrom")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("可用开始时间。");
+
+ b.Property("ValidTo")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("可用结束时间。");
+
+ b.Property("Value")
+ .HasColumnType("numeric")
+ .HasComment("面值或折扣额度。");
+
+ b.HasKey("Id");
+
+ b.ToTable("coupon_templates", null, t =>
+ {
+ t.HasComment("优惠券模板。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PromotionCampaign", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AudienceDescription")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("目标人群描述。");
+
+ b.Property("BannerUrl")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("营销素材(如 banner)。");
+
+ b.Property("Budget")
+ .HasColumnType("numeric")
+ .HasComment("预算金额。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("EndAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("结束时间。");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("活动名称。");
+
+ b.Property("PromotionType")
+ .HasColumnType("integer")
+ .HasComment("活动类型。");
+
+ b.Property("RulesJson")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasComment("活动规则 JSON。");
+
+ b.Property("StartAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("开始时间。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("活动状态。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.ToTable("promotion_campaigns", null, t =>
+ {
+ t.HasComment("营销活动配置。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ChatSessionId")
+ .HasColumnType("bigint")
+ .HasComment("会话标识。");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)")
+ .HasComment("消息内容。");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasComment("消息类型(文字/图片/语音等)。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("IsRead")
+ .HasColumnType("boolean")
+ .HasComment("是否已读。");
+
+ b.Property("ReadAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("读取时间。");
+
+ b.Property("SenderType")
+ .HasColumnType("integer")
+ .HasComment("发送方类型。");
+
+ b.Property("SenderUserId")
+ .HasColumnType("bigint")
+ .HasComment("发送方用户 ID。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "ChatSessionId", "CreatedAt");
+
+ b.ToTable("chat_messages", null, t =>
+ {
+ t.HasComment("会话消息。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatSession", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AgentUserId")
+ .HasColumnType("bigint")
+ .HasComment("当前客服员工 ID。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("CustomerUserId")
+ .HasColumnType("bigint")
+ .HasComment("顾客用户 ID。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("EndedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("结束时间。");
+
+ b.Property("IsBotActive")
+ .HasColumnType("boolean")
+ .HasComment("是否机器人接待中。");
+
+ b.Property("SessionCode")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasComment("会话编号。");
+
+ b.Property("StartedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("开始时间。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("会话状态。");
+
+ b.Property("StoreId")
+ .HasColumnType("bigint")
+ .HasComment("所属门店(可空为系统会话)。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "SessionCode")
+ .IsUnique();
+
+ b.ToTable("chat_sessions", null, t =>
+ {
+ t.HasComment("客服会话。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.SupportTicket", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AssignedAgentId")
+ .HasColumnType("bigint")
+ .HasComment("指派的客服。");
+
+ b.Property("ClosedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("关闭时间。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("CustomerUserId")
+ .HasColumnType("bigint")
+ .HasComment("客户用户 ID。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasComment("工单详情。");
+
+ b.Property("OrderId")
+ .HasColumnType("bigint")
+ .HasComment("关联订单(如有)。");
+
+ b.Property("Priority")
+ .HasColumnType("integer")
+ .HasComment("优先级。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("状态。");
+
+ b.Property("Subject")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("工单主题。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("TicketNo")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("工单编号。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "TicketNo")
+ .IsUnique();
+
+ b.ToTable("support_tickets", null, t =>
+ {
+ t.HasComment("客服工单。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.TicketComment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AttachmentsJson")
+ .HasColumnType("text")
+ .HasComment("附件 JSON。");
+
+ b.Property("AuthorUserId")
+ .HasColumnType("bigint")
+ .HasComment("评论人 ID。");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)")
+ .HasComment("评论内容。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("IsInternal")
+ .HasColumnType("boolean")
+ .HasComment("是否内部备注。");
+
+ b.Property("SupportTicketId")
+ .HasColumnType("bigint")
+ .HasComment("工单标识。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "SupportTicketId");
+
+ b.ToTable("ticket_comments", null, t =>
+ {
+ t.HasComment("工单评论/流转记录。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Deliveries.Entities.DeliveryEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("DeliveryOrderId")
+ .HasColumnType("bigint")
+ .HasComment("配送单标识。");
+
+ b.Property("EventType")
+ .HasColumnType("integer")
+ .HasComment("事件类型。");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("事件描述。");
+
+ b.Property("OccurredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("发生时间。");
+
+ b.Property("Payload")
+ .HasColumnType("text")
+ .HasComment("原始数据 JSON。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "DeliveryOrderId", "EventType");
+
+ b.ToTable("delivery_events", null, t =>
+ {
+ t.HasComment("配送状态事件流水。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Deliveries.Entities.DeliveryOrder", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CourierName")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasComment("骑手姓名。");
+
+ b.Property("CourierPhone")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("骑手电话。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("DeliveredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("完成时间。");
+
+ b.Property("DeliveryFee")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasComment("配送费。");
+
+ b.Property("DispatchedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("下发时间。");
+
+ b.Property