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>
|
/// </summary>
|
||||||
public ProductStatus Status { get; set; } = ProductStatus.Draft;
|
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>
|
||||||
/// 主图。
|
/// 主图。
|
||||||
/// </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>
|
/// </summary>
|
||||||
public ProductStatus Status { get; init; } = ProductStatus.Draft;
|
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>
|
||||||
/// 主图。
|
/// 主图。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -78,6 +78,56 @@ public sealed class ProductDto
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public ProductStatus Status { get; init; }
|
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>
|
||||||
/// 主图。
|
/// 主图。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ public sealed class CreateProductCommandHandler(IProductRepository productReposi
|
|||||||
StockQuantity = request.StockQuantity,
|
StockQuantity = request.StockQuantity,
|
||||||
MaxQuantityPerOrder = request.MaxQuantityPerOrder,
|
MaxQuantityPerOrder = request.MaxQuantityPerOrder,
|
||||||
Status = request.Status,
|
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(),
|
CoverImage = request.CoverImage?.Trim(),
|
||||||
GalleryImages = request.GalleryImages?.Trim(),
|
GalleryImages = request.GalleryImages?.Trim(),
|
||||||
Description = request.Description?.Trim(),
|
Description = request.Description?.Trim(),
|
||||||
@@ -66,6 +76,16 @@ public sealed class CreateProductCommandHandler(IProductRepository productReposi
|
|||||||
StockQuantity = product.StockQuantity,
|
StockQuantity = product.StockQuantity,
|
||||||
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
|
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
|
||||||
Status = product.Status,
|
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,
|
CoverImage = product.CoverImage,
|
||||||
GalleryImages = product.GalleryImages,
|
GalleryImages = product.GalleryImages,
|
||||||
Description = product.Description,
|
Description = product.Description,
|
||||||
|
|||||||
@@ -39,17 +39,21 @@ public sealed class SearchProductPickerQueryHandler(
|
|||||||
Name = product.Name,
|
Name = product.Name,
|
||||||
Price = product.Price,
|
Price = product.Price,
|
||||||
SpuCode = product.SpuCode,
|
SpuCode = product.SpuCode,
|
||||||
Status = ToPickerStatus(product.Status)
|
Status = ToPickerStatus(product.Status, product.SoldoutMode.HasValue)
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ToPickerStatus(ProductStatus status)
|
private static string ToPickerStatus(ProductStatus status, bool isSoldout)
|
||||||
{
|
{
|
||||||
|
if (isSoldout)
|
||||||
|
{
|
||||||
|
return "sold_out";
|
||||||
|
}
|
||||||
|
|
||||||
return status switch
|
return status switch
|
||||||
{
|
{
|
||||||
ProductStatus.OnSale => "on_sale",
|
ProductStatus.OnSale => "on_sale",
|
||||||
ProductStatus.Archived => "sold_out",
|
|
||||||
_ => "off_shelf"
|
_ => "off_shelf"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,16 +22,49 @@ public sealed class SearchProductsQueryHandler(
|
|||||||
public async Task<PagedResult<ProductDto>> Handle(SearchProductsQuery request, CancellationToken cancellationToken)
|
public async Task<PagedResult<ProductDto>> Handle(SearchProductsQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
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
|
var paged = sorted
|
||||||
.Skip((request.Page - 1) * request.PageSize)
|
.Skip((request.Page - 1) * request.PageSize)
|
||||||
.Take(request.PageSize)
|
.Take(request.PageSize)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var items = paged.Select(MapToDto).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(
|
private static IOrderedEnumerable<Domain.Products.Entities.Product> ApplySorting(
|
||||||
@@ -63,6 +96,16 @@ public sealed class SearchProductsQueryHandler(
|
|||||||
StockQuantity = product.StockQuantity,
|
StockQuantity = product.StockQuantity,
|
||||||
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
|
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
|
||||||
Status = product.Status,
|
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,
|
CoverImage = product.CoverImage,
|
||||||
GalleryImages = product.GalleryImages,
|
GalleryImages = product.GalleryImages,
|
||||||
Description = product.Description,
|
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.StockQuantity = request.StockQuantity;
|
||||||
existing.MaxQuantityPerOrder = request.MaxQuantityPerOrder;
|
existing.MaxQuantityPerOrder = request.MaxQuantityPerOrder;
|
||||||
existing.Status = request.Status;
|
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.CoverImage = request.CoverImage?.Trim();
|
||||||
existing.GalleryImages = request.GalleryImages?.Trim();
|
existing.GalleryImages = request.GalleryImages?.Trim();
|
||||||
existing.Description = request.Description?.Trim();
|
existing.Description = request.Description?.Trim();
|
||||||
@@ -76,6 +86,16 @@ public sealed class UpdateProductCommandHandler(
|
|||||||
StockQuantity = product.StockQuantity,
|
StockQuantity = product.StockQuantity,
|
||||||
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
|
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
|
||||||
Status = product.Status,
|
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,
|
CoverImage = product.CoverImage,
|
||||||
GalleryImages = product.GalleryImages,
|
GalleryImages = product.GalleryImages,
|
||||||
Description = product.Description,
|
Description = product.Description,
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ public static class ProductMapping
|
|||||||
StockQuantity = product.StockQuantity,
|
StockQuantity = product.StockQuantity,
|
||||||
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
|
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
|
||||||
Status = product.Status,
|
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,
|
CoverImage = product.CoverImage,
|
||||||
GalleryImages = product.GalleryImages,
|
GalleryImages = product.GalleryImages,
|
||||||
Description = product.Description,
|
Description = product.Description,
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ public sealed class SearchProductsQuery : IRequest<PagedResult<ProductDto>>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public ProductStatus? Status { get; init; }
|
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>
|
||||||
/// 页码。
|
/// 页码。
|
||||||
/// </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.OriginalPrice).GreaterThanOrEqualTo(0).When(x => x.OriginalPrice.HasValue);
|
||||||
RuleFor(x => x.StockQuantity).GreaterThanOrEqualTo(0).When(x => x.StockQuantity.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.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.CoverImage).MaximumLength(256);
|
||||||
RuleFor(x => x.GalleryImages).MaximumLength(1024);
|
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.Page).GreaterThan(0);
|
||||||
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
|
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
|
||||||
RuleFor(x => x.SortBy).MaximumLength(64);
|
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.OriginalPrice).GreaterThanOrEqualTo(0).When(x => x.OriginalPrice.HasValue);
|
||||||
RuleFor(x => x.StockQuantity).GreaterThanOrEqualTo(0).When(x => x.StockQuantity.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.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.CoverImage).MaximumLength(256);
|
||||||
RuleFor(x => x.GalleryImages).MaximumLength(1024);
|
RuleFor(x => x.GalleryImages).MaximumLength(1024);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,56 @@ public sealed class Product : MultiTenantEntityBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public ProductStatus Status { get; set; } = ProductStatus.Draft;
|
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>
|
||||||
/// 主图。
|
/// 主图。
|
||||||
/// </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.Unit).HasMaxLength(16);
|
||||||
builder.Property(x => x.Price).HasPrecision(18, 2);
|
builder.Property(x => x.Price).HasPrecision(18, 2);
|
||||||
builder.Property(x => x.OriginalPrice).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.CoverImage).HasMaxLength(256);
|
||||||
builder.Property(x => x.GalleryImages).HasMaxLength(1024);
|
builder.Property(x => x.GalleryImages).HasMaxLength(1024);
|
||||||
builder.Property(x => x.Description).HasColumnType("text");
|
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")
|
.HasColumnType("boolean")
|
||||||
.HasComment("是否热门推荐。");
|
.HasComment("是否热门推荐。");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0)
|
||||||
|
.HasComment("商品类型。");
|
||||||
|
|
||||||
b.Property<int?>("MaxQuantityPerOrder")
|
b.Property<int?>("MaxQuantityPerOrder")
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasComment("最大每单限购。");
|
.HasComment("最大每单限购。");
|
||||||
@@ -3990,6 +3996,12 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
|||||||
.HasColumnType("character varying(128)")
|
.HasColumnType("character varying(128)")
|
||||||
.HasComment("商品名称。");
|
.HasComment("商品名称。");
|
||||||
|
|
||||||
|
b.Property<bool>("NotifyManager")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasComment("是否通知店长。");
|
||||||
|
|
||||||
b.Property<decimal?>("OriginalPrice")
|
b.Property<decimal?>("OriginalPrice")
|
||||||
.HasPrecision(18, 2)
|
.HasPrecision(18, 2)
|
||||||
.HasColumnType("numeric(18,2)")
|
.HasColumnType("numeric(18,2)")
|
||||||
@@ -4000,6 +4012,29 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
|||||||
.HasColumnType("numeric(18,2)")
|
.HasColumnType("numeric(18,2)")
|
||||||
.HasComment("现价。");
|
.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")
|
b.Property<string>("SpuCode")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
@@ -4023,10 +4058,26 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
|||||||
.HasColumnType("character varying(256)")
|
.HasColumnType("character varying(256)")
|
||||||
.HasComment("副标题/卖点。");
|
.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")
|
b.Property<long>("TenantId")
|
||||||
.HasColumnType("bigint")
|
.HasColumnType("bigint")
|
||||||
.HasComment("所属租户 ID。");
|
.HasComment("所属租户 ID。");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("TimedOnShelfAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasComment("定时上架时间。");
|
||||||
|
|
||||||
b.Property<string>("Unit")
|
b.Property<string>("Unit")
|
||||||
.HasMaxLength(16)
|
.HasMaxLength(16)
|
||||||
.HasColumnType("character varying(16)")
|
.HasColumnType("character varying(16)")
|
||||||
|
|||||||
Reference in New Issue
Block a user