diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/CatalogController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/CatalogController.cs new file mode 100644 index 0000000..8045186 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/CatalogController.cs @@ -0,0 +1,113 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Mini; +using TakeoutSaaS.Application.App.Mini.Contracts; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.MiniApi.Controllers; + +/// +/// 小程序商品与门店查询接口。 +/// +[ApiVersion("1.0")] +[Route("api/mini/v{version:apiVersion}")] +public sealed class CatalogController(IMiniAppService miniAppService, ITenantProvider tenantProvider) : BaseApiController +{ + /// + /// 获取当前租户下可用门店列表。 + /// + /// 请求取消令牌。 + /// 门店摘要列表。 + [HttpGet("stores")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetStoresAsync(CancellationToken cancellationToken) + { + var data = await miniAppService.GetStoresAsync(tenantProvider.GetCurrentTenantId(), cancellationToken); + return ApiResponse>.Ok(data); + } + + /// + /// 获取指定门店在当前履约场景下的商品分类。 + /// + /// 门店编号。 + /// 履约场景。 + /// 访问渠道。 + /// 请求取消令牌。 + /// 分类列表。 + [HttpGet("categories")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetCategoriesAsync([FromQuery] string storeId, [FromQuery] string scene, [FromQuery] string channel, CancellationToken cancellationToken) + { + var data = await miniAppService.GetCategoriesAsync(tenantProvider.GetCurrentTenantId(), storeId, scene, channel, cancellationToken); + return ApiResponse>.Ok(data); + } + + /// + /// 获取指定门店的菜单分组与商品列表。 + /// + /// 门店编号。 + /// 履约场景。 + /// 访问渠道。 + /// 请求取消令牌。 + /// 菜单分组列表。 + [HttpGet("menus/{storeId}")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetMenuAsync([FromRoute] string storeId, [FromQuery] string scene, [FromQuery] string channel, CancellationToken cancellationToken) + { + var data = await miniAppService.GetMenuAsync(tenantProvider.GetCurrentTenantId(), storeId, scene, channel, cancellationToken); + return ApiResponse>.Ok(data); + } + + /// + /// 获取商品详情与可选规格信息。 + /// + /// 商品编号。 + /// 履约场景。 + /// 访问渠道。 + /// 请求取消令牌。 + /// 商品详情。 + [HttpGet("products/{productId}")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> GetProductDetailAsync([FromRoute] string productId, [FromQuery] string scene, [FromQuery] string channel, CancellationToken cancellationToken) + { + var data = await miniAppService.GetProductDetailAsync(tenantProvider.GetCurrentTenantId(), productId, scene, channel, cancellationToken); + return data == null ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") : ApiResponse.Ok(data); + } + + /// + /// 试算购物车金额。 + /// + /// 试算请求。 + /// 请求取消令牌。 + /// 金额试算结果。 + [HttpPost("products/price-estimate")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> EstimatePriceAsync([FromBody] MiniPriceEstimateRequest request, CancellationToken cancellationToken) + { + var data = await miniAppService.EstimatePriceAsync(tenantProvider.GetCurrentTenantId(), request, cancellationToken); + return ApiResponse.Ok(data); + } + + /// + /// 下单前校验商品与价格是否有效。 + /// + /// 结算校验请求。 + /// 请求取消令牌。 + /// 结算校验结果。 + [HttpPost("products/checkout-validate")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CheckoutValidateAsync([FromBody] MiniCheckoutValidationRequest request, CancellationToken cancellationToken) + { + var data = await miniAppService.ValidateCheckoutAsync(tenantProvider.GetCurrentTenantId(), request, cancellationToken); + return ApiResponse.Ok(data); + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/OrdersController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/OrdersController.cs new file mode 100644 index 0000000..fe3c853 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/OrdersController.cs @@ -0,0 +1,87 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Mini; +using TakeoutSaaS.Application.App.Mini.Contracts; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.MiniApi.Controllers; + +/// +/// 小程序订单接口。 +/// +[ApiVersion("1.0")] +[Route("api/mini/v{version:apiVersion}/orders")] +public sealed class OrdersController(IMiniAppService miniAppService, ITenantProvider tenantProvider) : BaseApiController +{ + /// + /// 获取当前顾客的订单列表。 + /// + /// 请求取消令牌。 + /// 订单摘要列表。 + [HttpGet] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetOrdersAsync(CancellationToken cancellationToken) + { + var data = await miniAppService.GetOrdersAsync(tenantProvider.GetCurrentTenantId(), ResolveCustomerPhone(), cancellationToken); + return ApiResponse>.Ok(data); + } + + /// + /// 获取指定订单详情。 + /// + /// 订单编号。 + /// 请求取消令牌。 + /// 订单详情。 + [HttpGet("{orderId}")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> GetOrderDetailAsync([FromRoute] string orderId, CancellationToken cancellationToken) + { + var data = await miniAppService.GetOrderDetailAsync(tenantProvider.GetCurrentTenantId(), orderId, ResolveCustomerPhone(), cancellationToken); + return data == null ? ApiResponse.Error(ErrorCodes.NotFound, "订单不存在") : ApiResponse.Ok(data); + } + + /// + /// 创建订单。 + /// + /// 下单请求。 + /// 请求取消令牌。 + /// 创建结果。 + [HttpPost] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateOrderAsync([FromBody] MiniCreateOrderRequest request, CancellationToken cancellationToken) + { + var data = await miniAppService.CreateOrderAsync(tenantProvider.GetCurrentTenantId(), ResolveCustomerName(), ResolveCustomerPhone(), request, cancellationToken); + return ApiResponse.Ok(data); + } + + /// + /// 模拟支付指定订单。 + /// + /// 订单编号。 + /// 请求取消令牌。 + /// 模拟支付结果。 + [HttpPost("{orderId}/mock-pay")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> MockPayAsync([FromRoute] string orderId, CancellationToken cancellationToken) + { + var data = await miniAppService.MockPayAsync(tenantProvider.GetCurrentTenantId(), orderId, ResolveCustomerPhone(), cancellationToken); + return ApiResponse.Ok(data); + } + + private string ResolveCustomerPhone() + { + return Request.Headers.TryGetValue("X-Mini-Customer-Phone", out var phone) ? phone.FirstOrDefault()?.Trim() ?? string.Empty : string.Empty; + } + + private string ResolveCustomerName() + { + return Request.Headers.TryGetValue("X-Mini-Customer-Name", out var name) ? name.FirstOrDefault()?.Trim() ?? string.Empty : string.Empty; + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json b/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json index 5433dca..f7faaa4 100644 --- a/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json @@ -1,9 +1,17 @@ { - "Cors": { - "Mini": [] + "Database": { + "DataSources": { + "AppDatabase": { + "Write": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Reads": [ + "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + ], + "CommandTimeoutSeconds": 30, + "MaxRetryCount": 3, + "MaxRetryDelaySeconds": 5 + } + } }, - "Otel": { - "Endpoint": "", - "UseConsoleExporter": true - } + "Cors": { "Mini": [] }, + "Otel": { "Endpoint": "", "UseConsoleExporter": true } } diff --git a/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json b/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json index 94a5d9a..19586d1 100644 --- a/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json @@ -1,9 +1,17 @@ { - "Cors": { - "Mini": [] + "Database": { + "DataSources": { + "AppDatabase": { + "Write": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Reads": [ + "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + ], + "CommandTimeoutSeconds": 30, + "MaxRetryCount": 3, + "MaxRetryDelaySeconds": 5 + } + } }, - "Otel": { - "Endpoint": "", - "UseConsoleExporter": false - } + "Cors": { "Mini": [] }, + "Otel": { "Endpoint": "", "UseConsoleExporter": false } } diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCategoryDto.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCategoryDto.cs new file mode 100644 index 0000000..1b889ba --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCategoryDto.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Application.App.Mini.Contracts; + +/// +/// 分类摘要。 +/// +public sealed class MiniCategoryDto +{ + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public int Sort { get; init; } + public int ProductCount { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCheckoutValidationRequest.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCheckoutValidationRequest.cs new file mode 100644 index 0000000..5ed00f8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCheckoutValidationRequest.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Application.App.Mini.Contracts; + +/// +/// 结算校验请求。 +/// +public sealed class MiniCheckoutValidationRequest +{ + public string StoreId { get; init; } = string.Empty; + public string Scene { get; init; } = string.Empty; + public string Channel { get; init; } = string.Empty; + public IReadOnlyList Items { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCheckoutValidationResponse.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCheckoutValidationResponse.cs new file mode 100644 index 0000000..f356760 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCheckoutValidationResponse.cs @@ -0,0 +1,10 @@ +namespace TakeoutSaaS.Application.App.Mini.Contracts; + +/// +/// 结算校验结果。 +/// +public sealed class MiniCheckoutValidationResponse +{ + public bool Valid { get; init; } + public string Message { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCreateOrderRequest.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCreateOrderRequest.cs new file mode 100644 index 0000000..3590849 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCreateOrderRequest.cs @@ -0,0 +1,14 @@ +namespace TakeoutSaaS.Application.App.Mini.Contracts; + +/// +/// 创建订单请求。 +/// +public sealed class MiniCreateOrderRequest +{ + public string StoreId { get; init; } = string.Empty; + public string Scene { get; init; } = string.Empty; + public string Channel { get; init; } = string.Empty; + public string? Remark { get; init; } + public string? TableNo { get; init; } + public IReadOnlyList Items { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCreateOrderResponse.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCreateOrderResponse.cs new file mode 100644 index 0000000..b045d31 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCreateOrderResponse.cs @@ -0,0 +1,15 @@ +namespace TakeoutSaaS.Application.App.Mini.Contracts; + +/// +/// 创建订单结果。 +/// +public sealed class MiniCreateOrderResponse +{ + public string OrderId { get; init; } = string.Empty; + public string OrderNo { get; init; } = string.Empty; + public string StatusText { get; init; } = string.Empty; + public string PaymentStatusText { get; init; } = string.Empty; + public decimal PayableAmount { get; init; } + public string PayableAmountText { get; init; } = "0.00"; + public bool MockPayAvailable { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniMenuSectionDto.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniMenuSectionDto.cs new file mode 100644 index 0000000..124432a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniMenuSectionDto.cs @@ -0,0 +1,30 @@ +namespace TakeoutSaaS.Application.App.Mini.Contracts; + +/// +/// 菜单分组。 +/// +public sealed class MiniMenuSectionDto +{ + public string CategoryId { get; init; } = string.Empty; + public string CategoryName { get; init; } = string.Empty; + public IReadOnlyList Products { get; init; } = []; + + /// + /// 菜单商品卡片。 + /// + public sealed class MiniMenuProductDto + { + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string CoverImageUrl { get; init; } = string.Empty; + public decimal Price { get; init; } + public string PriceText { get; init; } = "0.00"; + public decimal? OriginalPrice { get; init; } + public string? OriginalPriceText { get; init; } + public string SalesText { get; init; } = string.Empty; + public IReadOnlyList TagTexts { get; init; } = []; + public bool SoldOut { get; init; } + public bool HasOptions { get; init; } + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniMockPayResponse.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniMockPayResponse.cs new file mode 100644 index 0000000..9657ea4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniMockPayResponse.cs @@ -0,0 +1,14 @@ +namespace TakeoutSaaS.Application.App.Mini.Contracts; + +/// +/// 模拟支付结果。 +/// +public sealed class MiniMockPayResponse +{ + public string OrderId { get; init; } = string.Empty; + public string StatusText { get; init; } = string.Empty; + public string PaymentStatusText { get; init; } = string.Empty; + public decimal PaidAmount { get; init; } + public string PaidAmountText { get; init; } = "0.00"; + public string SuccessTip { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderDetailDto.cs new file mode 100644 index 0000000..ac6b165 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderDetailDto.cs @@ -0,0 +1,59 @@ +namespace TakeoutSaaS.Application.App.Mini.Contracts; + +/// +/// 订单详情。 +/// +public sealed class MiniOrderDetailDto +{ + public string Id { get; init; } = string.Empty; + public string OrderNo { get; init; } = string.Empty; + public string StoreId { get; init; } = string.Empty; + public string StoreName { get; init; } = string.Empty; + public string StatusText { get; init; } = string.Empty; + public string PaymentStatusText { get; init; } = string.Empty; + public string Scene { get; init; } = string.Empty; + public string CustomerName { get; init; } = string.Empty; + public string CustomerPhone { get; init; } = string.Empty; + public string TableNo { get; init; } = string.Empty; + public string Remark { get; init; } = string.Empty; + public decimal ItemsAmount { get; init; } + public decimal PackagingFee { get; init; } + public decimal DeliveryFee { get; init; } + public decimal DiscountAmount { get; init; } + public decimal PayableAmount { get; init; } + public decimal PaidAmount { get; init; } + public string ItemSummary { get; init; } = string.Empty; + public string CreatedAt { get; init; } = string.Empty; + public string? PaidAt { get; init; } + public string ActionText { get; init; } = string.Empty; + public IReadOnlyList Items { get; init; } = []; + public IReadOnlyList Timeline { get; init; } = []; + + /// + /// 订单商品明细。 + /// + public sealed class MiniOrderItemDto + { + public string Id { get; init; } = string.Empty; + public string ProductId { get; init; } = string.Empty; + public string ProductName { get; init; } = string.Empty; + public string SkuName { get; init; } = string.Empty; + public string Unit { get; init; } = string.Empty; + public int Quantity { get; init; } + public decimal UnitPrice { get; init; } + public string UnitPriceText { get; init; } = "0.00"; + public decimal SubTotal { get; init; } + public string SubTotalText { get; init; } = "0.00"; + } + + /// + /// 订单时间线。 + /// + public sealed class MiniOrderTimelineDto + { + public int Status { get; init; } + public string StatusText { get; init; } = string.Empty; + public string Notes { get; init; } = string.Empty; + public string OccurredAt { get; init; } = string.Empty; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderLineInput.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderLineInput.cs new file mode 100644 index 0000000..9df45c3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderLineInput.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Application.App.Mini.Contracts; + +/// +/// 结算行项目输入。 +/// +public sealed class MiniOrderLineInput +{ + public string ProductId { get; init; } = string.Empty; + public string? SkuId { get; init; } + public int Quantity { get; init; } + public IReadOnlyList AddonItemIds { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderSummaryDto.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderSummaryDto.cs new file mode 100644 index 0000000..f4382e2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderSummaryDto.cs @@ -0,0 +1,19 @@ +namespace TakeoutSaaS.Application.App.Mini.Contracts; + +/// +/// 订单摘要。 +/// +public sealed class MiniOrderSummaryDto +{ + public string Id { get; init; } = string.Empty; + public string OrderNo { get; init; } = string.Empty; + public string StoreName { get; init; } = string.Empty; + public string StatusText { get; init; } = string.Empty; + public string PaymentStatusText { get; init; } = string.Empty; + public string Scene { get; init; } = string.Empty; + public string ItemSummary { get; init; } = string.Empty; + public decimal TotalAmount { get; init; } + public string TotalAmountText { get; init; } = "0.00"; + public string CreatedAt { get; init; } = string.Empty; + public string ActionText { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniPriceEstimateRequest.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniPriceEstimateRequest.cs new file mode 100644 index 0000000..ba64d08 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniPriceEstimateRequest.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Application.App.Mini.Contracts; + +/// +/// 金额试算请求。 +/// +public sealed class MiniPriceEstimateRequest +{ + public string StoreId { get; init; } = string.Empty; + public string Scene { get; init; } = string.Empty; + public string Channel { get; init; } = string.Empty; + public IReadOnlyList Items { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniPriceEstimateResponse.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniPriceEstimateResponse.cs new file mode 100644 index 0000000..1a23708 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniPriceEstimateResponse.cs @@ -0,0 +1,21 @@ +namespace TakeoutSaaS.Application.App.Mini.Contracts; + +/// +/// 金额试算结果。 +/// +public sealed class MiniPriceEstimateResponse +{ + public string StoreId { get; init; } = string.Empty; + public string Scene { get; init; } = string.Empty; + public int TotalCount { get; init; } + public decimal OriginalAmount { get; init; } + public string OriginalAmountText { get; init; } = "0.00"; + public decimal PackagingFee { get; init; } + public string PackagingFeeText { get; init; } = "0.00"; + public decimal DeliveryFee { get; init; } + public string DeliveryFeeText { get; init; } = "0.00"; + public decimal DiscountAmount { get; init; } + public string DiscountAmountText { get; init; } = "0.00"; + public decimal PayableAmount { get; init; } + public string PayableAmountText { get; init; } = "0.00"; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniProductDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniProductDetailDto.cs new file mode 100644 index 0000000..a7a7e92 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniProductDetailDto.cs @@ -0,0 +1,69 @@ +namespace TakeoutSaaS.Application.App.Mini.Contracts; + +/// +/// 商品详情。 +/// +public sealed class MiniProductDetailDto +{ + public string Id { get; init; } = string.Empty; + public string StoreId { get; init; } = string.Empty; + public string CategoryId { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Subtitle { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string CoverImageUrl { get; init; } = string.Empty; + public IReadOnlyList GalleryImages { get; init; } = []; + public string Unit { get; init; } = string.Empty; + public decimal BasePrice { get; init; } + public string BasePriceText { get; init; } = "0.00"; + public decimal? OriginalPrice { get; init; } + public string? OriginalPriceText { get; init; } + public bool SoldOut { get; init; } + public int MonthlySales { get; init; } + public IReadOnlyList TagTexts { get; init; } = []; + public string? DefaultSkuId { get; init; } + public IReadOnlyList Skus { get; init; } = []; + public IReadOnlyList OptionGroups { get; init; } = []; + + /// + /// 商品 SKU。 + /// + public sealed class MiniProductSkuDto + { + public string Id { get; init; } = string.Empty; + public decimal Price { get; init; } + public string PriceText { get; init; } = "0.00"; + public decimal? OriginalPrice { get; init; } + public string? OriginalPriceText { get; init; } + public int? StockQuantity { get; init; } + public bool SoldOut { get; init; } + public IReadOnlyList SelectedOptionIds { get; init; } = []; + } + + /// + /// 商品选项分组。 + /// + public sealed class MiniProductOptionGroupDto + { + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string GroupType { get; init; } = string.Empty; + public string SelectionType { get; init; } = string.Empty; + public bool Required { get; init; } + public int MinSelect { get; init; } + public int MaxSelect { get; init; } + public IReadOnlyList Options { get; init; } = []; + } + + /// + /// 商品选项。 + /// + public sealed class MiniProductOptionDto + { + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public decimal ExtraPrice { get; init; } + public string ExtraPriceText { get; init; } = "0.00"; + public bool SoldOut { get; init; } + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniStoreSummaryDto.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniStoreSummaryDto.cs new file mode 100644 index 0000000..42b4341 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniStoreSummaryDto.cs @@ -0,0 +1,14 @@ +namespace TakeoutSaaS.Application.App.Mini.Contracts; + +/// +/// 门店摘要。 +/// +public sealed class MiniStoreSummaryDto +{ + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Address { get; init; } = string.Empty; + public string BusinessHours { get; init; } = string.Empty; + public IReadOnlyList Supports { get; init; } = []; + public IReadOnlyList TagTexts { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/IMiniAppService.cs b/src/Application/TakeoutSaaS.Application/App/Mini/IMiniAppService.cs new file mode 100644 index 0000000..384af22 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/IMiniAppService.cs @@ -0,0 +1,50 @@ +using TakeoutSaaS.Application.App.Mini.Contracts; + +namespace TakeoutSaaS.Application.App.Mini; + +/// +/// 小程序真实业务服务抽象。 +/// +public interface IMiniAppService +{ + /// + /// 查询可选门店。 + /// + Task> GetStoresAsync(long tenantId, CancellationToken cancellationToken = default); + /// + /// 查询门店分类。 + /// + Task> GetCategoriesAsync(long tenantId, string storeId, string scene, string channel, CancellationToken cancellationToken = default); + /// + /// 查询门店菜单。 + /// + Task> GetMenuAsync(long tenantId, string storeId, string scene, string channel, CancellationToken cancellationToken = default); + /// + /// 查询商品详情。 + /// + Task GetProductDetailAsync(long tenantId, string productId, string scene, string channel, CancellationToken cancellationToken = default); + /// + /// 试算订单金额。 + /// + Task EstimatePriceAsync(long tenantId, MiniPriceEstimateRequest request, CancellationToken cancellationToken = default); + /// + /// 校验结算请求。 + /// + Task ValidateCheckoutAsync(long tenantId, MiniCheckoutValidationRequest request, CancellationToken cancellationToken = default); + /// + /// 查询顾客订单列表。 + /// + Task> GetOrdersAsync(long tenantId, string customerPhone, CancellationToken cancellationToken = default); + /// + /// 查询顾客订单详情。 + /// + Task GetOrderDetailAsync(long tenantId, string orderId, string customerPhone, CancellationToken cancellationToken = default); + /// + /// 创建订单。 + /// + Task CreateOrderAsync(long tenantId, string customerName, string customerPhone, MiniCreateOrderRequest request, CancellationToken cancellationToken = default); + /// + /// 模拟支付。 + /// + Task MockPayAsync(long tenantId, string orderId, string customerPhone, CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Entities/Order.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/Order.cs new file mode 100644 index 0000000..ec8e326 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/Order.cs @@ -0,0 +1,92 @@ +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Payments.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Orders.Entities; + +/// +/// 小程序订单聚合根。 +/// +public sealed class Order : MultiTenantEntityBase +{ + /// + /// 订单号。 + /// + public string OrderNo { get; set; } = string.Empty; + /// + /// 门店 ID。 + /// + public long StoreId { get; set; } + /// + /// 下单渠道。 + /// + public OrderChannel Channel { get; set; } = OrderChannel.MiniProgram; + /// + /// 履约方式。 + /// + public DeliveryType DeliveryType { get; set; } = DeliveryType.Delivery; + /// + /// 订单状态。 + /// + public OrderStatus Status { get; set; } = OrderStatus.PendingPayment; + /// + /// 支付状态。 + /// + public PaymentStatus PaymentStatus { get; set; } = PaymentStatus.Unpaid; + /// + /// 顾客姓名。 + /// + public string? CustomerName { get; set; } + /// + /// 顾客手机号。 + /// + public string? CustomerPhone { get; set; } + /// + /// 桌号。 + /// + public string? TableNo { get; set; } + /// + /// 排队号。 + /// + public string? QueueNumber { get; set; } + /// + /// 预约 ID。 + /// + public long? ReservationId { get; set; } + /// + /// 商品原价合计。 + /// + public decimal ItemsAmount { get; set; } + /// + /// 优惠金额。 + /// + public decimal DiscountAmount { get; set; } + /// + /// 应付金额。 + /// + public decimal PayableAmount { get; set; } + /// + /// 实付金额。 + /// + public decimal PaidAmount { get; set; } + /// + /// 支付时间。 + /// + public DateTime? PaidAt { get; set; } + /// + /// 完成时间。 + /// + public DateTime? FinishedAt { get; set; } + /// + /// 取消时间。 + /// + public DateTime? CancelledAt { get; set; } + /// + /// 取消原因。 + /// + public string? CancelReason { get; set; } + /// + /// 订单备注。 + /// + public string? Remark { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderItem.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderItem.cs new file mode 100644 index 0000000..ce59115 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderItem.cs @@ -0,0 +1,50 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Orders.Entities; + +/// +/// 订单明细。 +/// +public sealed class OrderItem : MultiTenantEntityBase +{ + /// + /// 订单 ID。 + /// + public long OrderId { get; set; } + /// + /// 商品 ID。 + /// + public long ProductId { get; set; } + /// + /// 商品名称。 + /// + public string ProductName { get; set; } = string.Empty; + /// + /// SKU 名称。 + /// + public string? SkuName { get; set; } + /// + /// 单位。 + /// + public string? Unit { get; set; } + /// + /// 数量。 + /// + public int Quantity { get; set; } + /// + /// 单价。 + /// + public decimal UnitPrice { get; set; } + /// + /// 优惠金额。 + /// + public decimal DiscountAmount { get; set; } + /// + /// 小计。 + /// + public decimal SubTotal { get; set; } + /// + /// 属性 JSON。 + /// + public string? AttributesJson { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderStatusHistory.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderStatusHistory.cs new file mode 100644 index 0000000..cdca57b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderStatusHistory.cs @@ -0,0 +1,31 @@ +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Orders.Entities; + +/// +/// 订单状态流转记录。 +/// +public sealed class OrderStatusHistory : MultiTenantEntityBase +{ + /// + /// 订单 ID。 + /// + public long OrderId { get; set; } + /// + /// 变更后的状态。 + /// + public OrderStatus Status { get; set; } + /// + /// 操作人 ID。 + /// + public long? OperatorId { get; set; } + /// + /// 备注。 + /// + public string? Notes { get; set; } + /// + /// 发生时间。 + /// + public DateTime OccurredAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Enums/DeliveryType.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/DeliveryType.cs new file mode 100644 index 0000000..a288f70 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/DeliveryType.cs @@ -0,0 +1,20 @@ +namespace TakeoutSaaS.Domain.Orders.Enums; + +/// +/// 履约方式。 +/// +public enum DeliveryType +{ + /// + /// 堂食。 + /// + DineIn = 0, + /// + /// 自提。 + /// + Pickup = 1, + /// + /// 配送。 + /// + Delivery = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderChannel.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderChannel.cs new file mode 100644 index 0000000..1593295 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderChannel.cs @@ -0,0 +1,20 @@ +namespace TakeoutSaaS.Domain.Orders.Enums; + +/// +/// 下单渠道。 +/// +public enum OrderChannel +{ + /// + /// 未知渠道。 + /// + Unknown = 0, + /// + /// 小程序。 + /// + MiniProgram = 1, + /// + /// 扫码点餐。 + /// + ScanToOrder = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderStatus.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderStatus.cs new file mode 100644 index 0000000..14a6e4b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderStatus.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Orders.Enums; + +/// +/// 订单状态。 +/// +public enum OrderStatus +{ + /// + /// 待付款。 + /// + PendingPayment = 0, + /// + /// 已付款待制作。 + /// + AwaitingPreparation = 1, + /// + /// 制作中。 + /// + InProgress = 2, + /// + /// 待取餐或待送达。 + /// + Ready = 3, + /// + /// 已完成。 + /// + Completed = 4, + /// + /// 已取消。 + /// + Cancelled = 5 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs new file mode 100644 index 0000000..fb865b4 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs @@ -0,0 +1,30 @@ +using TakeoutSaaS.Domain.Orders.Entities; + +namespace TakeoutSaaS.Domain.Orders.Repositories; + +/// +/// 订单写仓储。 +/// +public interface IOrderRepository +{ + /// + /// 新增订单。 + /// + Task AddAsync(Order order, CancellationToken cancellationToken = default); + /// + /// 批量新增订单明细。 + /// + Task AddItemsAsync(IReadOnlyCollection items, CancellationToken cancellationToken = default); + /// + /// 新增状态流转记录。 + /// + Task AddStatusHistoryAsync(OrderStatusHistory history, CancellationToken cancellationToken = default); + /// + /// 查询可写订单。 + /// + Task FindAsync(long tenantId, long orderId, CancellationToken cancellationToken = default); + /// + /// 保存变更。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentStatus.cs b/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentStatus.cs new file mode 100644 index 0000000..199af3c --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentStatus.cs @@ -0,0 +1,28 @@ +namespace TakeoutSaaS.Domain.Payments.Enums; + +/// +/// 支付状态。 +/// +public enum PaymentStatus +{ + /// + /// 未支付。 + /// + Unpaid = 0, + /// + /// 支付中。 + /// + Paying = 1, + /// + /// 已支付。 + /// + Paid = 2, + /// + /// 支付失败。 + /// + Failed = 3, + /// + /// 已退款。 + /// + Refunded = 4 +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs index 1b46430..5485f5a 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -1,5 +1,12 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Application.App.Mini; +using TakeoutSaaS.Domain.Orders.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; +using TakeoutSaaS.Infrastructure.App.Repositories; +using TakeoutSaaS.Infrastructure.App.Tenancy; +using TakeoutSaaS.Module.Tenancy; namespace TakeoutSaaS.Infrastructure.App.Extensions; @@ -9,14 +16,21 @@ namespace TakeoutSaaS.Infrastructure.App.Extensions; public static class AppServiceCollectionExtensions { /// - /// 注册骨架所需的基础设施能力。 + /// 注册小程序端基础设施能力。 /// - /// 服务集合。 - /// 配置源。 - /// 服务集合。 public static IServiceCollection AddAppInfrastructure(this IServiceCollection services, IConfiguration configuration) { - _ = configuration; + var writeConnectionString = configuration["Database:DataSources:AppDatabase:Write"]; + if (string.IsNullOrWhiteSpace(writeConnectionString)) + { + throw new InvalidOperationException("缺少 Database:DataSources:AppDatabase:Write 配置。"); + } + + services.AddDbContext(options => options.UseNpgsql(writeConnectionString)); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } } + diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/MiniAppService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/MiniAppService.cs new file mode 100644 index 0000000..1fd93e7 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/MiniAppService.cs @@ -0,0 +1,1402 @@ +using Microsoft.Extensions.Configuration; +using System.Globalization; +using System.Text.Json; +using Dapper; +using Npgsql; +using TakeoutSaaS.Application.App.Mini; +using TakeoutSaaS.Application.App.Mini.Contracts; +using TakeoutSaaS.Domain.Orders.Entities; +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Orders.Repositories; +using TakeoutSaaS.Domain.Payments.Enums; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Infrastructure.App; + +/// +/// 小程序端真实业务服务实现。 +/// +public sealed class MiniAppService( + IConfiguration configuration, + IOrderRepository orderRepository, + IIdGenerator idGenerator) : IMiniAppService +{ + private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web); + + /// + public async Task> GetStoresAsync(long tenantId, CancellationToken cancellationToken = default) + { + EnsureTenantResolved(tenantId); + + // 1. 查询有效门店并按可售商品数倒排。 + await using var connection = OpenReadConnection(); + const string sql = """ +select s."Id", + s."Name", + coalesce(s."Address", '') as "Address", + coalesce(s."BusinessHours", '商家营业中') as "BusinessHours", + coalesce(s."Tags", '') as "Tags", + s."SupportsDelivery", + s."SupportsPickup", + s."SupportsDineIn", + ( + select count(1) + from public.products p + where p."TenantId" = s."TenantId" + and p."StoreId" = s."Id" + and p."DeletedAt" is null + and p."Status" = 1 + ) as "ProductCount" +from public.stores s +where s."TenantId" = @TenantId + and s."DeletedAt" is null + and s."Status" = 2 + and s."BusinessStatus" = 1 +order by "ProductCount" desc, s."CreatedAt" asc, s."Id" asc; +"""; + var rows = (await connection.QueryAsync(new CommandDefinition(sql, new { TenantId = tenantId }, cancellationToken: cancellationToken))).ToList(); + + // 2. 映射门店展示模型。 + return rows.Select(row => new MiniStoreSummaryDto + { + Id = row.Id.ToString(CultureInfo.InvariantCulture), + Name = row.Name, + Address = row.Address, + BusinessHours = row.BusinessHours, + Supports = BuildSupports(row), + TagTexts = SplitTexts(row.Tags) + }).ToList(); + } + + /// + public async Task> GetCategoriesAsync(long tenantId, string storeId, string scene, string channel, CancellationToken cancellationToken = default) + { + EnsureTenantResolved(tenantId); + var storeIdValue = ParseId(storeId, nameof(storeId)); + var sceneSpec = ParseScene(scene); + _ = channel; + + // 1. 查询当前场景下有商品的分类。 + await using var connection = OpenReadConnection(); + var sql = $""" +select c."Id", + c."Name", + c."SortOrder" as "Sort", + count(p."Id")::int as "ProductCount" +from public.product_categories c +left join public.products p + on p."CategoryId" = c."Id" + and p."TenantId" = c."TenantId" + and p."StoreId" = c."StoreId" + and p."DeletedAt" is null + and p."Status" = 1 + and p.{sceneSpec.ProductEnabledColumn} = true +where c."TenantId" = @TenantId + and c."StoreId" = @StoreId + and c."DeletedAt" is null + and c."IsEnabled" = true + and (c."ChannelsJson" is null or c."ChannelsJson" = '' or c."ChannelsJson" like @ChannelPattern) +group by c."Id", c."Name", c."SortOrder" +having count(p."Id") > 0 +order by c."SortOrder" asc, c."Id" asc; +"""; + var rows = (await connection.QueryAsync(new CommandDefinition( + sql, + new { TenantId = tenantId, StoreId = storeIdValue, ChannelPattern = $"%\"{sceneSpec.CategoryChannelToken}\"%" }, + cancellationToken: cancellationToken))).ToList(); + + return rows; + } + + /// + public async Task> GetMenuAsync(long tenantId, string storeId, string scene, string channel, CancellationToken cancellationToken = default) + { + EnsureTenantResolved(tenantId); + var storeIdValue = ParseId(storeId, nameof(storeId)); + var sceneSpec = ParseScene(scene); + _ = channel; + + // 1. 查询当前场景下的分类与商品。 + await using var connection = OpenReadConnection(); + var sql = $""" +select c."Id" as "CategoryId", + c."Name" as "CategoryName", + p."Id", + p."Name", + coalesce(nullif(p."Subtitle", ''), coalesce(p."Description", '')) as "Description", + coalesce(p."CoverImage", '') as "CoverImageUrl", + coalesce( + ( + select min(sku."Price") + from public.product_skus sku + where sku."ProductId" = p."Id" + and sku."DeletedAt" is null + and sku."IsEnabled" = true + ), + p."Price") as "DisplayPrice", + coalesce( + ( + select min(sku."OriginalPrice") + from public.product_skus sku + where sku."ProductId" = p."Id" + and sku."DeletedAt" is null + and sku."IsEnabled" = true + and sku."OriginalPrice" is not null + ), + p."OriginalPrice") as "DisplayOriginalPrice", + coalesce(p."SalesMonthly", 0) as "SalesMonthly", + coalesce(p."TagsJson", '[]') as "TagsJson", + exists( + select 1 + from public.product_spec_template_products link + join public.product_spec_templates template on template."Id" = link."TemplateId" + where link."ProductId" = p."Id" + and link."DeletedAt" is null + and template."DeletedAt" is null + and template."IsEnabled" = true + ) as "HasOptions", + case + when exists( + select 1 + from public.product_skus sku + where sku."ProductId" = p."Id" + and sku."DeletedAt" is null + and sku."IsEnabled" = true + and coalesce(sku."StockQuantity", 999999) > 0 + ) then false + when exists( + select 1 + from public.product_skus sku + where sku."ProductId" = p."Id" + and sku."DeletedAt" is null + and sku."IsEnabled" = true + ) then true + else coalesce(p."RemainStock", p."StockQuantity", 999999) <= 0 + end as "SoldOut" +from public.product_categories c +join public.products p + on p."CategoryId" = c."Id" + and p."TenantId" = c."TenantId" + and p."StoreId" = c."StoreId" + and p."DeletedAt" is null + and p."Status" = 1 + and p.{sceneSpec.ProductEnabledColumn} = true +where c."TenantId" = @TenantId + and c."StoreId" = @StoreId + and c."DeletedAt" is null + and c."IsEnabled" = true + and (c."ChannelsJson" is null or c."ChannelsJson" = '' or c."ChannelsJson" like @ChannelPattern) +order by c."SortOrder" asc, p."SortWeight" desc, p."CreatedAt" asc, p."Id" asc; +"""; + var rows = (await connection.QueryAsync(new CommandDefinition( + sql, + new { TenantId = tenantId, StoreId = storeIdValue, ChannelPattern = $"%\"{sceneSpec.CategoryChannelToken}\"%" }, + cancellationToken: cancellationToken))).ToList(); + + // 2. 按分类聚合菜单。 + return rows + .GroupBy(row => new { row.CategoryId, row.CategoryName }) + .Select(group => new MiniMenuSectionDto + { + CategoryId = group.Key.CategoryId.ToString(CultureInfo.InvariantCulture), + CategoryName = group.Key.CategoryName, + Products = group.Select(row => new MiniMenuSectionDto.MiniMenuProductDto + { + Id = row.Id.ToString(CultureInfo.InvariantCulture), + Name = row.Name, + Description = row.Description, + CoverImageUrl = row.CoverImageUrl, + Price = row.DisplayPrice, + PriceText = FormatMoney(row.DisplayPrice), + OriginalPrice = row.DisplayOriginalPrice, + OriginalPriceText = row.DisplayOriginalPrice.HasValue ? FormatMoney(row.DisplayOriginalPrice.Value) : null, + SalesText = $"月售 {row.SalesMonthly}", + TagTexts = SplitTexts(row.TagsJson), + SoldOut = row.SoldOut, + HasOptions = row.HasOptions + }).ToList() + }) + .ToList(); + } + + /// + public async Task GetProductDetailAsync(long tenantId, string productId, string scene, string channel, CancellationToken cancellationToken = default) + { + EnsureTenantResolved(tenantId); + var productIdValue = ParseId(productId, nameof(productId)); + var sceneSpec = ParseScene(scene); + _ = channel; + + // 1. 查询商品主体。 + await using var connection = OpenReadConnection(); + var productSql = $""" +select p."Id", + p."StoreId", + p."CategoryId", + p."Name", + coalesce(p."Subtitle", '') as "Subtitle", + coalesce(p."Description", '') as "Description", + coalesce(p."CoverImage", '') as "CoverImageUrl", + coalesce(p."GalleryImages", '[]') as "GalleryImages", + coalesce(p."Unit", '份') as "Unit", + p."Price", + p."OriginalPrice", + coalesce(p."SalesMonthly", 0) as "MonthlySales", + coalesce(p."TagsJson", '[]') as "TagsJson", + coalesce(p."PackingFee", 0) as "PackingFee", + coalesce(p."RemainStock", p."StockQuantity", 999999) as "StockQuantity", + exists( + select 1 + from public.product_skus sku + where sku."ProductId" = p."Id" + and sku."DeletedAt" is null + and sku."IsEnabled" = true + ) as "HasEnabledSkus" +from public.products p +where p."TenantId" = @TenantId + and p."Id" = @ProductId + and p."DeletedAt" is null + and p."Status" = 1 + and p.{sceneSpec.ProductEnabledColumn} = true +limit 1; +"""; + var product = await connection.QueryFirstOrDefaultAsync(new CommandDefinition( + productSql, + new { TenantId = tenantId, ProductId = productIdValue }, + cancellationToken: cancellationToken)); + + if (product == null) + { + return null; + } + + // 2. 查询商品关联的选项模板与选项。 + const string optionSql = """ +select template."Id" as "TemplateId", + template."Name" as "TemplateName", + template."TemplateType", + template."SelectionType", + template."IsRequired", + template."MinSelect", + template."MaxSelect", + template."SortOrder" as "TemplateSortOrder", + option."Id" as "OptionId", + option."Name" as "OptionName", + option."ExtraPrice", + option."Stock", + option."SortOrder" as "OptionSortOrder" +from public.product_spec_template_products link +join public.product_spec_templates template + on template."Id" = link."TemplateId" + and template."DeletedAt" is null + and template."IsEnabled" = true +join public.product_spec_template_options option + on option."TemplateId" = template."Id" + and option."DeletedAt" is null + and option."IsEnabled" = true +where link."TenantId" = @TenantId + and link."ProductId" = @ProductId + and link."DeletedAt" is null +order by template."SortOrder" asc, template."Id" asc, option."SortOrder" asc, option."Id" asc; +"""; + var optionRows = (await connection.QueryAsync(new CommandDefinition( + optionSql, + new { TenantId = tenantId, ProductId = productIdValue }, + cancellationToken: cancellationToken))).ToList(); + + // 3. 查询 SKU 列表。 + const string skuSql = """ +select sku."Id", + sku."Price", + sku."OriginalPrice", + sku."StockQuantity", + sku."AttributesJson", + sku."IsEnabled" +from public.product_skus sku +where sku."ProductId" = @ProductId + and sku."DeletedAt" is null + and sku."IsEnabled" = true +order by sku."SortOrder" asc, sku."Id" asc; +"""; + var skuRows = (await connection.QueryAsync(new CommandDefinition( + skuSql, + new { ProductId = productIdValue }, + cancellationToken: cancellationToken))).ToList(); + + // 4. 组装详情模型。 + var optionGroups = optionRows + .GroupBy(row => new { row.TemplateId, row.TemplateName, row.TemplateType, row.SelectionType, row.IsRequired, row.MinSelect, row.MaxSelect, row.TemplateSortOrder }) + .OrderBy(group => group.Key.TemplateSortOrder) + .Select(group => new MiniProductDetailDto.MiniProductOptionGroupDto + { + Id = group.Key.TemplateId.ToString(CultureInfo.InvariantCulture), + Name = group.Key.TemplateName, + GroupType = MapGroupType(group.Key.TemplateType), + SelectionType = MapSelectionType(group.Key.SelectionType), + Required = group.Key.IsRequired, + MinSelect = group.Key.MinSelect, + MaxSelect = group.Key.MaxSelect, + Options = group.Select(row => new MiniProductDetailDto.MiniProductOptionDto + { + Id = row.OptionId.ToString(CultureInfo.InvariantCulture), + Name = row.OptionName, + ExtraPrice = row.ExtraPrice, + ExtraPriceText = FormatMoney(row.ExtraPrice), + SoldOut = row.Stock <= 0 + }).ToList() + }) + .ToList(); + + var skus = skuRows.Select(row => new MiniProductDetailDto.MiniProductSkuDto + { + Id = row.Id.ToString(CultureInfo.InvariantCulture), + Price = row.Price, + PriceText = FormatMoney(row.Price), + OriginalPrice = row.OriginalPrice, + OriginalPriceText = row.OriginalPrice.HasValue ? FormatMoney(row.OriginalPrice.Value) : null, + StockQuantity = row.StockQuantity, + SoldOut = row.StockQuantity.HasValue && row.StockQuantity.Value <= 0, + SelectedOptionIds = ParseSkuAttributeOptionIds(row.AttributesJson).Select(id => id.ToString(CultureInfo.InvariantCulture)).ToList() + }).ToList(); + + return new MiniProductDetailDto + { + Id = product.Id.ToString(CultureInfo.InvariantCulture), + StoreId = product.StoreId.ToString(CultureInfo.InvariantCulture), + CategoryId = product.CategoryId.ToString(CultureInfo.InvariantCulture), + Name = product.Name, + Subtitle = product.Subtitle, + Description = product.Description, + CoverImageUrl = product.CoverImageUrl, + GalleryImages = SplitTexts(product.GalleryImages), + Unit = product.Unit, + BasePrice = product.Price, + BasePriceText = FormatMoney(product.Price), + OriginalPrice = product.OriginalPrice, + OriginalPriceText = product.OriginalPrice.HasValue ? FormatMoney(product.OriginalPrice.Value) : null, + SoldOut = skus.Count > 0 ? skus.All(item => item.SoldOut) : (product.StockQuantity.HasValue && product.StockQuantity.Value <= 0), + MonthlySales = product.MonthlySales, + TagTexts = SplitTexts(product.TagsJson), + DefaultSkuId = skus.FirstOrDefault(item => !item.SoldOut)?.Id ?? skus.FirstOrDefault()?.Id, + Skus = skus, + OptionGroups = optionGroups + }; + } + + /// + public async Task EstimatePriceAsync(long tenantId, MiniPriceEstimateRequest request, CancellationToken cancellationToken = default) + { + var draft = await BuildDraftAsync(tenantId, request.StoreId, request.Scene, request.Items, cancellationToken); + return BuildEstimateResponse(draft); + } + + /// + public async Task ValidateCheckoutAsync(long tenantId, MiniCheckoutValidationRequest request, CancellationToken cancellationToken = default) + { + _ = await BuildDraftAsync(tenantId, request.StoreId, request.Scene, request.Items, cancellationToken); + return new MiniCheckoutValidationResponse + { + Valid = true, + Message = "校验通过" + }; + } + + /// + public async Task> GetOrdersAsync(long tenantId, string customerPhone, CancellationToken cancellationToken = default) + { + EnsureTenantResolved(tenantId); + if (string.IsNullOrWhiteSpace(customerPhone)) + { + return []; + } + + // 1. 查询顾客名下订单。 + await using var connection = OpenReadConnection(); + const string orderSql = """ +select o."Id", + o."OrderNo", + o."StoreId", + s."Name" as "StoreName", + o."Status", + o."PaymentStatus", + o."DeliveryType", + o."PayableAmount", + o."CreatedAt" +from public.orders o +join public.stores s on s."Id" = o."StoreId" +where o."TenantId" = @TenantId + and o."DeletedAt" is null + and o."CustomerPhone" = @CustomerPhone +order by o."CreatedAt" desc, o."Id" desc +limit 100; +"""; + var orderRows = (await connection.QueryAsync(new CommandDefinition( + orderSql, + new { TenantId = tenantId, CustomerPhone = customerPhone.Trim() }, + cancellationToken: cancellationToken))).ToList(); + + if (orderRows.Count == 0) + { + return []; + } + + // 2. 查询订单商品,用于拼接摘要。 + var orderIds = orderRows.Select(row => row.Id).ToArray(); + const string itemSql = """ +select "Id", "OrderId", "ProductId", "ProductName", coalesce("SkuName", '') as "SkuName", coalesce("Unit", '') as "Unit", "Quantity", "UnitPrice", "SubTotal" +from public.order_items +where "TenantId" = @TenantId + and "DeletedAt" is null + and "OrderId" = any(@OrderIds) +order by "OrderId" asc, "Id" asc; +"""; + var itemRows = (await connection.QueryAsync(new CommandDefinition( + itemSql, + new { TenantId = tenantId, OrderIds = orderIds }, + cancellationToken: cancellationToken))).ToList(); + var itemLookup = itemRows.GroupBy(row => row.OrderId).ToDictionary(group => group.Key, group => group.ToList()); + + return orderRows.Select(row => new MiniOrderSummaryDto + { + Id = row.Id.ToString(CultureInfo.InvariantCulture), + OrderNo = row.OrderNo, + StoreName = row.StoreName, + StatusText = MapOrderStatusText(row.Status), + PaymentStatusText = MapPaymentStatusText(row.PaymentStatus), + Scene = MapSceneText(row.DeliveryType), + ItemSummary = BuildItemSummary(itemLookup.GetValueOrDefault(row.Id) ?? []), + TotalAmount = row.PayableAmount, + TotalAmountText = FormatMoney(row.PayableAmount), + CreatedAt = FormatDateTime(row.CreatedAt), + ActionText = MapActionText(row.Status, row.PaymentStatus) + }).ToList(); + } + + /// + public async Task GetOrderDetailAsync(long tenantId, string orderId, string customerPhone, CancellationToken cancellationToken = default) + { + EnsureTenantResolved(tenantId); + if (string.IsNullOrWhiteSpace(customerPhone)) + { + return null; + } + + var orderIdValue = ParseId(orderId, nameof(orderId)); + await using var connection = OpenReadConnection(); + const string orderSql = """ +select o."Id", + o."OrderNo", + o."StoreId", + s."Name" as "StoreName", + o."Status", + o."PaymentStatus", + o."DeliveryType", + coalesce(o."CustomerName", '') as "CustomerName", + coalesce(o."CustomerPhone", '') as "CustomerPhone", + coalesce(o."TableNo", '') as "TableNo", + coalesce(o."Remark", '') as "Remark", + o."ItemsAmount", + o."DiscountAmount", + o."PayableAmount", + o."PaidAmount", + o."CreatedAt", + o."PaidAt" +from public.orders o +join public.stores s on s."Id" = o."StoreId" +where o."TenantId" = @TenantId + and o."DeletedAt" is null + and o."Id" = @OrderId + and o."CustomerPhone" = @CustomerPhone +limit 1; +"""; + var orderRow = await connection.QueryFirstOrDefaultAsync(new CommandDefinition( + orderSql, + new { TenantId = tenantId, OrderId = orderIdValue, CustomerPhone = customerPhone.Trim() }, + cancellationToken: cancellationToken)); + + if (orderRow == null) + { + return null; + } + + const string itemSql = """ +select "Id", "OrderId", "ProductId", "ProductName", coalesce("SkuName", '') as "SkuName", coalesce("Unit", '') as "Unit", "Quantity", "UnitPrice", "SubTotal" +from public.order_items +where "TenantId" = @TenantId + and "DeletedAt" is null + and "OrderId" = @OrderId +order by "Id" asc; +"""; + var itemRows = (await connection.QueryAsync(new CommandDefinition( + itemSql, + new { TenantId = tenantId, OrderId = orderIdValue }, + cancellationToken: cancellationToken))).ToList(); + + const string historySql = """ +select "Status", coalesce("Notes", '') as "Notes", "OccurredAt" +from public.order_status_histories +where "TenantId" = @TenantId + and "DeletedAt" is null + and "OrderId" = @OrderId +order by "OccurredAt" asc, "Id" asc; +"""; + var historyRows = (await connection.QueryAsync(new CommandDefinition( + historySql, + new { TenantId = tenantId, OrderId = orderIdValue }, + cancellationToken: cancellationToken))).ToList(); + + var extraFee = Math.Max(orderRow.PayableAmount - orderRow.ItemsAmount + orderRow.DiscountAmount, 0); + var packagingFee = orderRow.DeliveryType == (int)DeliveryType.Delivery ? 0 : extraFee; + var deliveryFee = orderRow.DeliveryType == (int)DeliveryType.Delivery ? extraFee : 0; + + return new MiniOrderDetailDto + { + Id = orderRow.Id.ToString(CultureInfo.InvariantCulture), + OrderNo = orderRow.OrderNo, + StoreId = orderRow.StoreId.ToString(CultureInfo.InvariantCulture), + StoreName = orderRow.StoreName, + StatusText = MapOrderStatusText(orderRow.Status), + PaymentStatusText = MapPaymentStatusText(orderRow.PaymentStatus), + Scene = MapSceneText(orderRow.DeliveryType), + CustomerName = orderRow.CustomerName, + CustomerPhone = orderRow.CustomerPhone, + TableNo = orderRow.TableNo, + Remark = orderRow.Remark, + ItemsAmount = orderRow.ItemsAmount, + PackagingFee = packagingFee, + DeliveryFee = deliveryFee, + DiscountAmount = orderRow.DiscountAmount, + PayableAmount = orderRow.PayableAmount, + PaidAmount = orderRow.PaidAmount, + ItemSummary = BuildItemSummary(itemRows), + CreatedAt = FormatDateTime(orderRow.CreatedAt), + PaidAt = orderRow.PaidAt.HasValue ? FormatDateTime(orderRow.PaidAt.Value) : null, + ActionText = MapActionText(orderRow.Status, orderRow.PaymentStatus), + Items = itemRows.Select(row => new MiniOrderDetailDto.MiniOrderItemDto + { + Id = row.Id.ToString(CultureInfo.InvariantCulture), + ProductId = row.ProductId.ToString(CultureInfo.InvariantCulture), + ProductName = row.ProductName, + SkuName = row.SkuName, + Unit = row.Unit, + Quantity = row.Quantity, + UnitPrice = row.UnitPrice, + UnitPriceText = FormatMoney(row.UnitPrice), + SubTotal = row.SubTotal, + SubTotalText = FormatMoney(row.SubTotal) + }).ToList(), + Timeline = historyRows.Select(row => new MiniOrderDetailDto.MiniOrderTimelineDto + { + Status = row.Status, + StatusText = MapOrderStatusText(row.Status), + Notes = string.IsNullOrWhiteSpace(row.Notes) ? MapOrderStatusText(row.Status) : row.Notes, + OccurredAt = FormatDateTime(row.OccurredAt) + }).ToList() + }; + } + + /// + public async Task CreateOrderAsync(long tenantId, string customerName, string customerPhone, MiniCreateOrderRequest request, CancellationToken cancellationToken = default) + { + EnsureTenantResolved(tenantId); + if (string.IsNullOrWhiteSpace(customerPhone)) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "缺少顾客手机号"); + } + + // 1. 先复用试算逻辑生成订单草稿。 + var draft = await BuildDraftAsync(tenantId, request.StoreId, request.Scene, request.Items, cancellationToken); + var orderId = idGenerator.NextId(); + var now = DateTime.UtcNow; + var resolvedCustomerName = string.IsNullOrWhiteSpace(customerName) ? "临时顾客" : customerName.Trim(); + + // 2. 写入订单主表。 + var order = new Order + { + Id = orderId, + TenantId = tenantId, + OrderNo = $"T{orderId}", + StoreId = draft.StoreId, + Channel = OrderChannel.MiniProgram, + DeliveryType = draft.DeliveryType, + Status = OrderStatus.PendingPayment, + PaymentStatus = PaymentStatus.Unpaid, + CustomerName = resolvedCustomerName, + CustomerPhone = customerPhone.Trim(), + TableNo = request.Scene.Equals("DineIn", StringComparison.OrdinalIgnoreCase) ? (request.TableNo ?? string.Empty).Trim() : null, + ItemsAmount = draft.OriginalAmount, + DiscountAmount = draft.DiscountAmount, + PayableAmount = draft.PayableAmount, + PaidAmount = 0, + Remark = request.Remark?.Trim(), + CreatedAt = now, + UpdatedAt = now + }; + await orderRepository.AddAsync(order, cancellationToken); + + // 3. 写入订单明细。 + var orderItems = draft.Items.Select(item => new OrderItem + { + Id = idGenerator.NextId(), + TenantId = tenantId, + OrderId = orderId, + ProductId = item.ProductId, + ProductName = item.ProductName, + SkuName = item.SkuName, + Unit = item.Unit, + Quantity = item.Quantity, + UnitPrice = item.UnitPrice, + DiscountAmount = item.DiscountAmount, + SubTotal = item.SubTotal, + AttributesJson = item.AttributesJson, + CreatedAt = now, + UpdatedAt = now + }).ToList(); + await orderRepository.AddItemsAsync(orderItems, cancellationToken); + + // 4. 写入初始状态流转。 + await orderRepository.AddStatusHistoryAsync(new OrderStatusHistory + { + Id = idGenerator.NextId(), + TenantId = tenantId, + OrderId = orderId, + Status = OrderStatus.PendingPayment, + Notes = "下单", + OccurredAt = now, + CreatedAt = now, + UpdatedAt = now + }, cancellationToken); + + await orderRepository.SaveChangesAsync(cancellationToken); + + return new MiniCreateOrderResponse + { + OrderId = orderId.ToString(CultureInfo.InvariantCulture), + OrderNo = order.OrderNo, + StatusText = MapOrderStatusText((int)OrderStatus.PendingPayment), + PaymentStatusText = MapPaymentStatusText((int)PaymentStatus.Unpaid), + PayableAmount = draft.PayableAmount, + PayableAmountText = FormatMoney(draft.PayableAmount), + MockPayAvailable = true + }; + } + + /// + public async Task MockPayAsync(long tenantId, string orderId, string customerPhone, CancellationToken cancellationToken = default) + { + EnsureTenantResolved(tenantId); + if (string.IsNullOrWhiteSpace(customerPhone)) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "缺少顾客手机号"); + } + + // 1. 校验订单归属并查询可写实体。 + var orderIdValue = ParseId(orderId, nameof(orderId)); + var order = await orderRepository.FindAsync(tenantId, orderIdValue, cancellationToken); + if (order == null || !string.Equals(order.CustomerPhone, customerPhone.Trim(), StringComparison.Ordinal)) + { + throw new BusinessException(ErrorCodes.NotFound, "订单不存在"); + } + + if (order.PaymentStatus != PaymentStatus.Paid) + { + // 2. 更新支付状态与订单状态。 + var now = DateTime.UtcNow; + order.PaymentStatus = PaymentStatus.Paid; + order.Status = OrderStatus.AwaitingPreparation; + order.PaidAmount = order.PayableAmount; + order.PaidAt = now; + order.UpdatedAt = now; + + await orderRepository.AddStatusHistoryAsync(new OrderStatusHistory + { + Id = idGenerator.NextId(), + TenantId = tenantId, + OrderId = order.Id, + Status = OrderStatus.AwaitingPreparation, + Notes = "支付成功", + OccurredAt = now, + CreatedAt = now, + UpdatedAt = now + }, cancellationToken); + + await orderRepository.SaveChangesAsync(cancellationToken); + } + + return new MiniMockPayResponse + { + OrderId = order.Id.ToString(CultureInfo.InvariantCulture), + StatusText = MapOrderStatusText((int)order.Status), + PaymentStatusText = MapPaymentStatusText((int)order.PaymentStatus), + PaidAmount = order.PaidAmount, + PaidAmountText = FormatMoney(order.PaidAmount), + SuccessTip = "模拟支付成功,可在订单列表继续查看履约状态。" + }; + } + + private async Task BuildDraftAsync( + long tenantId, + string storeId, + string scene, + IReadOnlyList items, + CancellationToken cancellationToken) + { + EnsureTenantResolved(tenantId); + if (items.Count == 0) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "购物车不能为空"); + } + + var storeIdValue = ParseId(storeId, nameof(storeId)); + var sceneSpec = ParseScene(scene); + + // 1. 校验门店存在。 + await using var connection = OpenReadConnection(); + const string storeSql = """ +select "Id", "Name" +from public.stores +where "TenantId" = @TenantId + and "Id" = @StoreId + and "DeletedAt" is null + and "Status" = 2 + and "BusinessStatus" = 1 +limit 1; +"""; + var store = await connection.QueryFirstOrDefaultAsync(new CommandDefinition( + storeSql, + new { TenantId = tenantId, StoreId = storeIdValue }, + cancellationToken: cancellationToken)); + + if (store == null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在或不可用"); + } + + var lineItems = new List(); + var totalCount = 0; + decimal originalAmount = 0; + decimal packagingFee = 0; + decimal discountAmount = 0; + + // 2. 按购物车行逐条校验并生成价格草稿。 + foreach (var item in items) + { + if (item.Quantity <= 0) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "商品数量必须大于 0"); + } + + var productIdValue = ParseId(item.ProductId, nameof(item.ProductId)); + var productSql = $""" +select p."Id", + p."StoreId", + p."CategoryId", + p."Name", + coalesce(p."Subtitle", '') as "Subtitle", + coalesce(p."Description", '') as "Description", + coalesce(p."CoverImage", '') as "CoverImageUrl", + coalesce(p."GalleryImages", '[]') as "GalleryImages", + coalesce(p."Unit", '份') as "Unit", + p."Price", + p."OriginalPrice", + coalesce(p."SalesMonthly", 0) as "MonthlySales", + coalesce(p."TagsJson", '[]') as "TagsJson", + coalesce(p."PackingFee", 0) as "PackingFee", + coalesce(p."RemainStock", p."StockQuantity", 999999) as "StockQuantity", + exists( + select 1 + from public.product_skus sku + where sku."ProductId" = p."Id" + and sku."DeletedAt" is null + and sku."IsEnabled" = true + ) as "HasEnabledSkus" +from public.products p +where p."TenantId" = @TenantId + and p."StoreId" = @StoreId + and p."Id" = @ProductId + and p."DeletedAt" is null + and p."Status" = 1 + and p.{sceneSpec.ProductEnabledColumn} = true +limit 1; +"""; + var product = await connection.QueryFirstOrDefaultAsync(new CommandDefinition( + productSql, + new { TenantId = tenantId, StoreId = storeIdValue, ProductId = productIdValue }, + cancellationToken: cancellationToken)); + + if (product == null) + { + throw new BusinessException(ErrorCodes.NotFound, "商品不存在或当前场景不可售"); + } + + if (product.HasEnabledSkus && string.IsNullOrWhiteSpace(item.SkuId)) + { + throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{product.Name}】请先选择规格"); + } + + var selectedSkuOptionIds = new List(); + decimal unitPrice = product.Price; + decimal unitOriginalPrice = product.OriginalPrice ?? product.Price; + var skuName = string.Empty; + + // 3. 解析 SKU。 + if (!string.IsNullOrWhiteSpace(item.SkuId)) + { + var skuIdValue = ParseId(item.SkuId, nameof(item.SkuId)); + const string skuSql = """ +select sku."Id", sku."Price", sku."OriginalPrice", sku."StockQuantity", sku."AttributesJson", sku."IsEnabled" +from public.product_skus sku +where sku."ProductId" = @ProductId + and sku."Id" = @SkuId + and sku."DeletedAt" is null + and sku."IsEnabled" = true +limit 1; +"""; + var sku = await connection.QueryFirstOrDefaultAsync(new CommandDefinition( + skuSql, + new { ProductId = productIdValue, SkuId = skuIdValue }, + cancellationToken: cancellationToken)); + + if (sku == null) + { + throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{product.Name}】规格不存在"); + } + + if (sku.StockQuantity.HasValue && sku.StockQuantity.Value <= 0) + { + throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{product.Name}】规格库存不足"); + } + + unitPrice = sku.Price; + unitOriginalPrice = sku.OriginalPrice ?? product.OriginalPrice ?? sku.Price; + selectedSkuOptionIds = ParseSkuAttributeOptionIds(sku.AttributesJson); + + if (selectedSkuOptionIds.Count > 0) + { + const string skuOptionSql = """ +select "Id", "Name" +from public.product_spec_template_options +where "DeletedAt" is null + and "Id" = any(@OptionIds) +order by "Id" asc; +"""; + var skuOptionRows = (await connection.QueryAsync(new CommandDefinition( + skuOptionSql, + new { OptionIds = selectedSkuOptionIds.ToArray() }, + cancellationToken: cancellationToken))).ToList(); + skuName = string.Join("/", skuOptionRows.Select(row => row.Name)); + } + } + else if (product.StockQuantity.HasValue && product.StockQuantity.Value <= 0) + { + throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{product.Name}】库存不足"); + } + + // 4. 解析加料模板与加料项。 + const string addonTemplateSql = """ +select template."Id" as "TemplateId", + template."Name" as "TemplateName", + template."IsRequired", + template."MinSelect", + template."MaxSelect", + template."SelectionType" +from public.product_spec_template_products link +join public.product_spec_templates template + on template."Id" = link."TemplateId" + and template."DeletedAt" is null + and template."IsEnabled" = true + and template."TemplateType" = 2 +where link."TenantId" = @TenantId + and link."ProductId" = @ProductId + and link."DeletedAt" is null +order by template."SortOrder" asc, template."Id" asc; +"""; + var addonTemplates = (await connection.QueryAsync(new CommandDefinition( + addonTemplateSql, + new { TenantId = tenantId, ProductId = productIdValue }, + cancellationToken: cancellationToken))).ToList(); + + var addonIds = item.AddonItemIds + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => ParseId(value, nameof(item.AddonItemIds))) + .Distinct() + .ToArray(); + + var addonRows = addonIds.Length == 0 + ? [] + : (await connection.QueryAsync(new CommandDefinition( + """ +select distinct template."Id" as "TemplateId", + option."Id", + option."Name", + option."ExtraPrice", + option."Stock" +from public.product_spec_template_products link +join public.product_spec_templates template + on template."Id" = link."TemplateId" + and template."DeletedAt" is null + and template."IsEnabled" = true + and template."TemplateType" = 2 +join public.product_spec_template_options option + on option."TemplateId" = template."Id" + and option."DeletedAt" is null + and option."IsEnabled" = true +where link."TenantId" = @TenantId + and link."ProductId" = @ProductId + and link."DeletedAt" is null + and option."Id" = any(@OptionIds) +order by template."Id" asc, option."Id" asc; +""", + new { TenantId = tenantId, ProductId = productIdValue, OptionIds = addonIds }, + cancellationToken: cancellationToken))).ToList(); + + if (addonRows.Count != addonIds.Length) + { + throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{product.Name}】存在非法加料选项"); + } + + ValidateAddonSelections(product.Name, addonTemplates, addonRows); + + var addonPrice = addonRows.Sum(row => row.ExtraPrice); + var addonNames = addonRows.Select(row => row.Name).ToList(); + var actualUnitPrice = unitPrice + addonPrice; + var actualOriginalUnitPrice = unitOriginalPrice + addonPrice; + var lineOriginalAmount = actualOriginalUnitPrice * item.Quantity; + var lineActualAmount = actualUnitPrice * item.Quantity; + var lineDiscountAmount = Math.Max(lineOriginalAmount - lineActualAmount, 0); + originalAmount += lineOriginalAmount; + discountAmount += lineDiscountAmount; + packagingFee += product.PackingFee * item.Quantity; + totalCount += item.Quantity; + + lineItems.Add(new ResolvedOrderLine + { + ProductId = product.Id, + ProductName = product.Name, + Unit = product.Unit, + Quantity = item.Quantity, + UnitPrice = actualUnitPrice, + DiscountAmount = lineDiscountAmount, + SubTotal = lineActualAmount, + SkuName = MergeSkuName(skuName, addonNames), + AttributesJson = BuildAttributesJson(selectedSkuOptionIds, addonIds, addonNames) + }); + } + + // 5. 汇总订单级费用。 + var deliveryFee = sceneSpec.DeliveryType == DeliveryType.Delivery && totalCount > 0 ? 4m : 0m; + return new ResolvedDraft + { + StoreId = storeIdValue, + StoreName = store.Name, + Scene = sceneSpec.Scene, + DeliveryType = sceneSpec.DeliveryType, + TotalCount = totalCount, + OriginalAmount = originalAmount, + PackagingFee = packagingFee, + DeliveryFee = deliveryFee, + DiscountAmount = discountAmount, + PayableAmount = originalAmount + packagingFee + deliveryFee - discountAmount, + Items = lineItems + }; + } + + private static MiniPriceEstimateResponse BuildEstimateResponse(ResolvedDraft draft) + { + return new MiniPriceEstimateResponse + { + StoreId = draft.StoreId.ToString(CultureInfo.InvariantCulture), + Scene = draft.Scene, + TotalCount = draft.TotalCount, + OriginalAmount = draft.OriginalAmount, + OriginalAmountText = FormatMoney(draft.OriginalAmount), + PackagingFee = draft.PackagingFee, + PackagingFeeText = FormatMoney(draft.PackagingFee), + DeliveryFee = draft.DeliveryFee, + DeliveryFeeText = FormatMoney(draft.DeliveryFee), + DiscountAmount = draft.DiscountAmount, + DiscountAmountText = FormatMoney(draft.DiscountAmount), + PayableAmount = draft.PayableAmount, + PayableAmountText = FormatMoney(draft.PayableAmount) + }; + } + + private static void ValidateAddonSelections(string productName, IReadOnlyList templates, IReadOnlyList selectedOptions) + { + foreach (var template in templates) + { + var selectedCount = selectedOptions.Count(option => option.TemplateId == template.TemplateId); + if (template.IsRequired && selectedCount < Math.Max(template.MinSelect, 1)) + { + throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{productName}】缺少必选项【{template.TemplateName}】"); + } + + if (template.SelectionType == 0 && selectedCount > 1) + { + throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{productName}】选项【{template.TemplateName}】只能选择 1 项"); + } + + if (template.MaxSelect > 0 && selectedCount > template.MaxSelect) + { + throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{productName}】选项【{template.TemplateName}】超出可选上限"); + } + } + + if (selectedOptions.Any(option => option.Stock <= 0)) + { + throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{productName}】加料库存不足"); + } + } + + private NpgsqlConnection OpenReadConnection() + { + return new NpgsqlConnection(ResolveReadConnectionString()); + } + + private string ResolveReadConnectionString() + { + return configuration["Database:DataSources:AppDatabase:Reads:0"] + ?? configuration["Database:DataSources:AppDatabase:Write"] + ?? throw new InvalidOperationException("缺少 AppDatabase 连接配置。"); + } + + private static void EnsureTenantResolved(long tenantId) + { + if (tenantId <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识"); + } + } + + private static long ParseId(string value, string fieldName) + { + if (!long.TryParse(value, out var parsed) || parsed <= 0) + { + throw new BusinessException(ErrorCodes.ValidationFailed, $"参数【{fieldName}】格式不正确"); + } + + return parsed; + } + + private static SceneSpec ParseScene(string scene) + { + return scene.Trim() switch + { + "Delivery" => new SceneSpec("Delivery", DeliveryType.Delivery, "wm", '"' + "EnableDelivery" + '"'), + "Pickup" => new SceneSpec("Pickup", DeliveryType.Pickup, "pickup", '"' + "EnablePickup" + '"'), + "DineIn" => new SceneSpec("DineIn", DeliveryType.DineIn, "dine_in", '"' + "EnableDineIn" + '"'), + _ => throw new BusinessException(ErrorCodes.ValidationFailed, "不支持的场景类型") + }; + } + + private static IReadOnlyList BuildSupports(StoreRow row) + { + var supports = new List(); + if (row.SupportsDelivery) supports.Add("Delivery"); + if (row.SupportsPickup) supports.Add("Pickup"); + if (row.SupportsDineIn) supports.Add("DineIn"); + return supports; + } + + private static IReadOnlyList SplitTexts(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return []; + } + + var text = raw.Trim(); + if (text.StartsWith("[")) + { + try + { + return JsonSerializer.Deserialize>(text, JsonSerializerOptions)?.Where(item => !string.IsNullOrWhiteSpace(item)).ToList() ?? []; + } + catch + { + return []; + } + } + + return text.Split([',', ',', '|', '、'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static List ParseSkuAttributeOptionIds(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return []; + } + + try + { + var attributes = JsonSerializer.Deserialize>(raw, JsonSerializerOptions) ?? []; + return attributes.Where(item => item.OptionId > 0).Select(item => item.OptionId).ToList(); + } + catch + { + return []; + } + } + + private static string MergeSkuName(string skuName, IReadOnlyCollection addonNames) + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(skuName)) parts.Add(skuName); + if (addonNames.Count > 0) parts.Add(string.Join('、', addonNames)); + return string.Join(" / ", parts); + } + + private static string BuildAttributesJson(IReadOnlyCollection skuOptionIds, IReadOnlyCollection addonIds, IReadOnlyCollection addonNames) + { + return JsonSerializer.Serialize(new + { + skuOptionIds, + addonIds, + addonNames + }, JsonSerializerOptions); + } + + private static string MapGroupType(int templateType) => templateType switch + { + 0 => "spec", + 1 => "recipe", + 2 => "addon", + _ => "spec" + }; + + private static string MapSelectionType(int selectionType) => selectionType == 1 ? "multiple" : "single"; + + private static string MapOrderStatusText(int status) => status switch + { + 0 => "待支付", + 1 => "待制作", + 2 => "配送中", + 3 => "配送中", + 4 => "已完成", + 5 => "已取消", + _ => "处理中" + }; + + private static string MapPaymentStatusText(int paymentStatus) => paymentStatus switch + { + 0 => "未支付", + 1 => "支付中", + 2 => "已支付", + 3 => "支付失败", + 4 => "已退款", + _ => "未知" + }; + + private static string MapActionText(int status, int paymentStatus) + { + if (paymentStatus == 0 || status == 0) return "去支付"; + if (status == 4 || status == 5) return "再来一单"; + return "查看详情"; + } + + private static string MapSceneText(int deliveryType) => deliveryType switch + { + 2 => "Delivery", + 1 => "Pickup", + _ => "DineIn" + }; + + private static string BuildItemSummary(IReadOnlyList items) + { + if (items.Count == 0) return "暂无商品"; + if (items.Count == 1) return $"{items[0].ProductName} ×{items[0].Quantity}"; + return $"{items[0].ProductName} 等{items.Sum(item => item.Quantity)}件商品"; + } + + private static string FormatMoney(decimal value) => value.ToString("0.00", CultureInfo.InvariantCulture); + + private static string FormatDateTime(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); + + private sealed record SceneSpec(string Scene, DeliveryType DeliveryType, string CategoryChannelToken, string ProductEnabledColumn); + + private sealed class ResolvedDraft + { + public long StoreId { get; init; } + public string StoreName { get; init; } = string.Empty; + public string Scene { get; init; } = string.Empty; + public DeliveryType DeliveryType { get; init; } + public int TotalCount { get; init; } + public decimal OriginalAmount { get; init; } + public decimal PackagingFee { get; init; } + public decimal DeliveryFee { get; init; } + public decimal DiscountAmount { get; init; } + public decimal PayableAmount { get; init; } + public IReadOnlyList Items { get; init; } = []; + } + + private sealed class ResolvedOrderLine + { + public long ProductId { get; init; } + public string ProductName { get; init; } = string.Empty; + public string? SkuName { get; init; } + public string Unit { get; init; } = string.Empty; + public int Quantity { get; init; } + public decimal UnitPrice { get; init; } + public decimal DiscountAmount { get; init; } + public decimal SubTotal { get; init; } + public string AttributesJson { get; init; } = string.Empty; + } + + private sealed class StoreRow + { + public long Id { get; init; } + public string Name { get; init; } = string.Empty; + public string Address { get; init; } = string.Empty; + public string BusinessHours { get; init; } = string.Empty; + public string Tags { get; init; } = string.Empty; + public bool SupportsDelivery { get; init; } + public bool SupportsPickup { get; init; } + public bool SupportsDineIn { get; init; } + public int ProductCount { get; init; } + } + + private sealed class StoreNameRow + { + public long Id { get; init; } + public string Name { get; init; } = string.Empty; + } + + private sealed class MenuProductRow + { + public long CategoryId { get; init; } + public string CategoryName { get; init; } = string.Empty; + public long Id { get; init; } + public string Name { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string CoverImageUrl { get; init; } = string.Empty; + public decimal DisplayPrice { get; init; } + public decimal? DisplayOriginalPrice { get; init; } + public int SalesMonthly { get; init; } + public string TagsJson { get; init; } = string.Empty; + public bool HasOptions { get; init; } + public bool SoldOut { get; init; } + } + + private sealed class ProductCoreRow + { + public long Id { get; init; } + public long StoreId { get; init; } + public long CategoryId { get; init; } + public string Name { get; init; } = string.Empty; + public string Subtitle { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string CoverImageUrl { get; init; } = string.Empty; + public string GalleryImages { get; init; } = string.Empty; + public string Unit { get; init; } = string.Empty; + public decimal Price { get; init; } + public decimal? OriginalPrice { get; init; } + public int MonthlySales { get; init; } + public string TagsJson { get; init; } = string.Empty; + public decimal PackingFee { get; init; } + public int? StockQuantity { get; init; } + public bool HasEnabledSkus { get; init; } + } + + private sealed class TemplateOptionRow + { + public long TemplateId { get; init; } + public string TemplateName { get; init; } = string.Empty; + public int TemplateType { get; init; } + public int SelectionType { get; init; } + public bool IsRequired { get; init; } + public int MinSelect { get; init; } + public int MaxSelect { get; init; } + public int TemplateSortOrder { get; init; } + public long OptionId { get; init; } + public string OptionName { get; init; } = string.Empty; + public decimal ExtraPrice { get; init; } + public int Stock { get; init; } + public int OptionSortOrder { get; init; } + } + + private sealed class SkuRow + { + public long Id { get; init; } + public decimal Price { get; init; } + public decimal? OriginalPrice { get; init; } + public int? StockQuantity { get; init; } + public string AttributesJson { get; init; } = string.Empty; + public bool IsEnabled { get; init; } + } + + private sealed class OptionNameRow + { + public long Id { get; init; } + public string Name { get; init; } = string.Empty; + } + + private sealed class AddonTemplateRow + { + public long TemplateId { get; init; } + public string TemplateName { get; init; } = string.Empty; + public bool IsRequired { get; init; } + public int MinSelect { get; init; } + public int MaxSelect { get; init; } + public int SelectionType { get; init; } + } + + private sealed class AddonOptionRow + { + public long TemplateId { get; init; } + public long Id { get; init; } + public string Name { get; init; } = string.Empty; + public decimal ExtraPrice { get; init; } + public int Stock { get; init; } + } + + private sealed class OrderRow + { + public long Id { get; init; } + public string OrderNo { get; init; } = string.Empty; + public long StoreId { get; init; } + public string StoreName { get; init; } = string.Empty; + public int Status { get; init; } + public int PaymentStatus { get; init; } + public int DeliveryType { get; init; } + public string CustomerName { get; init; } = string.Empty; + public string CustomerPhone { get; init; } = string.Empty; + public string TableNo { get; init; } = string.Empty; + public string Remark { get; init; } = string.Empty; + public decimal ItemsAmount { get; init; } + public decimal DiscountAmount { get; init; } + public decimal PayableAmount { get; init; } + public decimal PaidAmount { get; init; } + public DateTime CreatedAt { get; init; } + public DateTime? PaidAt { get; init; } + } + + private sealed class OrderItemRow + { + public long Id { get; init; } + public long OrderId { get; init; } + public long ProductId { get; init; } + public string ProductName { get; init; } = string.Empty; + public string SkuName { get; init; } = string.Empty; + public string Unit { get; init; } = string.Empty; + public int Quantity { get; init; } + public decimal UnitPrice { get; init; } + public decimal SubTotal { get; init; } + } + + private sealed class OrderHistoryRow + { + public int Status { get; init; } + public string Notes { get; init; } = string.Empty; + public DateTime OccurredAt { get; init; } + } + + private sealed class SkuAttributeItem + { + public long TemplateId { get; init; } + public long OptionId { get; init; } + } +} + diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/MiniOrderDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/MiniOrderDbContext.cs new file mode 100644 index 0000000..19b4f41 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/MiniOrderDbContext.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Orders.Entities; + +namespace TakeoutSaaS.Infrastructure.App.Persistence; + +/// +/// 小程序订单写库上下文。 +/// +public sealed class MiniOrderDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + public DbSet OrderStatusHistories => Set(); + + /// + /// 模型配置。 + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var orderBuilder = modelBuilder.Entity(); + orderBuilder.ToTable("orders"); + orderBuilder.HasKey(x => x.Id); + orderBuilder.Property(x => x.OrderNo).HasMaxLength(64).IsRequired(); + orderBuilder.Property(x => x.Channel).HasConversion(); + orderBuilder.Property(x => x.DeliveryType).HasConversion(); + orderBuilder.Property(x => x.Status).HasConversion(); + orderBuilder.Property(x => x.PaymentStatus).HasConversion(); + orderBuilder.Property(x => x.ItemsAmount).HasPrecision(18, 2); + orderBuilder.Property(x => x.DiscountAmount).HasPrecision(18, 2); + orderBuilder.Property(x => x.PayableAmount).HasPrecision(18, 2); + orderBuilder.Property(x => x.PaidAmount).HasPrecision(18, 2); + orderBuilder.Property(x => x.CustomerName).HasMaxLength(64); + orderBuilder.Property(x => x.CustomerPhone).HasMaxLength(32); + orderBuilder.Property(x => x.TableNo).HasMaxLength(64); + orderBuilder.Property(x => x.QueueNumber).HasMaxLength(64); + orderBuilder.Property(x => x.CancelReason).HasMaxLength(256); + orderBuilder.Property(x => x.Remark).HasMaxLength(512); + + var itemBuilder = modelBuilder.Entity(); + itemBuilder.ToTable("order_items"); + itemBuilder.HasKey(x => x.Id); + itemBuilder.Property(x => x.ProductName).HasMaxLength(128).IsRequired(); + itemBuilder.Property(x => x.SkuName).HasMaxLength(256); + itemBuilder.Property(x => x.Unit).HasMaxLength(32); + itemBuilder.Property(x => x.UnitPrice).HasPrecision(18, 2); + itemBuilder.Property(x => x.DiscountAmount).HasPrecision(18, 2); + itemBuilder.Property(x => x.SubTotal).HasPrecision(18, 2); + itemBuilder.Property(x => x.AttributesJson).HasColumnType("text"); + itemBuilder.HasOne() + .WithMany() + .HasForeignKey(x => x.OrderId) + .OnDelete(DeleteBehavior.Cascade); + + var historyBuilder = modelBuilder.Entity(); + historyBuilder.ToTable("order_status_histories"); + historyBuilder.HasKey(x => x.Id); + historyBuilder.Property(x => x.Status).HasConversion(); + historyBuilder.Property(x => x.Notes).HasMaxLength(256); + historyBuilder.HasOne() + .WithMany() + .HasForeignKey(x => x.OrderId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs new file mode 100644 index 0000000..63ba94b --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Orders.Entities; +using TakeoutSaaS.Domain.Orders.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 订单写仓储 EF 实现。 +/// +public sealed class EfOrderRepository(MiniOrderDbContext dbContext) : IOrderRepository +{ + /// + public Task AddAsync(Order order, CancellationToken cancellationToken = default) + { + _ = cancellationToken; + dbContext.Orders.Add(order); + return Task.CompletedTask; + } + + /// + public Task AddItemsAsync(IReadOnlyCollection items, CancellationToken cancellationToken = default) + { + _ = cancellationToken; + if (items.Count > 0) + { + dbContext.OrderItems.AddRange(items); + } + + return Task.CompletedTask; + } + + /// + public Task AddStatusHistoryAsync(OrderStatusHistory history, CancellationToken cancellationToken = default) + { + _ = cancellationToken; + dbContext.OrderStatusHistories.Add(history); + return Task.CompletedTask; + } + + /// + public Task FindAsync(long tenantId, long orderId, CancellationToken cancellationToken = default) + { + return dbContext.Orders.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == orderId && x.DeletedAt == null, cancellationToken); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Tenancy/DatabaseTenantCodeResolver.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Tenancy/DatabaseTenantCodeResolver.cs new file mode 100644 index 0000000..573c5d2 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Tenancy/DatabaseTenantCodeResolver.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Configuration; +using Dapper; +using Npgsql; +using TakeoutSaaS.Module.Tenancy; + +namespace TakeoutSaaS.Infrastructure.App.Tenancy; + +/// +/// 基于业务库的租户编码解析器。 +/// +public sealed class DatabaseTenantCodeResolver(IConfiguration configuration) : ITenantCodeResolver +{ + /// + public async Task ResolveAsync(string code, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(code)) + { + return null; + } + + var connectionString = configuration["Database:DataSources:AppDatabase:Reads:0"] + ?? configuration["Database:DataSources:AppDatabase:Write"]; + + if (string.IsNullOrWhiteSpace(connectionString)) + { + return null; + } + + await using var connection = new NpgsqlConnection(connectionString); + const string sql = """ +select "Id" +from public.tenants +where "DeletedAt" is null + and "Code" = @Code +limit 1; +"""; + + return await connection.ExecuteScalarAsync(new CommandDefinition( + sql, + new { Code = code.Trim() }, + cancellationToken: cancellationToken)); + } +} + diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj index 3167dba..052be42 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj @@ -5,12 +5,18 @@ enable - - + + + + + + + +