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
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 47s
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
758
src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs
Normal file
758
src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
17
src/Domain/TakeoutSaaS.Domain/Products/Enums/ProductKind.cs
Normal file
17
src/Domain/TakeoutSaaS.Domain/Products/Enums/ProductKind.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Products.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 商品类型。
|
||||
/// </summary>
|
||||
public enum ProductKind
|
||||
{
|
||||
/// <summary>
|
||||
/// 单品。
|
||||
/// </summary>
|
||||
Single = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 套餐。
|
||||
/// </summary>
|
||||
Combo = 1
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user