feat(product): add product list/detail/save/soldout/batch api support
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 47s

This commit is contained in:
2026-02-21 17:23:48 +08:00
parent d41f69045f
commit f7c2ae4bac
24 changed files with 10302 additions and 6 deletions

View File

@@ -0,0 +1,447 @@
namespace TakeoutSaaS.TenantApi.Contracts.Product;
/// <summary>
/// 商品列表查询请求。
/// </summary>
public sealed class ProductListRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 分类 ID。
/// </summary>
public string? CategoryId { get; set; }
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 状态on_sale/off_shelf/sold_out
/// </summary>
public string? Status { get; set; }
/// <summary>
/// 类型single/combo
/// </summary>
public string? Kind { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 20;
}
/// <summary>
/// 商品详情查询请求。
/// </summary>
public sealed class ProductDetailRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 商品 ID。
/// </summary>
public string ProductId { get; set; } = string.Empty;
}
/// <summary>
/// 保存商品请求。
/// </summary>
public sealed class SaveProductRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 商品 ID编辑时传
/// </summary>
public string? Id { get; set; }
/// <summary>
/// 分类 ID。
/// </summary>
public string CategoryId { get; set; } = string.Empty;
/// <summary>
/// 商品类型single/combo
/// </summary>
public string Kind { get; set; } = "single";
/// <summary>
/// 商品名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 副标题。
/// </summary>
public string Subtitle { get; set; } = string.Empty;
/// <summary>
/// 描述。
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// 售价。
/// </summary>
public decimal Price { get; set; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; set; }
/// <summary>
/// 库存。
/// </summary>
public int Stock { get; set; }
/// <summary>
/// 标签。
/// </summary>
public List<string> Tags { get; set; } = [];
/// <summary>
/// 状态on_sale/off_shelf/sold_out
/// </summary>
public string Status { get; set; } = "off_shelf";
/// <summary>
/// 上架方式draft/now/scheduled
/// </summary>
public string ShelfMode { get; set; } = "draft";
/// <summary>
/// SPU 编码(可选)。
/// </summary>
public string? SpuCode { get; set; }
/// <summary>
/// 定时上架时间。
/// </summary>
public string? TimedOnShelfAt { get; set; }
/// <summary>
/// 商品图片地址列表。
/// </summary>
public List<string> ImageUrls { get; set; } = [];
}
/// <summary>
/// 删除商品请求。
/// </summary>
public sealed class DeleteProductRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 商品 ID。
/// </summary>
public string ProductId { get; set; } = string.Empty;
}
/// <summary>
/// 商品状态变更请求。
/// </summary>
public sealed class ChangeProductStatusRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 商品 ID。
/// </summary>
public string ProductId { get; set; } = string.Empty;
/// <summary>
/// 状态on_sale/off_shelf/sold_out
/// </summary>
public string Status { get; set; } = "off_shelf";
}
/// <summary>
/// 商品沽清请求。
/// </summary>
public sealed class SoldoutProductRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 商品 ID。
/// </summary>
public string ProductId { get; set; } = string.Empty;
/// <summary>
/// 沽清模式today/timed/permanent
/// </summary>
public string Mode { get; set; } = "today";
/// <summary>
/// 剩余可售。
/// </summary>
public int RemainStock { get; set; }
/// <summary>
/// 沽清原因。
/// </summary>
public string Reason { get; set; } = string.Empty;
/// <summary>
/// 恢复时间。
/// </summary>
public string? RecoverAt { get; set; }
/// <summary>
/// 同步平台。
/// </summary>
public bool SyncToPlatform { get; set; } = true;
/// <summary>
/// 通知店长。
/// </summary>
public bool NotifyManager { get; set; }
}
/// <summary>
/// 批量操作请求。
/// </summary>
public sealed class BatchProductActionRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 动作batch_on/batch_off/batch_delete/batch_soldout
/// </summary>
public string Action { get; set; } = string.Empty;
/// <summary>
/// 商品 ID 列表。
/// </summary>
public List<string> ProductIds { get; set; } = [];
/// <summary>
/// 剩余可售(沽清时)。
/// </summary>
public int? RemainStock { get; set; }
/// <summary>
/// 原因(沽清时)。
/// </summary>
public string? Reason { get; set; }
/// <summary>
/// 恢复时间(沽清时)。
/// </summary>
public string? RecoverAt { get; set; }
/// <summary>
/// 同步平台(沽清时)。
/// </summary>
public bool? SyncToPlatform { get; set; }
/// <summary>
/// 通知店长(沽清时)。
/// </summary>
public bool? NotifyManager { get; set; }
}
/// <summary>
/// 商品列表响应。
/// </summary>
public sealed class ProductListResultResponse
{
/// <summary>
/// 列表项。
/// </summary>
public List<ProductListItemResponse> Items { get; set; } = [];
/// <summary>
/// 总数。
/// </summary>
public int Total { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
}
/// <summary>
/// 商品列表项响应。
/// </summary>
public class ProductListItemResponse
{
/// <summary>
/// 商品 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 分类 ID。
/// </summary>
public string CategoryId { get; set; } = string.Empty;
/// <summary>
/// 分类名称。
/// </summary>
public string CategoryName { get; set; } = string.Empty;
/// <summary>
/// 图片地址。
/// </summary>
public string ImageUrl { get; set; } = string.Empty;
/// <summary>
/// 商品类型single/combo
/// </summary>
public string Kind { get; set; } = "single";
/// <summary>
/// 商品名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; set; }
/// <summary>
/// 售价。
/// </summary>
public decimal Price { get; set; }
/// <summary>
/// 月销量。
/// </summary>
public int SalesMonthly { get; set; }
/// <summary>
/// 沽清模式。
/// </summary>
public string? SoldoutMode { get; set; }
/// <summary>
/// SPU 编码。
/// </summary>
public string SpuCode { get; set; } = string.Empty;
/// <summary>
/// 状态on_sale/off_shelf/sold_out
/// </summary>
public string Status { get; set; } = "off_shelf";
/// <summary>
/// 库存。
/// </summary>
public int Stock { get; set; }
/// <summary>
/// 副标题。
/// </summary>
public string Subtitle { get; set; } = string.Empty;
/// <summary>
/// 标签。
/// </summary>
public List<string> Tags { get; set; } = [];
}
/// <summary>
/// 商品详情响应。
/// </summary>
public sealed class ProductDetailResponse : ProductListItemResponse
{
/// <summary>
/// 商品图片列表。
/// </summary>
public List<string> ImageUrls { get; set; } = [];
/// <summary>
/// 商品描述。
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// 是否通知店长。
/// </summary>
public bool NotifyManager { get; set; }
/// <summary>
/// 恢复时间。
/// </summary>
public string? RecoverAt { get; set; }
/// <summary>
/// 剩余可售。
/// </summary>
public int RemainStock { get; set; }
/// <summary>
/// 沽清原因。
/// </summary>
public string SoldoutReason { get; set; } = string.Empty;
/// <summary>
/// 是否同步平台。
/// </summary>
public bool SyncToPlatform { get; set; }
}
/// <summary>
/// 批量操作响应。
/// </summary>
public sealed class BatchProductActionResultResponse
{
/// <summary>
/// 动作。
/// </summary>
public string Action { get; set; } = string.Empty;
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 成功条数。
/// </summary>
public int SuccessCount { get; set; }
/// <summary>
/// 失败条数。
/// </summary>
public int FailedCount { get; set; }
}

View File

@@ -0,0 +1,758 @@
using System.Globalization;
using System.Text.Json;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
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.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Contracts.Product;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 租户端商品主接口。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/product")]
public sealed class ProductController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService) : BaseApiController
{
/// <summary>
/// 商品列表。
/// </summary>
[HttpGet("list")]
[ProducesResponseType(typeof(ApiResponse<ProductListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<ProductListResultResponse>> List(
[FromQuery] ProductListRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var (status, isSoldOut) = ParseUiStatusFilter(request.Status);
var kind = ParseKindOrNull(request.Kind);
var result = await mediator.Send(new SearchProductsQuery
{
StoreId = storeId,
CategoryId = StoreApiHelpers.ParseSnowflakeOrNull(request.CategoryId),
Keyword = request.Keyword,
Status = status,
IsSoldOut = isSoldOut,
Kind = kind,
Page = Math.Max(1, request.Page),
PageSize = Math.Clamp(request.PageSize, 1, 200)
}, cancellationToken);
var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
return ApiResponse<ProductListResultResponse>.Ok(new ProductListResultResponse
{
Items = result.Items.Select(item => MapListItem(item, categoryNameLookup)).ToList(),
Total = result.TotalCount,
Page = result.Page,
PageSize = result.PageSize
});
}
/// <summary>
/// 商品详情。
/// </summary>
[HttpGet("detail")]
[ProducesResponseType(typeof(ApiResponse<ProductDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<ProductDetailResponse>> Detail(
[FromQuery] ProductDetailRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var productId = StoreApiHelpers.ParseRequiredSnowflake(request.ProductId, nameof(request.ProductId));
var product = await mediator.Send(new GetProductByIdQuery
{
ProductId = productId
}, cancellationToken);
if (product is null || product.StoreId != storeId)
{
return ApiResponse<ProductDetailResponse>.Error(ErrorCodes.NotFound, "商品不存在");
}
var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
return ApiResponse<ProductDetailResponse>.Ok(MapDetailItem(product, categoryNameLookup));
}
/// <summary>
/// 新增或编辑商品。
/// </summary>
[HttpPost("save")]
[ProducesResponseType(typeof(ApiResponse<ProductDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<ProductDetailResponse>> Save(
[FromBody] SaveProductRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var categoryId = StoreApiHelpers.ParseRequiredSnowflake(request.CategoryId, nameof(request.CategoryId));
var kind = ParseKind(request.Kind);
var productId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id);
var timedOnShelfAt = ParseTimedOnShelfAt(request.ShelfMode, request.TimedOnShelfAt);
var normalizedStatus = ResolveStatusByShelfMode(request.ShelfMode, request.Status);
var imageUrls = NormalizeImageUrls(request.ImageUrls);
ProductDto? existing = null;
if (productId.HasValue)
{
existing = await mediator.Send(new GetProductByIdQuery
{
ProductId = productId.Value
}, cancellationToken);
if (existing is null || existing.StoreId != storeId)
{
return ApiResponse<ProductDetailResponse>.Error(ErrorCodes.NotFound, "商品不存在");
}
}
var tagsJson = SerializeTagsJson(request.Tags);
var soldoutMode = ResolveSoldoutModeForSave(existing, normalizedStatus, request.Status);
var recoverAt = soldoutMode == ProductSoldoutMode.Timed ? existing?.RecoverAt : null;
int? remainStock = soldoutMode.HasValue ? Math.Max(0, existing?.RemainStock ?? request.Stock) : null;
var soldoutReason = soldoutMode.HasValue ? existing?.SoldoutReason : null;
ProductDto? saved;
if (existing is null)
{
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 = existing?.SyncToPlatform ?? true,
NotifyManager = existing?.NotifyManager ?? false,
CoverImage = imageUrls.FirstOrDefault(),
GalleryImages = string.Join(',', imageUrls)
}, cancellationToken);
}
else
{
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
}, cancellationToken);
}
if (saved is null)
{
return ApiResponse<ProductDetailResponse>.Error(ErrorCodes.NotFound, "商品不存在");
}
var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
return ApiResponse<ProductDetailResponse>.Ok(MapDetailItem(saved, categoryNameLookup));
}
/// <summary>
/// 删除商品。
/// </summary>
[HttpPost("delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> Delete(
[FromBody] DeleteProductRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var productId = StoreApiHelpers.ParseRequiredSnowflake(request.ProductId, nameof(request.ProductId));
var existing = await mediator.Send(new GetProductByIdQuery
{
ProductId = productId
}, cancellationToken);
if (existing is null || existing.StoreId != storeId)
{
return ApiResponse<object>.Error(ErrorCodes.NotFound, "商品不存在");
}
await mediator.Send(new DeleteProductCommand
{
ProductId = productId
}, cancellationToken);
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 变更商品状态。
/// </summary>
[HttpPost("status/change")]
[ProducesResponseType(typeof(ApiResponse<ProductDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<ProductDetailResponse>> ChangeStatus(
[FromBody] ChangeProductStatusRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var productId = StoreApiHelpers.ParseRequiredSnowflake(request.ProductId, nameof(request.ProductId));
var existing = await mediator.Send(new GetProductByIdQuery
{
ProductId = productId
}, cancellationToken);
if (existing is null || existing.StoreId != storeId)
{
return ApiResponse<ProductDetailResponse>.Error(ErrorCodes.NotFound, "商品不存在");
}
var status = ParseUiStatus(request.Status);
ProductSoldoutMode? soldoutMode = status == ProductStatus.OffShelf && string.Equals(request.Status, "sold_out", StringComparison.OrdinalIgnoreCase)
? existing.SoldoutMode ?? ProductSoldoutMode.Today
: null;
var updated = await UpdateProductStatusAsync(
existing,
status,
soldoutMode,
soldoutMode.HasValue ? Math.Max(0, existing.RemainStock ?? existing.StockQuantity ?? 0) : null,
soldoutMode.HasValue ? existing.SoldoutReason : null,
soldoutMode == ProductSoldoutMode.Timed ? existing.RecoverAt : null,
cancellationToken);
var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
return ApiResponse<ProductDetailResponse>.Ok(MapDetailItem(updated, categoryNameLookup));
}
/// <summary>
/// 沽清商品。
/// </summary>
[HttpPost("soldout")]
[ProducesResponseType(typeof(ApiResponse<ProductDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<ProductDetailResponse>> Soldout(
[FromBody] SoldoutProductRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var productId = StoreApiHelpers.ParseRequiredSnowflake(request.ProductId, nameof(request.ProductId));
var existing = await mediator.Send(new GetProductByIdQuery
{
ProductId = productId
}, cancellationToken);
if (existing is null || existing.StoreId != storeId)
{
return ApiResponse<ProductDetailResponse>.Error(ErrorCodes.NotFound, "商品不存在");
}
var soldoutMode = ParseSoldoutMode(request.Mode);
var recoverAt = soldoutMode == ProductSoldoutMode.Timed ? ParseDateTimeOrNull(request.RecoverAt, nameof(request.RecoverAt)) : null;
var result = await mediator.Send(new SoldoutProductCommand
{
ProductId = productId,
Mode = soldoutMode,
RemainStock = Math.Max(0, request.RemainStock),
Reason = request.Reason,
RecoverAt = recoverAt,
SyncToPlatform = request.SyncToPlatform,
NotifyManager = request.NotifyManager
}, cancellationToken);
if (result is null)
{
return ApiResponse<ProductDetailResponse>.Error(ErrorCodes.NotFound, "商品不存在");
}
var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
return ApiResponse<ProductDetailResponse>.Ok(MapDetailItem(result, categoryNameLookup));
}
/// <summary>
/// 批量操作商品。
/// </summary>
[HttpPost("batch")]
[ProducesResponseType(typeof(ApiResponse<BatchProductActionResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<BatchProductActionResultResponse>> Batch(
[FromBody] BatchProductActionRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var productIds = StoreApiHelpers.ParseSnowflakeList(request.ProductIds);
if (productIds.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "productIds 不能为空");
}
var action = (request.Action ?? string.Empty).Trim().ToLowerInvariant();
var totalCount = productIds.Count;
var successCount = 0;
foreach (var productId in productIds)
{
try
{
var existing = await mediator.Send(new GetProductByIdQuery
{
ProductId = productId
}, cancellationToken);
if (existing is null || existing.StoreId != storeId)
{
continue;
}
switch (action)
{
case "batch_delete":
{
await mediator.Send(new DeleteProductCommand
{
ProductId = productId
}, cancellationToken);
successCount++;
break;
}
case "batch_on":
{
await UpdateProductStatusAsync(existing, ProductStatus.OnSale, null, null, null, null, cancellationToken);
successCount++;
break;
}
case "batch_off":
{
await UpdateProductStatusAsync(existing, ProductStatus.OffShelf, null, null, null, null, cancellationToken);
successCount++;
break;
}
case "batch_soldout":
{
var mode = ParseSoldoutMode("today");
var recoverAt = ParseDateTimeOrNull(request.RecoverAt, nameof(request.RecoverAt));
await mediator.Send(new SoldoutProductCommand
{
ProductId = productId,
Mode = mode,
RemainStock = Math.Max(0, request.RemainStock ?? 0),
Reason = request.Reason,
RecoverAt = recoverAt,
SyncToPlatform = request.SyncToPlatform ?? true,
NotifyManager = request.NotifyManager ?? false
}, cancellationToken);
successCount++;
break;
}
default:
throw new BusinessException(ErrorCodes.BadRequest, "不支持的批量操作");
}
}
catch
{
// 批量接口按项容错,统一统计失败数。
}
}
return ApiResponse<BatchProductActionResultResponse>.Ok(new BatchProductActionResultResponse
{
Action = action,
TotalCount = totalCount,
SuccessCount = successCount,
FailedCount = Math.Max(0, totalCount - successCount)
});
}
private async Task<ProductDto> UpdateProductStatusAsync(
ProductDto existing,
ProductStatus status,
ProductSoldoutMode? soldoutMode,
int? remainStock,
string? soldoutReason,
DateTime? recoverAt,
CancellationToken cancellationToken)
{
var stock = existing.StockQuantity ?? 0;
if (remainStock.HasValue)
{
stock = Math.Max(0, remainStock.Value);
}
var updated = await mediator.Send(new UpdateProductCommand
{
ProductId = existing.Id,
StoreId = existing.StoreId,
CategoryId = existing.CategoryId,
SpuCode = existing.SpuCode,
Name = existing.Name,
Subtitle = existing.Subtitle,
Unit = existing.Unit,
Price = existing.Price,
OriginalPrice = existing.OriginalPrice,
StockQuantity = stock,
MaxQuantityPerOrder = existing.MaxQuantityPerOrder,
Status = status,
Kind = existing.Kind,
SalesMonthly = existing.SalesMonthly,
TagsJson = existing.TagsJson,
SoldoutMode = soldoutMode,
RecoverAt = soldoutMode == ProductSoldoutMode.Timed ? recoverAt : null,
RemainStock = soldoutMode.HasValue ? stock : null,
SoldoutReason = soldoutMode.HasValue ? soldoutReason : null,
SyncToPlatform = soldoutMode.HasValue ? existing.SyncToPlatform : true,
NotifyManager = soldoutMode.HasValue ? existing.NotifyManager : false,
TimedOnShelfAt = existing.TimedOnShelfAt,
CoverImage = existing.CoverImage,
GalleryImages = existing.GalleryImages,
Description = existing.Description,
EnableDineIn = existing.EnableDineIn,
EnablePickup = existing.EnablePickup,
EnableDelivery = existing.EnableDelivery,
IsFeatured = existing.IsFeatured
}, cancellationToken);
if (updated is null)
{
throw new BusinessException(ErrorCodes.NotFound, "商品不存在");
}
return updated;
}
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
}
private async Task<Dictionary<long, string>> BuildCategoryNameLookupAsync(long storeId, CancellationToken cancellationToken)
{
var categories = await mediator.Send(new GetProductCategoryListQuery
{
StoreId = storeId
}, cancellationToken);
return categories.ToDictionary(item => item.Id, item => item.Name);
}
private static ProductListItemResponse MapListItem(ProductDto source, IReadOnlyDictionary<long, string> categoryNameLookup)
{
var stock = source.StockQuantity ?? 0;
var tags = ParseTagsJson(source.TagsJson);
return new ProductListItemResponse
{
Id = source.Id.ToString(),
CategoryId = source.CategoryId.ToString(),
CategoryName = categoryNameLookup.GetValueOrDefault(source.CategoryId, string.Empty),
ImageUrl = source.CoverImage ?? string.Empty,
Kind = ToKindText(source.Kind),
Name = source.Name,
OriginalPrice = source.OriginalPrice,
Price = source.Price,
SalesMonthly = Math.Max(0, source.SalesMonthly),
SoldoutMode = ToSoldoutModeText(source.SoldoutMode),
SpuCode = source.SpuCode,
Status = ToUiStatus(source.Status, source.SoldoutMode),
Stock = stock,
Subtitle = source.Subtitle ?? string.Empty,
Tags = tags
};
}
private static ProductDetailResponse MapDetailItem(ProductDto source, IReadOnlyDictionary<long, string> categoryNameLookup)
{
var listItem = MapListItem(source, categoryNameLookup);
return new ProductDetailResponse
{
Id = listItem.Id,
CategoryId = listItem.CategoryId,
CategoryName = listItem.CategoryName,
ImageUrl = listItem.ImageUrl,
Kind = listItem.Kind,
Name = listItem.Name,
OriginalPrice = listItem.OriginalPrice,
Price = listItem.Price,
SalesMonthly = listItem.SalesMonthly,
SoldoutMode = listItem.SoldoutMode,
SpuCode = listItem.SpuCode,
Status = listItem.Status,
Stock = listItem.Stock,
Subtitle = listItem.Subtitle,
Tags = listItem.Tags,
ImageUrls = BuildImageUrls(source),
Description = source.Description ?? string.Empty,
NotifyManager = source.NotifyManager,
RecoverAt = source.RecoverAt?.ToString("yyyy-MM-dd HH:mm:ss"),
RemainStock = Math.Max(0, source.RemainStock ?? source.StockQuantity ?? 0),
SoldoutReason = source.SoldoutReason ?? string.Empty,
SyncToPlatform = source.SyncToPlatform
};
}
private static ProductKind ParseKind(string? kind)
{
return (kind ?? string.Empty).Trim().ToLowerInvariant() switch
{
"combo" => ProductKind.Combo,
"" or "single" => ProductKind.Single,
_ => throw new BusinessException(ErrorCodes.BadRequest, "kind 非法")
};
}
private static ProductKind? ParseKindOrNull(string? kind)
{
if (string.IsNullOrWhiteSpace(kind))
{
return null;
}
return ParseKind(kind);
}
private static (ProductStatus? Status, bool? IsSoldOut) ParseUiStatusFilter(string? status)
{
if (string.IsNullOrWhiteSpace(status))
{
return (null, null);
}
return status.Trim().ToLowerInvariant() switch
{
"on_sale" => (ProductStatus.OnSale, false),
"off_shelf" => (ProductStatus.OffShelf, false),
"sold_out" => (null, true),
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 非法")
};
}
private static ProductStatus ParseUiStatus(string? status)
{
return (status ?? string.Empty).Trim().ToLowerInvariant() switch
{
"on_sale" => ProductStatus.OnSale,
"off_shelf" => ProductStatus.OffShelf,
"sold_out" => ProductStatus.OffShelf,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 非法")
};
}
private static ProductStatus ResolveStatusByShelfMode(string? shelfMode, string? status)
{
return (shelfMode ?? string.Empty).Trim().ToLowerInvariant() switch
{
"now" => ProductStatus.OnSale,
"draft" => ProductStatus.OffShelf,
"scheduled" => ProductStatus.OffShelf,
_ => ParseUiStatus(status)
};
}
private static ProductSoldoutMode ParseSoldoutMode(string? mode)
{
return (mode ?? string.Empty).Trim().ToLowerInvariant() switch
{
"today" or "" => ProductSoldoutMode.Today,
"timed" => ProductSoldoutMode.Timed,
"permanent" => ProductSoldoutMode.Permanent,
_ => throw new BusinessException(ErrorCodes.BadRequest, "mode 非法")
};
}
private static ProductSoldoutMode? ResolveSoldoutModeForSave(ProductDto? existing, ProductStatus status, string? requestStatus)
{
if (status == ProductStatus.OnSale)
{
return null;
}
if (string.Equals(requestStatus, "sold_out", StringComparison.OrdinalIgnoreCase))
{
return existing?.SoldoutMode ?? ProductSoldoutMode.Today;
}
return null;
}
private static DateTime? ParseTimedOnShelfAt(string? shelfMode, string? timedOnShelfAt)
{
if (!string.Equals(shelfMode, "scheduled", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var parsed = ParseDateTimeOrNull(timedOnShelfAt, nameof(timedOnShelfAt));
if (!parsed.HasValue)
{
throw new BusinessException(ErrorCodes.BadRequest, "定时上架时间不能为空");
}
return parsed;
}
private static DateTime? ParseDateTimeOrNull(string? value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (!DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed))
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 格式非法");
}
return parsed.ToUniversalTime();
}
private static List<string> ParseTagsJson(string? tagsJson)
{
if (string.IsNullOrWhiteSpace(tagsJson))
{
return [];
}
try
{
var tags = JsonSerializer.Deserialize<List<string>>(tagsJson, StoreApiHelpers.JsonOptions) ?? [];
return tags
.Select(item => item?.Trim() ?? string.Empty)
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(8)
.ToList();
}
catch
{
return [];
}
}
private static string SerializeTagsJson(IEnumerable<string>? tags)
{
var normalized = (tags ?? [])
.Select(item => item?.Trim() ?? string.Empty)
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(8)
.ToList();
return JsonSerializer.Serialize(normalized);
}
private static List<string> BuildImageUrls(ProductDto product)
{
var urls = new List<string>();
if (!string.IsNullOrWhiteSpace(product.CoverImage))
{
urls.Add(product.CoverImage.Trim());
}
if (!string.IsNullOrWhiteSpace(product.GalleryImages))
{
urls.AddRange(product.GalleryImages
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(url => !string.IsNullOrWhiteSpace(url)));
}
return urls
.Select(url => url.Trim())
.Where(url => !string.IsNullOrWhiteSpace(url))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(5)
.ToList();
}
private static List<string> NormalizeImageUrls(IEnumerable<string>? imageUrls)
{
return (imageUrls ?? [])
.Select(url => url?.Trim() ?? string.Empty)
.Where(url => !string.IsNullOrWhiteSpace(url))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(5)
.ToList();
}
private static string ToKindText(ProductKind kind)
{
return kind == ProductKind.Combo ? "combo" : "single";
}
private static string? ToSoldoutModeText(ProductSoldoutMode? mode)
{
return mode switch
{
ProductSoldoutMode.Today => "today",
ProductSoldoutMode.Timed => "timed",
ProductSoldoutMode.Permanent => "permanent",
_ => null
};
}
private static string ToUiStatus(ProductStatus status, ProductSoldoutMode? soldoutMode)
{
if (soldoutMode.HasValue)
{
return "sold_out";
}
return status switch
{
ProductStatus.OnSale => "on_sale",
_ => "off_shelf"
};
}
private static string GenerateSpuCode()
{
var now = DateTime.UtcNow;
var seed = (int)(now.Ticks % 10_000);
return $"SPU{now:yyyyMMddHHmmss}{seed:D4}";
}
}

View File

@@ -64,6 +64,56 @@ public sealed class CreateProductCommand : IRequest<ProductDto>
/// </summary>
public ProductStatus Status { get; set; } = ProductStatus.Draft;
/// <summary>
/// 商品类型。
/// </summary>
public ProductKind Kind { get; set; } = ProductKind.Single;
/// <summary>
/// 月销量。
/// </summary>
public int SalesMonthly { get; set; }
/// <summary>
/// 标签 JSON。
/// </summary>
public string? TagsJson { get; set; }
/// <summary>
/// 沽清模式。
/// </summary>
public ProductSoldoutMode? SoldoutMode { get; set; }
/// <summary>
/// 沽清恢复时间。
/// </summary>
public DateTime? RecoverAt { get; set; }
/// <summary>
/// 沽清后剩余可售库存。
/// </summary>
public int? RemainStock { get; set; }
/// <summary>
/// 沽清原因。
/// </summary>
public string? SoldoutReason { get; set; }
/// <summary>
/// 是否同步通知平台。
/// </summary>
public bool SyncToPlatform { get; set; } = true;
/// <summary>
/// 是否通知店长。
/// </summary>
public bool NotifyManager { get; set; }
/// <summary>
/// 定时上架时间。
/// </summary>
public DateTime? TimedOnShelfAt { get; set; }
/// <summary>
/// 主图。
/// </summary>

View File

@@ -0,0 +1,46 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Domain.Products.Enums;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 商品沽清命令。
/// </summary>
public sealed class SoldoutProductCommand : IRequest<ProductDto?>
{
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; init; }
/// <summary>
/// 沽清模式。
/// </summary>
public ProductSoldoutMode Mode { get; init; } = ProductSoldoutMode.Today;
/// <summary>
/// 剩余可售库存。
/// </summary>
public int RemainStock { get; init; }
/// <summary>
/// 沽清原因。
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// 恢复时间(定时沽清)。
/// </summary>
public DateTime? RecoverAt { get; init; }
/// <summary>
/// 是否同步平台。
/// </summary>
public bool SyncToPlatform { get; init; } = true;
/// <summary>
/// 是否通知店长。
/// </summary>
public bool NotifyManager { get; init; }
}

View File

@@ -69,6 +69,56 @@ public sealed record UpdateProductCommand : IRequest<ProductDto?>
/// </summary>
public ProductStatus Status { get; init; } = ProductStatus.Draft;
/// <summary>
/// 商品类型。
/// </summary>
public ProductKind Kind { get; init; } = ProductKind.Single;
/// <summary>
/// 月销量。
/// </summary>
public int SalesMonthly { get; init; }
/// <summary>
/// 标签 JSON。
/// </summary>
public string? TagsJson { get; init; }
/// <summary>
/// 沽清模式。
/// </summary>
public ProductSoldoutMode? SoldoutMode { get; init; }
/// <summary>
/// 沽清恢复时间。
/// </summary>
public DateTime? RecoverAt { get; init; }
/// <summary>
/// 沽清后剩余可售库存。
/// </summary>
public int? RemainStock { get; init; }
/// <summary>
/// 沽清原因。
/// </summary>
public string? SoldoutReason { get; init; }
/// <summary>
/// 是否同步通知平台。
/// </summary>
public bool SyncToPlatform { get; init; } = true;
/// <summary>
/// 是否通知店长。
/// </summary>
public bool NotifyManager { get; init; }
/// <summary>
/// 定时上架时间。
/// </summary>
public DateTime? TimedOnShelfAt { get; init; }
/// <summary>
/// 主图。
/// </summary>

View File

@@ -78,6 +78,56 @@ public sealed class ProductDto
/// </summary>
public ProductStatus Status { get; init; }
/// <summary>
/// 商品类型。
/// </summary>
public ProductKind Kind { get; init; }
/// <summary>
/// 月销量。
/// </summary>
public int SalesMonthly { get; init; }
/// <summary>
/// 标签 JSON。
/// </summary>
public string? TagsJson { get; init; }
/// <summary>
/// 沽清模式。
/// </summary>
public ProductSoldoutMode? SoldoutMode { get; init; }
/// <summary>
/// 沽清恢复时间。
/// </summary>
public DateTime? RecoverAt { get; init; }
/// <summary>
/// 剩余可售库存。
/// </summary>
public int? RemainStock { get; init; }
/// <summary>
/// 沽清原因。
/// </summary>
public string? SoldoutReason { get; init; }
/// <summary>
/// 是否同步通知平台。
/// </summary>
public bool SyncToPlatform { get; init; }
/// <summary>
/// 是否通知店长。
/// </summary>
public bool NotifyManager { get; init; }
/// <summary>
/// 定时上架时间。
/// </summary>
public DateTime? TimedOnShelfAt { get; init; }
/// <summary>
/// 主图。
/// </summary>

View File

@@ -33,6 +33,16 @@ public sealed class CreateProductCommandHandler(IProductRepository productReposi
StockQuantity = request.StockQuantity,
MaxQuantityPerOrder = request.MaxQuantityPerOrder,
Status = request.Status,
Kind = request.Kind,
SalesMonthly = request.SalesMonthly,
TagsJson = request.TagsJson?.Trim(),
SoldoutMode = request.SoldoutMode,
RecoverAt = request.RecoverAt,
RemainStock = request.RemainStock,
SoldoutReason = request.SoldoutReason?.Trim(),
SyncToPlatform = request.SyncToPlatform,
NotifyManager = request.NotifyManager,
TimedOnShelfAt = request.TimedOnShelfAt,
CoverImage = request.CoverImage?.Trim(),
GalleryImages = request.GalleryImages?.Trim(),
Description = request.Description?.Trim(),
@@ -66,6 +76,16 @@ public sealed class CreateProductCommandHandler(IProductRepository productReposi
StockQuantity = product.StockQuantity,
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
Status = product.Status,
Kind = product.Kind,
SalesMonthly = product.SalesMonthly,
TagsJson = product.TagsJson,
SoldoutMode = product.SoldoutMode,
RecoverAt = product.RecoverAt,
RemainStock = product.RemainStock,
SoldoutReason = product.SoldoutReason,
SyncToPlatform = product.SyncToPlatform,
NotifyManager = product.NotifyManager,
TimedOnShelfAt = product.TimedOnShelfAt,
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,

View File

@@ -39,17 +39,21 @@ public sealed class SearchProductPickerQueryHandler(
Name = product.Name,
Price = product.Price,
SpuCode = product.SpuCode,
Status = ToPickerStatus(product.Status)
Status = ToPickerStatus(product.Status, product.SoldoutMode.HasValue)
})
.ToList();
}
private static string ToPickerStatus(ProductStatus status)
private static string ToPickerStatus(ProductStatus status, bool isSoldout)
{
if (isSoldout)
{
return "sold_out";
}
return status switch
{
ProductStatus.OnSale => "on_sale",
ProductStatus.Archived => "sold_out",
_ => "off_shelf"
};
}

View File

@@ -22,16 +22,49 @@ public sealed class SearchProductsQueryHandler(
public async Task<PagedResult<ProductDto>> Handle(SearchProductsQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var products = await _productRepository.SearchAsync(tenantId, request.StoreId, request.CategoryId, request.Status, cancellationToken);
var sorted = ApplySorting(products, request.SortBy, request.SortDescending);
// 沽清查询需要包含 OffShelf 状态后再做二次过滤。
var repositoryStatus = request.IsSoldOut == true ? null : request.Status;
var products = await _productRepository.SearchAsync(
tenantId,
request.StoreId,
request.CategoryId,
repositoryStatus,
cancellationToken);
IEnumerable<Domain.Products.Entities.Product> filtered = products;
if (!string.IsNullOrWhiteSpace(request.Keyword))
{
var keyword = request.Keyword.Trim().ToLowerInvariant();
filtered = filtered.Where(item =>
item.Name.ToLower().Contains(keyword) ||
item.SpuCode.ToLower().Contains(keyword));
}
if (request.Kind.HasValue)
{
filtered = filtered.Where(item => item.Kind == request.Kind.Value);
}
if (request.IsSoldOut == true)
{
filtered = filtered.Where(item => item.SoldoutMode.HasValue);
}
else if (request.Status == Domain.Products.Enums.ProductStatus.OffShelf)
{
filtered = filtered.Where(item => !item.SoldoutMode.HasValue);
}
var filteredList = filtered.ToList();
var sorted = ApplySorting(filteredList, request.SortBy, request.SortDescending);
var paged = sorted
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
var items = paged.Select(MapToDto).ToList();
return new PagedResult<ProductDto>(items, request.Page, request.PageSize, products.Count);
return new PagedResult<ProductDto>(items, request.Page, request.PageSize, filteredList.Count);
}
private static IOrderedEnumerable<Domain.Products.Entities.Product> ApplySorting(
@@ -63,6 +96,16 @@ public sealed class SearchProductsQueryHandler(
StockQuantity = product.StockQuantity,
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
Status = product.Status,
Kind = product.Kind,
SalesMonthly = product.SalesMonthly,
TagsJson = product.TagsJson,
SoldoutMode = product.SoldoutMode,
RecoverAt = product.RecoverAt,
RemainStock = product.RemainStock,
SoldoutReason = product.SoldoutReason,
SyncToPlatform = product.SyncToPlatform,
NotifyManager = product.NotifyManager,
TimedOnShelfAt = product.TimedOnShelfAt,
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,

View File

@@ -0,0 +1,46 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Application.App.Products;
using TakeoutSaaS.Domain.Products.Enums;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 商品沽清命令处理器。
/// </summary>
public sealed class SoldoutProductCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<SoldoutProductCommandHandler> logger)
: IRequestHandler<SoldoutProductCommand, ProductDto?>
{
/// <inheritdoc />
public async Task<ProductDto?> Handle(SoldoutProductCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
if (product is null)
{
return null;
}
product.Status = ProductStatus.OffShelf;
product.SoldoutMode = request.Mode;
product.RemainStock = Math.Max(0, request.RemainStock);
product.StockQuantity = Math.Max(0, request.RemainStock);
product.SoldoutReason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
product.RecoverAt = request.Mode == ProductSoldoutMode.Timed ? request.RecoverAt : null;
product.SyncToPlatform = request.SyncToPlatform;
product.NotifyManager = request.NotifyManager;
await productRepository.UpdateProductAsync(product, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("商品沽清 {ProductId},模式 {Mode}", product.Id, request.Mode);
return ProductMapping.ToDto(product);
}
}

View File

@@ -44,6 +44,16 @@ public sealed class UpdateProductCommandHandler(
existing.StockQuantity = request.StockQuantity;
existing.MaxQuantityPerOrder = request.MaxQuantityPerOrder;
existing.Status = request.Status;
existing.Kind = request.Kind;
existing.SalesMonthly = request.SalesMonthly;
existing.TagsJson = request.TagsJson?.Trim();
existing.SoldoutMode = request.SoldoutMode;
existing.RecoverAt = request.RecoverAt;
existing.RemainStock = request.RemainStock;
existing.SoldoutReason = request.SoldoutReason?.Trim();
existing.SyncToPlatform = request.SyncToPlatform;
existing.NotifyManager = request.NotifyManager;
existing.TimedOnShelfAt = request.TimedOnShelfAt;
existing.CoverImage = request.CoverImage?.Trim();
existing.GalleryImages = request.GalleryImages?.Trim();
existing.Description = request.Description?.Trim();
@@ -76,6 +86,16 @@ public sealed class UpdateProductCommandHandler(
StockQuantity = product.StockQuantity,
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
Status = product.Status,
Kind = product.Kind,
SalesMonthly = product.SalesMonthly,
TagsJson = product.TagsJson,
SoldoutMode = product.SoldoutMode,
RecoverAt = product.RecoverAt,
RemainStock = product.RemainStock,
SoldoutReason = product.SoldoutReason,
SyncToPlatform = product.SyncToPlatform,
NotifyManager = product.NotifyManager,
TimedOnShelfAt = product.TimedOnShelfAt,
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,

View File

@@ -28,6 +28,16 @@ public static class ProductMapping
StockQuantity = product.StockQuantity,
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
Status = product.Status,
Kind = product.Kind,
SalesMonthly = product.SalesMonthly,
TagsJson = product.TagsJson,
SoldoutMode = product.SoldoutMode,
RecoverAt = product.RecoverAt,
RemainStock = product.RemainStock,
SoldoutReason = product.SoldoutReason,
SyncToPlatform = product.SyncToPlatform,
NotifyManager = product.NotifyManager,
TimedOnShelfAt = product.TimedOnShelfAt,
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,

View File

@@ -25,6 +25,21 @@ public sealed class SearchProductsQuery : IRequest<PagedResult<ProductDto>>
/// </summary>
public ProductStatus? Status { get; init; }
/// <summary>
/// 商品类型过滤。
/// </summary>
public ProductKind? Kind { get; init; }
/// <summary>
/// 关键字(名称/编码)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 是否仅沽清商品。
/// </summary>
public bool? IsSoldOut { get; init; }
/// <summary>
/// 页码。
/// </summary>

View File

@@ -23,6 +23,10 @@ public sealed class CreateProductCommandValidator : AbstractValidator<CreateProd
RuleFor(x => x.OriginalPrice).GreaterThanOrEqualTo(0).When(x => x.OriginalPrice.HasValue);
RuleFor(x => x.StockQuantity).GreaterThanOrEqualTo(0).When(x => x.StockQuantity.HasValue);
RuleFor(x => x.MaxQuantityPerOrder).GreaterThan(0).When(x => x.MaxQuantityPerOrder.HasValue);
RuleFor(x => x.SalesMonthly).GreaterThanOrEqualTo(0);
RuleFor(x => x.TagsJson).MaximumLength(2000);
RuleFor(x => x.RemainStock).GreaterThanOrEqualTo(0).When(x => x.RemainStock.HasValue);
RuleFor(x => x.SoldoutReason).MaximumLength(256);
RuleFor(x => x.CoverImage).MaximumLength(256);
RuleFor(x => x.GalleryImages).MaximumLength(1024);
}

View File

@@ -16,5 +16,6 @@ public sealed class SearchProductsQueryValidator : AbstractValidator<SearchProdu
RuleFor(x => x.Page).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
RuleFor(x => x.SortBy).MaximumLength(64);
RuleFor(x => x.Keyword).MaximumLength(64);
}
}

View File

@@ -0,0 +1,24 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Domain.Products.Enums;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 商品沽清命令验证器。
/// </summary>
public sealed class SoldoutProductCommandValidator : AbstractValidator<SoldoutProductCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SoldoutProductCommandValidator()
{
RuleFor(x => x.ProductId).GreaterThan(0);
RuleFor(x => x.RemainStock).GreaterThanOrEqualTo(0);
RuleFor(x => x.Reason).MaximumLength(256);
RuleFor(x => x.RecoverAt)
.NotNull()
.When(x => x.Mode == ProductSoldoutMode.Timed);
}
}

View File

@@ -24,6 +24,10 @@ public sealed class UpdateProductCommandValidator : AbstractValidator<UpdateProd
RuleFor(x => x.OriginalPrice).GreaterThanOrEqualTo(0).When(x => x.OriginalPrice.HasValue);
RuleFor(x => x.StockQuantity).GreaterThanOrEqualTo(0).When(x => x.StockQuantity.HasValue);
RuleFor(x => x.MaxQuantityPerOrder).GreaterThan(0).When(x => x.MaxQuantityPerOrder.HasValue);
RuleFor(x => x.SalesMonthly).GreaterThanOrEqualTo(0);
RuleFor(x => x.TagsJson).MaximumLength(2000);
RuleFor(x => x.RemainStock).GreaterThanOrEqualTo(0).When(x => x.RemainStock.HasValue);
RuleFor(x => x.SoldoutReason).MaximumLength(256);
RuleFor(x => x.CoverImage).MaximumLength(256);
RuleFor(x => x.GalleryImages).MaximumLength(1024);
}

View File

@@ -63,6 +63,56 @@ public sealed class Product : MultiTenantEntityBase
/// </summary>
public ProductStatus Status { get; set; } = ProductStatus.Draft;
/// <summary>
/// 商品类型。
/// </summary>
public ProductKind Kind { get; set; } = ProductKind.Single;
/// <summary>
/// 月销量。
/// </summary>
public int SalesMonthly { get; set; }
/// <summary>
/// 标签 JSON字符串数组
/// </summary>
public string? TagsJson { get; set; }
/// <summary>
/// 沽清模式。
/// </summary>
public ProductSoldoutMode? SoldoutMode { get; set; }
/// <summary>
/// 沽清恢复时间。
/// </summary>
public DateTime? RecoverAt { get; set; }
/// <summary>
/// 沽清后剩余可售。
/// </summary>
public int? RemainStock { get; set; }
/// <summary>
/// 沽清原因。
/// </summary>
public string? SoldoutReason { get; set; }
/// <summary>
/// 是否同步通知外卖平台。
/// </summary>
public bool SyncToPlatform { get; set; } = true;
/// <summary>
/// 是否通知店长。
/// </summary>
public bool NotifyManager { get; set; }
/// <summary>
/// 定时上架时间。
/// </summary>
public DateTime? TimedOnShelfAt { get; set; }
/// <summary>
/// 主图。
/// </summary>

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Products.Enums;
/// <summary>
/// 商品类型。
/// </summary>
public enum ProductKind
{
/// <summary>
/// 单品。
/// </summary>
Single = 0,
/// <summary>
/// 套餐。
/// </summary>
Combo = 1
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Products.Enums;
/// <summary>
/// 商品沽清模式。
/// </summary>
public enum ProductSoldoutMode
{
/// <summary>
/// 今日沽清。
/// </summary>
Today = 0,
/// <summary>
/// 定时沽清。
/// </summary>
Timed = 1,
/// <summary>
/// 永久沽清。
/// </summary>
Permanent = 2
}

View File

@@ -737,6 +737,13 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.Unit).HasMaxLength(16);
builder.Property(x => x.Price).HasPrecision(18, 2);
builder.Property(x => x.OriginalPrice).HasPrecision(18, 2);
builder.Property(x => x.Kind).HasConversion<int>().HasDefaultValue(Domain.Products.Enums.ProductKind.Single);
builder.Property(x => x.SalesMonthly).HasDefaultValue(0);
builder.Property(x => x.TagsJson).HasColumnType("text").HasDefaultValue("[]");
builder.Property(x => x.SoldoutMode).HasConversion<int?>();
builder.Property(x => x.SoldoutReason).HasMaxLength(256);
builder.Property(x => x.SyncToPlatform).HasDefaultValue(true);
builder.Property(x => x.NotifyManager).HasDefaultValue(false);
builder.Property(x => x.CoverImage).HasMaxLength(256);
builder.Property(x => x.GalleryImages).HasMaxLength(1024);
builder.Property(x => x.Description).HasColumnType("text");

View File

@@ -0,0 +1,135 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddProductListAndSoldoutFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Kind",
table: "products",
type: "integer",
nullable: false,
defaultValue: 0,
comment: "商品类型。");
migrationBuilder.AddColumn<bool>(
name: "NotifyManager",
table: "products",
type: "boolean",
nullable: false,
defaultValue: false,
comment: "是否通知店长。");
migrationBuilder.AddColumn<DateTime>(
name: "RecoverAt",
table: "products",
type: "timestamp with time zone",
nullable: true,
comment: "沽清恢复时间。");
migrationBuilder.AddColumn<int>(
name: "RemainStock",
table: "products",
type: "integer",
nullable: true,
comment: "沽清后剩余可售。");
migrationBuilder.AddColumn<int>(
name: "SalesMonthly",
table: "products",
type: "integer",
nullable: false,
defaultValue: 0,
comment: "月销量。");
migrationBuilder.AddColumn<int>(
name: "SoldoutMode",
table: "products",
type: "integer",
nullable: true,
comment: "沽清模式。");
migrationBuilder.AddColumn<string>(
name: "SoldoutReason",
table: "products",
type: "character varying(256)",
maxLength: 256,
nullable: true,
comment: "沽清原因。");
migrationBuilder.AddColumn<bool>(
name: "SyncToPlatform",
table: "products",
type: "boolean",
nullable: false,
defaultValue: true,
comment: "是否同步通知外卖平台。");
migrationBuilder.AddColumn<string>(
name: "TagsJson",
table: "products",
type: "text",
nullable: true,
defaultValue: "[]",
comment: "标签 JSON字符串数组。");
migrationBuilder.AddColumn<DateTime>(
name: "TimedOnShelfAt",
table: "products",
type: "timestamp with time zone",
nullable: true,
comment: "定时上架时间。");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Kind",
table: "products");
migrationBuilder.DropColumn(
name: "NotifyManager",
table: "products");
migrationBuilder.DropColumn(
name: "RecoverAt",
table: "products");
migrationBuilder.DropColumn(
name: "RemainStock",
table: "products");
migrationBuilder.DropColumn(
name: "SalesMonthly",
table: "products");
migrationBuilder.DropColumn(
name: "SoldoutMode",
table: "products");
migrationBuilder.DropColumn(
name: "SoldoutReason",
table: "products");
migrationBuilder.DropColumn(
name: "SyncToPlatform",
table: "products");
migrationBuilder.DropColumn(
name: "TagsJson",
table: "products");
migrationBuilder.DropColumn(
name: "TimedOnShelfAt",
table: "products");
}
}
}

View File

@@ -3980,6 +3980,12 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("boolean")
.HasComment("是否热门推荐。");
b.Property<int>("Kind")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasComment("商品类型。");
b.Property<int?>("MaxQuantityPerOrder")
.HasColumnType("integer")
.HasComment("最大每单限购。");
@@ -3990,6 +3996,12 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(128)")
.HasComment("商品名称。");
b.Property<bool>("NotifyManager")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasComment("是否通知店长。");
b.Property<decimal?>("OriginalPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
@@ -4000,6 +4012,29 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("numeric(18,2)")
.HasComment("现价。");
b.Property<DateTime?>("RecoverAt")
.HasColumnType("timestamp with time zone")
.HasComment("沽清恢复时间。");
b.Property<int?>("RemainStock")
.HasColumnType("integer")
.HasComment("沽清后剩余可售。");
b.Property<int>("SalesMonthly")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasComment("月销量。");
b.Property<int?>("SoldoutMode")
.HasColumnType("integer")
.HasComment("沽清模式。");
b.Property<string>("SoldoutReason")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasComment("沽清原因。");
b.Property<string>("SpuCode")
.IsRequired()
.HasMaxLength(32)
@@ -4023,10 +4058,26 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(256)")
.HasComment("副标题/卖点。");
b.Property<bool>("SyncToPlatform")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasComment("是否同步通知外卖平台。");
b.Property<string>("TagsJson")
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("[]")
.HasComment("标签 JSON字符串数组。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("TimedOnShelfAt")
.HasColumnType("timestamp with time zone")
.HasComment("定时上架时间。");
b.Property<string>("Unit")
.HasMaxLength(16)
.HasColumnType("character varying(16)")