Compare commits
90 Commits
f61554fc08
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ba8c0732b | |||
| f7eba55039 | |||
| fdbefca650 | |||
| 4a7d012a58 | |||
| d330db84fc | |||
| c79e9bd6e8 | |||
| 3f308c2d0c | |||
| 5dfaac01fd | |||
| 21a689edec | |||
| fa6e376b86 | |||
| 76366cbc30 | |||
| b0bb87d97c | |||
| b5aa060faf | |||
| 39e28c1a62 | |||
| dd2ac79d48 | |||
| bd418c5927 | |||
| a8cfda88f7 | |||
| e4f7ceeaa7 | |||
| d437b146d1 | |||
| 2970134200 | |||
| d96ca4971a | |||
| c2821202c7 | |||
| 26afffd874 | |||
| a993b81aeb | |||
| 1b28fa6db4 | |||
| decfa4fa12 | |||
| 3b3bdcee71 | |||
| 6588c85f27 | |||
| c9e2226b48 | |||
| 0f3542f33f | |||
| 5a6da9be0c | |||
| dda3f96d28 | |||
| 04e76cd519 | |||
| 3c423f87d4 | |||
| 1e5f0b2f93 | |||
| bb2ff6167e | |||
| 7c06ac3e29 | |||
| 502f80473e | |||
| b1c51c7712 | |||
| 11a1521b6a | |||
| dd90bdfe0f | |||
| 3f5ca9c3ee | |||
| 8f64eb897b | |||
| 7d3542735b | |||
| 77caac3af9 | |||
| aeef4ca649 | |||
| 18af62e111 | |||
| 474c0c88c0 | |||
| b970cd4ba7 | |||
| 21d7be5a4f | |||
| 5fcc1e1484 | |||
| c2a6cf7b1e | |||
| 2c7981b6d0 | |||
| d66879f5cf | |||
| f7c2ae4bac | |||
| d41f69045f | |||
| ad65ef3bf6 | |||
| 93bc072b8d | |||
| 848778b8b5 | |||
| 392d9f03a1 | |||
| 1b3525862a | |||
| eea8a53da3 | |||
| 237e0a1a9f | |||
| 750346fdb2 | |||
| 48cf852d46 | |||
| adac05521a | |||
| 8a6fc867c2 | |||
| 938da7469c | |||
| f2142de407 | |||
| 9651022261 | |||
| 669db14f64 | |||
| 069d7de05e | |||
| 145be01bfc | |||
| 787ebe6b73 | |||
| 7ecf069efd | |||
| 53f7c54c82 | |||
| ad245078a2 | |||
| d5b22a8d85 | |||
| 2c086d1149 | |||
| c853fd8edf | |||
| 8139c08b35 | |||
| dfa2b3ee52 | |||
| be701d8cf6 | |||
| b3429e2a0d | |||
| 1b185af718 | |||
| 3a94348cca | |||
| c032608a57 | |||
| 654b1ae3f7 | |||
| 992930a821 | |||
| 2711893474 |
@@ -1,4 +1,4 @@
|
||||
name: Build and Deploy TenantApi
|
||||
name: Build and Deploy TenantApi + SkuWorker
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -30,18 +30,34 @@ jobs:
|
||||
- name: Build on host
|
||||
run: |
|
||||
cd /opt/deploy/tenantapi
|
||||
dotnet restore src/Api/TakeoutSaaS.TenantApi/TakeoutSaaS.TenantApi.csproj
|
||||
dotnet restore TakeoutSaaS.sln
|
||||
dotnet publish src/Api/TakeoutSaaS.TenantApi/TakeoutSaaS.TenantApi.csproj -c Release -o /opt/deploy/tenantapi/publish --no-restore
|
||||
dotnet publish src/Worker/TakeoutSaaS.SkuWorker/TakeoutSaaS.SkuWorker.csproj -c Release -o /opt/deploy/tenantapi/publish-worker --no-restore
|
||||
|
||||
- name: Build Docker image
|
||||
- name: Apply database migrations
|
||||
run: |
|
||||
cd /opt/deploy/tenantapi
|
||||
export ASPNETCORE_ENVIRONMENT=Development
|
||||
export TAKEOUTSAAS_APPSETTINGS_DIR=/opt/deploy/tenantapi/src/Api/TakeoutSaaS.TenantApi
|
||||
dotnet tool restore
|
||||
dotnet tool run dotnet-ef database update \
|
||||
--context TakeoutAppDbContext \
|
||||
--project src/Infrastructure/TakeoutSaaS.Infrastructure \
|
||||
--startup-project src/Infrastructure/TakeoutSaaS.Infrastructure
|
||||
|
||||
- name: Build Docker images
|
||||
run: |
|
||||
cd /opt/deploy/tenantapi
|
||||
docker build -t takeoutsaas-tenantapi:latest -f src/Api/TakeoutSaaS.TenantApi/Dockerfile .
|
||||
docker build -t takeoutsaas-skuworker:latest -f src/Worker/TakeoutSaaS.SkuWorker/Dockerfile .
|
||||
|
||||
- name: Deploy container
|
||||
- name: Deploy containers
|
||||
run: |
|
||||
docker stop tenantapi || true
|
||||
docker rm tenantapi || true
|
||||
docker stop skuworker || true
|
||||
docker rm skuworker || true
|
||||
|
||||
docker run -d \
|
||||
--name tenantapi \
|
||||
--restart unless-stopped \
|
||||
@@ -49,6 +65,12 @@ jobs:
|
||||
-e ASPNETCORE_ENVIRONMENT=Development \
|
||||
takeoutsaas-tenantapi:latest
|
||||
|
||||
docker run -d \
|
||||
--name skuworker \
|
||||
--restart unless-stopped \
|
||||
-e ASPNETCORE_ENVIRONMENT=Development \
|
||||
takeoutsaas-skuworker:latest
|
||||
|
||||
- name: Clean up old images
|
||||
run: |
|
||||
docker image prune -f
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,10 +1,19 @@
|
||||
.vs/
|
||||
.vscode/
|
||||
.idea/
|
||||
bin/
|
||||
obj/
|
||||
**/bin/
|
||||
**/obj/
|
||||
.claude/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
*.suo
|
||||
*.user
|
||||
packages/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj.user
|
||||
|
||||
# 保留根目录 scripts 目录提交
|
||||
|
||||
Submodule TakeoutSaaS.Docs updated: de7aefd0ff...6daa444c5e
@@ -47,6 +47,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Sms", "s
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.TenantApi", "src\Api\TakeoutSaaS.TenantApi\TakeoutSaaS.TenantApi.csproj", "{F53E274A-838A-477A-8D29-6EEB0DBD62CD}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Worker", "Worker", "{89BA21D6-604E-9DA1-5F6C-9062FD58212E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.SkuWorker", "src\Worker\TakeoutSaaS.SkuWorker\TakeoutSaaS.SkuWorker.csproj", "{1482D41E-4F00-4B21-8CC0-9025FA41E5E3}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -56,7 +60,7 @@ Global
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
@@ -222,12 +226,12 @@ Global
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
@@ -237,6 +241,18 @@ Global
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1482D41E-4F00-4B21-8CC0-9025FA41E5E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1482D41E-4F00-4B21-8CC0-9025FA41E5E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1482D41E-4F00-4B21-8CC0-9025FA41E5E3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1482D41E-4F00-4B21-8CC0-9025FA41E5E3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1482D41E-4F00-4B21-8CC0-9025FA41E5E3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1482D41E-4F00-4B21-8CC0-9025FA41E5E3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1482D41E-4F00-4B21-8CC0-9025FA41E5E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1482D41E-4F00-4B21-8CC0-9025FA41E5E3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1482D41E-4F00-4B21-8CC0-9025FA41E5E3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1482D41E-4F00-4B21-8CC0-9025FA41E5E3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1482D41E-4F00-4B21-8CC0-9025FA41E5E3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1482D41E-4F00-4B21-8CC0-9025FA41E5E3}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -259,9 +275,11 @@ Global
|
||||
{05058F44-6FB7-43AF-8648-8BF538E283EF} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{5C12177E-6C25-4F78-BFD4-AA073CFC0650} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{5ADA37B6-09A0-48F7-8633-C266FD5BBFD1} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{FE49A9E7-1228-45BA-9B71-337AA353FE98} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{9C2F510E-4054-482D-AFD3-D2E374D60304} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD} = {81034408-37C8-1011-444E-4C15C2FADA8E}
|
||||
EndGlobalSection
|
||||
{FE49A9E7-1228-45BA-9B71-337AA353FE98} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{9C2F510E-4054-482D-AFD3-D2E374D60304} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD} = {81034408-37C8-1011-444E-4C15C2FADA8E}
|
||||
{89BA21D6-604E-9DA1-5F6C-9062FD58212E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{1482D41E-4F00-4B21-8CC0-9025FA41E5E3} = {89BA21D6-604E-9DA1-5F6C-9062FD58212E}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
27
src/Api/TakeoutSaaS.TenantApi/Auth/SignalRJwtEvents.cs
Normal file
27
src/Api/TakeoutSaaS.TenantApi/Auth/SignalRJwtEvents.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR WebSocket 连接的 JWT 认证事件处理。
|
||||
/// </summary>
|
||||
public static class SignalRJwtEvents
|
||||
{
|
||||
/// <summary>
|
||||
/// 从 query string 提取 access_token 供 SignalR Hub 认证使用。
|
||||
/// </summary>
|
||||
public static Task OnMessageReceived(MessageReceivedContext context)
|
||||
{
|
||||
// 1. 仅对 Hub 路径生效
|
||||
if (context.Request.Path.StartsWithSegments("/hubs"))
|
||||
{
|
||||
var token = context.Request.Query["access_token"];
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
context.Token = token;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using MassTransit;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using TakeoutSaaS.Application.Messaging.Events;
|
||||
using TakeoutSaaS.TenantApi.Hubs;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Consumers;
|
||||
|
||||
/// <summary>
|
||||
/// 订单创建事件消费者 — 推送新订单到看板。
|
||||
/// </summary>
|
||||
public sealed class OrderCreatedConsumer(IHubContext<OrderBoardHub> hubContext)
|
||||
: IConsumer<OrderCreatedEvent>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
|
||||
{
|
||||
var e = context.Message;
|
||||
var group = $"store:{e.TenantId}:{e.StoreId}";
|
||||
|
||||
// 1. 推送新订单到对应门店 Group
|
||||
await hubContext.Clients.Group(group).SendAsync("NewOrder", new
|
||||
{
|
||||
e.OrderId,
|
||||
e.OrderNo,
|
||||
e.Amount,
|
||||
e.StoreId,
|
||||
e.Channel,
|
||||
e.DeliveryType,
|
||||
e.CustomerName,
|
||||
e.ItemsSummary,
|
||||
e.TableNo,
|
||||
e.CreatedAt
|
||||
}, context.CancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using MassTransit;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using TakeoutSaaS.Application.Messaging.Events;
|
||||
using TakeoutSaaS.TenantApi.Hubs;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Consumers;
|
||||
|
||||
/// <summary>
|
||||
/// 订单状态变更事件消费者 — 推送状态更新到看板。
|
||||
/// </summary>
|
||||
public sealed class OrderStatusChangedConsumer(IHubContext<OrderBoardHub> hubContext)
|
||||
: IConsumer<OrderStatusChangedEvent>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task Consume(ConsumeContext<OrderStatusChangedEvent> context)
|
||||
{
|
||||
var e = context.Message;
|
||||
var group = $"store:{e.TenantId}:{e.StoreId}";
|
||||
|
||||
// 1. 推送状态变更到对应门店 Group
|
||||
await hubContext.Clients.Group(group).SendAsync("OrderStatusChanged", new
|
||||
{
|
||||
e.OrderId,
|
||||
e.OrderNo,
|
||||
e.OldStatus,
|
||||
e.NewStatus,
|
||||
e.Channel,
|
||||
e.DeliveryType,
|
||||
e.CustomerName,
|
||||
e.ItemsSummary,
|
||||
e.PaidAmount,
|
||||
e.OccurredAt
|
||||
}, context.CancellationToken);
|
||||
}
|
||||
}
|
||||
29
src/Api/TakeoutSaaS.TenantApi/Consumers/OrderUrgeConsumer.cs
Normal file
29
src/Api/TakeoutSaaS.TenantApi/Consumers/OrderUrgeConsumer.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using MassTransit;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using TakeoutSaaS.Application.Messaging.Events;
|
||||
using TakeoutSaaS.TenantApi.Hubs;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Consumers;
|
||||
|
||||
/// <summary>
|
||||
/// 订单催单事件消费者 — 推送催单通知到看板。
|
||||
/// </summary>
|
||||
public sealed class OrderUrgeConsumer(IHubContext<OrderBoardHub> hubContext)
|
||||
: IConsumer<OrderUrgeEvent>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task Consume(ConsumeContext<OrderUrgeEvent> context)
|
||||
{
|
||||
var e = context.Message;
|
||||
var group = $"store:{e.TenantId}:{e.StoreId}";
|
||||
|
||||
// 1. 推送催单通知到对应门店 Group
|
||||
await hubContext.Clients.Group(group).SendAsync("OrderUrged", new
|
||||
{
|
||||
e.OrderId,
|
||||
e.OrderNo,
|
||||
e.UrgeCount,
|
||||
e.OccurredAt
|
||||
}, context.CancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using MassTransit;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using TakeoutSaaS.Application.Messaging.Events;
|
||||
using TakeoutSaaS.TenantApi.Hubs;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Consumers;
|
||||
|
||||
/// <summary>
|
||||
/// 支付成功事件消费者 — 推送新订单到看板。
|
||||
/// </summary>
|
||||
public sealed class PaymentSucceededConsumer(IHubContext<OrderBoardHub> hubContext)
|
||||
: IConsumer<PaymentSucceededEvent>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task Consume(ConsumeContext<PaymentSucceededEvent> context)
|
||||
{
|
||||
var e = context.Message;
|
||||
var group = $"store:{e.TenantId}:{e.StoreId}";
|
||||
|
||||
// 1. 支付成功 = 新订单出现在待接单列,推送 NewOrder
|
||||
await hubContext.Clients.Group(group).SendAsync("NewOrder", new
|
||||
{
|
||||
e.OrderId,
|
||||
e.OrderNo,
|
||||
e.Amount,
|
||||
e.StoreId,
|
||||
e.Channel,
|
||||
e.DeliveryType,
|
||||
e.CustomerName,
|
||||
e.ItemsSummary,
|
||||
e.PaidAt
|
||||
}, context.CancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Customer;
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析总览请求。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisOverviewRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期(7d/30d/90d/365d)。
|
||||
/// </summary>
|
||||
public string? Period { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客群明细筛选请求。
|
||||
/// </summary>
|
||||
public class CustomerAnalysisSegmentFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期(7d/30d/90d/365d)。
|
||||
/// </summary>
|
||||
public string? Period { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(姓名/手机号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客群明细分页请求。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisSegmentListRequest : CustomerAnalysisSegmentFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员详情请求。
|
||||
/// </summary>
|
||||
public sealed class CustomerMemberDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户标识(手机号归一化)。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析导出请求。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期(7d/30d/90d/365d)。
|
||||
/// </summary>
|
||||
public string? Period { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析趋势点响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisTrendPointResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度标签。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数量值。
|
||||
/// </summary>
|
||||
public int Value { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新老客构成项响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisCompositionItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分群名称。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 人数。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(百分比)。
|
||||
/// </summary>
|
||||
public decimal Percent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 色调。
|
||||
/// </summary>
|
||||
public string Tone { get; set; } = "blue";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客单价分布项响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisAmountDistributionItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 区间标签。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 人数。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(百分比)。
|
||||
/// </summary>
|
||||
public decimal Percent { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RFM 分层单元响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisRfmCellResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 人数。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 温度(hot/warm/cool/cold)。
|
||||
/// </summary>
|
||||
public string Tone { get; set; } = "cold";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RFM 分层行响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisRfmRowResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 行标签。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 单元格集合。
|
||||
/// </summary>
|
||||
public List<CustomerAnalysisRfmCellResponse> Cells { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 高价值客户响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisTopCustomerResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 排名。
|
||||
/// </summary>
|
||||
public int Rank { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户标识。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string PhoneMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下单次数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近下单时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string LastOrderAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public List<CustomerTagResponse> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析总览响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisOverviewResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计周期编码。
|
||||
/// </summary>
|
||||
public string PeriodCode { get; set; } = "30d";
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期天数。
|
||||
/// </summary>
|
||||
public int PeriodDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 客户总数。
|
||||
/// </summary>
|
||||
public int TotalCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期新增客户数。
|
||||
/// </summary>
|
||||
public int NewCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 新增较上一周期增长百分比。
|
||||
/// </summary>
|
||||
public decimal GrowthRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期内日均新增客户。
|
||||
/// </summary>
|
||||
public decimal NewCustomersDailyAverage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活跃客户数。
|
||||
/// </summary>
|
||||
public int ActiveCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活跃率(百分比)。
|
||||
/// </summary>
|
||||
public decimal ActiveRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均客户价值。
|
||||
/// </summary>
|
||||
public decimal AverageLifetimeValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户增长趋势。
|
||||
/// </summary>
|
||||
public List<CustomerAnalysisTrendPointResponse> GrowthTrend { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 新老客占比。
|
||||
/// </summary>
|
||||
public List<CustomerAnalysisCompositionItemResponse> Composition { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 客单价分布。
|
||||
/// </summary>
|
||||
public List<CustomerAnalysisAmountDistributionItemResponse> AmountDistribution { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// RFM 分层。
|
||||
/// </summary>
|
||||
public List<CustomerAnalysisRfmRowResponse> RfmRows { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 高价值客户 Top10。
|
||||
/// </summary>
|
||||
public List<CustomerAnalysisTopCustomerResponse> TopCustomers { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客群明细行响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisSegmentListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户标识。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string PhoneMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像文案。
|
||||
/// </summary>
|
||||
public string AvatarText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像颜色。
|
||||
/// </summary>
|
||||
public string AvatarColor { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public List<CustomerTagResponse> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 是否会员。
|
||||
/// </summary>
|
||||
public bool IsMember { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级。
|
||||
/// </summary>
|
||||
public string MemberTierName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下单次数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string RegisteredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 最近下单时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string LastOrderAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化显示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客群明细分页响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisSegmentListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分群标题。
|
||||
/// </summary>
|
||||
public string SegmentTitle { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分群说明。
|
||||
/// </summary>
|
||||
public string SegmentDescription { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<CustomerAnalysisSegmentListItemResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 当前页。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总记录数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员详情响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerMemberDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户标识。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string PhoneMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
public string Source { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string RegisteredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 最近下单时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string LastOrderAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员摘要。
|
||||
/// </summary>
|
||||
public CustomerMemberSummaryResponse Member { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public List<CustomerTagResponse> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 累计下单次数。
|
||||
/// </summary>
|
||||
public int TotalOrders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 复购率(百分比)。
|
||||
/// </summary>
|
||||
public decimal RepurchaseRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近订单。
|
||||
/// </summary>
|
||||
public List<CustomerRecentOrderResponse> RecentOrders { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Customer;
|
||||
|
||||
/// <summary>
|
||||
/// 客户列表筛选请求。
|
||||
/// </summary>
|
||||
public class CustomerListFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(姓名/手机号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签(high_value/active/dormant/churn/new_customer)。
|
||||
/// </summary>
|
||||
public string? Tag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下单次数区间(once/two_to_five/six_to_ten/ten_plus)。
|
||||
/// </summary>
|
||||
public string? OrderCountRange { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册周期(7/30/90 或 7d/30d/90d)。
|
||||
/// </summary>
|
||||
public string? RegisterPeriod { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户列表分页请求。
|
||||
/// </summary>
|
||||
public sealed class CustomerListRequest : CustomerListFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户详情请求。
|
||||
/// </summary>
|
||||
public sealed class CustomerDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户标识(手机号归一化)。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户画像请求。
|
||||
/// </summary>
|
||||
public sealed class CustomerProfileRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户标识(手机号归一化)。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerTagResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 标签编码。
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签文案。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签色调(orange/blue/green/gray/red)。
|
||||
/// </summary>
|
||||
public string Tone { get; set; } = "blue";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户列表行响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户标识(手机号归一化)。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string PhoneMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像文案。
|
||||
/// </summary>
|
||||
public string AvatarText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像颜色。
|
||||
/// </summary>
|
||||
public string AvatarColor { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 下单次数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下单次数条形宽度百分比。
|
||||
/// </summary>
|
||||
public int OrderCountBarPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近下单时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string LastOrderAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public List<CustomerTagResponse> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户列表响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<CustomerListItemResponse> 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 sealed class CustomerListStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户总数。
|
||||
/// </summary>
|
||||
public int TotalCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月新增客户数。
|
||||
/// </summary>
|
||||
public int MonthlyNewCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月较上月增长百分比。
|
||||
/// </summary>
|
||||
public decimal MonthlyGrowthRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活跃客户数(近 30 天有下单)。
|
||||
/// </summary>
|
||||
public int ActiveCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 近 30 天客均消费(按订单均值)。
|
||||
/// </summary>
|
||||
public decimal AverageAmountLast30Days { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户偏好响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerPreferenceResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 偏好品类。
|
||||
/// </summary>
|
||||
public List<string> PreferredCategories { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 偏好下单时段。
|
||||
/// </summary>
|
||||
public string PreferredOrderPeaks { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 偏好履约方式。
|
||||
/// </summary>
|
||||
public string PreferredDelivery { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 偏好支付方式。
|
||||
/// </summary>
|
||||
public string PreferredPaymentMethod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 平均配送距离文案(当前无配送距离数据时返回空字符串)。
|
||||
/// </summary>
|
||||
public string AverageDeliveryDistance { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户常购商品响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerTopProductResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 排名。
|
||||
/// </summary>
|
||||
public int Rank { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 购买次数。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(0-100)。
|
||||
/// </summary>
|
||||
public decimal ProportionPercent { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户月度趋势响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerTrendPointResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 月份标签。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 消费金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户最近订单响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerRecentOrderResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订单金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品摘要。
|
||||
/// </summary>
|
||||
public string ItemsSummary { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 履约方式。
|
||||
/// </summary>
|
||||
public string DeliveryType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订单状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 下单时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string OrderedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户会员摘要响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerMemberSummaryResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否会员。
|
||||
/// </summary>
|
||||
public bool IsMember { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级名称。
|
||||
/// </summary>
|
||||
public string TierName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分余额。
|
||||
/// </summary>
|
||||
public int PointsBalance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成长值。
|
||||
/// </summary>
|
||||
public int GrowthValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入会时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string JoinedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户详情响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户标识。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string PhoneMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string RegisteredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 首次下单时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string FirstOrderAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户来源。
|
||||
/// </summary>
|
||||
public string Source { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public List<CustomerTagResponse> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 会员摘要。
|
||||
/// </summary>
|
||||
public CustomerMemberSummaryResponse Member { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 累计下单次数。
|
||||
/// </summary>
|
||||
public int TotalOrders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 复购率(百分比)。
|
||||
/// </summary>
|
||||
public decimal RepurchaseRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消费偏好。
|
||||
/// </summary>
|
||||
public CustomerPreferenceResponse Preference { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 常购商品 Top 5。
|
||||
/// </summary>
|
||||
public List<CustomerTopProductResponse> TopProducts { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 6 月消费趋势。
|
||||
/// </summary>
|
||||
public List<CustomerTrendPointResponse> Trend { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 最近订单(最多 3 条)。
|
||||
/// </summary>
|
||||
public List<CustomerRecentOrderResponse> RecentOrders { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户画像响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerProfileResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户标识。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string PhoneMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string RegisteredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 首次下单时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string FirstOrderAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户来源。
|
||||
/// </summary>
|
||||
public string Source { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public List<CustomerTagResponse> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 会员摘要。
|
||||
/// </summary>
|
||||
public CustomerMemberSummaryResponse Member { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 累计下单次数。
|
||||
/// </summary>
|
||||
public int TotalOrders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 复购率(百分比)。
|
||||
/// </summary>
|
||||
public decimal RepurchaseRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均下单间隔(天)。
|
||||
/// </summary>
|
||||
public decimal AverageOrderIntervalDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消费偏好。
|
||||
/// </summary>
|
||||
public CustomerPreferenceResponse Preference { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 常购商品 Top 5。
|
||||
/// </summary>
|
||||
public List<CustomerTopProductResponse> TopProducts { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 12 月消费趋势。
|
||||
/// </summary>
|
||||
public List<CustomerTrendPointResponse> Trend { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 最近订单(最多 5 条)。
|
||||
/// </summary>
|
||||
public List<CustomerRecentOrderResponse> RecentOrders { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户导出响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerExportResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件 Base64。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表列表请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型(daily/weekly/monthly)。
|
||||
/// </summary>
|
||||
public string? PeriodType { get; set; } = "daily";
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表详情请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public string ReportId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表批量导出请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportBatchExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型(daily/weekly/monthly)。
|
||||
/// </summary>
|
||||
public string? PeriodType { get; set; } = "daily";
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表列表行响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public string ReportId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 日期文案。
|
||||
/// </summary>
|
||||
public string DateText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 营业额。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageOrderValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款率(百分数)。
|
||||
/// </summary>
|
||||
public decimal RefundRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本总额。
|
||||
/// </summary>
|
||||
public decimal CostTotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 净利润。
|
||||
/// </summary>
|
||||
public decimal NetProfitAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 利润率(百分数)。
|
||||
/// </summary>
|
||||
public decimal ProfitRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否可下载。
|
||||
/// </summary>
|
||||
public bool CanDownload { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表列表响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportListItemResponse> 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>
|
||||
/// KPI 响应项。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportKpiResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 指标键。
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 指标名称。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 指标值文案。
|
||||
/// </summary>
|
||||
public string ValueText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 同比变化率(百分数)。
|
||||
/// </summary>
|
||||
public decimal YoyChangeRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 环比变化率(百分数)。
|
||||
/// </summary>
|
||||
public decimal MomChangeRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 明细行响应项。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportBreakdownItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细键。
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(百分数)。
|
||||
/// </summary>
|
||||
public decimal RatioPercent { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表详情响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public string ReportId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型编码。
|
||||
/// </summary>
|
||||
public string PeriodType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// KPI 列表。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportKpiResponse> Kpis { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 收入明细(按渠道)。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportBreakdownItemResponse> IncomeBreakdowns { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细(按类别)。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportBreakdownItemResponse> CostBreakdowns { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表导出响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportExportResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base64 文件内容。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总记录数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 成本模块通用作用域请求。
|
||||
/// </summary>
|
||||
public class FinanceCostScopeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度(tenant/store)。
|
||||
/// </summary>
|
||||
public string? Dimension { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度必填)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string? Month { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入查询请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryRequest : FinanceCostScopeRequest;
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析查询请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostAnalysisRequest : FinanceCostScopeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 趋势月份数量。
|
||||
/// </summary>
|
||||
public int TrendMonthCount { get; set; } = 6;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入保存请求。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostEntryRequest : FinanceCostScopeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类列表。
|
||||
/// </summary>
|
||||
public List<SaveFinanceCostCategoryRequest> Categories { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类保存项请求。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostCategoryRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码(food/labor/fixed/packaging)。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类总金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类明细。
|
||||
/// </summary>
|
||||
public List<SaveFinanceCostDetailRequest> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细保存项请求。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细标识(可空)。
|
||||
/// </summary>
|
||||
public string? ItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public string ItemName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? UnitPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度编码。
|
||||
/// </summary>
|
||||
public string Dimension { get; set; } = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度时有值)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 本月营业额。
|
||||
/// </summary>
|
||||
public decimal MonthRevenue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月成本率(%)。
|
||||
/// </summary>
|
||||
public decimal CostRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostEntryCategoryResponse> Categories { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryCategoryResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类文案。
|
||||
/// </summary>
|
||||
public string CategoryText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类总金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 明细数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostEntryDetailResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细标识。
|
||||
/// </summary>
|
||||
public string? ItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public string ItemName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? UnitPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostAnalysisResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度编码。
|
||||
/// </summary>
|
||||
public string Dimension { get; set; } = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度时有值)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 统计卡。
|
||||
/// </summary>
|
||||
public FinanceCostAnalysisStatsResponse Stats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 趋势数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostTrendPointResponse> Trend { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 构成数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostCompositionResponse> Composition { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 明细表数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostMonthlyDetailResponse> DetailRows { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析统计卡响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostAnalysisStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 食材成本率(%)。
|
||||
/// </summary>
|
||||
public decimal FoodCostRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单均成本。
|
||||
/// </summary>
|
||||
public decimal AverageCostPerPaidOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 环比变化(%)。
|
||||
/// </summary>
|
||||
public decimal MonthOnMonthChangeRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月营业额。
|
||||
/// </summary>
|
||||
public decimal Revenue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月支付成功订单数。
|
||||
/// </summary>
|
||||
public int PaidOrderCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本趋势点响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostTrendPointResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 月度总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月度营业额。
|
||||
/// </summary>
|
||||
public decimal Revenue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月度成本率(%)。
|
||||
/// </summary>
|
||||
public decimal CostRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostCompositionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类文案。
|
||||
/// </summary>
|
||||
public string CategoryText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析明细表行响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostMonthlyDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 食材成本。
|
||||
/// </summary>
|
||||
public decimal FoodAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 人工成本。
|
||||
/// </summary>
|
||||
public decimal LaborAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定费用。
|
||||
/// </summary>
|
||||
public decimal FixedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 包装耗材。
|
||||
/// </summary>
|
||||
public decimal PackagingAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本率(%)。
|
||||
/// </summary>
|
||||
public decimal CostRate { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 保存发票设置请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceSettingSaveRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 企业名称。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 纳税人识别号。
|
||||
/// </summary>
|
||||
public string TaxpayerNumber { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 注册地址。
|
||||
/// </summary>
|
||||
public string? RegisteredAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册电话。
|
||||
/// </summary>
|
||||
public string? RegisteredPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开户银行。
|
||||
/// </summary>
|
||||
public string? BankName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 银行账号。
|
||||
/// </summary>
|
||||
public string? BankAccount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用电子普通发票。
|
||||
/// </summary>
|
||||
public bool EnableElectronicNormalInvoice { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用电子专用发票。
|
||||
/// </summary>
|
||||
public bool EnableElectronicSpecialInvoice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用自动开票。
|
||||
/// </summary>
|
||||
public bool EnableAutoIssue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 自动开票单张最大金额。
|
||||
/// </summary>
|
||||
public decimal AutoIssueMaxAmount { get; set; } = 10_000m;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录列表请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending/issued/voided)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型(normal/special)。
|
||||
/// </summary>
|
||||
public string? InvoiceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(发票号/公司名/申请人)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录详情请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 发票记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票开票请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordIssueRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 发票记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收邮箱(可选)。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开票备注。
|
||||
/// </summary>
|
||||
public string? IssueRemark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票作废请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordVoidRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 发票记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 作废原因。
|
||||
/// </summary>
|
||||
public string VoidReason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票申请请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordApplyRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 申请人。
|
||||
/// </summary>
|
||||
public string ApplicantName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开票抬头(公司名)。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 纳税人识别号。
|
||||
/// </summary>
|
||||
public string? TaxpayerNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型(normal/special)。
|
||||
/// </summary>
|
||||
public string InvoiceType { get; set; } = "normal";
|
||||
|
||||
/// <summary>
|
||||
/// 开票金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收邮箱。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
public string? ContactPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 申请备注。
|
||||
/// </summary>
|
||||
public string? ApplyRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 申请时间(可空)。
|
||||
/// </summary>
|
||||
public DateTime? AppliedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票设置响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceSettingResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 企业名称。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 纳税人识别号。
|
||||
/// </summary>
|
||||
public string TaxpayerNumber { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 注册地址。
|
||||
/// </summary>
|
||||
public string? RegisteredAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册电话。
|
||||
/// </summary>
|
||||
public string? RegisteredPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开户银行。
|
||||
/// </summary>
|
||||
public string? BankName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 银行账号。
|
||||
/// </summary>
|
||||
public string? BankAccount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用电子普通发票。
|
||||
/// </summary>
|
||||
public bool EnableElectronicNormalInvoice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用电子专用发票。
|
||||
/// </summary>
|
||||
public bool EnableElectronicSpecialInvoice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用自动开票。
|
||||
/// </summary>
|
||||
public bool EnableAutoIssue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 自动开票单张最大金额。
|
||||
/// </summary>
|
||||
public decimal AutoIssueMaxAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票统计响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月已开票金额。
|
||||
/// </summary>
|
||||
public decimal CurrentMonthIssuedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月已开票张数。
|
||||
/// </summary>
|
||||
public int CurrentMonthIssuedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待开票数量。
|
||||
/// </summary>
|
||||
public int PendingCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已作废数量。
|
||||
/// </summary>
|
||||
public int VoidedCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录列表项响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票号码。
|
||||
/// </summary>
|
||||
public string InvoiceNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 申请人。
|
||||
/// </summary>
|
||||
public string ApplicantName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开票抬头(公司名)。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型编码。
|
||||
/// </summary>
|
||||
public string InvoiceType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型文案。
|
||||
/// </summary>
|
||||
public string InvoiceTypeText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 申请时间(本地显示字符串)。
|
||||
/// </summary>
|
||||
public string AppliedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录详情响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票号码。
|
||||
/// </summary>
|
||||
public string InvoiceNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 申请人。
|
||||
/// </summary>
|
||||
public string ApplicantName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开票抬头(公司名)。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 纳税人识别号。
|
||||
/// </summary>
|
||||
public string? TaxpayerNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型编码。
|
||||
/// </summary>
|
||||
public string InvoiceType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型文案。
|
||||
/// </summary>
|
||||
public string InvoiceTypeText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收邮箱。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
public string? ContactPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 申请备注。
|
||||
/// </summary>
|
||||
public string? ApplyRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 申请时间(本地显示字符串)。
|
||||
/// </summary>
|
||||
public string AppliedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开票时间(本地显示字符串)。
|
||||
/// </summary>
|
||||
public string? IssuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开票人 ID。
|
||||
/// </summary>
|
||||
public string? IssuedByUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开票备注。
|
||||
/// </summary>
|
||||
public string? IssueRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 作废时间(本地显示字符串)。
|
||||
/// </summary>
|
||||
public string? VoidedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 作废人 ID。
|
||||
/// </summary>
|
||||
public string? VoidedByUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 作废原因。
|
||||
/// </summary>
|
||||
public string? VoidReason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票开票结果响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceIssueResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票号码。
|
||||
/// </summary>
|
||||
public string InvoiceNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开票抬头。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 接收邮箱。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开票时间(本地显示字符串)。
|
||||
/// </summary>
|
||||
public string IssuedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录分页响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<FinanceInvoiceRecordResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计。
|
||||
/// </summary>
|
||||
public FinanceInvoiceStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览查询请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewDashboardRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度(tenant/store)。
|
||||
/// </summary>
|
||||
public string? Dimension { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID(门店维度必填)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览指标卡响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewKpiCardResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 指标值。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 对比值。
|
||||
/// </summary>
|
||||
public decimal CompareAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 变化率(%)。
|
||||
/// </summary>
|
||||
public decimal ChangeRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 趋势(up/down/flat)。
|
||||
/// </summary>
|
||||
public string Trend { get; set; } = "flat";
|
||||
|
||||
/// <summary>
|
||||
/// 对比文案。
|
||||
/// </summary>
|
||||
public string CompareLabel { get; set; } = "较昨日";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入趋势点响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeTrendPointResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string Date { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 轴标签(MM/dd)。
|
||||
/// </summary>
|
||||
public string DateLabel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 实收金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入趋势响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeTrendResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 近 7 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewIncomeTrendPointResponse> Last7Days { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 30 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewIncomeTrendPointResponse> Last30Days { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 利润趋势点响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewProfitTrendPointResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string Date { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 轴标签(MM/dd)。
|
||||
/// </summary>
|
||||
public string DateLabel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 营收。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本。
|
||||
/// </summary>
|
||||
public decimal CostAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 净利润。
|
||||
/// </summary>
|
||||
public decimal NetProfitAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 利润趋势响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewProfitTrendResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 近 7 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewProfitTrendPointResponse> Last7Days { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 30 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewProfitTrendPointResponse> Last30Days { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入构成项响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeCompositionItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 渠道编码。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string ChannelText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入构成响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeCompositionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 总实收。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 构成项。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewIncomeCompositionItemResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成项响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewCostCompositionItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类文案。
|
||||
/// </summary>
|
||||
public string CategoryText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewCostCompositionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 总成本。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 构成项。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewCostCompositionItemResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TOP 商品项响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewTopProductItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 排名。
|
||||
/// </summary>
|
||||
public int Rank { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 销量。
|
||||
/// </summary>
|
||||
public int SalesQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 营收金额。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TOP 商品响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewTopProductResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 周期天数。
|
||||
/// </summary>
|
||||
public int PeriodDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 排行项。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewTopProductItemResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewDashboardResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度编码。
|
||||
/// </summary>
|
||||
public string Dimension { get; set; } = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 今日营业额卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse TodayRevenue { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 实收卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse ActualReceived { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 退款卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse RefundAmount { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 净收入卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse NetIncome { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 可提现余额卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse WithdrawableBalance { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 收入趋势。
|
||||
/// </summary>
|
||||
public FinanceOverviewIncomeTrendResponse IncomeTrend { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 利润趋势。
|
||||
/// </summary>
|
||||
public FinanceOverviewProfitTrendResponse ProfitTrend { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 收入构成。
|
||||
/// </summary>
|
||||
public FinanceOverviewIncomeCompositionResponse IncomeComposition { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成。
|
||||
/// </summary>
|
||||
public FinanceOverviewCostCompositionResponse CostComposition { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// TOP 商品排行。
|
||||
/// </summary>
|
||||
public FinanceOverviewTopProductResponse TopProducts { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 到账统计请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementStatsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账筛选请求。
|
||||
/// </summary>
|
||||
public class FinanceSettlementFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道(wechat/alipay)。
|
||||
/// </summary>
|
||||
public string? Channel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账列表请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementListRequest : FinanceSettlementFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账明细请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 到账日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string ArrivedDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道(wechat/alipay)。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账统计响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日到账。
|
||||
/// </summary>
|
||||
public decimal TodayArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 昨日到账。
|
||||
/// </summary>
|
||||
public decimal YesterdayArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月到账。
|
||||
/// </summary>
|
||||
public decimal CurrentMonthArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月交易笔数。
|
||||
/// </summary>
|
||||
public int CurrentMonthTransactionCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账账户信息响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementAccountResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 银行名称。
|
||||
/// </summary>
|
||||
public string BankName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开户名。
|
||||
/// </summary>
|
||||
public string BankAccountName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏银行账号。
|
||||
/// </summary>
|
||||
public string BankAccountNoMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏微信商户号。
|
||||
/// </summary>
|
||||
public string WechatMerchantNoMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏支付宝 PID。
|
||||
/// </summary>
|
||||
public string AlipayPidMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 结算周期文案。
|
||||
/// </summary>
|
||||
public string SettlementPeriodText { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账列表行响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 到账日期。
|
||||
/// </summary>
|
||||
public string ArrivedDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道编码。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string ChannelText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易笔数。
|
||||
/// </summary>
|
||||
public int TransactionCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账金额。
|
||||
/// </summary>
|
||||
public decimal ArrivedAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账列表响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<FinanceSettlementListItemResponse> 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 sealed class FinanceSettlementDetailItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间。
|
||||
/// </summary>
|
||||
public string PaidAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账明细响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementDetailResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细列表。
|
||||
/// </summary>
|
||||
public List<FinanceSettlementDetailItemResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账导出响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementExportResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件内容(Base64)。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水筛选请求。
|
||||
/// </summary>
|
||||
public class FinanceTransactionFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易类型(income/refund/stored_card_recharge/point_redeem)。
|
||||
/// </summary>
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public string? Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式(wechat/alipay/cash/card/balance)。
|
||||
/// </summary>
|
||||
public string? PaymentMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(流水号/订单号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水列表请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionListRequest : FinanceTransactionFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水详情请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易标识(sourceType:sourceId)。
|
||||
/// </summary>
|
||||
public string TransactionId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水列表结果。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<FinanceTransactionListItemResponse> 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 decimal PageIncomeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本页退款。
|
||||
/// </summary>
|
||||
public decimal PageRefundAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水行。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 交易标识。
|
||||
/// </summary>
|
||||
public string TransactionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 流水号。
|
||||
/// </summary>
|
||||
public string TransactionNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string? OrderNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型编码。
|
||||
/// </summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 类型文案。
|
||||
/// </summary>
|
||||
public string TypeText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式文案。
|
||||
/// </summary>
|
||||
public string PaymentMethod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易时间。
|
||||
/// </summary>
|
||||
public string OccurredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string Remark { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否收入。
|
||||
/// </summary>
|
||||
public bool IsIncome { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水统计结果。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 总收入。
|
||||
/// </summary>
|
||||
public decimal TotalIncome { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总退款。
|
||||
/// </summary>
|
||||
public decimal TotalRefund { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总笔数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水详情。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 交易标识。
|
||||
/// </summary>
|
||||
public string TransactionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 流水号。
|
||||
/// </summary>
|
||||
public string TransactionNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 类型编码。
|
||||
/// </summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 类型文案。
|
||||
/// </summary>
|
||||
public string TypeText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string? OrderNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式文案。
|
||||
/// </summary>
|
||||
public string PaymentMethod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易时间。
|
||||
/// </summary>
|
||||
public string OccurredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string Remark { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客姓名。
|
||||
/// </summary>
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客手机号。
|
||||
/// </summary>
|
||||
public string CustomerPhone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 退款单号。
|
||||
/// </summary>
|
||||
public string? RefundNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款原因。
|
||||
/// </summary>
|
||||
public string? RefundReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string? MemberName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号。
|
||||
/// </summary>
|
||||
public string? MemberMobileMasked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 充值金额。
|
||||
/// </summary>
|
||||
public decimal? RechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送金额。
|
||||
/// </summary>
|
||||
public decimal? GiftAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账金额。
|
||||
/// </summary>
|
||||
public decimal? ArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分变动值。
|
||||
/// </summary>
|
||||
public int? PointChangeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分变动后余额。
|
||||
/// </summary>
|
||||
public int? PointBalanceAfterChange { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水导出结果。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionExportResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件内容(Base64)。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
/// <summary>
|
||||
/// 营销日历总览查询请求。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarOverviewRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 年份。
|
||||
/// </summary>
|
||||
public int Year { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(1-12)。
|
||||
/// </summary>
|
||||
public int Month { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 营销日历总览响应。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarOverviewResponse
|
||||
{
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
public int Year { get; set; }
|
||||
|
||||
public int MonthValue { get; set; }
|
||||
|
||||
public string MonthStartDate { get; set; } = string.Empty;
|
||||
|
||||
public string MonthEndDate { get; set; } = string.Empty;
|
||||
|
||||
public int TodayDay { get; set; }
|
||||
|
||||
public List<MarketingCalendarDayResponse> Days { get; set; } = [];
|
||||
|
||||
public List<MarketingCalendarLegendResponse> Legends { get; set; } = [];
|
||||
|
||||
public MarketingCalendarStatsResponse Stats { get; set; } = new();
|
||||
|
||||
public MarketingCalendarConflictBannerResponse? ConflictBanner { get; set; }
|
||||
|
||||
public List<MarketingCalendarConflictResponse> Conflicts { get; set; } = [];
|
||||
|
||||
public List<MarketingCalendarActivityResponse> Activities { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarDayResponse
|
||||
{
|
||||
public int Day { get; set; }
|
||||
|
||||
public bool IsWeekend { get; set; }
|
||||
|
||||
public bool IsToday { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarLegendResponse
|
||||
{
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
public string Color { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarStatsResponse
|
||||
{
|
||||
public int TotalActivityCount { get; set; }
|
||||
|
||||
public int OngoingCount { get; set; }
|
||||
|
||||
public int MaxConcurrentCount { get; set; }
|
||||
|
||||
public decimal EstimatedDiscountAmount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarActivityResponse
|
||||
{
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
|
||||
public string SourceType { get; set; } = string.Empty;
|
||||
|
||||
public string SourceId { get; set; } = string.Empty;
|
||||
|
||||
public string CalendarType { get; set; } = string.Empty;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Color { get; set; } = string.Empty;
|
||||
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
public string DisplayStatus { get; set; } = string.Empty;
|
||||
|
||||
public bool IsDimmed { get; set; }
|
||||
|
||||
public string StartDate { get; set; } = string.Empty;
|
||||
|
||||
public string EndDate { get; set; } = string.Empty;
|
||||
|
||||
public decimal EstimatedDiscountAmount { get; set; }
|
||||
|
||||
public List<MarketingCalendarActivityBarResponse> Bars { get; set; } = [];
|
||||
|
||||
public MarketingCalendarActivityDetailResponse Detail { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarActivityBarResponse
|
||||
{
|
||||
public string BarId { get; set; } = string.Empty;
|
||||
|
||||
public int StartDay { get; set; }
|
||||
|
||||
public int EndDay { get; set; }
|
||||
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
public bool IsMilestone { get; set; }
|
||||
|
||||
public bool IsDimmed { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarActivityDetailResponse
|
||||
{
|
||||
public string ModuleName { get; set; } = string.Empty;
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public List<MarketingCalendarDetailFieldResponse> Fields { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarDetailFieldResponse
|
||||
{
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarConflictBannerResponse
|
||||
{
|
||||
public string ConflictId { get; set; } = string.Empty;
|
||||
|
||||
public int StartDay { get; set; }
|
||||
|
||||
public int EndDay { get; set; }
|
||||
|
||||
public int ActivityCount { get; set; }
|
||||
|
||||
public int MaxConcurrentCount { get; set; }
|
||||
|
||||
public int ConflictCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarConflictResponse
|
||||
{
|
||||
public string ConflictId { get; set; } = string.Empty;
|
||||
|
||||
public int StartDay { get; set; }
|
||||
|
||||
public int EndDay { get; set; }
|
||||
|
||||
public int ActivityCount { get; set; }
|
||||
|
||||
public int MaxConcurrentCount { get; set; }
|
||||
|
||||
public List<string> ActivityIds { get; set; } = [];
|
||||
|
||||
public List<MarketingCalendarConflictActivityResponse> Activities { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarConflictActivityResponse
|
||||
{
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
|
||||
public string CalendarType { get; set; } = string.Empty;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
public string Color { get; set; } = string.Empty;
|
||||
|
||||
public string DisplayStatus { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券列表请求。
|
||||
/// </summary>
|
||||
public sealed class CouponListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(ongoing/upcoming/ended/disabled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 券类型筛选(amount_off/discount/free_delivery)。
|
||||
/// </summary>
|
||||
public string? CouponType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券详情请求。
|
||||
/// </summary>
|
||||
public sealed class CouponDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券 ID。
|
||||
/// </summary>
|
||||
public string CouponId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存优惠券请求。
|
||||
/// </summary>
|
||||
public sealed class SaveCouponRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(当前操作上下文)。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 券名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_delivery)。
|
||||
/// </summary>
|
||||
public string CouponType { get; set; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发放总量。
|
||||
/// </summary>
|
||||
public int TotalQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限领。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(fixed/days)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; set; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期开始。
|
||||
/// </summary>
|
||||
public DateTime? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期结束。
|
||||
/// </summary>
|
||||
public DateTime? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取后有效天数。
|
||||
/// </summary>
|
||||
public int? RelativeValidDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public List<string>? Channels { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围模式(all/stores)。
|
||||
/// </summary>
|
||||
public string StoreScopeMode { get; set; } = "stores";
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围 ID 集合(stores 模式必传)。
|
||||
/// </summary>
|
||||
public List<string>? StoreIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改优惠券状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangeCouponStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券 ID。
|
||||
/// </summary>
|
||||
public string CouponId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除优惠券请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteCouponRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券 ID。
|
||||
/// </summary>
|
||||
public string CouponId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券列表结果。
|
||||
/// </summary>
|
||||
public sealed class CouponListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表数据。
|
||||
/// </summary>
|
||||
public List<CouponListItemResponse> 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 CouponStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券列表项。
|
||||
/// </summary>
|
||||
public sealed class CouponListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 优惠券 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_delivery)。
|
||||
/// </summary>
|
||||
public string CouponType { get; set; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期开始。
|
||||
/// </summary>
|
||||
public string? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期结束。
|
||||
/// </summary>
|
||||
public string? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取后有效天数。
|
||||
/// </summary>
|
||||
public int? RelativeValidDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发放总量。
|
||||
/// </summary>
|
||||
public int TotalQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已领取数量。
|
||||
/// </summary>
|
||||
public int ClaimedQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已核销数量。
|
||||
/// </summary>
|
||||
public int RedeemedQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限领。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态(ongoing/upcoming/ended/disabled)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; set; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围模式(all/stores)。
|
||||
/// </summary>
|
||||
public string StoreScopeMode { get; set; } = "stores";
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID 列表。
|
||||
/// </summary>
|
||||
public List<string> StoreIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 渠道列表(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券详情。
|
||||
/// </summary>
|
||||
public sealed class CouponDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 优惠券 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_delivery)。
|
||||
/// </summary>
|
||||
public string CouponType { get; set; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发放总量。
|
||||
/// </summary>
|
||||
public int TotalQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已领取数量。
|
||||
/// </summary>
|
||||
public int ClaimedQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限领。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(fixed/days)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; set; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期开始。
|
||||
/// </summary>
|
||||
public string? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期结束。
|
||||
/// </summary>
|
||||
public string? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取后有效天数。
|
||||
/// </summary>
|
||||
public int? RelativeValidDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道列表(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围模式(all/stores)。
|
||||
/// </summary>
|
||||
public string StoreScopeMode { get; set; } = "stores";
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID 列表。
|
||||
/// </summary>
|
||||
public List<string> StoreIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券统计响应。
|
||||
/// </summary>
|
||||
public sealed class CouponStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 优惠券总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 进行中数量。
|
||||
/// </summary>
|
||||
public int OngoingCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已领取总数。
|
||||
/// </summary>
|
||||
public int ClaimedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已核销总数。
|
||||
/// </summary>
|
||||
public int RedeemedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销率(百分比)。
|
||||
/// </summary>
|
||||
public decimal RedeemRate { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可空,空表示全部门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态筛选(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 4;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣详情请求。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存限时折扣请求。
|
||||
/// </summary>
|
||||
public sealed class SaveFlashSaleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动周期(once/recurring)。
|
||||
/// </summary>
|
||||
public string CycleType { get; set; } = "once";
|
||||
|
||||
/// <summary>
|
||||
/// 周期日期模式(fixed/long_term)。
|
||||
/// </summary>
|
||||
public string RecurringDateMode { get; set; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日开始时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日结束时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeEnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 循环星期(1-7,周一到周日)。
|
||||
/// </summary>
|
||||
public List<int>? WeekDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public List<string>? Channels { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动门店 ID。
|
||||
/// </summary>
|
||||
public List<string>? StoreIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣商品列表。
|
||||
/// </summary>
|
||||
public List<FlashSaleSaveProductRequest> Products { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标。
|
||||
/// </summary>
|
||||
public FlashSaleMetricsRequest? Metrics { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改限时折扣状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangeFlashSaleStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "completed";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除限时折扣请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteFlashSaleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣列表响应。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<FlashSaleListItemResponse> 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 FlashSaleStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣列表项响应。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动周期(once/recurring)。
|
||||
/// </summary>
|
||||
public string CycleType { get; set; } = "once";
|
||||
|
||||
/// <summary>
|
||||
/// 周期日期模式(fixed/long_term)。
|
||||
/// </summary>
|
||||
public string RecurringDateMode { get; set; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日开始时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日结束时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeEnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 循环星期(1-7)。
|
||||
/// </summary>
|
||||
public List<int> WeekDays { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 编辑状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; set; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动门店。
|
||||
/// </summary>
|
||||
public List<string> StoreIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 折扣商品。
|
||||
/// </summary>
|
||||
public List<FlashSaleProductResponse> Products { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标。
|
||||
/// </summary>
|
||||
public FlashSaleMetricsResponse Metrics { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣详情响应。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动周期(once/recurring)。
|
||||
/// </summary>
|
||||
public string CycleType { get; set; } = "once";
|
||||
|
||||
/// <summary>
|
||||
/// 周期日期模式(fixed/long_term)。
|
||||
/// </summary>
|
||||
public string RecurringDateMode { get; set; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日开始时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日结束时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeEnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 循环星期(1-7)。
|
||||
/// </summary>
|
||||
public List<int> WeekDays { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 编辑状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; set; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动门店。
|
||||
/// </summary>
|
||||
public List<string> StoreIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 折扣商品。
|
||||
/// </summary>
|
||||
public List<FlashSaleProductResponse> Products { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标。
|
||||
/// </summary>
|
||||
public FlashSaleMetricsResponse Metrics { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣统计响应。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 进行中数量。
|
||||
/// </summary>
|
||||
public int OngoingCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 参与商品数。
|
||||
/// </summary>
|
||||
public int ParticipatingProductCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月折扣销量。
|
||||
/// </summary>
|
||||
public int MonthlyDiscountSalesCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣商品请求。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleSaveProductRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public string ProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 折扣价。
|
||||
/// </summary>
|
||||
public decimal DiscountPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣商品响应。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleProductResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public string ProductId { 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 Name { get; set; } = string.Empty;
|
||||
|
||||
/// <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 decimal OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣价。
|
||||
/// </summary>
|
||||
public decimal DiscountPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已售数量。
|
||||
/// </summary>
|
||||
public int SoldCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣指标请求。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleMetricsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动销量(单)。
|
||||
/// </summary>
|
||||
public int ActivitySalesCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣总额。
|
||||
/// </summary>
|
||||
public decimal DiscountTotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已循环周数。
|
||||
/// </summary>
|
||||
public int LoopedWeeks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月折扣销量(单)。
|
||||
/// </summary>
|
||||
public int MonthlyDiscountSalesCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣指标响应。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleMetricsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动销量(单)。
|
||||
/// </summary>
|
||||
public int ActivitySalesCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣总额。
|
||||
/// </summary>
|
||||
public decimal DiscountTotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已循环周数。
|
||||
/// </summary>
|
||||
public int LoopedWeeks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月折扣销量(单)。
|
||||
/// </summary>
|
||||
public int MonthlyDiscountSalesCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣商品分类选择器请求。
|
||||
/// </summary>
|
||||
public sealed class FlashSalePickerCategoriesRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣商品分类选择器响应项。
|
||||
/// </summary>
|
||||
public sealed class FlashSalePickerCategoryItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品数量。
|
||||
/// </summary>
|
||||
public int ProductCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣商品选择器请求。
|
||||
/// </summary>
|
||||
public sealed class FlashSalePickerProductsRequest
|
||||
{
|
||||
/// <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>
|
||||
/// 数量上限。
|
||||
/// </summary>
|
||||
public int? Limit { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣商品选择器响应项。
|
||||
/// </summary>
|
||||
public sealed class FlashSalePickerProductItemResponse
|
||||
{
|
||||
/// <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 Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal Price { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存。
|
||||
/// </summary>
|
||||
public int Stock { 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";
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动列表请求。
|
||||
/// </summary>
|
||||
public sealed class FullReductionListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可空;空表示全部门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动类型筛选(reduce/gift/second_half)。
|
||||
/// </summary>
|
||||
public string? ActivityType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 4;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动详情请求。
|
||||
/// </summary>
|
||||
public sealed class FullReductionDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作上下文门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存满减活动请求。
|
||||
/// </summary>
|
||||
public sealed class SaveFullReductionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作上下文门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动类型(reduce/gift/second_half)。
|
||||
/// </summary>
|
||||
public string ActivityType { get; set; } = "reduce";
|
||||
|
||||
/// <summary>
|
||||
/// 满减阶梯规则。
|
||||
/// </summary>
|
||||
public List<FullReductionTierRuleRequest> ReduceTiers { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 满赠规则。
|
||||
/// </summary>
|
||||
public FullReductionGiftRuleRequest? GiftRule { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 第二份半价规则。
|
||||
/// </summary>
|
||||
public FullReductionSecondHalfRuleRequest? SecondHalfRule { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string StartDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string EndDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public List<string>? Channels { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围模式(all/stores)。
|
||||
/// </summary>
|
||||
public string StoreScopeMode { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围 ID 集合(stores 模式必传)。
|
||||
/// </summary>
|
||||
public List<string>? StoreIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 选品基准门店 ID。
|
||||
/// </summary>
|
||||
public string? ScopeStoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否可叠加优惠券。
|
||||
/// </summary>
|
||||
public bool StackWithCoupon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标(用于列表展示)。
|
||||
/// </summary>
|
||||
public FullReductionMetricsRequest? Metrics { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改活动状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangeFullReductionStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作上下文门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "completed";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除满减活动请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteFullReductionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作上下文门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动列表结果。
|
||||
/// </summary>
|
||||
public sealed class FullReductionListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<FullReductionListItemResponse> 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 FullReductionStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动列表项。
|
||||
/// </summary>
|
||||
public sealed class FullReductionListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动类型(reduce/gift/second_half)。
|
||||
/// </summary>
|
||||
public string ActivityType { get; set; } = "reduce";
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string StartDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string EndDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; set; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 满减阶梯规则。
|
||||
/// </summary>
|
||||
public List<FullReductionTierRuleResponse> ReduceTiers { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 满赠规则。
|
||||
/// </summary>
|
||||
public FullReductionGiftRuleResponse? GiftRule { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 第二份半价规则。
|
||||
/// </summary>
|
||||
public FullReductionSecondHalfRuleResponse? SecondHalfRule { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围模式(all/stores)。
|
||||
/// </summary>
|
||||
public string StoreScopeMode { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围 ID。
|
||||
/// </summary>
|
||||
public List<string> StoreIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 选品基准门店 ID。
|
||||
/// </summary>
|
||||
public string ScopeStoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否可叠加优惠券。
|
||||
/// </summary>
|
||||
public bool StackWithCoupon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标。
|
||||
/// </summary>
|
||||
public FullReductionMetricsResponse Metrics { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动详情。
|
||||
/// </summary>
|
||||
public sealed class FullReductionDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动类型(reduce/gift/second_half)。
|
||||
/// </summary>
|
||||
public string ActivityType { get; set; } = "reduce";
|
||||
|
||||
/// <summary>
|
||||
/// 满减阶梯规则。
|
||||
/// </summary>
|
||||
public List<FullReductionTierRuleResponse> ReduceTiers { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 满赠规则。
|
||||
/// </summary>
|
||||
public FullReductionGiftRuleResponse? GiftRule { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 第二份半价规则。
|
||||
/// </summary>
|
||||
public FullReductionSecondHalfRuleResponse? SecondHalfRule { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string StartDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string EndDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; set; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 编辑状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围模式(all/stores)。
|
||||
/// </summary>
|
||||
public string StoreScopeMode { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围 ID。
|
||||
/// </summary>
|
||||
public List<string> StoreIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 选品基准门店 ID。
|
||||
/// </summary>
|
||||
public string ScopeStoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否可叠加优惠券。
|
||||
/// </summary>
|
||||
public bool StackWithCoupon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标。
|
||||
/// </summary>
|
||||
public FullReductionMetricsResponse Metrics { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动统计。
|
||||
/// </summary>
|
||||
public sealed class FullReductionStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 进行中数量。
|
||||
/// </summary>
|
||||
public int OngoingCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月带动销售额。
|
||||
/// </summary>
|
||||
public decimal MonthlyDrivenSalesAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均客单价提升。
|
||||
/// </summary>
|
||||
public decimal AverageTicketIncrease { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 满减阶梯规则请求。
|
||||
/// </summary>
|
||||
public sealed class FullReductionTierRuleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 满足金额。
|
||||
/// </summary>
|
||||
public decimal MeetAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 减免金额。
|
||||
/// </summary>
|
||||
public decimal ReduceAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 满减阶梯规则响应。
|
||||
/// </summary>
|
||||
public sealed class FullReductionTierRuleResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 满足金额。
|
||||
/// </summary>
|
||||
public decimal MeetAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 减免金额。
|
||||
/// </summary>
|
||||
public decimal ReduceAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 满赠规则请求。
|
||||
/// </summary>
|
||||
public sealed class FullReductionGiftRuleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 购买数量门槛。
|
||||
/// </summary>
|
||||
public int BuyQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送数量。
|
||||
/// </summary>
|
||||
public int GiftQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠品范围类型(same_lowest/specified)。
|
||||
/// </summary>
|
||||
public string GiftScopeType { get; set; } = "same_lowest";
|
||||
|
||||
/// <summary>
|
||||
/// 适用商品范围。
|
||||
/// </summary>
|
||||
public FullReductionScopeRuleRequest ApplicableScope { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 指定赠品范围。
|
||||
/// </summary>
|
||||
public FullReductionScopeRuleRequest GiftScope { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 满赠规则响应。
|
||||
/// </summary>
|
||||
public sealed class FullReductionGiftRuleResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 购买数量门槛。
|
||||
/// </summary>
|
||||
public int BuyQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送数量。
|
||||
/// </summary>
|
||||
public int GiftQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠品范围类型(same_lowest/specified)。
|
||||
/// </summary>
|
||||
public string GiftScopeType { get; set; } = "same_lowest";
|
||||
|
||||
/// <summary>
|
||||
/// 适用商品范围。
|
||||
/// </summary>
|
||||
public FullReductionScopeRuleResponse ApplicableScope { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 指定赠品范围。
|
||||
/// </summary>
|
||||
public FullReductionScopeRuleResponse GiftScope { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 第二份半价规则请求。
|
||||
/// </summary>
|
||||
public sealed class FullReductionSecondHalfRuleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 折扣类型(half/sixty/seventy/free)。
|
||||
/// </summary>
|
||||
public string DiscountType { get; set; } = "half";
|
||||
|
||||
/// <summary>
|
||||
/// 适用商品范围。
|
||||
/// </summary>
|
||||
public FullReductionScopeRuleRequest ApplicableScope { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 第二份半价规则响应。
|
||||
/// </summary>
|
||||
public sealed class FullReductionSecondHalfRuleResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 折扣类型(half/sixty/seventy/free)。
|
||||
/// </summary>
|
||||
public string DiscountType { get; set; } = "half";
|
||||
|
||||
/// <summary>
|
||||
/// 适用商品范围。
|
||||
/// </summary>
|
||||
public FullReductionScopeRuleResponse ApplicableScope { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 商品范围请求。
|
||||
/// </summary>
|
||||
public sealed class FullReductionScopeRuleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 范围类型(all/category/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public List<string> CategoryIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 商品范围响应。
|
||||
/// </summary>
|
||||
public sealed class FullReductionScopeRuleResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 范围类型(all/category/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public List<string> CategoryIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 指标请求。
|
||||
/// </summary>
|
||||
public sealed class FullReductionMetricsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 参与订单数。
|
||||
/// </summary>
|
||||
public int ParticipatingOrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优惠总额。
|
||||
/// </summary>
|
||||
public decimal DiscountTotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价提升。
|
||||
/// </summary>
|
||||
public decimal TicketIncreaseAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠出商品数量。
|
||||
/// </summary>
|
||||
public int GiftedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 带动销售额。
|
||||
/// </summary>
|
||||
public decimal DrivenSalesAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 连带率提升百分比。
|
||||
/// </summary>
|
||||
public decimal AttachRateIncreasePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月带动销售额。
|
||||
/// </summary>
|
||||
public decimal MonthlyDrivenSalesAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均客单价提升。
|
||||
/// </summary>
|
||||
public decimal AverageTicketIncrease { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 指标响应。
|
||||
/// </summary>
|
||||
public sealed class FullReductionMetricsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 参与订单数。
|
||||
/// </summary>
|
||||
public int ParticipatingOrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优惠总额。
|
||||
/// </summary>
|
||||
public decimal DiscountTotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价提升。
|
||||
/// </summary>
|
||||
public decimal TicketIncreaseAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠出商品数量。
|
||||
/// </summary>
|
||||
public int GiftedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 带动销售额。
|
||||
/// </summary>
|
||||
public decimal DrivenSalesAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 连带率提升百分比。
|
||||
/// </summary>
|
||||
public decimal AttachRateIncreasePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月带动销售额。
|
||||
/// </summary>
|
||||
public decimal MonthlyDrivenSalesAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均客单价提升。
|
||||
/// </summary>
|
||||
public decimal AverageTicketIncrease { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼详情请求。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请记录页码。
|
||||
/// </summary>
|
||||
public int RecordPage { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请记录每页条数。
|
||||
/// </summary>
|
||||
public int RecordPageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼配置保存请求。
|
||||
/// </summary>
|
||||
public sealed class SaveNewCustomerSettingsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启新客礼包。
|
||||
/// </summary>
|
||||
public bool GiftEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼包类型(coupon/direct)。
|
||||
/// </summary>
|
||||
public string GiftType { get; set; } = "coupon";
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减金额。
|
||||
/// </summary>
|
||||
public decimal? DirectReduceAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减门槛金额。
|
||||
/// </summary>
|
||||
public decimal? DirectMinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启老带新分享。
|
||||
/// </summary>
|
||||
public bool InviteEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分享渠道(wechat_friend/moments/sms)。
|
||||
/// </summary>
|
||||
public List<string> ShareChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 新客礼包券列表。
|
||||
/// </summary>
|
||||
public List<NewCustomerSaveCouponRuleRequest> WelcomeCoupons { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人奖励券列表。
|
||||
/// </summary>
|
||||
public List<NewCustomerSaveCouponRuleRequest> InviterCoupons { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人奖励券列表。
|
||||
/// </summary>
|
||||
public List<NewCustomerSaveCouponRuleRequest> InviteeCoupons { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客邀请记录分页请求。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerInviteRecordListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客邀请记录请求。
|
||||
/// </summary>
|
||||
public sealed class WriteNewCustomerInviteRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviterName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviteeName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请时间。
|
||||
/// </summary>
|
||||
public DateTime InviteTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单状态(pending_order/ordered)。
|
||||
/// </summary>
|
||||
public string OrderStatus { get; set; } = "pending_order";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励状态(pending/issued)。
|
||||
/// </summary>
|
||||
public string RewardStatus { get; set; } = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励发放时间。
|
||||
/// </summary>
|
||||
public DateTime? RewardIssuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源渠道。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客成长记录请求。
|
||||
/// </summary>
|
||||
public sealed class WriteNewCustomerGrowthRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客业务唯一键。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客展示名。
|
||||
/// </summary>
|
||||
public string? CustomerName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间。
|
||||
/// </summary>
|
||||
public DateTime RegisteredAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼包领取时间。
|
||||
/// </summary>
|
||||
public DateTime? GiftClaimedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单时间。
|
||||
/// </summary>
|
||||
public DateTime? FirstOrderAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源渠道。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存优惠券规则请求项。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerSaveCouponRuleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_shipping)。
|
||||
/// </summary>
|
||||
public string CouponType { get; set; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal? Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛金额。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期天数。
|
||||
/// </summary>
|
||||
public int ValidDays { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼详情响应。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置详情。
|
||||
/// </summary>
|
||||
public NewCustomerSettingsResponse Settings { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据。
|
||||
/// </summary>
|
||||
public NewCustomerStatsResponse Stats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 邀请记录分页。
|
||||
/// </summary>
|
||||
public NewCustomerInviteRecordListResultResponse InviteRecords { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼配置响应。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerSettingsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启新客礼包。
|
||||
/// </summary>
|
||||
public bool GiftEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼包类型(coupon/direct)。
|
||||
/// </summary>
|
||||
public string GiftType { get; set; } = "coupon";
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减金额。
|
||||
/// </summary>
|
||||
public decimal? DirectReduceAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减门槛金额。
|
||||
/// </summary>
|
||||
public decimal? DirectMinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启老带新分享。
|
||||
/// </summary>
|
||||
public bool InviteEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分享渠道(wechat_friend/moments/sms)。
|
||||
/// </summary>
|
||||
public List<string> ShareChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 新客礼包券列表。
|
||||
/// </summary>
|
||||
public List<NewCustomerCouponRuleResponse> WelcomeCoupons { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人奖励券列表。
|
||||
/// </summary>
|
||||
public List<NewCustomerCouponRuleResponse> InviterCoupons { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人奖励券列表。
|
||||
/// </summary>
|
||||
public List<NewCustomerCouponRuleResponse> InviteeCoupons { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼统计响应。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月新客数。
|
||||
/// </summary>
|
||||
public int MonthlyNewCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 较上月增长人数。
|
||||
/// </summary>
|
||||
public int MonthlyGrowthCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 较上月增长百分比。
|
||||
/// </summary>
|
||||
public decimal MonthlyGrowthRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月礼包领取率(百分比)。
|
||||
/// </summary>
|
||||
public decimal GiftClaimRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月礼包已领取人数。
|
||||
/// </summary>
|
||||
public int GiftClaimedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月首单转化率(百分比)。
|
||||
/// </summary>
|
||||
public decimal FirstOrderConversionRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月首单完成人数。
|
||||
/// </summary>
|
||||
public int FirstOrderedCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 邀请记录分页结果响应。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerInviteRecordListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<NewCustomerInviteRecordResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客邀请记录响应。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerInviteRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviterName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviteeName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string InviteTime { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订单状态(pending_order/ordered)。
|
||||
/// </summary>
|
||||
public string OrderStatus { get; set; } = "pending_order";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励状态(pending/issued)。
|
||||
/// </summary>
|
||||
public string RewardStatus { get; set; } = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励发放时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? RewardIssuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源渠道。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客成长记录响应。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerGrowthRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客业务唯一键。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客展示名。
|
||||
/// </summary>
|
||||
public string? CustomerName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string RegisteredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 礼包领取时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? GiftClaimedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? FirstOrderAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源渠道。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客券规则响应。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerCouponRuleResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 规则 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 场景(welcome/inviter/invitee)。
|
||||
/// </summary>
|
||||
public string Scene { get; set; } = "welcome";
|
||||
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_shipping)。
|
||||
/// </summary>
|
||||
public string CouponType { get; set; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal? Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛金额。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期天数。
|
||||
/// </summary>
|
||||
public int ValidDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class PunchCardListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 名称关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 4;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡详情请求。
|
||||
/// </summary>
|
||||
public sealed class PunchCardDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存次卡请求。
|
||||
/// </summary>
|
||||
public sealed class SavePunchCardRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图地址。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(days/range)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; set; } = "days";
|
||||
|
||||
/// <summary>
|
||||
/// 固定天数。
|
||||
/// </summary>
|
||||
public int? ValidityDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 范围类型(all/category/tag/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 指定分类 ID。
|
||||
/// </summary>
|
||||
public List<string> ScopeCategoryIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 指定标签 ID。
|
||||
/// </summary>
|
||||
public List<string> ScopeTagIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 指定商品 ID。
|
||||
/// </summary>
|
||||
public List<string> ScopeProductIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式(free/cap)。
|
||||
/// </summary>
|
||||
public string UsageMode { get; set; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 单次上限金额。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用次数。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单限用次数。
|
||||
/// </summary>
|
||||
public int? PerOrderLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限购。
|
||||
/// </summary>
|
||||
public int? PerUserPurchaseLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许转赠。
|
||||
/// </summary>
|
||||
public bool AllowTransfer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期策略(invalidate/refund)。
|
||||
/// </summary>
|
||||
public string ExpireStrategy { get; set; } = "invalidate";
|
||||
|
||||
/// <summary>
|
||||
/// 次卡描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道(in_app/sms)。
|
||||
/// </summary>
|
||||
public List<string> NotifyChannels { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡状态修改请求。
|
||||
/// </summary>
|
||||
public sealed class ChangePunchCardStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "disabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡删除请求。
|
||||
/// </summary>
|
||||
public sealed class DeletePunchCardRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录查询请求。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public string? PunchCardId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(normal/used_up/expired)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(会员/商品)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录导出请求。
|
||||
/// </summary>
|
||||
public sealed class ExportPunchCardUsageRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public string? PunchCardId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(normal/used_up/expired)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(会员/商品)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入次卡使用记录请求。
|
||||
/// </summary>
|
||||
public sealed class WritePunchCardUsageRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例 ID(可空)。
|
||||
/// </summary>
|
||||
public string? PunchCardInstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例编号(可空)。
|
||||
/// </summary>
|
||||
public string? PunchCardInstanceNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string? MemberName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string? MemberPhoneMasked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换商品。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用时间。
|
||||
/// </summary>
|
||||
public DateTime? UsedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次使用次数。
|
||||
/// </summary>
|
||||
public int UsedTimes { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 超额补差金额。
|
||||
/// </summary>
|
||||
public decimal? ExtraPayAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板统计。
|
||||
/// </summary>
|
||||
public sealed class PunchCardStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 在售次卡数量。
|
||||
/// </summary>
|
||||
public int OnSaleCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计售出数量。
|
||||
/// </summary>
|
||||
public int TotalSoldCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal TotalRevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveInUseCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡列表项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期展示。
|
||||
/// </summary>
|
||||
public string ValiditySummary { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 适用范围类型。
|
||||
/// </summary>
|
||||
public string ScopeType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式。
|
||||
/// </summary>
|
||||
public string UsageMode { get; set; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 单次上限金额。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已售数量。
|
||||
/// </summary>
|
||||
public int SoldCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡列表结果。
|
||||
/// </summary>
|
||||
public sealed class PunchCardListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<PunchCardListItemResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 当前页。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计。
|
||||
/// </summary>
|
||||
public PunchCardStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡范围。
|
||||
/// </summary>
|
||||
public sealed class PunchCardScopeResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 范围类型(all/category/tag/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public List<string> CategoryIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 标签 ID。
|
||||
/// </summary>
|
||||
public List<string> TagIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡详情。
|
||||
/// </summary>
|
||||
public sealed class PunchCardDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(days/range)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; set; } = "days";
|
||||
|
||||
/// <summary>
|
||||
/// 固定天数。
|
||||
/// </summary>
|
||||
public int? ValidityDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用范围。
|
||||
/// </summary>
|
||||
public PunchCardScopeResponse Scope { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式(free/cap)。
|
||||
/// </summary>
|
||||
public string UsageMode { get; set; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 单次上限金额。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单限用。
|
||||
/// </summary>
|
||||
public int? PerOrderLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限购。
|
||||
/// </summary>
|
||||
public int? PerUserPurchaseLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许转赠。
|
||||
/// </summary>
|
||||
public bool AllowTransfer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期策略(invalidate/refund)。
|
||||
/// </summary>
|
||||
public string ExpireStrategy { get; set; } = "invalidate";
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道。
|
||||
/// </summary>
|
||||
public List<string> NotifyChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 已售数量。
|
||||
/// </summary>
|
||||
public int SoldCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡下拉选项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardTemplateOptionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用记录统计。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日使用次数。
|
||||
/// </summary>
|
||||
public int TodayUsedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月使用次数。
|
||||
/// </summary>
|
||||
public int MonthUsedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 7 天内即将过期数量。
|
||||
/// </summary>
|
||||
public int ExpiringSoonCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用记录 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string PunchCardName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例 ID。
|
||||
/// </summary>
|
||||
public string PunchCardInstanceId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string MemberName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MemberPhoneMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换商品。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UsedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 本次使用次数。
|
||||
/// </summary>
|
||||
public int UsedTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余次数。
|
||||
/// </summary>
|
||||
public int RemainingTimesAfterUse { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(normal/almost_used_up/used_up/expired)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; set; } = "normal";
|
||||
|
||||
/// <summary>
|
||||
/// 超额补差金额。
|
||||
/// </summary>
|
||||
public decimal? ExtraPayAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用记录分页结果。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<PunchCardUsageRecordResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计。
|
||||
/// </summary>
|
||||
public PunchCardUsageStatsResponse Stats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 次卡筛选项。
|
||||
/// </summary>
|
||||
public List<PunchCardTemplateOptionResponse> TemplateOptions { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用记录导出回执。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordExportResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base64 文件内容。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀活动列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class SeckillListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可空,空表示全部门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态筛选(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 4;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀活动详情请求。
|
||||
/// </summary>
|
||||
public sealed class SeckillDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存秒杀活动请求。
|
||||
/// </summary>
|
||||
public sealed class SaveSeckillRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动类型(timed/hourly)。
|
||||
/// </summary>
|
||||
public string ActivityType { get; set; } = "timed";
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日开始时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日结束时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeEnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 整点秒杀场次。
|
||||
/// </summary>
|
||||
public List<SeckillSessionRequest>? Sessions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public List<string>? Channels { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启预热。
|
||||
/// </summary>
|
||||
public bool PreheatEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预热小时数(空表示不启用)。
|
||||
/// </summary>
|
||||
public int? PreheatHours { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动门店 ID。
|
||||
/// </summary>
|
||||
public List<string>? StoreIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀商品列表。
|
||||
/// </summary>
|
||||
public List<SeckillSaveProductRequest> Products { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标。
|
||||
/// </summary>
|
||||
public SeckillMetricsRequest? Metrics { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改秒杀活动状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangeSeckillStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "completed";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除秒杀活动请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteSeckillRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀活动列表响应。
|
||||
/// </summary>
|
||||
public sealed class SeckillListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<SeckillListItemResponse> 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 SeckillStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀活动列表项响应。
|
||||
/// </summary>
|
||||
public sealed class SeckillListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动类型(timed/hourly)。
|
||||
/// </summary>
|
||||
public string ActivityType { get; set; } = "timed";
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日开始时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日结束时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeEnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 整点秒杀场次。
|
||||
/// </summary>
|
||||
public List<SeckillSessionResponse> Sessions { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 编辑状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; set; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启预热。
|
||||
/// </summary>
|
||||
public bool PreheatEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预热小时数(空表示不启用)。
|
||||
/// </summary>
|
||||
public int? PreheatHours { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动门店。
|
||||
/// </summary>
|
||||
public List<string> StoreIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀商品。
|
||||
/// </summary>
|
||||
public List<SeckillProductResponse> Products { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标。
|
||||
/// </summary>
|
||||
public SeckillMetricsResponse Metrics { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀活动详情响应。
|
||||
/// </summary>
|
||||
public sealed class SeckillDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动类型(timed/hourly)。
|
||||
/// </summary>
|
||||
public string ActivityType { get; set; } = "timed";
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日开始时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日结束时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeEnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 整点秒杀场次。
|
||||
/// </summary>
|
||||
public List<SeckillSessionResponse> Sessions { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 编辑状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; set; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启预热。
|
||||
/// </summary>
|
||||
public bool PreheatEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预热小时数(空表示不启用)。
|
||||
/// </summary>
|
||||
public int? PreheatHours { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动门店。
|
||||
/// </summary>
|
||||
public List<string> StoreIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀商品。
|
||||
/// </summary>
|
||||
public List<SeckillProductResponse> Products { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标。
|
||||
/// </summary>
|
||||
public SeckillMetricsResponse Metrics { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀活动统计响应。
|
||||
/// </summary>
|
||||
public sealed class SeckillStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 进行中数量。
|
||||
/// </summary>
|
||||
public int OngoingCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月秒杀销量。
|
||||
/// </summary>
|
||||
public int MonthlySeckillSalesCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀转化率。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀场次请求。
|
||||
/// </summary>
|
||||
public sealed class SeckillSessionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 场次开始时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string StartTime { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 场次持续时长(分钟)。
|
||||
/// </summary>
|
||||
public int DurationMinutes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀场次响应。
|
||||
/// </summary>
|
||||
public sealed class SeckillSessionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 场次开始时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string StartTime { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 场次持续时长(分钟)。
|
||||
/// </summary>
|
||||
public int DurationMinutes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀商品请求。
|
||||
/// </summary>
|
||||
public sealed class SeckillSaveProductRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public string ProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀价。
|
||||
/// </summary>
|
||||
public decimal SeckillPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 限量库存(份)。
|
||||
/// </summary>
|
||||
public int StockLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀商品响应。
|
||||
/// </summary>
|
||||
public sealed class SeckillProductResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public string ProductId { 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 Name { get; set; } = string.Empty;
|
||||
|
||||
/// <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 decimal OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀价。
|
||||
/// </summary>
|
||||
public decimal SeckillPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 限量库存(份)。
|
||||
/// </summary>
|
||||
public int StockLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已售数量。
|
||||
/// </summary>
|
||||
public int SoldCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀指标请求。
|
||||
/// </summary>
|
||||
public sealed class SeckillMetricsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 参与人数。
|
||||
/// </summary>
|
||||
public int ParticipantCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成交单数。
|
||||
/// </summary>
|
||||
public int DealCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化率(百分比)。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月秒杀销量(单)。
|
||||
/// </summary>
|
||||
public int MonthlySeckillSalesCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀指标响应。
|
||||
/// </summary>
|
||||
public sealed class SeckillMetricsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 参与人数。
|
||||
/// </summary>
|
||||
public int ParticipantCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成交单数。
|
||||
/// </summary>
|
||||
public int DealCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化率(百分比)。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月秒杀销量(单)。
|
||||
/// </summary>
|
||||
public int MonthlySeckillSalesCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀商品分类选择器请求。
|
||||
/// </summary>
|
||||
public sealed class SeckillPickerCategoriesRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀商品分类选择器响应项。
|
||||
/// </summary>
|
||||
public sealed class SeckillPickerCategoryItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品数量。
|
||||
/// </summary>
|
||||
public int ProductCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀商品选择器请求。
|
||||
/// </summary>
|
||||
public sealed class SeckillPickerProductsRequest
|
||||
{
|
||||
/// <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>
|
||||
/// 数量上限。
|
||||
/// </summary>
|
||||
public int? Limit { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 秒杀商品选择器响应项。
|
||||
/// </summary>
|
||||
public sealed class SeckillPickerProductItemResponse
|
||||
{
|
||||
/// <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 Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal Price { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存。
|
||||
/// </summary>
|
||||
public int Stock { 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";
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
/// <summary>
|
||||
/// 会员列表筛选请求。
|
||||
/// </summary>
|
||||
public class MemberListFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(姓名/手机号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 等级标识。
|
||||
/// </summary>
|
||||
public string? TierId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员列表分页请求。
|
||||
/// </summary>
|
||||
public sealed class MemberListRequest : MemberListFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员详情请求。
|
||||
/// </summary>
|
||||
public sealed class MemberDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员标识。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存会员标签请求。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberTagsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员标识。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签集合。
|
||||
/// </summary>
|
||||
public List<string> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员列表行响应。
|
||||
/// </summary>
|
||||
public sealed class MemberListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 会员标识。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像文案。
|
||||
/// </summary>
|
||||
public string AvatarText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像颜色。
|
||||
/// </summary>
|
||||
public string AvatarColor { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MobileMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级标识。
|
||||
/// </summary>
|
||||
public string? TierId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级名称。
|
||||
/// </summary>
|
||||
public string TierName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 等级主题色。
|
||||
/// </summary>
|
||||
public string TierColorHex { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消费次数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近消费时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string LastOrderAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 储值余额。
|
||||
/// </summary>
|
||||
public decimal StoredBalance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分余额。
|
||||
/// </summary>
|
||||
public int PointsBalance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否沉睡会员。
|
||||
/// </summary>
|
||||
public bool IsDormant { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员列表响应。
|
||||
/// </summary>
|
||||
public sealed class MemberListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<MemberListItemResponse> 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 sealed class MemberListStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 会员总数。
|
||||
/// </summary>
|
||||
public int TotalMembers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月新增会员数。
|
||||
/// </summary>
|
||||
public int MonthlyNewMembers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活跃会员数。
|
||||
/// </summary>
|
||||
public int ActiveMembers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 沉睡会员数。
|
||||
/// </summary>
|
||||
public int DormantMembers { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员最近订单响应。
|
||||
/// </summary>
|
||||
public sealed class MemberRecentOrderResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 下单日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string OrderedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订单金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员详情响应。
|
||||
/// </summary>
|
||||
public sealed class MemberDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 会员标识。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像文案。
|
||||
/// </summary>
|
||||
public string AvatarText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像颜色。
|
||||
/// </summary>
|
||||
public string AvatarColor { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MobileMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string JoinedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级标识。
|
||||
/// </summary>
|
||||
public string? TierId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级名称。
|
||||
/// </summary>
|
||||
public string TierName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 等级主题色。
|
||||
/// </summary>
|
||||
public string TierColorHex { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消费次数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 储值余额。
|
||||
/// </summary>
|
||||
public decimal StoredBalance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 储值实充余额。
|
||||
/// </summary>
|
||||
public decimal StoredRechargeBalance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 储值赠金余额。
|
||||
/// </summary>
|
||||
public decimal StoredGiftBalance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分余额。
|
||||
/// </summary>
|
||||
public int PointsBalance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员标签。
|
||||
/// </summary>
|
||||
public List<string> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 最近订单。
|
||||
/// </summary>
|
||||
public List<MemberRecentOrderResponse> RecentOrders { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员导出响应。
|
||||
/// </summary>
|
||||
public sealed class MemberExportResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件内容 Base64。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
/// <summary>
|
||||
/// 消息触达统计请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachStatsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息列表请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态过滤(draft/pending/sending/sent/failed)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道过滤(inapp/sms/wechat-mini)。
|
||||
/// </summary>
|
||||
public string? Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(标题)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息详情请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存消息请求。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberMessageReachRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? MessageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID(可选)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ID(可选)。
|
||||
/// </summary>
|
||||
public string? TemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发送渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标类型(all/tag)。
|
||||
/// </summary>
|
||||
public string AudienceType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 目标标签。
|
||||
/// </summary>
|
||||
public List<string> AudienceTags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间类型(immediate/scheduled)。
|
||||
/// </summary>
|
||||
public string ScheduleType { get; set; } = "immediate";
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(UTC 或本地时间,后端统一转 UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ScheduledAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提交动作(draft/send)。
|
||||
/// </summary>
|
||||
public string SubmitAction { get; set; } = "draft";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除消息请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteMemberMessageReachRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 估算人群请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageAudienceEstimateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 目标类型(all/tag)。
|
||||
/// </summary>
|
||||
public string AudienceType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 标签。
|
||||
/// </summary>
|
||||
public List<string> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板列表请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplateListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板分类(marketing/notice/recall)。
|
||||
/// </summary>
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(模板名称)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板详情请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplateDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存模板请求。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberMessageTemplateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? TemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板分类(marketing/notice/recall)。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = "notice";
|
||||
|
||||
/// <summary>
|
||||
/// 模板内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除模板请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteMemberMessageTemplateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息触达统计响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月发送条数。
|
||||
/// </summary>
|
||||
public int MonthlySentCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 触达人数。
|
||||
/// </summary>
|
||||
public int ReachMemberCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开率(百分比)。
|
||||
/// </summary>
|
||||
public decimal OpenRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化率(百分比)。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息列表项响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标文案。
|
||||
/// </summary>
|
||||
public string AudienceText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 预计触达人数。
|
||||
/// </summary>
|
||||
public int EstimatedReachCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? SentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ScheduledAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开率(百分比)。
|
||||
/// </summary>
|
||||
public decimal OpenRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化率(百分比)。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息列表响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<MemberMessageReachListItemResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收件明细响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachRecipientResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 会员 ID。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
public string? Mobile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OpenId。
|
||||
/// </summary>
|
||||
public string? OpenId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? SentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已读时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ReadAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ConvertedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息。
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息详情响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string? TemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标类型。
|
||||
/// </summary>
|
||||
public string AudienceType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标标签。
|
||||
/// </summary>
|
||||
public List<string> AudienceTags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标文案。
|
||||
/// </summary>
|
||||
public string AudienceText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 预计触达人数。
|
||||
/// </summary>
|
||||
public int EstimatedReachCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间类型。
|
||||
/// </summary>
|
||||
public string ScheduleType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ScheduledAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 实际发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? SentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成功发送数。
|
||||
/// </summary>
|
||||
public int SentCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已读数。
|
||||
/// </summary>
|
||||
public int ReadCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化数。
|
||||
/// </summary>
|
||||
public int ConvertedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开率(百分比)。
|
||||
/// </summary>
|
||||
public decimal OpenRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化率(百分比)。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息。
|
||||
/// </summary>
|
||||
public string? LastError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 收件明细。
|
||||
/// </summary>
|
||||
public List<MemberMessageReachRecipientResponse> Recipients { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息调度元信息响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageDispatchMetaResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 时间类型。
|
||||
/// </summary>
|
||||
public string ScheduleType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ScheduledAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hangfire 任务 ID。
|
||||
/// </summary>
|
||||
public string? HangfireJobId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用次数。
|
||||
/// </summary>
|
||||
public int UsageCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近使用时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? LastUsedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板列表响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplateListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<MemberMessageTemplateResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 目标人群估算响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageAudienceEstimateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 预计触达人数。
|
||||
/// </summary>
|
||||
public int ReachCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则详情查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分商城规则请求。
|
||||
/// </summary>
|
||||
public sealed class SavePointMallRuleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用消费获取。
|
||||
/// </summary>
|
||||
public bool IsConsumeRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每消费多少元触发一次积分计算。
|
||||
/// </summary>
|
||||
public int ConsumeAmountPerStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每步获得积分。
|
||||
/// </summary>
|
||||
public int ConsumeRewardPointsPerStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用评价奖励。
|
||||
/// </summary>
|
||||
public bool IsReviewRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 评价奖励积分。
|
||||
/// </summary>
|
||||
public int ReviewRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用注册奖励。
|
||||
/// </summary>
|
||||
public bool IsRegisterRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册奖励积分。
|
||||
/// </summary>
|
||||
public int RegisterRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用签到奖励。
|
||||
/// </summary>
|
||||
public bool IsSigninRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签到奖励积分。
|
||||
/// </summary>
|
||||
public int SigninRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期模式(permanent/yearly_clear)。
|
||||
/// </summary>
|
||||
public string ExpiryMode { get; set; } = "yearly_clear";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled,可空)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品详情查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分商城商品请求。
|
||||
/// </summary>
|
||||
public sealed class SavePointMallProductRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? PointMallProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示图片。
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string RedeemType { get; set; } = "product";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID。
|
||||
/// </summary>
|
||||
public string? ProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联优惠券模板 ID。
|
||||
/// </summary>
|
||||
public string? CouponTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实物名称。
|
||||
/// </summary>
|
||||
public string? PhysicalName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取方式(store_pickup/delivery)。
|
||||
/// </summary>
|
||||
public string? PickupMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式(points/mixed)。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; set; } = "points";
|
||||
|
||||
/// <summary>
|
||||
/// 所需积分。
|
||||
/// </summary>
|
||||
public int RequiredPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存总量。
|
||||
/// </summary>
|
||||
public int StockTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限兑次数。
|
||||
/// </summary>
|
||||
public int? PerMemberLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道(in_app/sms)。
|
||||
/// </summary>
|
||||
public List<string> NotifyChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改积分商城商品状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangePointMallProductStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "disabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除积分商城商品请求。
|
||||
/// </summary>
|
||||
public sealed class DeletePointMallProductRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录分页查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string? RedeemType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录详情请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出积分商城兑换记录请求。
|
||||
/// </summary>
|
||||
public sealed class ExportPointMallRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string? RedeemType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入积分商城兑换记录请求。
|
||||
/// </summary>
|
||||
public sealed class WritePointMallRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员 ID。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换时间(可空,默认当前时间)。
|
||||
/// </summary>
|
||||
public DateTime? RedeemedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 核销积分商城兑换记录请求。
|
||||
/// </summary>
|
||||
public sealed class VerifyPointMallRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 核销方式(scan/manual)。
|
||||
/// </summary>
|
||||
public string VerifyMethod { get; set; } = "manual";
|
||||
|
||||
/// <summary>
|
||||
/// 核销备注。
|
||||
/// </summary>
|
||||
public string? VerifyRemark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用消费获取。
|
||||
/// </summary>
|
||||
public bool IsConsumeRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每消费多少元触发一次积分计算。
|
||||
/// </summary>
|
||||
public int ConsumeAmountPerStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每步获得积分。
|
||||
/// </summary>
|
||||
public int ConsumeRewardPointsPerStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用评价奖励。
|
||||
/// </summary>
|
||||
public bool IsReviewRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 评价奖励积分。
|
||||
/// </summary>
|
||||
public int ReviewRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用注册奖励。
|
||||
/// </summary>
|
||||
public bool IsRegisterRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册奖励积分。
|
||||
/// </summary>
|
||||
public int RegisterRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用签到奖励。
|
||||
/// </summary>
|
||||
public bool IsSigninRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签到奖励积分。
|
||||
/// </summary>
|
||||
public int SigninRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期模式(permanent/yearly_clear)。
|
||||
/// </summary>
|
||||
public string ExpiryMode { get; set; } = "yearly_clear";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则统计响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 累计发放积分。
|
||||
/// </summary>
|
||||
public int TotalIssuedPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已兑换积分。
|
||||
/// </summary>
|
||||
public int RedeemedPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分用户。
|
||||
/// </summary>
|
||||
public int PointMembers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换率(0-100)。
|
||||
/// </summary>
|
||||
public decimal RedeemRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则详情响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleDetailResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 规则。
|
||||
/// </summary>
|
||||
public PointMallRuleResponse Rule { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 统计。
|
||||
/// </summary>
|
||||
public PointMallRuleStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示图片。
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string RedeemType { get; set; } = "product";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型文案。
|
||||
/// </summary>
|
||||
public string RedeemTypeText { get; set; } = "商品";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID。
|
||||
/// </summary>
|
||||
public string? ProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联优惠券模板 ID。
|
||||
/// </summary>
|
||||
public string? CouponTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实物名称。
|
||||
/// </summary>
|
||||
public string? PhysicalName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取方式(store_pickup/delivery)。
|
||||
/// </summary>
|
||||
public string? PickupMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式(points/mixed)。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; set; } = "points";
|
||||
|
||||
/// <summary>
|
||||
/// 所需积分。
|
||||
/// </summary>
|
||||
public int RequiredPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始库存。
|
||||
/// </summary>
|
||||
public int StockTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余库存。
|
||||
/// </summary>
|
||||
public int StockAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已兑换数量。
|
||||
/// </summary>
|
||||
public int RedeemedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限兑次数。
|
||||
/// </summary>
|
||||
public int? PerMemberLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道。
|
||||
/// </summary>
|
||||
public List<string> NotifyChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = "上架";
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品列表响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<PointMallProductResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录响应。
|
||||
/// </summary>
|
||||
public class PointMallRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 兑换记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string RedeemType { get; set; } = "product";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型文案。
|
||||
/// </summary>
|
||||
public string RedeemTypeText { get; set; } = "商品";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式(points/mixed)。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; set; } = "points";
|
||||
|
||||
/// <summary>
|
||||
/// 会员 ID。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string MemberName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MemberMobileMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 消耗积分。
|
||||
/// </summary>
|
||||
public int UsedPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "issued";
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = "已发放";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换时间。
|
||||
/// </summary>
|
||||
public string RedeemedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发放时间。
|
||||
/// </summary>
|
||||
public string? IssuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销时间。
|
||||
/// </summary>
|
||||
public string? VerifiedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录详情响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordDetailResponse : PointMallRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 核销方式(scan/manual)。
|
||||
/// </summary>
|
||||
public string? VerifyMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销方式文案。
|
||||
/// </summary>
|
||||
public string? VerifyMethodText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销备注。
|
||||
/// </summary>
|
||||
public string? VerifyRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销人 ID。
|
||||
/// </summary>
|
||||
public string? VerifiedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录统计响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日兑换。
|
||||
/// </summary>
|
||||
public int TodayRedeemCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待领取实物。
|
||||
/// </summary>
|
||||
public int PendingPhysicalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月消耗积分。
|
||||
/// </summary>
|
||||
public int CurrentMonthUsedPoints { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录分页响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<PointMallRecordResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计。
|
||||
/// </summary>
|
||||
public PointMallRecordStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录导出响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordExportResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base64 文件内容。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
/// <summary>
|
||||
/// 储值卡方案列表请求。
|
||||
/// </summary>
|
||||
public sealed class StoredCardPlanListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存储值卡方案请求。
|
||||
/// </summary>
|
||||
public sealed class SaveStoredCardPlanRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 方案 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? PlanId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 充值金额。
|
||||
/// </summary>
|
||||
public decimal RechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送金额。
|
||||
/// </summary>
|
||||
public decimal GiftAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改方案状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangeStoredCardPlanStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 方案 ID。
|
||||
/// </summary>
|
||||
public string PlanId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "disabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除方案请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteStoredCardPlanRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 方案 ID。
|
||||
/// </summary>
|
||||
public string PlanId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 充值记录分页查询请求。
|
||||
/// </summary>
|
||||
public sealed class StoredCardRechargeRecordListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(会员名称/手机号/单号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 8;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 充值记录导出请求。
|
||||
/// </summary>
|
||||
public sealed class ExportStoredCardRechargeRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(会员名称/手机号/单号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入充值记录请求。
|
||||
/// </summary>
|
||||
public sealed class WriteStoredCardRechargeRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员 ID(必填)。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 方案 ID(可空)。
|
||||
/// </summary>
|
||||
public string? PlanId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 充值金额。
|
||||
/// </summary>
|
||||
public decimal RechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送金额。
|
||||
/// </summary>
|
||||
public decimal GiftAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式(wechat/alipay/cash/card/balance)。
|
||||
/// </summary>
|
||||
public string PaymentMethod { get; set; } = "wechat";
|
||||
|
||||
/// <summary>
|
||||
/// 充值时间(可空,默认当前时间)。
|
||||
/// </summary>
|
||||
public DateTime? RechargedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 储值卡方案统计响应。
|
||||
/// </summary>
|
||||
public sealed class StoredCardPlanStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 储值总额。
|
||||
/// </summary>
|
||||
public decimal TotalRechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠金总额。
|
||||
/// </summary>
|
||||
public decimal TotalGiftAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月充值。
|
||||
/// </summary>
|
||||
public decimal CurrentMonthRechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 储值用户。
|
||||
/// </summary>
|
||||
public int RechargeMemberCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 储值卡方案响应。
|
||||
/// </summary>
|
||||
public sealed class StoredCardPlanResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 方案 ID。
|
||||
/// </summary>
|
||||
public string PlanId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 充值金额。
|
||||
/// </summary>
|
||||
public decimal RechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送金额。
|
||||
/// </summary>
|
||||
public decimal GiftAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账金额。
|
||||
/// </summary>
|
||||
public decimal ArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 累计充值次数。
|
||||
/// </summary>
|
||||
public int RechargeCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计充值金额。
|
||||
/// </summary>
|
||||
public decimal TotalRechargeAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 储值卡方案列表响应。
|
||||
/// </summary>
|
||||
public sealed class StoredCardPlanListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 方案列表。
|
||||
/// </summary>
|
||||
public List<StoredCardPlanResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页面统计。
|
||||
/// </summary>
|
||||
public StoredCardPlanStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 充值记录响应。
|
||||
/// </summary>
|
||||
public sealed class StoredCardRechargeRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 充值单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员 ID。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string MemberName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MemberMobileMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 充值金额。
|
||||
/// </summary>
|
||||
public decimal RechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送金额。
|
||||
/// </summary>
|
||||
public decimal GiftAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账金额。
|
||||
/// </summary>
|
||||
public decimal ArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式编码。
|
||||
/// </summary>
|
||||
public string PaymentMethod { get; set; } = "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式文案。
|
||||
/// </summary>
|
||||
public string PaymentMethodText { get; set; } = "未知";
|
||||
|
||||
/// <summary>
|
||||
/// 充值时间(本地显示字符串)。
|
||||
/// </summary>
|
||||
public string RechargedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 方案 ID。
|
||||
/// </summary>
|
||||
public string? PlanId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 充值记录分页结果响应。
|
||||
/// </summary>
|
||||
public sealed class StoredCardRechargeRecordListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<StoredCardRechargeRecordResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 充值记录导出响应。
|
||||
/// </summary>
|
||||
public sealed class StoredCardRechargeRecordExportResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base64 内容。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级列表项响应。
|
||||
/// </summary>
|
||||
public sealed class MemberTierListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 等级标识。
|
||||
/// </summary>
|
||||
public string TierId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 排序序号。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 等级名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 图标键。
|
||||
/// </summary>
|
||||
public string IconKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 主题色。
|
||||
/// </summary>
|
||||
public string ColorHex { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 升级条件文案。
|
||||
/// </summary>
|
||||
public string ConditionText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 权益摘要。
|
||||
/// </summary>
|
||||
public List<string> Perks { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 等级会员数。
|
||||
/// </summary>
|
||||
public int MemberCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认等级。
|
||||
/// </summary>
|
||||
public bool IsDefault { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否可删除。
|
||||
/// </summary>
|
||||
public bool CanDelete { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等级详情查询请求。
|
||||
/// </summary>
|
||||
public sealed class MemberTierDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 等级标识。
|
||||
/// </summary>
|
||||
public string? TierId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等级规则响应。
|
||||
/// </summary>
|
||||
public sealed class MemberTierRuleResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 升级规则类型。
|
||||
/// </summary>
|
||||
public string UpgradeRuleType { get; set; } = "none";
|
||||
|
||||
/// <summary>
|
||||
/// 升级累计消费门槛。
|
||||
/// </summary>
|
||||
public decimal? UpgradeAmountThreshold { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 升级消费次数门槛。
|
||||
/// </summary>
|
||||
public int? UpgradeOrderCountThreshold { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 降级观察窗口天数。
|
||||
/// </summary>
|
||||
public int DowngradeWindowDays { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 折扣权益响应。
|
||||
/// </summary>
|
||||
public sealed class MemberTierDiscountBenefitResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣值。
|
||||
/// </summary>
|
||||
public decimal? DiscountRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分倍率权益响应。
|
||||
/// </summary>
|
||||
public sealed class MemberTierPointMultiplierBenefitResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 倍率。
|
||||
/// </summary>
|
||||
public decimal? Multiplier { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生日特权响应。
|
||||
/// </summary>
|
||||
public sealed class MemberTierBirthdayBenefitResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否双倍积分。
|
||||
/// </summary>
|
||||
public bool DoublePointsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 券模板 ID。
|
||||
/// </summary>
|
||||
public List<string> CouponTemplateIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 每月赠券响应。
|
||||
/// </summary>
|
||||
public sealed class MemberTierMonthlyCouponBenefitResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每月发放日。
|
||||
/// </summary>
|
||||
public int GrantDay { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 券模板 ID。
|
||||
/// </summary>
|
||||
public List<string> CouponTemplateIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 免配送费权益响应。
|
||||
/// </summary>
|
||||
public sealed class MemberTierFreeDeliveryBenefitResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每月免配送费次数。
|
||||
/// </summary>
|
||||
public int MonthlyFreeTimes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等级权益响应。
|
||||
/// </summary>
|
||||
public sealed class MemberTierBenefitsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 折扣权益。
|
||||
/// </summary>
|
||||
public MemberTierDiscountBenefitResponse Discount { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 积分倍率权益。
|
||||
/// </summary>
|
||||
public MemberTierPointMultiplierBenefitResponse PointMultiplier { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 生日特权。
|
||||
/// </summary>
|
||||
public MemberTierBirthdayBenefitResponse Birthday { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 每月赠券。
|
||||
/// </summary>
|
||||
public MemberTierMonthlyCouponBenefitResponse MonthlyCoupon { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 免配送费权益。
|
||||
/// </summary>
|
||||
public MemberTierFreeDeliveryBenefitResponse FreeDelivery { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 优先配送。
|
||||
/// </summary>
|
||||
public bool PriorityDeliveryEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 专属客服。
|
||||
/// </summary>
|
||||
public bool ExclusiveServiceEnabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等级详情响应。
|
||||
/// </summary>
|
||||
public sealed class MemberTierDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 等级标识。
|
||||
/// </summary>
|
||||
public string? TierId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序序号。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 等级名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 图标键。
|
||||
/// </summary>
|
||||
public string IconKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 主题色。
|
||||
/// </summary>
|
||||
public string ColorHex { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认等级。
|
||||
/// </summary>
|
||||
public bool IsDefault { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 升降级规则。
|
||||
/// </summary>
|
||||
public MemberTierRuleResponse Rule { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 等级权益。
|
||||
/// </summary>
|
||||
public MemberTierBenefitsResponse Benefits { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 是否可删除。
|
||||
/// </summary>
|
||||
public bool CanDelete { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存等级请求。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberTierRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 等级标识(为空时新增)。
|
||||
/// </summary>
|
||||
public string? TierId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序序号。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 等级名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 图标键。
|
||||
/// </summary>
|
||||
public string IconKey { get; set; } = "user";
|
||||
|
||||
/// <summary>
|
||||
/// 主题色。
|
||||
/// </summary>
|
||||
public string ColorHex { get; set; } = "#999999";
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认等级。
|
||||
/// </summary>
|
||||
public bool IsDefault { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 升降级规则。
|
||||
/// </summary>
|
||||
public MemberTierRuleResponse Rule { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 等级权益。
|
||||
/// </summary>
|
||||
public MemberTierBenefitsResponse Benefits { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除等级请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteMemberTierRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 等级标识。
|
||||
/// </summary>
|
||||
public string TierId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员日配置响应。
|
||||
/// </summary>
|
||||
public sealed class MemberDaySettingResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用会员日。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周几(1-7,对应周一到周日)。
|
||||
/// </summary>
|
||||
public int Weekday { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员日额外折扣。
|
||||
/// </summary>
|
||||
public decimal ExtraDiscountRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存会员日配置请求。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberDaySettingRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用会员日。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周几(1-7,对应周一到周日)。
|
||||
/// </summary>
|
||||
public int Weekday { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员日额外折扣。
|
||||
/// </summary>
|
||||
public decimal ExtraDiscountRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券选择器请求。
|
||||
/// </summary>
|
||||
public sealed class MemberCouponPickerRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券选择器项响应。
|
||||
/// </summary>
|
||||
public sealed class MemberCouponPickerItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 券模板标识。
|
||||
/// </summary>
|
||||
public string CouponTemplateId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券类型。
|
||||
/// </summary>
|
||||
public string CouponType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最低消费门槛。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示文案。
|
||||
/// </summary>
|
||||
public string DisplayText { get; set; } = string.Empty;
|
||||
}
|
||||
331
src/Api/TakeoutSaaS.TenantApi/Contracts/Order/OrderContracts.cs
Normal file
331
src/Api/TakeoutSaaS.TenantApi/Contracts/Order/OrderContracts.cs
Normal file
@@ -0,0 +1,331 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Order;
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单筛选请求。
|
||||
/// </summary>
|
||||
public class OrderAllFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道筛选(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public string? Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式筛选(wechat/alipay/balance/cash/card)。
|
||||
/// </summary>
|
||||
public string? PaymentMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(订单号/手机号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单列表请求。
|
||||
/// </summary>
|
||||
public sealed class OrderAllListRequest : OrderAllFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单详情请求。
|
||||
/// </summary>
|
||||
public sealed class OrderAllDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单列表结果。
|
||||
/// </summary>
|
||||
public sealed class OrderAllListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<OrderAllListItemResponse> 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 sealed class OrderAllListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 下单时间。
|
||||
/// </summary>
|
||||
public string OrderedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客。
|
||||
/// </summary>
|
||||
public string Customer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品摘要。
|
||||
/// </summary>
|
||||
public string ItemsSummary { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单统计。
|
||||
/// </summary>
|
||||
public sealed class OrderAllStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单数。
|
||||
/// </summary>
|
||||
public int TotalOrders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款单数。
|
||||
/// </summary>
|
||||
public int RefundCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单详情。
|
||||
/// </summary>
|
||||
public sealed class OrderAllDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public string PaymentMethod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 下单时间。
|
||||
/// </summary>
|
||||
public string OrderedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间。
|
||||
/// </summary>
|
||||
public string? PaidAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 完成时间。
|
||||
/// </summary>
|
||||
public string? FinishedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 顾客姓名。
|
||||
/// </summary>
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客手机号。
|
||||
/// </summary>
|
||||
public string CustomerPhone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 收货地址。
|
||||
/// </summary>
|
||||
public string CustomerAddress { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品金额。
|
||||
/// </summary>
|
||||
public decimal ItemsAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 配送费。
|
||||
/// </summary>
|
||||
public decimal DeliveryFee { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优惠减免。
|
||||
/// </summary>
|
||||
public decimal DiscountAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实付金额。
|
||||
/// </summary>
|
||||
public decimal PaidAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string Remark { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品明细。
|
||||
/// </summary>
|
||||
public List<OrderAllDetailItemResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态时间线。
|
||||
/// </summary>
|
||||
public List<OrderAllTimelineResponse> Timeline { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单商品明细行。
|
||||
/// </summary>
|
||||
public sealed class OrderAllDetailItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 规格。
|
||||
/// </summary>
|
||||
public string Spec { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价。
|
||||
/// </summary>
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 小计。
|
||||
/// </summary>
|
||||
public decimal SubTotal { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单时间线节点。
|
||||
/// </summary>
|
||||
public sealed class OrderAllTimelineResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 节点文案。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 时间。
|
||||
/// </summary>
|
||||
public string Time { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单导出回执。
|
||||
/// </summary>
|
||||
public sealed class OrderAllExportResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件 Base64。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出记录数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.OrderBoard;
|
||||
|
||||
/// <summary>
|
||||
/// 拒单请求体。
|
||||
/// </summary>
|
||||
public sealed record RejectOrderRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 拒单原因。
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Product;
|
||||
|
||||
/// <summary>
|
||||
/// 加料组列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class ProductAddonGroupListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存加料组请求。
|
||||
/// </summary>
|
||||
public sealed class SaveProductAddonGroupRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 加料组 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 加料组名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 加料组描述。
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否必选。
|
||||
/// </summary>
|
||||
public bool Required { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最小可选数。
|
||||
/// </summary>
|
||||
public int MinSelect { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最大可选数。
|
||||
/// </summary>
|
||||
public int MaxSelect { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID 列表。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 加料项列表。
|
||||
/// </summary>
|
||||
public List<SaveProductAddonItemRequest> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存加料项请求。
|
||||
/// </summary>
|
||||
public sealed class SaveProductAddonItemRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 加料项 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 加料项名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 加价金额。
|
||||
/// </summary>
|
||||
public decimal Price { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存数量。
|
||||
/// </summary>
|
||||
public int Stock { get; set; } = 999;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除加料组请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteProductAddonGroupRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 加料组 ID。
|
||||
/// </summary>
|
||||
public string GroupId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改加料组状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangeProductAddonGroupStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 加料组 ID。
|
||||
/// </summary>
|
||||
public string GroupId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 绑定加料组商品请求。
|
||||
/// </summary>
|
||||
public sealed class BindProductAddonGroupProductsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 加料组 ID。
|
||||
/// </summary>
|
||||
public string GroupId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID 列表。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加料项响应。
|
||||
/// </summary>
|
||||
public sealed class ProductAddonItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 加料项 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 加料项名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 加价金额。
|
||||
/// </summary>
|
||||
public decimal Price { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存数量。
|
||||
/// </summary>
|
||||
public int Stock { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加料组响应。
|
||||
/// </summary>
|
||||
public sealed class ProductAddonGroupItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 加料组 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 加料组名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否必选。
|
||||
/// </summary>
|
||||
public bool Required { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最小可选数。
|
||||
/// </summary>
|
||||
public int MinSelect { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最大可选数。
|
||||
/// </summary>
|
||||
public int MaxSelect { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品数量。
|
||||
/// </summary>
|
||||
public int ProductCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID 列表。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 加料项列表。
|
||||
/// </summary>
|
||||
public List<ProductAddonItemResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Product;
|
||||
|
||||
/// <summary>
|
||||
/// 批量范围请求。
|
||||
/// </summary>
|
||||
public sealed class ProductBatchScopeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 范围类型(all/category/selected/manual)。
|
||||
/// </summary>
|
||||
public string Type { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 单个分类 ID(兼容字段)。
|
||||
/// </summary>
|
||||
public string? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID 列表(按分类时)。
|
||||
/// </summary>
|
||||
public List<string> CategoryIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID 列表(手动选择时)。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量调价预览请求。
|
||||
/// </summary>
|
||||
public sealed class BatchPriceAdjustPreviewRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 批量范围。
|
||||
/// </summary>
|
||||
public ProductBatchScopeRequest Scope { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 调价方向(up/down)。
|
||||
/// </summary>
|
||||
public string Direction { get; set; } = "up";
|
||||
|
||||
/// <summary>
|
||||
/// 调价方式(fixed/percent)。
|
||||
/// </summary>
|
||||
public string AmountType { get; set; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 调价数值。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量调价请求。
|
||||
/// </summary>
|
||||
public sealed class BatchPriceAdjustRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 批量范围。
|
||||
/// </summary>
|
||||
public ProductBatchScopeRequest Scope { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 调价方向(up/down)。
|
||||
/// </summary>
|
||||
public string Direction { get; set; } = "up";
|
||||
|
||||
/// <summary>
|
||||
/// 调价方式(fixed/percent)。
|
||||
/// </summary>
|
||||
public string AmountType { get; set; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 调价数值。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量上下架请求。
|
||||
/// </summary>
|
||||
public sealed class BatchSaleSwitchRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 批量范围。
|
||||
/// </summary>
|
||||
public ProductBatchScopeRequest Scope { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 动作(on/off)。
|
||||
/// </summary>
|
||||
public string Action { get; set; } = "off";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量移动分类请求。
|
||||
/// </summary>
|
||||
public sealed class BatchMoveCategoryRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 源分类 ID(可选)。
|
||||
/// </summary>
|
||||
public string? SourceCategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标分类 ID。
|
||||
/// </summary>
|
||||
public string TargetCategoryId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 批量范围。
|
||||
/// </summary>
|
||||
public ProductBatchScopeRequest Scope { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量同步门店请求。
|
||||
/// </summary>
|
||||
public sealed class BatchSyncStoreRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 源门店 ID。
|
||||
/// </summary>
|
||||
public string SourceStoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标门店 ID 列表。
|
||||
/// </summary>
|
||||
public List<string> TargetStoreIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID 列表。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 是否同步价格。
|
||||
/// </summary>
|
||||
public bool SyncPrice { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 是否同步库存。
|
||||
/// </summary>
|
||||
public bool SyncStock { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 是否同步状态。
|
||||
/// </summary>
|
||||
public bool SyncStatus { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量导出请求。
|
||||
/// </summary>
|
||||
public sealed class BatchExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 批量范围。
|
||||
/// </summary>
|
||||
public ProductBatchScopeRequest Scope { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量导入请求(表单)。
|
||||
/// </summary>
|
||||
public sealed class BatchImportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导入文件。
|
||||
/// </summary>
|
||||
public IFormFile? File { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量工具通用结果。
|
||||
/// </summary>
|
||||
public sealed class BatchToolResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成功条数。
|
||||
/// </summary>
|
||||
public int SuccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败条数。
|
||||
/// </summary>
|
||||
public int FailedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 跳过条数。
|
||||
/// </summary>
|
||||
public int SkippedCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调价预览项。
|
||||
/// </summary>
|
||||
public sealed class BatchPricePreviewItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public string ProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 新价。
|
||||
/// </summary>
|
||||
public decimal NewPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 变动值。
|
||||
/// </summary>
|
||||
public decimal DeltaPrice { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调价预览结果。
|
||||
/// </summary>
|
||||
public sealed class BatchPricePreviewResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 预览项。
|
||||
/// </summary>
|
||||
public List<BatchPricePreviewItemResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 总影响商品数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Excel 文件响应。
|
||||
/// </summary>
|
||||
public sealed class BatchExcelFileResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base64 文件内容。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { 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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入错误项。
|
||||
/// </summary>
|
||||
public sealed class BatchImportErrorItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 行号。
|
||||
/// </summary>
|
||||
public int RowNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误说明。
|
||||
/// </summary>
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量导入结果。
|
||||
/// </summary>
|
||||
public sealed class BatchImportResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成功条数。
|
||||
/// </summary>
|
||||
public int SuccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败条数。
|
||||
/// </summary>
|
||||
public int FailedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 跳过条数。
|
||||
/// </summary>
|
||||
public int SkippedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 错误明细。
|
||||
/// </summary>
|
||||
public List<BatchImportErrorItemResponse> Errors { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Product;
|
||||
|
||||
/// <summary>
|
||||
/// 分类列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class ProductCategoryListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类管理列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class ProductCategoryManageListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存分类请求。
|
||||
/// </summary>
|
||||
public sealed class SaveProductCategoryRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类描述。
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 图标地址。
|
||||
/// </summary>
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道列表。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除分类请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteProductCategoryRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public string CategoryId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 变更分类状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangeProductCategoryStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public string CategoryId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类排序请求项。
|
||||
/// </summary>
|
||||
public sealed class ProductCategorySortItemRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public string CategoryId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类排序请求。
|
||||
/// </summary>
|
||||
public sealed class SortProductCategoryRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 排序项。
|
||||
/// </summary>
|
||||
public List<ProductCategorySortItemRequest> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类绑定商品请求。
|
||||
/// </summary>
|
||||
public sealed class BindCategoryProductsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public string CategoryId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID 列表。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类解绑商品请求。
|
||||
/// </summary>
|
||||
public sealed class UnbindCategoryProductRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public string CategoryId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public string ProductId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 商品选择器查询请求。
|
||||
/// </summary>
|
||||
public sealed class ProductPickerListRequest
|
||||
{
|
||||
/// <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>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
public int? Limit { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类列表项响应。
|
||||
/// </summary>
|
||||
public class ProductCategoryListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品数。
|
||||
/// </summary>
|
||||
public int ProductCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类管理项响应。
|
||||
/// </summary>
|
||||
public sealed class ProductCategoryManageItemResponse : ProductCategoryListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类描述。
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 图标地址。
|
||||
/// </summary>
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 渠道列表。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类绑定结果响应。
|
||||
/// </summary>
|
||||
public sealed class ProductBindResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成功条数。
|
||||
/// </summary>
|
||||
public int SuccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败条数。
|
||||
/// </summary>
|
||||
public int FailedCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 商品选择器项响应。
|
||||
/// </summary>
|
||||
public sealed class ProductPickerItemResponse
|
||||
{
|
||||
/// <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 Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 价格。
|
||||
/// </summary>
|
||||
public decimal Price { 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";
|
||||
}
|
||||
@@ -0,0 +1,894 @@
|
||||
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; }
|
||||
|
||||
/// <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 int? SortWeight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存预警值。
|
||||
/// </summary>
|
||||
public int? WarningStock { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 打包费。
|
||||
/// </summary>
|
||||
public decimal? PackingFee { 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>
|
||||
/// 关联规格模板 ID。
|
||||
/// </summary>
|
||||
public List<string>? SpecTemplateIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联加料组模板 ID。
|
||||
/// </summary>
|
||||
public List<string>? AddonGroupIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联标签 ID。
|
||||
/// </summary>
|
||||
public List<string>? LabelIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU 列表。
|
||||
/// </summary>
|
||||
public List<SaveProductSkuRequest>? Skus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 套餐分组。
|
||||
/// </summary>
|
||||
public List<SaveProductComboGroupRequest> ComboGroups { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 商品异步保存响应(基础信息已落库,SKU 任务异步处理)。
|
||||
/// </summary>
|
||||
public sealed class SaveProductAsyncResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public string ProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SKU 任务 ID(无任务时为 null)。
|
||||
/// </summary>
|
||||
public string? SkuJobId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU 任务状态(queued/running/failed/not_required)。
|
||||
/// </summary>
|
||||
public string SkuJobStatus { get; set; } = "not_required";
|
||||
|
||||
/// <summary>
|
||||
/// 结果说明。
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存商品套餐分组请求。
|
||||
/// </summary>
|
||||
public sealed class SaveProductComboGroupRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 分组名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 最小选择数。
|
||||
/// </summary>
|
||||
public int MinSelect { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 最大选择数。
|
||||
/// </summary>
|
||||
public int MaxSelect { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 分组排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组内商品。
|
||||
/// </summary>
|
||||
public List<SaveProductComboGroupItemRequest> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存商品套餐分组商品请求。
|
||||
/// </summary>
|
||||
public sealed class SaveProductComboGroupItemRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public string ProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存商品 SKU 请求。
|
||||
/// </summary>
|
||||
public sealed class SaveProductSkuRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SKU 编码(可选)。
|
||||
/// </summary>
|
||||
public string? SkuCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal Price { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 划线价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存。
|
||||
/// </summary>
|
||||
public int Stock { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 规格属性组合。
|
||||
/// </summary>
|
||||
public List<SaveProductSkuAttributeRequest> Attributes { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SKU 规格属性请求。
|
||||
/// </summary>
|
||||
public sealed class SaveProductSkuAttributeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 选项 ID。
|
||||
/// </summary>
|
||||
public string OptionId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建商品 SKU 异步保存任务请求。
|
||||
/// </summary>
|
||||
public sealed class CreateProductSkuSaveJobRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public string ProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联规格模板 ID(可选,未传则使用当前商品关联)。
|
||||
/// </summary>
|
||||
public List<string>? SpecTemplateIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU 列表。
|
||||
/// </summary>
|
||||
public List<SaveProductSkuRequest>? Skus { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询商品 SKU 异步任务状态请求。
|
||||
/// </summary>
|
||||
public sealed class ProductSkuSaveJobStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 商品 SKU 异步保存任务响应。
|
||||
/// </summary>
|
||||
public sealed class ProductSkuSaveJobResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务 ID。
|
||||
/// </summary>
|
||||
public string JobId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public string ProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 任务状态(queued/running/succeeded/failed/canceled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "queued";
|
||||
|
||||
/// <summary>
|
||||
/// 总处理数。
|
||||
/// </summary>
|
||||
public int ProgressTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已处理数。
|
||||
/// </summary>
|
||||
public int ProgressProcessed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败数。
|
||||
/// </summary>
|
||||
public int FailedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息。
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public string CreatedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间。
|
||||
/// </summary>
|
||||
public string? StartedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 完成时间。
|
||||
/// </summary>
|
||||
public string? FinishedAt { 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 int SortWeight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存预警值。
|
||||
/// </summary>
|
||||
public int? WarningStock { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 打包费。
|
||||
/// </summary>
|
||||
public decimal? PackingFee { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 套餐分组。
|
||||
/// </summary>
|
||||
public List<ProductComboGroupResponse> ComboGroups { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 商品图片列表。
|
||||
/// </summary>
|
||||
public List<string> ImageUrls { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 商品描述。
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联规格模板 ID。
|
||||
/// </summary>
|
||||
public List<string> SpecTemplateIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 关联加料组模板 ID。
|
||||
/// </summary>
|
||||
public List<string> AddonGroupIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 关联标签 ID。
|
||||
/// </summary>
|
||||
public List<string> LabelIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// SKU 列表。
|
||||
/// </summary>
|
||||
public List<ProductSkuResponse> Skus { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 定时上架时间。
|
||||
/// </summary>
|
||||
public string? TimedOnShelfAt { get; set; }
|
||||
|
||||
/// <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 ProductComboGroupResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分组 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分组名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 最小选择数。
|
||||
/// </summary>
|
||||
public int MinSelect { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最大选择数。
|
||||
/// </summary>
|
||||
public int MaxSelect { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组商品列表。
|
||||
/// </summary>
|
||||
public List<ProductComboGroupItemResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 套餐分组商品响应。
|
||||
/// </summary>
|
||||
public sealed class ProductComboGroupItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public string ProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 商品 SKU 响应。
|
||||
/// </summary>
|
||||
public sealed class ProductSkuResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SKU 编码。
|
||||
/// </summary>
|
||||
public string SkuCode { 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 bool IsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 规格属性。
|
||||
/// </summary>
|
||||
public List<ProductSkuAttributeResponse> Attributes { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SKU 规格属性响应。
|
||||
/// </summary>
|
||||
public sealed class ProductSkuAttributeResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 选项 ID。
|
||||
/// </summary>
|
||||
public string OptionId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Product;
|
||||
|
||||
/// <summary>
|
||||
/// 商品标签列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class ProductLabelListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存商品标签请求。
|
||||
/// </summary>
|
||||
public sealed class SaveProductLabelRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标签名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签颜色(HEX)。
|
||||
/// </summary>
|
||||
public string Color { get; set; } = "#1890ff";
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除商品标签请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteProductLabelRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签 ID。
|
||||
/// </summary>
|
||||
public string LabelId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改商品标签状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangeProductLabelStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签 ID。
|
||||
/// </summary>
|
||||
public string LabelId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 商品标签列表项响应。
|
||||
/// </summary>
|
||||
public sealed class ProductLabelItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 标签 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签颜色(HEX)。
|
||||
/// </summary>
|
||||
public string Color { get; set; } = "#1890ff";
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品数量。
|
||||
/// </summary>
|
||||
public int ProductCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Product;
|
||||
|
||||
/// <summary>
|
||||
/// 商品时段规则列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class ProductScheduleListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存商品时段规则请求。
|
||||
/// </summary>
|
||||
public sealed class SaveProductScheduleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 规则 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 规则名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string StartTime { get; set; } = "00:00";
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string EndTime { get; set; } = "00:00";
|
||||
|
||||
/// <summary>
|
||||
/// 适用星期(1-7)。
|
||||
/// </summary>
|
||||
public List<int> WeekDays { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID 列表。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除商品时段规则请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteProductScheduleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 规则 ID。
|
||||
/// </summary>
|
||||
public string ScheduleId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改商品时段规则状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangeProductScheduleStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 规则 ID。
|
||||
/// </summary>
|
||||
public string ScheduleId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 商品时段规则列表项响应。
|
||||
/// </summary>
|
||||
public sealed class ProductScheduleItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 规则 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 规则名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string StartTime { get; set; } = "00:00";
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string EndTime { get; set; } = "00:00";
|
||||
|
||||
/// <summary>
|
||||
/// 适用星期(1-7)。
|
||||
/// </summary>
|
||||
public List<int> WeekDays { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品数量。
|
||||
/// </summary>
|
||||
public int ProductCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID 列表。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Product;
|
||||
|
||||
/// <summary>
|
||||
/// 规格做法列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class ProductSpecListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板类型(spec/method)。
|
||||
/// </summary>
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存规格做法模板请求。
|
||||
/// </summary>
|
||||
public sealed class SaveProductSpecRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板类型(spec/method)。
|
||||
/// </summary>
|
||||
public string Type { get; set; } = "spec";
|
||||
|
||||
/// <summary>
|
||||
/// 选择方式(single/multi)。
|
||||
/// </summary>
|
||||
public string SelectionType { get; set; } = "single";
|
||||
|
||||
/// <summary>
|
||||
/// 是否必选。
|
||||
/// </summary>
|
||||
public bool IsRequired { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID 列表。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 模板选项。
|
||||
/// </summary>
|
||||
public List<SaveProductSpecValueRequest> Values { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存规格做法模板选项请求。
|
||||
/// </summary>
|
||||
public sealed class SaveProductSpecValueRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 选项 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 选项名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 附加价格。
|
||||
/// </summary>
|
||||
public decimal ExtraPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除规格做法模板请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteProductSpecRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string SpecId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 规格做法模板状态变更请求。
|
||||
/// </summary>
|
||||
public sealed class ChangeProductSpecStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string SpecId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制规格做法模板请求。
|
||||
/// </summary>
|
||||
public sealed class CopyProductSpecRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string SpecId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 新模板名称(可选)。
|
||||
/// </summary>
|
||||
public string? NewName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 规格做法模板选项响应。
|
||||
/// </summary>
|
||||
public sealed class ProductSpecValueResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 选项 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 选项名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 附加价格。
|
||||
/// </summary>
|
||||
public decimal ExtraPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 规格做法模板列表项响应。
|
||||
/// </summary>
|
||||
public sealed class ProductSpecItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板类型(spec/method)。
|
||||
/// </summary>
|
||||
public string Type { get; set; } = "spec";
|
||||
|
||||
/// <summary>
|
||||
/// 选择方式(single/multi)。
|
||||
/// </summary>
|
||||
public string SelectionType { get; set; } = "single";
|
||||
|
||||
/// <summary>
|
||||
/// 是否必选。
|
||||
/// </summary>
|
||||
public bool IsRequired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品数量。
|
||||
/// </summary>
|
||||
public int ProductCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID 列表。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 模板选项。
|
||||
/// </summary>
|
||||
public List<ProductSpecValueResponse> Values { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// 文件上传表单请求。
|
||||
/// </summary>
|
||||
public sealed record FileUploadFormRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 上传文件。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required IFormFile File { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 上传类型。
|
||||
/// </summary>
|
||||
public string? Type { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
/// <summary>
|
||||
/// 半径梯度。
|
||||
/// </summary>
|
||||
public sealed class RadiusTierDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// MinDistance。
|
||||
/// </summary>
|
||||
public decimal MinDistance { get; set; }
|
||||
/// <summary>
|
||||
/// MaxDistance。
|
||||
/// </summary>
|
||||
public decimal MaxDistance { get; set; }
|
||||
/// <summary>
|
||||
/// DeliveryFee。
|
||||
/// </summary>
|
||||
public decimal DeliveryFee { get; set; }
|
||||
/// <summary>
|
||||
/// EtaMinutes。
|
||||
/// </summary>
|
||||
public int EtaMinutes { get; set; }
|
||||
/// <summary>
|
||||
/// MinOrderAmount。
|
||||
/// </summary>
|
||||
public decimal MinOrderAmount { get; set; }
|
||||
/// <summary>
|
||||
/// Color。
|
||||
/// </summary>
|
||||
public string Color { get; set; } = "#1677ff";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 多边形区域。
|
||||
/// </summary>
|
||||
public sealed class PolygonZoneDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Name。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Color。
|
||||
/// </summary>
|
||||
public string Color { get; set; } = "#1677ff";
|
||||
/// <summary>
|
||||
/// DeliveryFee。
|
||||
/// </summary>
|
||||
public decimal DeliveryFee { get; set; }
|
||||
/// <summary>
|
||||
/// EtaMinutes。
|
||||
/// </summary>
|
||||
public int EtaMinutes { get; set; }
|
||||
/// <summary>
|
||||
/// MinOrderAmount。
|
||||
/// </summary>
|
||||
public decimal MinOrderAmount { get; set; }
|
||||
/// <summary>
|
||||
/// Priority。
|
||||
/// </summary>
|
||||
public int Priority { get; set; }
|
||||
/// <summary>
|
||||
/// PolygonGeoJson。
|
||||
/// </summary>
|
||||
public string PolygonGeoJson { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通用配送设置。
|
||||
/// </summary>
|
||||
public sealed class DeliveryGeneralSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// EtaAdjustmentMinutes。
|
||||
/// </summary>
|
||||
public int EtaAdjustmentMinutes { get; set; }
|
||||
/// <summary>
|
||||
/// FreeDeliveryThreshold。
|
||||
/// </summary>
|
||||
public decimal? FreeDeliveryThreshold { get; set; }
|
||||
/// <summary>
|
||||
/// HourlyCapacityLimit。
|
||||
/// </summary>
|
||||
public int HourlyCapacityLimit { get; set; }
|
||||
/// <summary>
|
||||
/// MaxDeliveryDistance。
|
||||
/// </summary>
|
||||
public decimal MaxDeliveryDistance { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 门店配送设置聚合。
|
||||
/// </summary>
|
||||
public sealed class StoreDeliverySettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// IsConfigured。
|
||||
/// </summary>
|
||||
public bool IsConfigured { get; set; }
|
||||
/// <summary>
|
||||
/// Mode。
|
||||
/// </summary>
|
||||
public string? Mode { get; set; }
|
||||
/// <summary>
|
||||
/// RadiusCenterLatitude。
|
||||
/// </summary>
|
||||
public decimal? RadiusCenterLatitude { get; set; }
|
||||
/// <summary>
|
||||
/// RadiusCenterLongitude。
|
||||
/// </summary>
|
||||
public decimal? RadiusCenterLongitude { get; set; }
|
||||
/// <summary>
|
||||
/// RadiusTiers。
|
||||
/// </summary>
|
||||
public List<RadiusTierDto> RadiusTiers { get; set; } = [];
|
||||
/// <summary>
|
||||
/// PolygonZones。
|
||||
/// </summary>
|
||||
public List<PolygonZoneDto> PolygonZones { get; set; } = [];
|
||||
/// <summary>
|
||||
/// GeneralSettings。
|
||||
/// </summary>
|
||||
public DeliveryGeneralSettingsDto? GeneralSettings { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制配送设置请求。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreDeliverySettingsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SourceStoreId。
|
||||
/// </summary>
|
||||
public string SourceStoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// TargetStoreIds。
|
||||
/// </summary>
|
||||
public List<string> TargetStoreIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制结果。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreDeliverySettingsResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CopiedCount。
|
||||
/// </summary>
|
||||
public int CopiedCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
/// <summary>
|
||||
/// 地址地理编码返回结果。
|
||||
/// </summary>
|
||||
public sealed class StoreDeliveryGeocodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 输入地址。
|
||||
/// </summary>
|
||||
public string Address { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 纬度。
|
||||
/// </summary>
|
||||
public decimal? Latitude { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 经度。
|
||||
/// </summary>
|
||||
public decimal? Longitude { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
/// <summary>
|
||||
/// 堂食基础设置。
|
||||
/// </summary>
|
||||
public sealed class DineInBasicSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Enabled。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
/// <summary>
|
||||
/// DefaultDiningMinutes。
|
||||
/// </summary>
|
||||
public int DefaultDiningMinutes { get; set; }
|
||||
/// <summary>
|
||||
/// OvertimeReminderMinutes。
|
||||
/// </summary>
|
||||
public int OvertimeReminderMinutes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 堂食区域。
|
||||
/// </summary>
|
||||
public sealed class DineInAreaDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Name。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Description。
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Sort。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 堂食桌位。
|
||||
/// </summary>
|
||||
public sealed class DineInTableDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// AreaId。
|
||||
/// </summary>
|
||||
public string AreaId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Code。
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Seats。
|
||||
/// </summary>
|
||||
public int Seats { get; set; }
|
||||
/// <summary>
|
||||
/// Status。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "free";
|
||||
/// <summary>
|
||||
/// Tags。
|
||||
/// </summary>
|
||||
public List<string> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 堂食设置聚合。
|
||||
/// </summary>
|
||||
public sealed class StoreDineInSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// IsConfigured。
|
||||
/// </summary>
|
||||
public bool IsConfigured { get; set; }
|
||||
/// <summary>
|
||||
/// BasicSettings。
|
||||
/// </summary>
|
||||
public DineInBasicSettingsDto? BasicSettings { get; set; }
|
||||
/// <summary>
|
||||
/// Areas。
|
||||
/// </summary>
|
||||
public List<DineInAreaDto> Areas { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Tables。
|
||||
/// </summary>
|
||||
public List<DineInTableDto> Tables { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存基础设置请求。
|
||||
/// </summary>
|
||||
public sealed class SaveStoreDineInBasicSettingsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// BasicSettings。
|
||||
/// </summary>
|
||||
public DineInBasicSettingsDto BasicSettings { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存区域请求。
|
||||
/// </summary>
|
||||
public sealed class SaveDineInAreaRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Area。
|
||||
/// </summary>
|
||||
public DineInAreaDto Area { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除区域请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteDineInAreaRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// AreaId。
|
||||
/// </summary>
|
||||
public string AreaId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存桌位请求。
|
||||
/// </summary>
|
||||
public sealed class SaveDineInTableRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Table。
|
||||
/// </summary>
|
||||
public DineInTableDto Table { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除桌位请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteDineInTableRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// TableId。
|
||||
/// </summary>
|
||||
public string TableId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量生成桌位请求。
|
||||
/// </summary>
|
||||
public sealed class BatchCreateDineInTablesRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// AreaId。
|
||||
/// </summary>
|
||||
public string AreaId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// CodePrefix。
|
||||
/// </summary>
|
||||
public string CodePrefix { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// StartNumber。
|
||||
/// </summary>
|
||||
public int StartNumber { get; set; }
|
||||
/// <summary>
|
||||
/// Count。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
/// <summary>
|
||||
/// Seats。
|
||||
/// </summary>
|
||||
public int Seats { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量生成桌位结果。
|
||||
/// </summary>
|
||||
public sealed class BatchCreateDineInTablesResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// CreatedTables。
|
||||
/// </summary>
|
||||
public List<DineInTableDto> CreatedTables { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制堂食设置请求。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreDineInSettingsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SourceStoreId。
|
||||
/// </summary>
|
||||
public string SourceStoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// TargetStoreIds。
|
||||
/// </summary>
|
||||
public List<string> TargetStoreIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制结果。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreDineInSettingsResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CopiedCount。
|
||||
/// </summary>
|
||||
public int CopiedCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
/// <summary>
|
||||
/// 阶梯包装费。
|
||||
/// </summary>
|
||||
public sealed class PackagingFeeTierDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// MinAmount。
|
||||
/// </summary>
|
||||
public decimal MinAmount { get; set; }
|
||||
/// <summary>
|
||||
/// MaxAmount。
|
||||
/// </summary>
|
||||
public decimal? MaxAmount { get; set; }
|
||||
/// <summary>
|
||||
/// Fee。
|
||||
/// </summary>
|
||||
public decimal Fee { get; set; }
|
||||
/// <summary>
|
||||
/// Sort。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 附加费用项。
|
||||
/// </summary>
|
||||
public sealed class AdditionalFeeItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Enabled。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
/// <summary>
|
||||
/// Amount。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 其他费用。
|
||||
/// </summary>
|
||||
public sealed class StoreOtherFeesDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Cutlery。
|
||||
/// </summary>
|
||||
public AdditionalFeeItemDto Cutlery { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Rush。
|
||||
/// </summary>
|
||||
public AdditionalFeeItemDto Rush { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 门店费用设置。
|
||||
/// </summary>
|
||||
public sealed class StoreFeesSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// IsConfigured。
|
||||
/// </summary>
|
||||
public bool IsConfigured { get; set; }
|
||||
/// <summary>
|
||||
/// MinimumOrderAmount。
|
||||
/// </summary>
|
||||
public decimal MinimumOrderAmount { get; set; }
|
||||
/// <summary>
|
||||
/// BaseDeliveryFee。
|
||||
/// </summary>
|
||||
public decimal BaseDeliveryFee { get; set; }
|
||||
/// <summary>
|
||||
/// PlatformServiceRate。
|
||||
/// </summary>
|
||||
public decimal PlatformServiceRate { get; set; }
|
||||
/// <summary>
|
||||
/// FreeDeliveryThreshold。
|
||||
/// </summary>
|
||||
public decimal? FreeDeliveryThreshold { get; set; }
|
||||
/// <summary>
|
||||
/// PackagingFeeMode。
|
||||
/// </summary>
|
||||
public string PackagingFeeMode { get; set; } = "order";
|
||||
/// <summary>
|
||||
/// OrderPackagingFeeMode。
|
||||
/// </summary>
|
||||
public string OrderPackagingFeeMode { get; set; } = "fixed";
|
||||
/// <summary>
|
||||
/// FixedPackagingFee。
|
||||
/// </summary>
|
||||
public decimal FixedPackagingFee { get; set; }
|
||||
/// <summary>
|
||||
/// PackagingFeeTiers。
|
||||
/// </summary>
|
||||
public List<PackagingFeeTierDto> PackagingFeeTiers { get; set; } = [];
|
||||
/// <summary>
|
||||
/// OtherFees。
|
||||
/// </summary>
|
||||
public StoreOtherFeesDto OtherFees { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存包装费模式请求。
|
||||
/// </summary>
|
||||
public sealed class SaveStoreFeesModeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// PackagingFeeMode。
|
||||
/// </summary>
|
||||
public string PackagingFeeMode { get; set; } = "order";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制费用请求。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreFeesSettingsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SourceStoreId。
|
||||
/// </summary>
|
||||
public string SourceStoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// TargetStoreIds。
|
||||
/// </summary>
|
||||
public List<string> TargetStoreIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制结果。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreFeesSettingsResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CopiedCount。
|
||||
/// </summary>
|
||||
public int CopiedCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
/// <summary>
|
||||
/// 时段类型。
|
||||
/// </summary>
|
||||
public enum StoreHourSlotType
|
||||
{
|
||||
/// <summary>
|
||||
/// 营业时段。
|
||||
/// </summary>
|
||||
Business = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 配送时段。
|
||||
/// </summary>
|
||||
Delivery = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 自提时段。
|
||||
/// </summary>
|
||||
Pickup = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 特殊日期类型。
|
||||
/// </summary>
|
||||
public enum StoreHolidayType
|
||||
{
|
||||
/// <summary>
|
||||
/// 休息日。
|
||||
/// </summary>
|
||||
Closed = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 特殊营业日。
|
||||
/// </summary>
|
||||
Special = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 时段 DTO。
|
||||
/// </summary>
|
||||
public sealed class StoreHourTimeSlotDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Type。
|
||||
/// </summary>
|
||||
public int Type { get; set; }
|
||||
/// <summary>
|
||||
/// StartTime。
|
||||
/// </summary>
|
||||
public string StartTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// EndTime。
|
||||
/// </summary>
|
||||
public string EndTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Capacity。
|
||||
/// </summary>
|
||||
public int? Capacity { get; set; }
|
||||
/// <summary>
|
||||
/// Remark。
|
||||
/// </summary>
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 每日营业时间 DTO。
|
||||
/// </summary>
|
||||
public sealed class StoreHourDayHoursDto
|
||||
{
|
||||
/// <summary>
|
||||
/// DayOfWeek。
|
||||
/// </summary>
|
||||
public int DayOfWeek { get; set; }
|
||||
/// <summary>
|
||||
/// IsOpen。
|
||||
/// </summary>
|
||||
public bool IsOpen { get; set; }
|
||||
/// <summary>
|
||||
/// Slots。
|
||||
/// </summary>
|
||||
public List<StoreHourTimeSlotDto> Slots { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 特殊日期 DTO。
|
||||
/// </summary>
|
||||
public sealed class StoreHourHolidayDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// StartDate。
|
||||
/// </summary>
|
||||
public string StartDate { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// EndDate。
|
||||
/// </summary>
|
||||
public string EndDate { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Type。
|
||||
/// </summary>
|
||||
public int Type { get; set; }
|
||||
/// <summary>
|
||||
/// StartTime。
|
||||
/// </summary>
|
||||
public string? StartTime { get; set; }
|
||||
/// <summary>
|
||||
/// EndTime。
|
||||
/// </summary>
|
||||
public string? EndTime { get; set; }
|
||||
/// <summary>
|
||||
/// Reason。
|
||||
/// </summary>
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Remark。
|
||||
/// </summary>
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 门店营业时间聚合。
|
||||
/// </summary>
|
||||
public sealed class StoreHoursDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// WeeklyHours。
|
||||
/// </summary>
|
||||
public List<StoreHourDayHoursDto> WeeklyHours { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Holidays。
|
||||
/// </summary>
|
||||
public List<StoreHourHolidayDto> Holidays { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存每周时段请求。
|
||||
/// </summary>
|
||||
public sealed class SaveWeeklyHoursRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// WeeklyHours。
|
||||
/// </summary>
|
||||
public List<StoreHourDayHoursDto> WeeklyHours { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存特殊日期请求。
|
||||
/// </summary>
|
||||
public sealed class SaveHolidayRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Holiday。
|
||||
/// </summary>
|
||||
public StoreHourHolidayDto Holiday { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除特殊日期请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteHolidayRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制营业时间请求。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreHoursRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SourceStoreId。
|
||||
/// </summary>
|
||||
public string SourceStoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// TargetStoreIds。
|
||||
/// </summary>
|
||||
public List<string> TargetStoreIds { get; set; } = [];
|
||||
/// <summary>
|
||||
/// IncludeWeeklyHours。
|
||||
/// </summary>
|
||||
public bool? IncludeWeeklyHours { get; set; }
|
||||
/// <summary>
|
||||
/// IncludeHolidays。
|
||||
/// </summary>
|
||||
public bool? IncludeHolidays { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制结果。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreHoursResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CopiedCount。
|
||||
/// </summary>
|
||||
public int CopiedCount { get; set; }
|
||||
/// <summary>
|
||||
/// IncludeWeeklyHours。
|
||||
/// </summary>
|
||||
public bool IncludeWeeklyHours { get; set; }
|
||||
/// <summary>
|
||||
/// IncludeHolidays。
|
||||
/// </summary>
|
||||
public bool IncludeHolidays { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
/// <summary>
|
||||
/// 自提基础设置。
|
||||
/// </summary>
|
||||
public sealed class PickupBasicSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// AllowSameDayPickup。
|
||||
/// </summary>
|
||||
public bool AllowSameDayPickup { get; set; }
|
||||
/// <summary>
|
||||
/// BookingDays。
|
||||
/// </summary>
|
||||
public int BookingDays { get; set; }
|
||||
/// <summary>
|
||||
/// MaxItemsPerOrder。
|
||||
/// </summary>
|
||||
public int? MaxItemsPerOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 自提大时段。
|
||||
/// </summary>
|
||||
public sealed class PickupSlotDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Name。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// StartTime。
|
||||
/// </summary>
|
||||
public string StartTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// EndTime。
|
||||
/// </summary>
|
||||
public string EndTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// CutoffMinutes。
|
||||
/// </summary>
|
||||
public int CutoffMinutes { get; set; }
|
||||
/// <summary>
|
||||
/// Capacity。
|
||||
/// </summary>
|
||||
public int Capacity { get; set; }
|
||||
/// <summary>
|
||||
/// ReservedCount。
|
||||
/// </summary>
|
||||
public int ReservedCount { get; set; }
|
||||
/// <summary>
|
||||
/// DayOfWeeks。
|
||||
/// </summary>
|
||||
public List<int> DayOfWeeks { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Enabled。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 精细规则。
|
||||
/// </summary>
|
||||
public sealed class PickupFineRuleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// IntervalMinutes。
|
||||
/// </summary>
|
||||
public int IntervalMinutes { get; set; }
|
||||
/// <summary>
|
||||
/// SlotCapacity。
|
||||
/// </summary>
|
||||
public int SlotCapacity { get; set; }
|
||||
/// <summary>
|
||||
/// DayStartTime。
|
||||
/// </summary>
|
||||
public string DayStartTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// DayEndTime。
|
||||
/// </summary>
|
||||
public string DayEndTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// MinAdvanceHours。
|
||||
/// </summary>
|
||||
public int MinAdvanceHours { get; set; }
|
||||
/// <summary>
|
||||
/// DayOfWeeks。
|
||||
/// </summary>
|
||||
public List<int> DayOfWeeks { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预览时段。
|
||||
/// </summary>
|
||||
public sealed class PickupPreviewSlotDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Time。
|
||||
/// </summary>
|
||||
public string Time { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Status。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "available";
|
||||
/// <summary>
|
||||
/// RemainingCount。
|
||||
/// </summary>
|
||||
public int RemainingCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预览日期。
|
||||
/// </summary>
|
||||
public sealed class PickupPreviewDayDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Date。
|
||||
/// </summary>
|
||||
public string Date { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Label。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// SubLabel。
|
||||
/// </summary>
|
||||
public string SubLabel { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Slots。
|
||||
/// </summary>
|
||||
public List<PickupPreviewSlotDto> Slots { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 门店自提设置。
|
||||
/// </summary>
|
||||
public sealed class StorePickupSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// IsConfigured。
|
||||
/// </summary>
|
||||
public bool IsConfigured { get; set; }
|
||||
/// <summary>
|
||||
/// Mode。
|
||||
/// </summary>
|
||||
public string? Mode { get; set; }
|
||||
/// <summary>
|
||||
/// BasicSettings。
|
||||
/// </summary>
|
||||
public PickupBasicSettingsDto? BasicSettings { get; set; }
|
||||
/// <summary>
|
||||
/// BigSlots。
|
||||
/// </summary>
|
||||
public List<PickupSlotDto> BigSlots { get; set; } = [];
|
||||
/// <summary>
|
||||
/// FineRule。
|
||||
/// </summary>
|
||||
public PickupFineRuleDto? FineRule { get; set; }
|
||||
/// <summary>
|
||||
/// PreviewDays。
|
||||
/// </summary>
|
||||
public List<PickupPreviewDayDto> PreviewDays { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存基础设置请求。
|
||||
/// </summary>
|
||||
public sealed class SavePickupBasicSettingsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Mode。
|
||||
/// </summary>
|
||||
public string? Mode { get; set; }
|
||||
/// <summary>
|
||||
/// BasicSettings。
|
||||
/// </summary>
|
||||
public PickupBasicSettingsDto BasicSettings { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存大时段请求。
|
||||
/// </summary>
|
||||
public sealed class SavePickupSlotsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Mode。
|
||||
/// </summary>
|
||||
public string? Mode { get; set; }
|
||||
/// <summary>
|
||||
/// Slots。
|
||||
/// </summary>
|
||||
public List<PickupSlotDto> Slots { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存精细规则请求。
|
||||
/// </summary>
|
||||
public sealed class SavePickupFineRuleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Mode。
|
||||
/// </summary>
|
||||
public string? Mode { get; set; }
|
||||
/// <summary>
|
||||
/// FineRule。
|
||||
/// </summary>
|
||||
public PickupFineRuleDto FineRule { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存自提模式请求。
|
||||
/// </summary>
|
||||
public sealed class SavePickupModeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Mode。
|
||||
/// </summary>
|
||||
public string Mode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制自提设置请求。
|
||||
/// </summary>
|
||||
public sealed class CopyStorePickupSettingsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SourceStoreId。
|
||||
/// </summary>
|
||||
public string SourceStoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// TargetStoreIds。
|
||||
/// </summary>
|
||||
public List<string> TargetStoreIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制结果。
|
||||
/// </summary>
|
||||
public sealed class CopyStorePickupSettingsResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CopiedCount。
|
||||
/// </summary>
|
||||
public int CopiedCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
/// <summary>
|
||||
/// 分页结构。
|
||||
/// </summary>
|
||||
public sealed class PaginatedResultDto<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Items。
|
||||
/// </summary>
|
||||
public List<T> Items { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Total。
|
||||
/// </summary>
|
||||
public int Total { get; set; }
|
||||
/// <summary>
|
||||
/// Page。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
/// <summary>
|
||||
/// PageSize。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工档案。
|
||||
/// </summary>
|
||||
public sealed class StoreStaffItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Name。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Phone。
|
||||
/// </summary>
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Email。
|
||||
/// </summary>
|
||||
public string Email { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// RoleType。
|
||||
/// </summary>
|
||||
public string RoleType { get; set; } = "cashier";
|
||||
/// <summary>
|
||||
/// Status。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "active";
|
||||
/// <summary>
|
||||
/// Permissions。
|
||||
/// </summary>
|
||||
public List<string> Permissions { get; set; } = [];
|
||||
/// <summary>
|
||||
/// AvatarColor。
|
||||
/// </summary>
|
||||
public string AvatarColor { get; set; } = "#1677ff";
|
||||
/// <summary>
|
||||
/// HiredAt。
|
||||
/// </summary>
|
||||
public string HiredAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 班次时间段模板。
|
||||
/// </summary>
|
||||
public sealed class ShiftTemplateItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StartTime。
|
||||
/// </summary>
|
||||
public string StartTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// EndTime。
|
||||
/// </summary>
|
||||
public string EndTime { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 门店班次模板。
|
||||
/// </summary>
|
||||
public sealed class StoreShiftTemplatesDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Morning。
|
||||
/// </summary>
|
||||
public ShiftTemplateItemDto Morning { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Evening。
|
||||
/// </summary>
|
||||
public ShiftTemplateItemDto Evening { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Full。
|
||||
/// </summary>
|
||||
public ShiftTemplateItemDto Full { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工单日排班。
|
||||
/// </summary>
|
||||
public sealed class StaffDayShiftDto
|
||||
{
|
||||
/// <summary>
|
||||
/// DayOfWeek。
|
||||
/// </summary>
|
||||
public int DayOfWeek { get; set; }
|
||||
/// <summary>
|
||||
/// ShiftType。
|
||||
/// </summary>
|
||||
public string ShiftType { get; set; } = "off";
|
||||
/// <summary>
|
||||
/// StartTime。
|
||||
/// </summary>
|
||||
public string StartTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// EndTime。
|
||||
/// </summary>
|
||||
public string EndTime { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工排班。
|
||||
/// </summary>
|
||||
public sealed class StaffScheduleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StaffId。
|
||||
/// </summary>
|
||||
public string StaffId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Shifts。
|
||||
/// </summary>
|
||||
public List<StaffDayShiftDto> Shifts { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 门店排班聚合。
|
||||
/// </summary>
|
||||
public sealed class StoreStaffScheduleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// WeekStartDate。
|
||||
/// </summary>
|
||||
public string WeekStartDate { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Templates。
|
||||
/// </summary>
|
||||
public StoreShiftTemplatesDto? Templates { get; set; }
|
||||
/// <summary>
|
||||
/// IsTemplateConfigured。
|
||||
/// </summary>
|
||||
public bool IsTemplateConfigured { get; set; }
|
||||
/// <summary>
|
||||
/// IsScheduleConfigured。
|
||||
/// </summary>
|
||||
public bool IsScheduleConfigured { get; set; }
|
||||
/// <summary>
|
||||
/// Schedules。
|
||||
/// </summary>
|
||||
public List<StaffScheduleDto> Schedules { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存员工请求。
|
||||
/// </summary>
|
||||
public sealed class SaveStoreStaffRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
/// <summary>
|
||||
/// Name。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Phone。
|
||||
/// </summary>
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Email。
|
||||
/// </summary>
|
||||
public string Email { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// RoleType。
|
||||
/// </summary>
|
||||
public string RoleType { get; set; } = "cashier";
|
||||
/// <summary>
|
||||
/// Status。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "active";
|
||||
/// <summary>
|
||||
/// Permissions。
|
||||
/// </summary>
|
||||
public List<string> Permissions { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除员工请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteStoreStaffRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// StaffId。
|
||||
/// </summary>
|
||||
public string StaffId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存班次模板请求。
|
||||
/// </summary>
|
||||
public sealed class SaveStoreStaffTemplatesRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Templates。
|
||||
/// </summary>
|
||||
public StoreShiftTemplatesDto Templates { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存个人排班请求。
|
||||
/// </summary>
|
||||
public sealed class SaveStoreStaffPersonalScheduleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// StaffId。
|
||||
/// </summary>
|
||||
public string StaffId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Shifts。
|
||||
/// </summary>
|
||||
public List<StaffDayShiftDto> Shifts { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存周排班请求。
|
||||
/// </summary>
|
||||
public sealed class SaveStoreStaffWeeklyScheduleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Schedules。
|
||||
/// </summary>
|
||||
public List<StaffScheduleDto> Schedules { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制排班请求。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreStaffScheduleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SourceStoreId。
|
||||
/// </summary>
|
||||
public string SourceStoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// TargetStoreIds。
|
||||
/// </summary>
|
||||
public List<string> TargetStoreIds { get; set; } = [];
|
||||
/// <summary>
|
||||
/// CopyScope。
|
||||
/// </summary>
|
||||
public string CopyScope { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制排班结果。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreStaffScheduleResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CopiedCount。
|
||||
/// </summary>
|
||||
public int CopiedCount { get; set; }
|
||||
/// <summary>
|
||||
/// CopyScope。
|
||||
/// </summary>
|
||||
public string CopyScope { get; set; } = "template_and_schedule";
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Customer;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/customer/analysis")]
|
||||
public sealed class CustomerAnalysisController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:customer:analysis:view";
|
||||
|
||||
/// <summary>
|
||||
/// 获取客户分析总览。
|
||||
/// </summary>
|
||||
[HttpGet("overview")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerAnalysisOverviewResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerAnalysisOverviewResponse>> Overview(
|
||||
[FromQuery] CustomerAnalysisOverviewRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
var (periodCode, periodDays) = ParsePeriod(request.Period);
|
||||
|
||||
var result = await mediator.Send(new GetCustomerAnalysisOverviewQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
PeriodCode = periodCode,
|
||||
PeriodDays = periodDays
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CustomerAnalysisOverviewResponse>.Ok(MapOverview(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取客群明细。
|
||||
/// </summary>
|
||||
[HttpGet("segment/list")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerAnalysisSegmentListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerAnalysisSegmentListResultResponse>> SegmentList(
|
||||
[FromQuery] CustomerAnalysisSegmentListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
var (periodCode, periodDays) = ParsePeriod(request.Period);
|
||||
|
||||
var result = await mediator.Send(new GetCustomerAnalysisSegmentListQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
PeriodCode = periodCode,
|
||||
PeriodDays = periodDays,
|
||||
SegmentCode = request.SegmentCode,
|
||||
Keyword = request.Keyword,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CustomerAnalysisSegmentListResultResponse>.Ok(MapSegmentList(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取客户详情(分析页二级抽屉)。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerDetailResponse>> Detail(
|
||||
[FromQuery] CustomerDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var customerKey = NormalizePhone(request.CustomerKey);
|
||||
if (string.IsNullOrWhiteSpace(customerKey))
|
||||
{
|
||||
return ApiResponse<CustomerDetailResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
|
||||
}
|
||||
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
var result = await mediator.Send(new GetCustomerDetailQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
CustomerKey = customerKey
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<CustomerDetailResponse>.Error(ErrorCodes.NotFound, "客户不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<CustomerDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取客户完整画像(分析页二级抽屉)。
|
||||
/// </summary>
|
||||
[HttpGet("profile")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerProfileResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerProfileResponse>> Profile(
|
||||
[FromQuery] CustomerProfileRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var customerKey = NormalizePhone(request.CustomerKey);
|
||||
if (string.IsNullOrWhiteSpace(customerKey))
|
||||
{
|
||||
return ApiResponse<CustomerProfileResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
|
||||
}
|
||||
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
var result = await mediator.Send(new GetCustomerProfileQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
CustomerKey = customerKey
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<CustomerProfileResponse>.Error(ErrorCodes.NotFound, "客户不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<CustomerProfileResponse>.Ok(MapProfile(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取会员详情。
|
||||
/// </summary>
|
||||
[HttpGet("member/detail")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerMemberDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerMemberDetailResponse>> MemberDetail(
|
||||
[FromQuery] CustomerMemberDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var customerKey = NormalizePhone(request.CustomerKey);
|
||||
if (string.IsNullOrWhiteSpace(customerKey))
|
||||
{
|
||||
return ApiResponse<CustomerMemberDetailResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
|
||||
}
|
||||
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetCustomerMemberDetailQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
CustomerKey = customerKey
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<CustomerMemberDetailResponse>.Error(ErrorCodes.NotFound, "客户不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<CustomerMemberDetailResponse>.Ok(MapMemberDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出客户分析报表。
|
||||
/// </summary>
|
||||
[HttpGet("export")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerExportResponse>> Export(
|
||||
[FromQuery] CustomerAnalysisExportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
var (periodCode, periodDays) = ParsePeriod(request.Period);
|
||||
|
||||
var result = await mediator.Send(new ExportCustomerAnalysisCsvQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
PeriodCode = periodCode,
|
||||
PeriodDays = periodDays
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CustomerExportResponse>.Ok(new CustomerExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
|
||||
string? storeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(storeId))
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreId,
|
||||
cancellationToken);
|
||||
return [parsedStoreId];
|
||||
}
|
||||
|
||||
var allStoreIds = await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
|
||||
.Select(item => item.Id)
|
||||
.OrderBy(item => item)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (allStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||
}
|
||||
|
||||
return allStoreIds;
|
||||
}
|
||||
|
||||
private static (string PeriodCode, int PeriodDays) ParsePeriod(string? period)
|
||||
{
|
||||
var normalized = (period ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return ("30d", 30);
|
||||
}
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"7" or "7d" => ("7d", 7),
|
||||
"30" or "30d" => ("30d", 30),
|
||||
"90" or "90d" => ("90d", 90),
|
||||
"365" or "365d" or "1y" or "1year" => ("365d", 365),
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "period 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizePhone(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var chars = value.Where(char.IsDigit).ToArray();
|
||||
return chars.Length == 0 ? string.Empty : new string(chars);
|
||||
}
|
||||
|
||||
private static CustomerAnalysisOverviewResponse MapOverview(CustomerAnalysisOverviewDto source)
|
||||
{
|
||||
return new CustomerAnalysisOverviewResponse
|
||||
{
|
||||
PeriodCode = source.PeriodCode,
|
||||
PeriodDays = source.PeriodDays,
|
||||
TotalCustomers = source.TotalCustomers,
|
||||
NewCustomers = source.NewCustomers,
|
||||
GrowthRatePercent = source.GrowthRatePercent,
|
||||
NewCustomersDailyAverage = source.NewCustomersDailyAverage,
|
||||
ActiveCustomers = source.ActiveCustomers,
|
||||
ActiveRatePercent = source.ActiveRatePercent,
|
||||
AverageLifetimeValue = source.AverageLifetimeValue,
|
||||
GrowthTrend = source.GrowthTrend.Select(MapTrendPoint).ToList(),
|
||||
Composition = source.Composition.Select(MapCompositionItem).ToList(),
|
||||
AmountDistribution = source.AmountDistribution.Select(MapAmountDistributionItem).ToList(),
|
||||
RfmRows = source.RfmRows.Select(MapRfmRow).ToList(),
|
||||
TopCustomers = source.TopCustomers.Select(MapTopCustomer).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisTrendPointResponse MapTrendPoint(CustomerAnalysisTrendPointDto source)
|
||||
{
|
||||
return new CustomerAnalysisTrendPointResponse
|
||||
{
|
||||
Label = source.Label,
|
||||
Value = source.Value
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisCompositionItemResponse MapCompositionItem(CustomerAnalysisCompositionItemDto source)
|
||||
{
|
||||
return new CustomerAnalysisCompositionItemResponse
|
||||
{
|
||||
SegmentCode = source.SegmentCode,
|
||||
Label = source.Label,
|
||||
Count = source.Count,
|
||||
Percent = source.Percent,
|
||||
Tone = source.Tone
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisAmountDistributionItemResponse MapAmountDistributionItem(
|
||||
CustomerAnalysisAmountDistributionItemDto source)
|
||||
{
|
||||
return new CustomerAnalysisAmountDistributionItemResponse
|
||||
{
|
||||
SegmentCode = source.SegmentCode,
|
||||
Label = source.Label,
|
||||
Count = source.Count,
|
||||
Percent = source.Percent
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisRfmRowResponse MapRfmRow(CustomerAnalysisRfmRowDto source)
|
||||
{
|
||||
return new CustomerAnalysisRfmRowResponse
|
||||
{
|
||||
Label = source.Label,
|
||||
Cells = source.Cells.Select(MapRfmCell).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisRfmCellResponse MapRfmCell(CustomerAnalysisRfmCellDto source)
|
||||
{
|
||||
return new CustomerAnalysisRfmCellResponse
|
||||
{
|
||||
SegmentCode = source.SegmentCode,
|
||||
Label = source.Label,
|
||||
Count = source.Count,
|
||||
Tone = source.Tone
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisTopCustomerResponse MapTopCustomer(CustomerAnalysisTopCustomerDto source)
|
||||
{
|
||||
return new CustomerAnalysisTopCustomerResponse
|
||||
{
|
||||
Rank = source.Rank,
|
||||
CustomerKey = source.CustomerKey,
|
||||
Name = source.Name,
|
||||
PhoneMasked = source.PhoneMasked,
|
||||
TotalAmount = source.TotalAmount,
|
||||
OrderCount = source.OrderCount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
LastOrderAt = ToDateOnly(source.LastOrderAt),
|
||||
Tags = source.Tags.Select(MapTag).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerDetailResponse MapDetail(CustomerDetailDto source)
|
||||
{
|
||||
return new CustomerDetailResponse
|
||||
{
|
||||
CustomerKey = source.CustomerKey,
|
||||
Name = source.Name,
|
||||
PhoneMasked = source.PhoneMasked,
|
||||
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||
FirstOrderAt = ToDateOnly(source.FirstOrderAt),
|
||||
Source = source.Source,
|
||||
Tags = source.Tags.Select(MapTag).ToList(),
|
||||
Member = MapMember(source.Member),
|
||||
TotalOrders = source.TotalOrders,
|
||||
TotalAmount = source.TotalAmount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
RepurchaseRatePercent = source.RepurchaseRatePercent,
|
||||
Preference = MapPreference(source.Preference),
|
||||
TopProducts = source.TopProducts.Select(MapTopProduct).ToList(),
|
||||
Trend = source.Trend.Select(MapTrend).ToList(),
|
||||
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerProfileResponse MapProfile(CustomerProfileDto source)
|
||||
{
|
||||
return new CustomerProfileResponse
|
||||
{
|
||||
CustomerKey = source.CustomerKey,
|
||||
Name = source.Name,
|
||||
PhoneMasked = source.PhoneMasked,
|
||||
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||
FirstOrderAt = ToDateOnly(source.FirstOrderAt),
|
||||
Source = source.Source,
|
||||
Tags = source.Tags.Select(MapTag).ToList(),
|
||||
Member = MapMember(source.Member),
|
||||
TotalOrders = source.TotalOrders,
|
||||
TotalAmount = source.TotalAmount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
RepurchaseRatePercent = source.RepurchaseRatePercent,
|
||||
AverageOrderIntervalDays = source.AverageOrderIntervalDays,
|
||||
Preference = MapPreference(source.Preference),
|
||||
TopProducts = source.TopProducts.Select(MapTopProduct).ToList(),
|
||||
Trend = source.Trend.Select(MapTrend).ToList(),
|
||||
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisSegmentListResultResponse MapSegmentList(CustomerAnalysisSegmentListResultDto source)
|
||||
{
|
||||
return new CustomerAnalysisSegmentListResultResponse
|
||||
{
|
||||
SegmentCode = source.SegmentCode,
|
||||
SegmentTitle = source.SegmentTitle,
|
||||
SegmentDescription = source.SegmentDescription,
|
||||
Items = source.Items.Select(MapSegmentListItem).ToList(),
|
||||
Page = source.Page,
|
||||
PageSize = source.PageSize,
|
||||
TotalCount = source.TotalCount
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisSegmentListItemResponse MapSegmentListItem(CustomerAnalysisSegmentListItemDto source)
|
||||
{
|
||||
return new CustomerAnalysisSegmentListItemResponse
|
||||
{
|
||||
CustomerKey = source.CustomerKey,
|
||||
Name = source.Name,
|
||||
PhoneMasked = source.PhoneMasked,
|
||||
AvatarText = source.AvatarText,
|
||||
AvatarColor = source.AvatarColor,
|
||||
Tags = source.Tags.Select(MapTag).ToList(),
|
||||
IsMember = source.IsMember,
|
||||
MemberTierName = source.MemberTierName,
|
||||
TotalAmount = source.TotalAmount,
|
||||
OrderCount = source.OrderCount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||
LastOrderAt = ToDateOnly(source.LastOrderAt),
|
||||
IsDimmed = source.IsDimmed
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerMemberDetailResponse MapMemberDetail(CustomerMemberDetailDto source)
|
||||
{
|
||||
return new CustomerMemberDetailResponse
|
||||
{
|
||||
CustomerKey = source.CustomerKey,
|
||||
Name = source.Name,
|
||||
PhoneMasked = source.PhoneMasked,
|
||||
Source = source.Source,
|
||||
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||
LastOrderAt = ToDateOnly(source.LastOrderAt),
|
||||
Member = MapMember(source.Member),
|
||||
Tags = source.Tags.Select(MapTag).ToList(),
|
||||
TotalOrders = source.TotalOrders,
|
||||
TotalAmount = source.TotalAmount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
RepurchaseRatePercent = source.RepurchaseRatePercent,
|
||||
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerTagResponse MapTag(CustomerTagDto source)
|
||||
{
|
||||
return new CustomerTagResponse
|
||||
{
|
||||
Code = source.Code,
|
||||
Label = source.Label,
|
||||
Tone = source.Tone
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerMemberSummaryResponse MapMember(CustomerMemberSummaryDto source)
|
||||
{
|
||||
return new CustomerMemberSummaryResponse
|
||||
{
|
||||
IsMember = source.IsMember,
|
||||
TierName = source.TierName,
|
||||
PointsBalance = source.PointsBalance,
|
||||
GrowthValue = source.GrowthValue,
|
||||
JoinedAt = source.JoinedAt.HasValue ? ToDateOnly(source.JoinedAt.Value) : string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerPreferenceResponse MapPreference(CustomerPreferenceDto source)
|
||||
{
|
||||
return new CustomerPreferenceResponse
|
||||
{
|
||||
PreferredCategories = source.PreferredCategories.ToList(),
|
||||
PreferredOrderPeaks = source.PreferredOrderPeaks,
|
||||
PreferredDelivery = source.PreferredDelivery,
|
||||
PreferredPaymentMethod = source.PreferredPaymentMethod,
|
||||
AverageDeliveryDistance = source.AverageDeliveryDistance
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerTopProductResponse MapTopProduct(CustomerTopProductDto source)
|
||||
{
|
||||
return new CustomerTopProductResponse
|
||||
{
|
||||
Rank = source.Rank,
|
||||
ProductName = source.ProductName,
|
||||
Count = source.Count,
|
||||
ProportionPercent = source.ProportionPercent
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerTrendPointResponse MapTrend(CustomerTrendPointDto source)
|
||||
{
|
||||
return new CustomerTrendPointResponse
|
||||
{
|
||||
Label = source.Label,
|
||||
Amount = source.Amount
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerRecentOrderResponse MapRecentOrder(CustomerRecentOrderDto source)
|
||||
{
|
||||
return new CustomerRecentOrderResponse
|
||||
{
|
||||
OrderNo = source.OrderNo,
|
||||
Amount = source.Amount,
|
||||
ItemsSummary = source.ItemsSummary,
|
||||
DeliveryType = source.DeliveryType,
|
||||
Status = source.Status,
|
||||
OrderedAt = ToDateTime(source.OrderedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToDateOnly(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string ToDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
392
src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerController.cs
Normal file
392
src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerController.cs
Normal file
@@ -0,0 +1,392 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Customer;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 客户管理列表与画像。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/customer/list")]
|
||||
public sealed class CustomerController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:customer:list:view";
|
||||
private const string ManagePermission = "tenant:customer:list:manage";
|
||||
private const string ProfilePermission = "tenant:customer:profile:view";
|
||||
|
||||
/// <summary>
|
||||
/// 获取客户列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerListResultResponse>> List(
|
||||
[FromQuery] CustomerListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SearchCustomerListQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
Keyword = request.Keyword,
|
||||
Tag = request.Tag,
|
||||
OrderCountRange = request.OrderCountRange,
|
||||
RegisterPeriodDays = ParseRegisterPeriodDays(request.RegisterPeriod),
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CustomerListResultResponse>.Ok(new CustomerListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.TotalCount,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取客户列表统计。
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerListStatsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerListStatsResponse>> Stats(
|
||||
[FromQuery] CustomerListFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetCustomerListStatsQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
Keyword = request.Keyword,
|
||||
Tag = request.Tag,
|
||||
OrderCountRange = request.OrderCountRange,
|
||||
RegisterPeriodDays = ParseRegisterPeriodDays(request.RegisterPeriod)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CustomerListStatsResponse>.Ok(new CustomerListStatsResponse
|
||||
{
|
||||
TotalCustomers = result.TotalCustomers,
|
||||
MonthlyNewCustomers = result.MonthlyNewCustomers,
|
||||
MonthlyGrowthRatePercent = result.MonthlyGrowthRatePercent,
|
||||
ActiveCustomers = result.ActiveCustomers,
|
||||
AverageAmountLast30Days = result.AverageAmountLast30Days
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取客户详情(一级抽屉)。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerDetailResponse>> Detail(
|
||||
[FromQuery] CustomerDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var customerKey = NormalizePhone(request.CustomerKey);
|
||||
if (string.IsNullOrWhiteSpace(customerKey))
|
||||
{
|
||||
return ApiResponse<CustomerDetailResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
|
||||
}
|
||||
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
var result = await mediator.Send(new GetCustomerDetailQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
CustomerKey = customerKey
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<CustomerDetailResponse>.Error(ErrorCodes.NotFound, "客户不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<CustomerDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取客户画像(二级抽屉)。
|
||||
/// </summary>
|
||||
[HttpGet("profile")]
|
||||
[PermissionAuthorize(ProfilePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerProfileResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerProfileResponse>> Profile(
|
||||
[FromQuery] CustomerProfileRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var customerKey = NormalizePhone(request.CustomerKey);
|
||||
if (string.IsNullOrWhiteSpace(customerKey))
|
||||
{
|
||||
return ApiResponse<CustomerProfileResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
|
||||
}
|
||||
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
var result = await mediator.Send(new GetCustomerProfileQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
CustomerKey = customerKey
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<CustomerProfileResponse>.Error(ErrorCodes.NotFound, "客户不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<CustomerProfileResponse>.Ok(MapProfile(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出客户 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("export")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerExportResponse>> Export(
|
||||
[FromQuery] CustomerListFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportCustomerCsvQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
Keyword = request.Keyword,
|
||||
Tag = request.Tag,
|
||||
OrderCountRange = request.OrderCountRange,
|
||||
RegisterPeriodDays = ParseRegisterPeriodDays(request.RegisterPeriod)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CustomerExportResponse>.Ok(new CustomerExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
|
||||
string? storeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(storeId))
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreId,
|
||||
cancellationToken);
|
||||
return [parsedStoreId];
|
||||
}
|
||||
|
||||
var allStoreIds = await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
|
||||
.Select(item => item.Id)
|
||||
.OrderBy(item => item)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (allStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||
}
|
||||
|
||||
return allStoreIds;
|
||||
}
|
||||
|
||||
private static int? ParseRegisterPeriodDays(string? registerPeriod)
|
||||
{
|
||||
var normalized = (registerPeriod ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"7" or "7d" => 7,
|
||||
"30" or "30d" => 30,
|
||||
"90" or "90d" => 90,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "registerPeriod 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizePhone(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var chars = value.Where(char.IsDigit).ToArray();
|
||||
return chars.Length == 0 ? string.Empty : new string(chars);
|
||||
}
|
||||
|
||||
private static CustomerListItemResponse MapListItem(CustomerListItemDto source)
|
||||
{
|
||||
return new CustomerListItemResponse
|
||||
{
|
||||
CustomerKey = source.CustomerKey,
|
||||
Name = source.Name,
|
||||
PhoneMasked = source.PhoneMasked,
|
||||
AvatarText = source.AvatarText,
|
||||
AvatarColor = source.AvatarColor,
|
||||
OrderCount = source.OrderCount,
|
||||
OrderCountBarPercent = source.OrderCountBarPercent,
|
||||
TotalAmount = source.TotalAmount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
LastOrderAt = ToDateOnly(source.LastOrderAt),
|
||||
Tags = source.Tags.Select(MapTag).ToList(),
|
||||
IsDimmed = source.IsDimmed
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerDetailResponse MapDetail(CustomerDetailDto source)
|
||||
{
|
||||
return new CustomerDetailResponse
|
||||
{
|
||||
CustomerKey = source.CustomerKey,
|
||||
Name = source.Name,
|
||||
PhoneMasked = source.PhoneMasked,
|
||||
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||
FirstOrderAt = ToDateOnly(source.FirstOrderAt),
|
||||
Source = source.Source,
|
||||
Tags = source.Tags.Select(MapTag).ToList(),
|
||||
Member = MapMember(source.Member),
|
||||
TotalOrders = source.TotalOrders,
|
||||
TotalAmount = source.TotalAmount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
RepurchaseRatePercent = source.RepurchaseRatePercent,
|
||||
Preference = MapPreference(source.Preference),
|
||||
TopProducts = source.TopProducts.Select(MapTopProduct).ToList(),
|
||||
Trend = source.Trend.Select(MapTrend).ToList(),
|
||||
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerProfileResponse MapProfile(CustomerProfileDto source)
|
||||
{
|
||||
return new CustomerProfileResponse
|
||||
{
|
||||
CustomerKey = source.CustomerKey,
|
||||
Name = source.Name,
|
||||
PhoneMasked = source.PhoneMasked,
|
||||
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||
FirstOrderAt = ToDateOnly(source.FirstOrderAt),
|
||||
Source = source.Source,
|
||||
Tags = source.Tags.Select(MapTag).ToList(),
|
||||
Member = MapMember(source.Member),
|
||||
TotalOrders = source.TotalOrders,
|
||||
TotalAmount = source.TotalAmount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
RepurchaseRatePercent = source.RepurchaseRatePercent,
|
||||
AverageOrderIntervalDays = source.AverageOrderIntervalDays,
|
||||
Preference = MapPreference(source.Preference),
|
||||
TopProducts = source.TopProducts.Select(MapTopProduct).ToList(),
|
||||
Trend = source.Trend.Select(MapTrend).ToList(),
|
||||
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerTagResponse MapTag(CustomerTagDto source)
|
||||
{
|
||||
return new CustomerTagResponse
|
||||
{
|
||||
Code = source.Code,
|
||||
Label = source.Label,
|
||||
Tone = source.Tone
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerPreferenceResponse MapPreference(CustomerPreferenceDto source)
|
||||
{
|
||||
return new CustomerPreferenceResponse
|
||||
{
|
||||
PreferredCategories = source.PreferredCategories.ToList(),
|
||||
PreferredOrderPeaks = source.PreferredOrderPeaks,
|
||||
PreferredDelivery = source.PreferredDelivery,
|
||||
PreferredPaymentMethod = source.PreferredPaymentMethod,
|
||||
AverageDeliveryDistance = source.AverageDeliveryDistance
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerMemberSummaryResponse MapMember(CustomerMemberSummaryDto source)
|
||||
{
|
||||
return new CustomerMemberSummaryResponse
|
||||
{
|
||||
IsMember = source.IsMember,
|
||||
TierName = source.TierName,
|
||||
PointsBalance = source.PointsBalance,
|
||||
GrowthValue = source.GrowthValue,
|
||||
JoinedAt = source.JoinedAt.HasValue ? ToDateOnly(source.JoinedAt.Value) : string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerTopProductResponse MapTopProduct(CustomerTopProductDto source)
|
||||
{
|
||||
return new CustomerTopProductResponse
|
||||
{
|
||||
Rank = source.Rank,
|
||||
ProductName = source.ProductName,
|
||||
Count = source.Count,
|
||||
ProportionPercent = source.ProportionPercent
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerTrendPointResponse MapTrend(CustomerTrendPointDto source)
|
||||
{
|
||||
return new CustomerTrendPointResponse
|
||||
{
|
||||
Label = source.Label,
|
||||
Amount = source.Amount
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerRecentOrderResponse MapRecentOrder(CustomerRecentOrderDto source)
|
||||
{
|
||||
return new CustomerRecentOrderResponse
|
||||
{
|
||||
OrderNo = source.OrderNo,
|
||||
Amount = source.Amount,
|
||||
ItemsSummary = source.ItemsSummary,
|
||||
DeliveryType = source.DeliveryType,
|
||||
Status = source.Status,
|
||||
OrderedAt = ToDateTime(source.OrderedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToDateOnly(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string ToDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
79
src/Api/TakeoutSaaS.TenantApi/Controllers/FilesController.cs
Normal file
79
src/Api/TakeoutSaaS.TenantApi/Controllers/FilesController.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.Storage.Abstractions;
|
||||
using TakeoutSaaS.Application.Storage.Contracts;
|
||||
using TakeoutSaaS.Application.Storage.Extensions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Requests;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 租户端文件上传。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/files")]
|
||||
public sealed class FilesController(
|
||||
IFileStorageService fileStorageService,
|
||||
ITenantProvider tenantProvider) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 上传图片或文件。
|
||||
/// </summary>
|
||||
/// <returns>文件上传响应信息。</returns>
|
||||
[HttpPost("upload")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status403Forbidden)]
|
||||
public async Task<ApiResponse<FileUploadResponse>> Upload([FromForm] FileUploadFormRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验文件有效性
|
||||
if (request.File is null || request.File.Length == 0)
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "文件不能为空");
|
||||
}
|
||||
|
||||
// 2. 校验租户标识参数
|
||||
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "TenantId 不能为空");
|
||||
}
|
||||
|
||||
// 3. 校验当前租户上下文
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (currentTenantId <= 0)
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "缺少租户标识");
|
||||
}
|
||||
|
||||
if (request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.Forbidden, "禁止跨租户上传文件");
|
||||
}
|
||||
|
||||
// 4. 解析上传类型
|
||||
if (!UploadFileTypeParser.TryParse(request.Type, out var uploadType))
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "上传类型不合法");
|
||||
}
|
||||
|
||||
// 5. 提取请求来源
|
||||
var origin = Request.Headers["Origin"].FirstOrDefault() ?? Request.Headers["Referer"].FirstOrDefault();
|
||||
await using var stream = request.File.OpenReadStream();
|
||||
|
||||
// 6. 调用存储服务执行上传
|
||||
var result = await fileStorageService.UploadAsync(
|
||||
new UploadFileRequest(uploadType, stream, request.File.FileName, request.File.ContentType ?? string.Empty, request.File.Length, origin),
|
||||
cancellationToken);
|
||||
|
||||
// 7. 返回上传结果
|
||||
return ApiResponse<FileUploadResponse>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Cost.Commands;
|
||||
using TakeoutSaaS.Application.App.Finance.Cost.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Cost.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心成本管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/finance/cost")]
|
||||
public sealed class FinanceCostController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:finance:cost:view";
|
||||
private const string ManagePermission = "tenant:finance:cost:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 查询成本录入数据。
|
||||
/// </summary>
|
||||
[HttpGet("entry")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceCostEntryResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceCostEntryResponse>> Entry(
|
||||
[FromQuery] FinanceCostEntryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析维度与作用域。
|
||||
var scope = await ParseScopeAsync(request, cancellationToken);
|
||||
|
||||
// 2. 查询录入数据并映射响应。
|
||||
var result = await mediator.Send(new GetFinanceCostEntryQuery
|
||||
{
|
||||
Dimension = scope.Dimension,
|
||||
StoreId = scope.StoreId,
|
||||
CostMonth = scope.CostMonth
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceCostEntryResponse>.Ok(MapEntry(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存成本录入数据。
|
||||
/// </summary>
|
||||
[HttpPost("entry/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceCostEntryResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceCostEntryResponse>> SaveEntry(
|
||||
[FromBody] SaveFinanceCostEntryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析维度与作用域。
|
||||
var scope = await ParseScopeAsync(request, cancellationToken);
|
||||
|
||||
// 2. 发起保存命令并映射响应。
|
||||
var result = await mediator.Send(new SaveFinanceCostEntryCommand
|
||||
{
|
||||
Dimension = scope.Dimension,
|
||||
StoreId = scope.StoreId,
|
||||
CostMonth = scope.CostMonth,
|
||||
Categories = (request.Categories ?? [])
|
||||
.Select(MapSaveCategory)
|
||||
.ToList()
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceCostEntryResponse>.Ok(MapEntry(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询成本分析数据。
|
||||
/// </summary>
|
||||
[HttpGet("analysis")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceCostAnalysisResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceCostAnalysisResponse>> Analysis(
|
||||
[FromQuery] FinanceCostAnalysisRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析维度与作用域。
|
||||
var scope = await ParseScopeAsync(request, cancellationToken);
|
||||
|
||||
// 2. 查询分析数据并映射响应。
|
||||
var result = await mediator.Send(new GetFinanceCostAnalysisQuery
|
||||
{
|
||||
Dimension = scope.Dimension,
|
||||
StoreId = scope.StoreId,
|
||||
CostMonth = scope.CostMonth,
|
||||
TrendMonthCount = Math.Clamp(request.TrendMonthCount, 3, 12)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceCostAnalysisResponse>.Ok(MapAnalysis(result));
|
||||
}
|
||||
|
||||
private async Task<(FinanceCostDimension Dimension, long? StoreId, DateTime CostMonth)> ParseScopeAsync(
|
||||
FinanceCostScopeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var dimension = ParseDimension(request.Dimension);
|
||||
var costMonth = ParseMonthOrDefault(request.Month);
|
||||
|
||||
if (dimension == FinanceCostDimension.Tenant)
|
||||
{
|
||||
return (dimension, null, costMonth);
|
||||
}
|
||||
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
return (dimension, storeId, costMonth);
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static FinanceCostDimension ParseDimension(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"" or "tenant" => FinanceCostDimension.Tenant,
|
||||
"store" => FinanceCostDimension.Store,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "dimension 非法")
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime ParseMonthOrDefault(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
var utcNow = DateTime.UtcNow;
|
||||
return new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
value.Trim(),
|
||||
"yyyy-MM",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var parsed))
|
||||
{
|
||||
return new DateTime(parsed.Year, parsed.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "month 格式必须为 yyyy-MM");
|
||||
}
|
||||
|
||||
private static FinanceCostCategory ParseCategory(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"food" => FinanceCostCategory.FoodMaterial,
|
||||
"labor" => FinanceCostCategory.Labor,
|
||||
"fixed" => FinanceCostCategory.FixedExpense,
|
||||
"packaging" => FinanceCostCategory.PackagingConsumable,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "category 非法")
|
||||
};
|
||||
}
|
||||
|
||||
private static SaveFinanceCostCategoryCommandItem MapSaveCategory(SaveFinanceCostCategoryRequest source)
|
||||
{
|
||||
return new SaveFinanceCostCategoryCommandItem
|
||||
{
|
||||
Category = ParseCategory(source.Category),
|
||||
TotalAmount = source.TotalAmount,
|
||||
Items = (source.Items ?? [])
|
||||
.Select(item => new SaveFinanceCostDetailCommandItem
|
||||
{
|
||||
ItemId = StoreApiHelpers.ParseSnowflakeOrNull(item.ItemId),
|
||||
ItemName = item.ItemName,
|
||||
Amount = item.Amount,
|
||||
Quantity = item.Quantity,
|
||||
UnitPrice = item.UnitPrice,
|
||||
SortOrder = item.SortOrder
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceCostEntryResponse MapEntry(FinanceCostEntryDto source)
|
||||
{
|
||||
return new FinanceCostEntryResponse
|
||||
{
|
||||
Dimension = source.Dimension,
|
||||
StoreId = source.StoreId,
|
||||
Month = source.Month,
|
||||
MonthRevenue = source.MonthRevenue,
|
||||
TotalCost = source.TotalCost,
|
||||
CostRate = source.CostRate,
|
||||
Categories = source.Categories.Select(category => new FinanceCostEntryCategoryResponse
|
||||
{
|
||||
Category = category.Category,
|
||||
CategoryText = category.CategoryText,
|
||||
TotalAmount = category.TotalAmount,
|
||||
Percentage = category.Percentage,
|
||||
Items = category.Items.Select(item => new FinanceCostEntryDetailResponse
|
||||
{
|
||||
ItemId = item.ItemId,
|
||||
ItemName = item.ItemName,
|
||||
Amount = item.Amount,
|
||||
Quantity = item.Quantity,
|
||||
UnitPrice = item.UnitPrice,
|
||||
SortOrder = item.SortOrder
|
||||
}).ToList()
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceCostAnalysisResponse MapAnalysis(FinanceCostAnalysisDto source)
|
||||
{
|
||||
return new FinanceCostAnalysisResponse
|
||||
{
|
||||
Dimension = source.Dimension,
|
||||
StoreId = source.StoreId,
|
||||
Month = source.Month,
|
||||
Stats = new FinanceCostAnalysisStatsResponse
|
||||
{
|
||||
TotalCost = source.Stats.TotalCost,
|
||||
FoodCostRate = source.Stats.FoodCostRate,
|
||||
AverageCostPerPaidOrder = source.Stats.AverageCostPerPaidOrder,
|
||||
MonthOnMonthChangeRate = source.Stats.MonthOnMonthChangeRate,
|
||||
Revenue = source.Stats.Revenue,
|
||||
PaidOrderCount = source.Stats.PaidOrderCount
|
||||
},
|
||||
Trend = source.Trend.Select(item => new FinanceCostTrendPointResponse
|
||||
{
|
||||
Month = item.Month,
|
||||
TotalCost = item.TotalCost,
|
||||
Revenue = item.Revenue,
|
||||
CostRate = item.CostRate
|
||||
}).ToList(),
|
||||
Composition = source.Composition.Select(item => new FinanceCostCompositionResponse
|
||||
{
|
||||
Category = item.Category,
|
||||
CategoryText = item.CategoryText,
|
||||
Amount = item.Amount,
|
||||
Percentage = item.Percentage
|
||||
}).ToList(),
|
||||
DetailRows = source.DetailRows.Select(item => new FinanceCostMonthlyDetailResponse
|
||||
{
|
||||
Month = item.Month,
|
||||
FoodAmount = item.FoodAmount,
|
||||
LaborAmount = item.LaborAmount,
|
||||
FixedAmount = item.FixedAmount,
|
||||
PackagingAmount = item.PackagingAmount,
|
||||
TotalCost = item.TotalCost,
|
||||
CostRate = item.CostRate
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Invoice.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心发票管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/finance/invoice")]
|
||||
public sealed class FinanceInvoiceController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:finance:invoice:view";
|
||||
private const string IssuePermission = "tenant:finance:invoice:issue";
|
||||
private const string VoidPermission = "tenant:finance:invoice:void";
|
||||
private const string SettingsPermission = "tenant:finance:invoice:settings";
|
||||
|
||||
/// <summary>
|
||||
/// 查询发票设置详情。
|
||||
/// </summary>
|
||||
[HttpGet("settings/detail")]
|
||||
[PermissionAuthorize(ViewPermission, SettingsPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceSettingResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceInvoiceSettingResponse>> SettingsDetail(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetFinanceInvoiceSettingDetailQuery(), cancellationToken);
|
||||
return ApiResponse<FinanceInvoiceSettingResponse>.Ok(MapSetting(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存发票设置。
|
||||
/// </summary>
|
||||
[HttpPost("settings/save")]
|
||||
[PermissionAuthorize(SettingsPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceSettingResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceInvoiceSettingResponse>> SettingsSave(
|
||||
[FromBody] FinanceInvoiceSettingSaveRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new SaveFinanceInvoiceSettingCommand
|
||||
{
|
||||
CompanyName = request.CompanyName,
|
||||
TaxpayerNumber = request.TaxpayerNumber,
|
||||
RegisteredAddress = request.RegisteredAddress,
|
||||
RegisteredPhone = request.RegisteredPhone,
|
||||
BankName = request.BankName,
|
||||
BankAccount = request.BankAccount,
|
||||
EnableElectronicNormalInvoice = request.EnableElectronicNormalInvoice,
|
||||
EnableElectronicSpecialInvoice = request.EnableElectronicSpecialInvoice,
|
||||
EnableAutoIssue = request.EnableAutoIssue,
|
||||
AutoIssueMaxAmount = request.AutoIssueMaxAmount
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceInvoiceSettingResponse>.Ok(MapSetting(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询发票记录分页。
|
||||
/// </summary>
|
||||
[HttpGet("record/list")]
|
||||
[PermissionAuthorize(ViewPermission, IssuePermission, VoidPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceInvoiceRecordListResultResponse>> RecordList(
|
||||
[FromQuery] FinanceInvoiceRecordListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetFinanceInvoiceRecordListQuery
|
||||
{
|
||||
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
|
||||
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
|
||||
Status = ParseStatusOrNull(request.Status),
|
||||
InvoiceType = ParseInvoiceTypeOrNull(request.InvoiceType),
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceInvoiceRecordListResultResponse>.Ok(new FinanceInvoiceRecordListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapRecord).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount,
|
||||
Stats = new FinanceInvoiceStatsResponse
|
||||
{
|
||||
CurrentMonthIssuedAmount = result.Stats.CurrentMonthIssuedAmount,
|
||||
CurrentMonthIssuedCount = result.Stats.CurrentMonthIssuedCount,
|
||||
PendingCount = result.Stats.PendingCount,
|
||||
VoidedCount = result.Stats.VoidedCount
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询发票记录详情。
|
||||
/// </summary>
|
||||
[HttpGet("record/detail")]
|
||||
[PermissionAuthorize(ViewPermission, IssuePermission, VoidPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceInvoiceRecordDetailResponse>> RecordDetail(
|
||||
[FromQuery] FinanceInvoiceRecordDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetFinanceInvoiceRecordDetailQuery
|
||||
{
|
||||
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceInvoiceRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票开票。
|
||||
/// </summary>
|
||||
[HttpPost("record/issue")]
|
||||
[PermissionAuthorize(IssuePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceIssueResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceInvoiceIssueResultResponse>> RecordIssue(
|
||||
[FromBody] FinanceInvoiceRecordIssueRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new IssueFinanceInvoiceRecordCommand
|
||||
{
|
||||
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
|
||||
ContactEmail = request.ContactEmail,
|
||||
IssueRemark = request.IssueRemark
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceInvoiceIssueResultResponse>.Ok(MapIssueResult(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 作废发票。
|
||||
/// </summary>
|
||||
[HttpPost("record/void")]
|
||||
[PermissionAuthorize(VoidPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceInvoiceRecordDetailResponse>> RecordVoid(
|
||||
[FromBody] FinanceInvoiceRecordVoidRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new VoidFinanceInvoiceRecordCommand
|
||||
{
|
||||
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
|
||||
VoidReason = request.VoidReason
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceInvoiceRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 申请发票。
|
||||
/// </summary>
|
||||
[HttpPost("record/apply")]
|
||||
[PermissionAuthorize(ViewPermission, IssuePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceInvoiceRecordDetailResponse>> RecordApply(
|
||||
[FromBody] FinanceInvoiceRecordApplyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new ApplyFinanceInvoiceRecordCommand
|
||||
{
|
||||
ApplicantName = request.ApplicantName,
|
||||
CompanyName = request.CompanyName,
|
||||
TaxpayerNumber = request.TaxpayerNumber,
|
||||
InvoiceType = request.InvoiceType,
|
||||
Amount = request.Amount,
|
||||
OrderNo = request.OrderNo,
|
||||
ContactEmail = request.ContactEmail,
|
||||
ContactPhone = request.ContactPhone,
|
||||
ApplyRemark = request.ApplyRemark,
|
||||
AppliedAt = request.AppliedAt
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceInvoiceRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value, string fieldName)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: StoreApiHelpers.ParseDateOnly(value, fieldName);
|
||||
}
|
||||
|
||||
private static TenantInvoiceStatus? ParseStatusOrNull(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"pending" => TenantInvoiceStatus.Pending,
|
||||
"issued" => TenantInvoiceStatus.Issued,
|
||||
"voided" => TenantInvoiceStatus.Voided,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
private static TenantInvoiceType? ParseInvoiceTypeOrNull(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"normal" => TenantInvoiceType.Normal,
|
||||
"special" => TenantInvoiceType.Special,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "invoiceType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceInvoiceSettingResponse MapSetting(FinanceInvoiceSettingDto source)
|
||||
{
|
||||
return new FinanceInvoiceSettingResponse
|
||||
{
|
||||
CompanyName = source.CompanyName,
|
||||
TaxpayerNumber = source.TaxpayerNumber,
|
||||
RegisteredAddress = source.RegisteredAddress,
|
||||
RegisteredPhone = source.RegisteredPhone,
|
||||
BankName = source.BankName,
|
||||
BankAccount = source.BankAccount,
|
||||
EnableElectronicNormalInvoice = source.EnableElectronicNormalInvoice,
|
||||
EnableElectronicSpecialInvoice = source.EnableElectronicSpecialInvoice,
|
||||
EnableAutoIssue = source.EnableAutoIssue,
|
||||
AutoIssueMaxAmount = source.AutoIssueMaxAmount
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceInvoiceRecordResponse MapRecord(FinanceInvoiceRecordDto source)
|
||||
{
|
||||
return new FinanceInvoiceRecordResponse
|
||||
{
|
||||
RecordId = source.RecordId.ToString(),
|
||||
InvoiceNo = source.InvoiceNo,
|
||||
ApplicantName = source.ApplicantName,
|
||||
CompanyName = source.CompanyName,
|
||||
InvoiceType = source.InvoiceType,
|
||||
InvoiceTypeText = source.InvoiceTypeText,
|
||||
Amount = source.Amount,
|
||||
OrderNo = source.OrderNo,
|
||||
Status = source.Status,
|
||||
StatusText = source.StatusText,
|
||||
AppliedAt = source.AppliedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceInvoiceRecordDetailResponse MapRecordDetail(FinanceInvoiceRecordDetailDto source)
|
||||
{
|
||||
return new FinanceInvoiceRecordDetailResponse
|
||||
{
|
||||
RecordId = source.RecordId.ToString(),
|
||||
InvoiceNo = source.InvoiceNo,
|
||||
ApplicantName = source.ApplicantName,
|
||||
CompanyName = source.CompanyName,
|
||||
TaxpayerNumber = source.TaxpayerNumber,
|
||||
InvoiceType = source.InvoiceType,
|
||||
InvoiceTypeText = source.InvoiceTypeText,
|
||||
Amount = source.Amount,
|
||||
OrderNo = source.OrderNo,
|
||||
ContactEmail = source.ContactEmail,
|
||||
ContactPhone = source.ContactPhone,
|
||||
ApplyRemark = source.ApplyRemark,
|
||||
Status = source.Status,
|
||||
StatusText = source.StatusText,
|
||||
AppliedAt = source.AppliedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
IssuedByUserId = source.IssuedByUserId?.ToString(),
|
||||
IssueRemark = source.IssueRemark,
|
||||
VoidedAt = source.VoidedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
VoidedByUserId = source.VoidedByUserId?.ToString(),
|
||||
VoidReason = source.VoidReason
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceInvoiceIssueResultResponse MapIssueResult(FinanceInvoiceIssueResultDto source)
|
||||
{
|
||||
return new FinanceInvoiceIssueResultResponse
|
||||
{
|
||||
RecordId = source.RecordId.ToString(),
|
||||
InvoiceNo = source.InvoiceNo,
|
||||
CompanyName = source.CompanyName,
|
||||
Amount = source.Amount,
|
||||
ContactEmail = source.ContactEmail,
|
||||
IssuedAt = source.IssuedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
Status = source.Status,
|
||||
StatusText = source.StatusText
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Overview.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Overview.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心概览驾驶舱。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/finance/overview")]
|
||||
public sealed class FinanceOverviewController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:finance:overview:view";
|
||||
|
||||
/// <summary>
|
||||
/// 查询财务概览驾驶舱数据。
|
||||
/// </summary>
|
||||
[HttpGet("dashboard")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceOverviewDashboardResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceOverviewDashboardResponse>> Dashboard(
|
||||
[FromQuery] FinanceOverviewDashboardRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析维度与作用域。
|
||||
var dimension = ParseDimension(request.Dimension);
|
||||
long? storeId = null;
|
||||
if (dimension == FinanceCostDimension.Store)
|
||||
{
|
||||
storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId.Value, cancellationToken);
|
||||
}
|
||||
|
||||
// 2. 查询概览数据。
|
||||
var dashboard = await mediator.Send(new GetFinanceOverviewDashboardQuery
|
||||
{
|
||||
Dimension = dimension,
|
||||
StoreId = storeId,
|
||||
CurrentUtc = DateTime.UtcNow
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 映射响应并返回。
|
||||
return ApiResponse<FinanceOverviewDashboardResponse>.Ok(MapDashboard(dashboard));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static FinanceCostDimension ParseDimension(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"" or "tenant" => FinanceCostDimension.Tenant,
|
||||
"store" => FinanceCostDimension.Store,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "dimension 非法")
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceOverviewDashboardResponse MapDashboard(FinanceOverviewDashboardDto source)
|
||||
{
|
||||
return new FinanceOverviewDashboardResponse
|
||||
{
|
||||
Dimension = source.Dimension,
|
||||
StoreId = source.StoreId,
|
||||
TodayRevenue = MapKpi(source.TodayRevenue),
|
||||
ActualReceived = MapKpi(source.ActualReceived),
|
||||
RefundAmount = MapKpi(source.RefundAmount),
|
||||
NetIncome = MapKpi(source.NetIncome),
|
||||
WithdrawableBalance = MapKpi(source.WithdrawableBalance),
|
||||
IncomeTrend = new FinanceOverviewIncomeTrendResponse
|
||||
{
|
||||
Last7Days = source.IncomeTrend.Last7Days.Select(item => new FinanceOverviewIncomeTrendPointResponse
|
||||
{
|
||||
Date = item.Date,
|
||||
DateLabel = item.DateLabel,
|
||||
Amount = item.Amount
|
||||
}).ToList(),
|
||||
Last30Days = source.IncomeTrend.Last30Days.Select(item => new FinanceOverviewIncomeTrendPointResponse
|
||||
{
|
||||
Date = item.Date,
|
||||
DateLabel = item.DateLabel,
|
||||
Amount = item.Amount
|
||||
}).ToList()
|
||||
},
|
||||
ProfitTrend = new FinanceOverviewProfitTrendResponse
|
||||
{
|
||||
Last7Days = source.ProfitTrend.Last7Days.Select(item => new FinanceOverviewProfitTrendPointResponse
|
||||
{
|
||||
Date = item.Date,
|
||||
DateLabel = item.DateLabel,
|
||||
RevenueAmount = item.RevenueAmount,
|
||||
CostAmount = item.CostAmount,
|
||||
NetProfitAmount = item.NetProfitAmount
|
||||
}).ToList(),
|
||||
Last30Days = source.ProfitTrend.Last30Days.Select(item => new FinanceOverviewProfitTrendPointResponse
|
||||
{
|
||||
Date = item.Date,
|
||||
DateLabel = item.DateLabel,
|
||||
RevenueAmount = item.RevenueAmount,
|
||||
CostAmount = item.CostAmount,
|
||||
NetProfitAmount = item.NetProfitAmount
|
||||
}).ToList()
|
||||
},
|
||||
IncomeComposition = new FinanceOverviewIncomeCompositionResponse
|
||||
{
|
||||
TotalAmount = source.IncomeComposition.TotalAmount,
|
||||
Items = source.IncomeComposition.Items.Select(item => new FinanceOverviewIncomeCompositionItemResponse
|
||||
{
|
||||
Channel = item.Channel,
|
||||
ChannelText = item.ChannelText,
|
||||
Amount = item.Amount,
|
||||
Percentage = item.Percentage
|
||||
}).ToList()
|
||||
},
|
||||
CostComposition = new FinanceOverviewCostCompositionResponse
|
||||
{
|
||||
TotalAmount = source.CostComposition.TotalAmount,
|
||||
Items = source.CostComposition.Items.Select(item => new FinanceOverviewCostCompositionItemResponse
|
||||
{
|
||||
Category = item.Category,
|
||||
CategoryText = item.CategoryText,
|
||||
Amount = item.Amount,
|
||||
Percentage = item.Percentage
|
||||
}).ToList()
|
||||
},
|
||||
TopProducts = new FinanceOverviewTopProductResponse
|
||||
{
|
||||
PeriodDays = source.TopProducts.PeriodDays,
|
||||
Items = source.TopProducts.Items.Select(item => new FinanceOverviewTopProductItemResponse
|
||||
{
|
||||
Rank = item.Rank,
|
||||
ProductName = item.ProductName,
|
||||
SalesQuantity = item.SalesQuantity,
|
||||
RevenueAmount = item.RevenueAmount,
|
||||
Percentage = item.Percentage
|
||||
}).ToList()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceOverviewKpiCardResponse MapKpi(FinanceOverviewKpiCardDto source)
|
||||
{
|
||||
return new FinanceOverviewKpiCardResponse
|
||||
{
|
||||
Amount = source.Amount,
|
||||
CompareAmount = source.CompareAmount,
|
||||
ChangeRate = source.ChangeRate,
|
||||
Trend = source.Trend,
|
||||
CompareLabel = source.CompareLabel
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心经营报表。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/finance/report")]
|
||||
public sealed class FinanceReportController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:statistics:report:view";
|
||||
private const string ExportPermission = "tenant:statistics:report:export";
|
||||
|
||||
/// <summary>
|
||||
/// 查询经营报表列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportListResultResponse>> List(
|
||||
[FromQuery] FinanceBusinessReportListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析查询参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var periodType = ParsePeriodType(request.PeriodType);
|
||||
|
||||
// 2. 发起查询并返回结果。
|
||||
var result = await mediator.Send(new SearchFinanceBusinessReportListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
PeriodType = periodType,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceBusinessReportListResultResponse>.Ok(new FinanceBusinessReportListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.Total,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询经营报表详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportDetailResponse>> Detail(
|
||||
[FromQuery] FinanceBusinessReportDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
|
||||
|
||||
// 2. 发起详情查询。
|
||||
var detail = await mediator.Send(new GetFinanceBusinessReportDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
ReportId = reportId
|
||||
}, cancellationToken);
|
||||
|
||||
if (detail is null)
|
||||
{
|
||||
return ApiResponse<FinanceBusinessReportDetailResponse>.Error(ErrorCodes.NotFound, "经营报表不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<FinanceBusinessReportDetailResponse>.Ok(MapDetail(detail));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出单条报表 PDF。
|
||||
/// </summary>
|
||||
[HttpGet("export/pdf")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportExportResponse>> ExportPdf(
|
||||
[FromQuery] FinanceBusinessReportDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
|
||||
|
||||
// 2. 执行导出。
|
||||
var export = await mediator.Send(new ExportFinanceBusinessReportPdfQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
ReportId = reportId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceBusinessReportExportResponse>.Ok(MapExport(export));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出单条报表 Excel。
|
||||
/// </summary>
|
||||
[HttpGet("export/excel")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportExportResponse>> ExportExcel(
|
||||
[FromQuery] FinanceBusinessReportDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
|
||||
|
||||
// 2. 执行导出。
|
||||
var export = await mediator.Send(new ExportFinanceBusinessReportExcelQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
ReportId = reportId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceBusinessReportExportResponse>.Ok(MapExport(export));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量导出报表 ZIP(PDF + Excel)。
|
||||
/// </summary>
|
||||
[HttpGet("export/batch")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportExportResponse>> ExportBatch(
|
||||
[FromQuery] FinanceBusinessReportBatchExportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var periodType = ParsePeriodType(request.PeriodType);
|
||||
|
||||
// 2. 执行批量导出。
|
||||
var export = await mediator.Send(new ExportFinanceBusinessReportBatchQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
PeriodType = periodType,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceBusinessReportExportResponse>.Ok(MapExport(export));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportPeriodType ParsePeriodType(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"" or "daily" => FinanceBusinessReportPeriodType.Daily,
|
||||
"weekly" => FinanceBusinessReportPeriodType.Weekly,
|
||||
"monthly" => FinanceBusinessReportPeriodType.Monthly,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "periodType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportListItemResponse MapListItem(FinanceBusinessReportListItemDto source)
|
||||
{
|
||||
return new FinanceBusinessReportListItemResponse
|
||||
{
|
||||
ReportId = source.ReportId,
|
||||
DateText = source.DateText,
|
||||
RevenueAmount = source.RevenueAmount,
|
||||
OrderCount = source.OrderCount,
|
||||
AverageOrderValue = source.AverageOrderValue,
|
||||
RefundRatePercent = source.RefundRatePercent,
|
||||
CostTotalAmount = source.CostTotalAmount,
|
||||
NetProfitAmount = source.NetProfitAmount,
|
||||
ProfitRatePercent = source.ProfitRatePercent,
|
||||
Status = source.Status,
|
||||
StatusText = source.StatusText,
|
||||
CanDownload = source.CanDownload
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportDetailResponse MapDetail(FinanceBusinessReportDetailDto source)
|
||||
{
|
||||
return new FinanceBusinessReportDetailResponse
|
||||
{
|
||||
ReportId = source.ReportId,
|
||||
Title = source.Title,
|
||||
PeriodType = source.PeriodType,
|
||||
Status = source.Status,
|
||||
StatusText = source.StatusText,
|
||||
Kpis = source.Kpis.Select(item => new FinanceBusinessReportKpiResponse
|
||||
{
|
||||
Key = item.Key,
|
||||
Label = item.Label,
|
||||
ValueText = item.ValueText,
|
||||
YoyChangeRate = item.YoyChangeRate,
|
||||
MomChangeRate = item.MomChangeRate
|
||||
}).ToList(),
|
||||
IncomeBreakdowns = source.IncomeBreakdowns.Select(MapBreakdown).ToList(),
|
||||
CostBreakdowns = source.CostBreakdowns.Select(MapBreakdown).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportBreakdownItemResponse MapBreakdown(FinanceBusinessReportBreakdownItemDto source)
|
||||
{
|
||||
return new FinanceBusinessReportBreakdownItemResponse
|
||||
{
|
||||
Key = source.Key,
|
||||
Label = source.Label,
|
||||
Amount = source.Amount,
|
||||
RatioPercent = source.RatioPercent
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportExportResponse MapExport(FinanceBusinessReportExportDto source)
|
||||
{
|
||||
return new FinanceBusinessReportExportResponse
|
||||
{
|
||||
FileName = source.FileName,
|
||||
FileContentBase64 = source.FileContentBase64,
|
||||
TotalCount = source.TotalCount
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心到账查询。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/finance/settlement")]
|
||||
public sealed class FinanceSettlementController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:finance:settlement:view";
|
||||
private const string ExportPermission = "tenant:finance:settlement:export";
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账统计。
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementStatsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementStatsResponse>> Stats(
|
||||
[FromQuery] FinanceSettlementStatsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var stats = await mediator.Send(new GetFinanceSettlementStatsQuery
|
||||
{
|
||||
StoreId = storeId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceSettlementStatsResponse>.Ok(new FinanceSettlementStatsResponse
|
||||
{
|
||||
TodayArrivedAmount = stats.TodayArrivedAmount,
|
||||
YesterdayArrivedAmount = stats.YesterdayArrivedAmount,
|
||||
CurrentMonthArrivedAmount = stats.CurrentMonthArrivedAmount,
|
||||
CurrentMonthTransactionCount = stats.CurrentMonthTransactionCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账账户信息。
|
||||
/// </summary>
|
||||
[HttpGet("account")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementAccountResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementAccountResponse>> Account(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var account = await mediator.Send(new GetFinanceSettlementAccountQuery(), cancellationToken);
|
||||
if (account is null)
|
||||
{
|
||||
return ApiResponse<FinanceSettlementAccountResponse>.Error(ErrorCodes.NotFound, "结算账户信息不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<FinanceSettlementAccountResponse>.Ok(new FinanceSettlementAccountResponse
|
||||
{
|
||||
BankName = account.BankName,
|
||||
BankAccountName = account.BankAccountName,
|
||||
BankAccountNoMasked = account.BankAccountNoMasked,
|
||||
WechatMerchantNoMasked = account.WechatMerchantNoMasked,
|
||||
AlipayPidMasked = account.AlipayPidMasked,
|
||||
SettlementPeriodText = account.SettlementPeriodText
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账汇总列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementListResultResponse>> List(
|
||||
[FromQuery] FinanceSettlementListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SearchFinanceSettlementListQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
PaymentMethod = parsed.PaymentMethod,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceSettlementListResultResponse>.Ok(new FinanceSettlementListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.Total,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账明细(展开行)。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementDetailResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementDetailResultResponse>> Detail(
|
||||
[FromQuery] FinanceSettlementDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var arrivedDate = ParseRequiredDate(request.ArrivedDate, nameof(request.ArrivedDate));
|
||||
var paymentMethod = ParseRequiredSettlementChannel(request.Channel);
|
||||
|
||||
var result = await mediator.Send(new GetFinanceSettlementDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
ArrivedDate = arrivedDate,
|
||||
PaymentMethod = paymentMethod,
|
||||
Take = 50
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceSettlementDetailResultResponse>.Ok(new FinanceSettlementDetailResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapDetailItem).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出到账汇总 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("export")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementExportResponse>> Export(
|
||||
[FromQuery] FinanceSettlementFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportFinanceSettlementCsvQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
PaymentMethod = parsed.PaymentMethod
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceSettlementExportResponse>.Ok(new FinanceSettlementExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(long StoreId, DateTime? StartAt, DateTime? EndAt, PaymentMethod? PaymentMethod)> ParseFilterAsync(
|
||||
FinanceSettlementFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var startAt = ParseDateOrNull(request.StartDate);
|
||||
var endAt = ParseDateOrNull(request.EndDate)?.AddDays(1);
|
||||
if (startAt.HasValue && endAt.HasValue && startAt >= endAt)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
|
||||
}
|
||||
|
||||
return (storeId, startAt, endAt, ParseOptionalSettlementChannel(request.Channel));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static DateTime ParseRequiredDate(string? value, string parameterName)
|
||||
{
|
||||
return ParseDateOrNull(value)
|
||||
?? throw new BusinessException(ErrorCodes.BadRequest, $"{parameterName} 必填,格式为 yyyy-MM-dd");
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
value,
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var parsed))
|
||||
{
|
||||
return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "日期格式必须为 yyyy-MM-dd");
|
||||
}
|
||||
|
||||
private static PaymentMethod ParseRequiredSettlementChannel(string? channel)
|
||||
{
|
||||
return ParseOptionalSettlementChannel(channel)
|
||||
?? throw new BusinessException(ErrorCodes.BadRequest, "channel 必填,仅支持 wechat 或 alipay");
|
||||
}
|
||||
|
||||
private static PaymentMethod? ParseOptionalSettlementChannel(string? channel)
|
||||
{
|
||||
return (channel ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"wechat" => PaymentMethod.WeChatPay,
|
||||
"alipay" => PaymentMethod.Alipay,
|
||||
"" => null,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "channel 仅支持 wechat 或 alipay")
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceSettlementListItemResponse MapListItem(FinanceSettlementListItemDto source)
|
||||
{
|
||||
return new FinanceSettlementListItemResponse
|
||||
{
|
||||
ArrivedDate = source.ArrivedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
Channel = source.Channel,
|
||||
ChannelText = source.ChannelText,
|
||||
TransactionCount = source.TransactionCount,
|
||||
ArrivedAmount = source.ArrivedAmount
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceSettlementDetailItemResponse MapDetailItem(FinanceSettlementDetailItemDto source)
|
||||
{
|
||||
return new FinanceSettlementDetailItemResponse
|
||||
{
|
||||
OrderNo = source.OrderNo,
|
||||
Amount = source.Amount,
|
||||
PaidAt = source.PaidAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心交易流水。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/finance/transaction")]
|
||||
public sealed class FinanceTransactionController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:finance:transaction:view";
|
||||
private const string DetailPermission = "tenant:finance:transaction:detail";
|
||||
private const string ExportPermission = "tenant:finance:transaction:export";
|
||||
|
||||
/// <summary>
|
||||
/// 查询交易流水列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceTransactionListResultResponse>> List(
|
||||
[FromQuery] FinanceTransactionListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验筛选参数。
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
// 2. 发起查询并映射响应。
|
||||
var result = await mediator.Send(new SearchFinanceTransactionListQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
TransactionType = parsed.TransactionType,
|
||||
DeliveryType = parsed.DeliveryType,
|
||||
PaymentMethod = parsed.PaymentMethod,
|
||||
Keyword = request.Keyword,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceTransactionListResultResponse>.Ok(new FinanceTransactionListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.Total,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
PageIncomeAmount = result.PageIncomeAmount,
|
||||
PageRefundAmount = result.PageRefundAmount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询交易流水统计。
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionStatsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceTransactionStatsResponse>> Stats(
|
||||
[FromQuery] FinanceTransactionFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验筛选参数。
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
// 2. 发起查询并映射响应。
|
||||
var result = await mediator.Send(new GetFinanceTransactionStatsQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
TransactionType = parsed.TransactionType,
|
||||
DeliveryType = parsed.DeliveryType,
|
||||
PaymentMethod = parsed.PaymentMethod,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceTransactionStatsResponse>.Ok(new FinanceTransactionStatsResponse
|
||||
{
|
||||
TotalIncome = result.TotalIncome,
|
||||
TotalRefund = result.TotalRefund,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询交易流水详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, DetailPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceTransactionDetailResponse>> Detail(
|
||||
[FromQuery] FinanceTransactionDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店参数与门店访问权限。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 解析交易复合标识。
|
||||
if (!TryParseTransactionId(request.TransactionId, out var sourceType, out var sourceId))
|
||||
{
|
||||
return ApiResponse<FinanceTransactionDetailResponse>.Error(ErrorCodes.BadRequest, "transactionId 非法");
|
||||
}
|
||||
|
||||
// 3. 查询详情并返回。
|
||||
var detail = await mediator.Send(new GetFinanceTransactionDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
SourceType = sourceType,
|
||||
SourceId = sourceId
|
||||
}, cancellationToken);
|
||||
|
||||
if (detail is null)
|
||||
{
|
||||
return ApiResponse<FinanceTransactionDetailResponse>.Error(ErrorCodes.NotFound, "交易流水不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<FinanceTransactionDetailResponse>.Ok(MapDetail(detail));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出交易流水 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("export")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceTransactionExportResponse>> Export(
|
||||
[FromQuery] FinanceTransactionFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验筛选参数。
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
// 2. 发起导出并返回结果。
|
||||
var result = await mediator.Send(new ExportFinanceTransactionCsvQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
TransactionType = parsed.TransactionType,
|
||||
DeliveryType = parsed.DeliveryType,
|
||||
PaymentMethod = parsed.PaymentMethod,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceTransactionExportResponse>.Ok(new FinanceTransactionExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(long StoreId, DateTime? StartAt, DateTime? EndAt, FinanceTransactionType? TransactionType, DeliveryType? DeliveryType, PaymentMethod? PaymentMethod)> ParseFilterAsync(
|
||||
FinanceTransactionFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var startAt = ParseDateOrNull(request.StartDate);
|
||||
var endAt = ParseDateOrNull(request.EndDate)?.AddDays(1);
|
||||
if (startAt.HasValue && endAt.HasValue && startAt >= endAt)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
|
||||
}
|
||||
|
||||
var transactionType = ParseTransactionType(request.Type);
|
||||
var deliveryType = ParseDeliveryType(request.Channel);
|
||||
var paymentMethod = ParsePaymentMethod(request.PaymentMethod);
|
||||
|
||||
return (storeId, startAt, endAt, transactionType, deliveryType, paymentMethod);
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
value,
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var parsed))
|
||||
{
|
||||
return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "日期格式必须为 yyyy-MM-dd");
|
||||
}
|
||||
|
||||
private static FinanceTransactionType? ParseTransactionType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"income" => FinanceTransactionType.Income,
|
||||
"refund" => FinanceTransactionType.Refund,
|
||||
"stored_card_recharge" => FinanceTransactionType.StoredCardRecharge,
|
||||
"point_redeem" => FinanceTransactionType.PointRedeem,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static DeliveryType? ParseDeliveryType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"delivery" => DeliveryType.Delivery,
|
||||
"pickup" => DeliveryType.Pickup,
|
||||
"dine_in" => DeliveryType.DineIn,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static PaymentMethod? ParsePaymentMethod(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"wechat" => PaymentMethod.WeChatPay,
|
||||
"alipay" => PaymentMethod.Alipay,
|
||||
"cash" => PaymentMethod.Cash,
|
||||
"card" => PaymentMethod.Card,
|
||||
"balance" => PaymentMethod.Balance,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryParseTransactionId(string? value, out FinanceTransactionSourceType sourceType, out long sourceId)
|
||||
{
|
||||
sourceType = default;
|
||||
sourceId = 0;
|
||||
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = normalized.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!long.TryParse(parts[1], out sourceId) || sourceId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
sourceType = parts[0].ToLowerInvariant() switch
|
||||
{
|
||||
"payment" => FinanceTransactionSourceType.PaymentRecord,
|
||||
"payment_refund" => FinanceTransactionSourceType.PaymentRefundRecord,
|
||||
"refund_request" => FinanceTransactionSourceType.RefundRequest,
|
||||
"stored_card_recharge" => FinanceTransactionSourceType.StoredCardRechargeRecord,
|
||||
"member_point" => FinanceTransactionSourceType.MemberPointLedger,
|
||||
_ => default
|
||||
};
|
||||
|
||||
return sourceType != default;
|
||||
}
|
||||
|
||||
private static FinanceTransactionListItemResponse MapListItem(FinanceTransactionListItemDto source)
|
||||
{
|
||||
return new FinanceTransactionListItemResponse
|
||||
{
|
||||
TransactionId = source.TransactionId,
|
||||
TransactionNo = source.TransactionNo,
|
||||
OrderNo = source.OrderNo,
|
||||
Type = source.TransactionType,
|
||||
TypeText = source.TransactionTypeText,
|
||||
Channel = source.ChannelText,
|
||||
PaymentMethod = source.PaymentMethodText,
|
||||
Amount = source.AmountSigned,
|
||||
OccurredAt = source.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
Remark = source.Remark,
|
||||
IsIncome = source.IsIncome
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceTransactionDetailResponse MapDetail(FinanceTransactionDetailDto source)
|
||||
{
|
||||
return new FinanceTransactionDetailResponse
|
||||
{
|
||||
TransactionId = source.TransactionId,
|
||||
TransactionNo = source.TransactionNo,
|
||||
Type = source.TransactionType,
|
||||
TypeText = source.TransactionTypeText,
|
||||
StoreId = source.StoreId.ToString(),
|
||||
OrderNo = source.OrderNo,
|
||||
Channel = source.ChannelText,
|
||||
PaymentMethod = source.PaymentMethodText,
|
||||
Amount = source.AmountSigned,
|
||||
OccurredAt = source.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
Remark = source.Remark,
|
||||
CustomerName = source.CustomerName,
|
||||
CustomerPhone = source.CustomerPhone,
|
||||
RefundNo = source.RefundNo,
|
||||
RefundReason = source.RefundReason,
|
||||
MemberName = source.MemberName,
|
||||
MemberMobileMasked = source.MemberMobileMasked,
|
||||
RechargeAmount = source.RechargeAmount,
|
||||
GiftAmount = source.GiftAmount,
|
||||
ArrivedAmount = source.ArrivedAmount,
|
||||
PointChangeAmount = source.PointChangeAmount,
|
||||
PointBalanceAfterChange = source.PointBalanceAfterChange
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Coupons.Calendar.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.Calendar.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 营销中心营销日历。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/marketing/calendar")]
|
||||
public sealed class MarketingCalendarController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:marketing:calendar:view";
|
||||
private const string ManagePermission = "tenant:marketing:calendar:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取营销日历总览。
|
||||
/// </summary>
|
||||
[HttpGet("overview")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MarketingCalendarOverviewResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MarketingCalendarOverviewResponse>> Overview(
|
||||
[FromQuery] MarketingCalendarOverviewRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetMarketingCalendarOverviewQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Year = request.Year,
|
||||
Month = request.Month
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<MarketingCalendarOverviewResponse>.Ok(MapOverview(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static MarketingCalendarOverviewResponse MapOverview(MarketingCalendarOverviewDto source)
|
||||
{
|
||||
return new MarketingCalendarOverviewResponse
|
||||
{
|
||||
Month = source.Month,
|
||||
Year = source.Year,
|
||||
MonthValue = source.MonthValue,
|
||||
MonthStartDate = StoreApiHelpers.ToDateOnly(source.MonthStartDate),
|
||||
MonthEndDate = StoreApiHelpers.ToDateOnly(source.MonthEndDate),
|
||||
TodayDay = source.TodayDay,
|
||||
Days = source.Days
|
||||
.Select(item => new MarketingCalendarDayResponse
|
||||
{
|
||||
Day = item.Day,
|
||||
IsWeekend = item.IsWeekend,
|
||||
IsToday = item.IsToday
|
||||
})
|
||||
.ToList(),
|
||||
Legends = source.Legends
|
||||
.Select(item => new MarketingCalendarLegendResponse
|
||||
{
|
||||
Type = item.Type,
|
||||
Label = item.Label,
|
||||
Color = item.Color
|
||||
})
|
||||
.ToList(),
|
||||
Stats = new MarketingCalendarStatsResponse
|
||||
{
|
||||
TotalActivityCount = source.Stats.TotalActivityCount,
|
||||
OngoingCount = source.Stats.OngoingCount,
|
||||
MaxConcurrentCount = source.Stats.MaxConcurrentCount,
|
||||
EstimatedDiscountAmount = source.Stats.EstimatedDiscountAmount
|
||||
},
|
||||
ConflictBanner = source.ConflictBanner is null
|
||||
? null
|
||||
: new MarketingCalendarConflictBannerResponse
|
||||
{
|
||||
ConflictId = source.ConflictBanner.ConflictId,
|
||||
StartDay = source.ConflictBanner.StartDay,
|
||||
EndDay = source.ConflictBanner.EndDay,
|
||||
ActivityCount = source.ConflictBanner.ActivityCount,
|
||||
MaxConcurrentCount = source.ConflictBanner.MaxConcurrentCount,
|
||||
ConflictCount = source.ConflictBanner.ConflictCount
|
||||
},
|
||||
Conflicts = source.Conflicts
|
||||
.Select(MapConflict)
|
||||
.ToList(),
|
||||
Activities = source.Activities
|
||||
.Select(MapActivity)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static MarketingCalendarActivityResponse MapActivity(MarketingCalendarActivityDto source)
|
||||
{
|
||||
return new MarketingCalendarActivityResponse
|
||||
{
|
||||
ActivityId = source.ActivityId,
|
||||
SourceType = source.SourceType,
|
||||
SourceId = source.SourceId,
|
||||
CalendarType = source.CalendarType,
|
||||
Name = source.Name,
|
||||
Color = source.Color,
|
||||
Summary = source.Summary,
|
||||
DisplayStatus = source.DisplayStatus,
|
||||
IsDimmed = source.IsDimmed,
|
||||
StartDate = StoreApiHelpers.ToDateOnly(source.StartDate),
|
||||
EndDate = StoreApiHelpers.ToDateOnly(source.EndDate),
|
||||
EstimatedDiscountAmount = source.EstimatedDiscountAmount,
|
||||
Bars = source.Bars.Select(item => new MarketingCalendarActivityBarResponse
|
||||
{
|
||||
BarId = item.BarId,
|
||||
StartDay = item.StartDay,
|
||||
EndDay = item.EndDay,
|
||||
Label = item.Label,
|
||||
IsMilestone = item.IsMilestone,
|
||||
IsDimmed = item.IsDimmed
|
||||
}).ToList(),
|
||||
Detail = new MarketingCalendarActivityDetailResponse
|
||||
{
|
||||
ModuleName = source.Detail.ModuleName,
|
||||
Description = source.Detail.Description,
|
||||
Fields = source.Detail.Fields.Select(item => new MarketingCalendarDetailFieldResponse
|
||||
{
|
||||
Label = item.Label,
|
||||
Value = item.Value
|
||||
}).ToList()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static MarketingCalendarConflictResponse MapConflict(MarketingCalendarConflictDto source)
|
||||
{
|
||||
return new MarketingCalendarConflictResponse
|
||||
{
|
||||
ConflictId = source.ConflictId,
|
||||
StartDay = source.StartDay,
|
||||
EndDay = source.EndDay,
|
||||
ActivityCount = source.ActivityCount,
|
||||
MaxConcurrentCount = source.MaxConcurrentCount,
|
||||
ActivityIds = source.ActivityIds.ToList(),
|
||||
Activities = source.Activities.Select(item => new MarketingCalendarConflictActivityResponse
|
||||
{
|
||||
ActivityId = item.ActivityId,
|
||||
CalendarType = item.CalendarType,
|
||||
Name = item.Name,
|
||||
Summary = item.Summary,
|
||||
Color = item.Color,
|
||||
DisplayStatus = item.DisplayStatus
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Coupons.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
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.Marketing;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 营销中心优惠券模板管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/marketing/coupon")]
|
||||
public sealed class MarketingCouponController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取优惠券列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CouponListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CouponListResultResponse>> List(
|
||||
[FromQuery] CouponListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验当前门店上下文
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 调用应用层查询
|
||||
var result = await mediator.Send(new GetCouponTemplateListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status,
|
||||
CouponType = request.CouponType,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 映射响应
|
||||
return ApiResponse<CouponListResultResponse>.Ok(new CouponListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.TotalCount,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
Stats = new CouponStatsResponse
|
||||
{
|
||||
TotalCount = result.Stats.TotalCount,
|
||||
OngoingCount = result.Stats.OngoingCount,
|
||||
ClaimedCount = result.Stats.ClaimedCount,
|
||||
RedeemedCount = result.Stats.RedeemedCount,
|
||||
RedeemRate = result.Stats.RedeemRate
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取优惠券详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CouponDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CouponDetailResponse>> Detail(
|
||||
[FromQuery] CouponDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店访问权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 查询详情
|
||||
var result = await mediator.Send(new GetCouponTemplateDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.CouponId, nameof(request.CouponId))
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 处理不存在场景
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<CouponDetailResponse>.Error(ErrorCodes.NotFound, "优惠券不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<CouponDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存优惠券模板(新增/编辑)。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CouponDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CouponDetailResponse>> Save(
|
||||
[FromBody] SaveCouponRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验操作门店
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
|
||||
// 2. 解析适用门店范围
|
||||
var resolvedStoreIds = await ResolveStoreScopeStoreIdsAsync(
|
||||
request.StoreScopeMode,
|
||||
request.StoreIds,
|
||||
tenantId,
|
||||
merchantId,
|
||||
cancellationToken);
|
||||
|
||||
// 3. 调用应用层保存
|
||||
var result = await mediator.Send(new SaveCouponTemplateCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
|
||||
Name = request.Name,
|
||||
CouponType = request.CouponType,
|
||||
Value = request.Value,
|
||||
MinimumSpend = request.MinimumSpend,
|
||||
TotalQuantity = request.TotalQuantity,
|
||||
PerUserLimit = request.PerUserLimit,
|
||||
ValidityType = request.ValidityType,
|
||||
ValidFrom = request.ValidFrom,
|
||||
ValidTo = request.ValidTo,
|
||||
RelativeValidDays = request.RelativeValidDays,
|
||||
Channels = request.Channels ?? [],
|
||||
StoreScopeMode = NormalizeStoreScopeMode(request.StoreScopeMode),
|
||||
StoreScopeStoreIds = resolvedStoreIds,
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CouponDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改优惠券状态。
|
||||
/// </summary>
|
||||
[HttpPost("status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CouponDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CouponDetailResponse>> ChangeStatus(
|
||||
[FromBody] ChangeCouponStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店访问权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 调用应用层修改状态
|
||||
var result = await mediator.Send(new ChangeCouponTemplateStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.CouponId, nameof(request.CouponId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CouponDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除优惠券模板。
|
||||
/// </summary>
|
||||
[HttpPost("delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Delete(
|
||||
[FromBody] DeleteCouponRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店访问权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 调用应用层删除
|
||||
await mediator.Send(new DeleteCouponTemplateCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.CouponId, nameof(request.CouponId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
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<IReadOnlyCollection<long>> ResolveStoreScopeStoreIdsAsync(
|
||||
string? storeScopeMode,
|
||||
IEnumerable<string>? storeIds,
|
||||
long tenantId,
|
||||
long merchantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 标准化 mode
|
||||
var normalizedMode = NormalizeStoreScopeMode(storeScopeMode);
|
||||
|
||||
// 2. all 模式下展开为租户商户可见全门店
|
||||
if (string.Equals(normalizedMode, "all", StringComparison.Ordinal))
|
||||
{
|
||||
var allStoreIds = await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||
.Select(x => x.Id)
|
||||
.OrderBy(x => x)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (allStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||
}
|
||||
|
||||
return allStoreIds;
|
||||
}
|
||||
|
||||
// 3. stores 模式下严格校验输入门店集合
|
||||
var parsedStoreIds = StoreApiHelpers.ParseSnowflakeList(storeIds);
|
||||
if (parsedStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
|
||||
}
|
||||
|
||||
var accessibleStoreIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
if (accessibleStoreIds.Count != parsedStoreIds.Count)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 存在无权限门店");
|
||||
}
|
||||
|
||||
return accessibleStoreIds.OrderBy(x => x).ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeStoreScopeMode(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (normalized is not ("all" or "stores"))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeScopeMode 参数不合法");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static CouponListItemResponse MapListItem(CouponTemplateListItemDto source)
|
||||
{
|
||||
return new CouponListItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
CouponType = source.CouponType,
|
||||
Value = source.Value,
|
||||
MinimumSpend = source.MinimumSpend,
|
||||
ValidFrom = ToDateOnly(source.ValidFrom),
|
||||
ValidTo = ToDateOnly(source.ValidTo),
|
||||
RelativeValidDays = source.RelativeValidDays,
|
||||
TotalQuantity = source.TotalQuantity,
|
||||
ClaimedQuantity = source.ClaimedQuantity,
|
||||
RedeemedQuantity = source.RedeemedQuantity,
|
||||
PerUserLimit = source.PerUserLimit,
|
||||
DisplayStatus = source.DisplayStatus,
|
||||
IsDimmed = source.IsDimmed,
|
||||
StoreScopeMode = source.StoreScopeMode,
|
||||
StoreIds = source.StoreIds.Select(item => item.ToString()).ToList(),
|
||||
Channels = source.Channels.ToList(),
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static CouponDetailResponse MapDetail(CouponTemplateDetailDto source)
|
||||
{
|
||||
return new CouponDetailResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
CouponType = source.CouponType,
|
||||
Value = source.Value,
|
||||
MinimumSpend = source.MinimumSpend,
|
||||
TotalQuantity = source.TotalQuantity,
|
||||
ClaimedQuantity = source.ClaimedQuantity,
|
||||
PerUserLimit = source.PerUserLimit,
|
||||
ValidityType = source.ValidityType,
|
||||
ValidFrom = ToDateOnly(source.ValidFrom),
|
||||
ValidTo = ToDateOnly(source.ValidTo),
|
||||
RelativeValidDays = source.RelativeValidDays,
|
||||
Channels = source.Channels.ToList(),
|
||||
StoreScopeMode = source.StoreScopeMode,
|
||||
StoreIds = source.StoreIds.Select(item => item.ToString()).ToList(),
|
||||
Status = source.Status,
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ToDateOnly(DateTime? value)
|
||||
{
|
||||
return value?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string ToDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Coupons.FlashSale.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.FlashSale.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
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.Marketing;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 营销中心限时折扣活动管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/marketing/flash-sale")]
|
||||
public sealed class MarketingFlashSaleController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取限时折扣活动列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<FlashSaleListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FlashSaleListResultResponse>> List(
|
||||
[FromQuery] FlashSaleListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetFlashSaleCampaignListQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FlashSaleListResultResponse>.Ok(new FlashSaleListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.TotalCount,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
Stats = new FlashSaleStatsResponse
|
||||
{
|
||||
TotalCount = result.Stats.TotalCount,
|
||||
OngoingCount = result.Stats.OngoingCount,
|
||||
ParticipatingProductCount = result.Stats.ParticipatingProductCount,
|
||||
MonthlyDiscountSalesCount = result.Stats.MonthlyDiscountSalesCount
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取限时折扣活动详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[ProducesResponseType(typeof(ApiResponse<FlashSaleDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FlashSaleDetailResponse>> Detail(
|
||||
[FromQuery] FlashSaleDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetFlashSaleCampaignDetailQuery
|
||||
{
|
||||
OperationStoreId = operationStoreId,
|
||||
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId))
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<FlashSaleDetailResponse>.Error(ErrorCodes.NotFound, "活动不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<FlashSaleDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存限时折扣活动(新增/编辑)。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<FlashSaleDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FlashSaleDetailResponse>> Save(
|
||||
[FromBody] SaveFlashSaleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
var resolvedStoreIds = await ResolveStoreIdsForSaveAsync(
|
||||
request.StoreIds,
|
||||
operationStoreId,
|
||||
cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SaveFlashSaleCampaignCommand
|
||||
{
|
||||
OperationStoreId = operationStoreId,
|
||||
CampaignId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
|
||||
Name = request.Name,
|
||||
CycleType = request.CycleType,
|
||||
RecurringDateMode = request.RecurringDateMode,
|
||||
StartDate = ParseDateOnlyOrNull(request.StartDate, nameof(request.StartDate)),
|
||||
EndDate = ParseDateOnlyOrNull(request.EndDate, nameof(request.EndDate)),
|
||||
TimeStart = ParseTimeOrNull(request.TimeStart, nameof(request.TimeStart)),
|
||||
TimeEnd = ParseTimeOrNull(request.TimeEnd, nameof(request.TimeEnd)),
|
||||
WeekDays = request.WeekDays ?? [],
|
||||
Channels = request.Channels ?? [],
|
||||
PerUserLimit = request.PerUserLimit,
|
||||
StoreIds = resolvedStoreIds,
|
||||
Products = request.Products.Select(item => new FlashSaleSaveProductInputDto
|
||||
{
|
||||
ProductId = StoreApiHelpers.ParseRequiredSnowflake(item.ProductId, nameof(item.ProductId)),
|
||||
DiscountPrice = item.DiscountPrice,
|
||||
PerUserLimit = item.PerUserLimit
|
||||
}).ToList(),
|
||||
Metrics = request.Metrics is null
|
||||
? null
|
||||
: new FlashSaleMetricsDto
|
||||
{
|
||||
ActivitySalesCount = request.Metrics.ActivitySalesCount,
|
||||
DiscountTotalAmount = request.Metrics.DiscountTotalAmount,
|
||||
LoopedWeeks = request.Metrics.LoopedWeeks,
|
||||
MonthlyDiscountSalesCount = request.Metrics.MonthlyDiscountSalesCount
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FlashSaleDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改限时折扣活动状态。
|
||||
/// </summary>
|
||||
[HttpPost("status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<FlashSaleDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FlashSaleDetailResponse>> ChangeStatus(
|
||||
[FromBody] ChangeFlashSaleStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ChangeFlashSaleCampaignStatusCommand
|
||||
{
|
||||
OperationStoreId = operationStoreId,
|
||||
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FlashSaleDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除限时折扣活动。
|
||||
/// </summary>
|
||||
[HttpPost("delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Delete(
|
||||
[FromBody] DeleteFlashSaleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
await mediator.Send(new DeleteFlashSaleCampaignCommand
|
||||
{
|
||||
OperationStoreId = operationStoreId,
|
||||
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取限时折扣选品分类。
|
||||
/// </summary>
|
||||
[HttpGet("picker/categories")]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<FlashSalePickerCategoryItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<List<FlashSalePickerCategoryItemResponse>>> PickerCategories(
|
||||
[FromQuery] FlashSalePickerCategoriesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetFlashSalePickerCategoriesQuery
|
||||
{
|
||||
OperationStoreId = operationStoreId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<List<FlashSalePickerCategoryItemResponse>>.Ok(result
|
||||
.Select(item => new FlashSalePickerCategoryItemResponse
|
||||
{
|
||||
Id = item.Id.ToString(),
|
||||
Name = item.Name,
|
||||
ProductCount = item.ProductCount
|
||||
})
|
||||
.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取限时折扣选品商品。
|
||||
/// </summary>
|
||||
[HttpGet("picker/products")]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<FlashSalePickerProductItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<List<FlashSalePickerProductItemResponse>>> PickerProducts(
|
||||
[FromQuery] FlashSalePickerProductsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetFlashSalePickerProductsQuery
|
||||
{
|
||||
OperationStoreId = operationStoreId,
|
||||
CategoryId = StoreApiHelpers.ParseSnowflakeOrNull(request.CategoryId),
|
||||
Keyword = request.Keyword,
|
||||
Limit = request.Limit
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<List<FlashSalePickerProductItemResponse>>.Ok(result
|
||||
.Select(item => new FlashSalePickerProductItemResponse
|
||||
{
|
||||
Id = item.Id.ToString(),
|
||||
CategoryId = item.CategoryId.ToString(),
|
||||
CategoryName = item.CategoryName,
|
||||
Name = item.Name,
|
||||
Price = item.Price,
|
||||
Stock = item.Stock,
|
||||
SpuCode = item.SpuCode,
|
||||
Status = item.Status
|
||||
})
|
||||
.ToList());
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
|
||||
string? storeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(storeId))
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
return [parsedStoreId];
|
||||
}
|
||||
|
||||
var allStoreIds = await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
|
||||
.Select(item => item.Id)
|
||||
.OrderBy(item => item)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (allStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||
}
|
||||
|
||||
return allStoreIds;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<long>> ResolveStoreIdsForSaveAsync(
|
||||
IEnumerable<string>? storeIds,
|
||||
long operationStoreId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreIds = StoreApiHelpers.ParseSnowflakeList(storeIds);
|
||||
if (parsedStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
|
||||
}
|
||||
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
var accessibleStoreIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
if (accessibleStoreIds.Count != parsedStoreIds.Count)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 存在无权限门店");
|
||||
}
|
||||
|
||||
if (!accessibleStoreIds.Contains(operationStoreId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 必须包含当前操作门店");
|
||||
}
|
||||
|
||||
return accessibleStoreIds.OrderBy(item => item).ToList();
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOnlyOrNull(string? value, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!DateTime.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 日期格式必须为 yyyy-MM-dd");
|
||||
}
|
||||
|
||||
return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseTimeOrNull(string? value, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return StoreApiHelpers.ParseRequiredTime(value, fieldName);
|
||||
}
|
||||
|
||||
private static FlashSaleListItemResponse MapListItem(FlashSaleListItemDto source)
|
||||
{
|
||||
return new FlashSaleListItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
CycleType = source.Rules.CycleType,
|
||||
RecurringDateMode = source.Rules.RecurringDateMode,
|
||||
StartDate = ToDateOnly(source.Rules.StartDate),
|
||||
EndDate = ToDateOnly(source.Rules.EndDate),
|
||||
TimeStart = StoreApiHelpers.ToHHmm(source.Rules.TimeStart),
|
||||
TimeEnd = StoreApiHelpers.ToHHmm(source.Rules.TimeEnd),
|
||||
WeekDays = source.Rules.WeekDays.ToList(),
|
||||
Status = source.Status,
|
||||
DisplayStatus = source.DisplayStatus,
|
||||
IsDimmed = source.IsDimmed,
|
||||
Channels = source.Rules.Channels.ToList(),
|
||||
PerUserLimit = source.Rules.PerUserLimit,
|
||||
StoreIds = source.Rules.StoreIds.Select(item => item.ToString()).ToList(),
|
||||
Products = source.Rules.Products.Select(MapProduct).ToList(),
|
||||
Metrics = MapMetrics(source.Rules.Metrics),
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static FlashSaleDetailResponse MapDetail(FlashSaleDetailDto source)
|
||||
{
|
||||
return new FlashSaleDetailResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
CycleType = source.Rules.CycleType,
|
||||
RecurringDateMode = source.Rules.RecurringDateMode,
|
||||
StartDate = ToDateOnly(source.Rules.StartDate),
|
||||
EndDate = ToDateOnly(source.Rules.EndDate),
|
||||
TimeStart = StoreApiHelpers.ToHHmm(source.Rules.TimeStart),
|
||||
TimeEnd = StoreApiHelpers.ToHHmm(source.Rules.TimeEnd),
|
||||
WeekDays = source.Rules.WeekDays.ToList(),
|
||||
Status = source.Status,
|
||||
DisplayStatus = source.DisplayStatus,
|
||||
Channels = source.Rules.Channels.ToList(),
|
||||
PerUserLimit = source.Rules.PerUserLimit,
|
||||
StoreIds = source.Rules.StoreIds.Select(item => item.ToString()).ToList(),
|
||||
Products = source.Rules.Products.Select(MapProduct).ToList(),
|
||||
Metrics = MapMetrics(source.Rules.Metrics),
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static FlashSaleProductResponse MapProduct(FlashSaleProductRuleDto source)
|
||||
{
|
||||
return new FlashSaleProductResponse
|
||||
{
|
||||
ProductId = source.ProductId.ToString(),
|
||||
CategoryId = source.CategoryId.ToString(),
|
||||
CategoryName = source.CategoryName,
|
||||
Name = source.Name,
|
||||
SpuCode = source.SpuCode,
|
||||
Status = source.Status,
|
||||
OriginalPrice = source.OriginalPrice,
|
||||
DiscountPrice = source.DiscountPrice,
|
||||
PerUserLimit = source.PerUserLimit,
|
||||
SoldCount = source.SoldCount
|
||||
};
|
||||
}
|
||||
|
||||
private static FlashSaleMetricsResponse MapMetrics(FlashSaleMetricsDto source)
|
||||
{
|
||||
return new FlashSaleMetricsResponse
|
||||
{
|
||||
ActivitySalesCount = source.ActivitySalesCount,
|
||||
DiscountTotalAmount = source.DiscountTotalAmount,
|
||||
LoopedWeeks = source.LoopedWeeks,
|
||||
MonthlyDiscountSalesCount = source.MonthlyDiscountSalesCount
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ToDateOnly(DateTime? value)
|
||||
{
|
||||
return value?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string ToDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
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.Marketing;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 营销中心满减活动管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/marketing/full-reduction")]
|
||||
public sealed class MarketingFullReductionController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取满减活动列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<FullReductionListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FullReductionListResultResponse>> List(
|
||||
[FromQuery] FullReductionListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析可见门店范围(支持全部门店)
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
// 2. 查询应用层列表
|
||||
var result = await mediator.Send(new GetFullReductionCampaignListQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
Keyword = request.Keyword,
|
||||
ActivityType = request.ActivityType,
|
||||
Status = request.Status,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 映射响应
|
||||
return ApiResponse<FullReductionListResultResponse>.Ok(new FullReductionListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.TotalCount,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
Stats = new FullReductionStatsResponse
|
||||
{
|
||||
TotalCount = result.Stats.TotalCount,
|
||||
OngoingCount = result.Stats.OngoingCount,
|
||||
MonthlyDrivenSalesAmount = result.Stats.MonthlyDrivenSalesAmount,
|
||||
AverageTicketIncrease = result.Stats.AverageTicketIncrease
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取满减活动详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[ProducesResponseType(typeof(ApiResponse<FullReductionDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FullReductionDetailResponse>> Detail(
|
||||
[FromQuery] FullReductionDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验操作门店
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
// 2. 查询详情
|
||||
var detail = await mediator.Send(new GetFullReductionCampaignDetailQuery
|
||||
{
|
||||
OperationStoreId = operationStoreId,
|
||||
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId))
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 处理不存在场景
|
||||
if (detail is null)
|
||||
{
|
||||
return ApiResponse<FullReductionDetailResponse>.Error(ErrorCodes.NotFound, "活动不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<FullReductionDetailResponse>.Ok(MapDetail(detail));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存满减活动(新增/编辑)。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<FullReductionDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FullReductionDetailResponse>> Save(
|
||||
[FromBody] SaveFullReductionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验操作门店
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
// 2. 展开活动门店范围
|
||||
var resolvedStoreIds = await ResolveStoreScopeStoreIdsAsync(
|
||||
request.StoreScopeMode,
|
||||
request.StoreIds,
|
||||
cancellationToken);
|
||||
|
||||
// 3. 解析选品基准门店
|
||||
var scopeStoreId = string.IsNullOrWhiteSpace(request.ScopeStoreId)
|
||||
? operationStoreId
|
||||
: StoreApiHelpers.ParseRequiredSnowflake(request.ScopeStoreId, nameof(request.ScopeStoreId));
|
||||
|
||||
if (!resolvedStoreIds.Contains(scopeStoreId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "scopeStoreId 必须在活动门店范围内");
|
||||
}
|
||||
|
||||
// 4. 保存活动
|
||||
var result = await mediator.Send(new SaveFullReductionCampaignCommand
|
||||
{
|
||||
OperationStoreId = operationStoreId,
|
||||
CampaignId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
|
||||
Name = request.Name,
|
||||
ActivityType = request.ActivityType,
|
||||
ReduceTiers = request.ReduceTiers.Select(item => new FullReductionTierRuleDto
|
||||
{
|
||||
MeetAmount = item.MeetAmount,
|
||||
ReduceAmount = item.ReduceAmount
|
||||
}).ToList(),
|
||||
GiftRule = MapGiftRuleRequest(request.GiftRule),
|
||||
SecondHalfRule = MapSecondHalfRuleRequest(request.SecondHalfRule),
|
||||
StartAt = StoreApiHelpers.ParseDateOnly(request.StartDate, nameof(request.StartDate)),
|
||||
EndAt = StoreApiHelpers.ParseDateOnly(request.EndDate, nameof(request.EndDate)),
|
||||
Channels = request.Channels ?? [],
|
||||
StoreScopeMode = request.StoreScopeMode,
|
||||
StoreScopeStoreIds = resolvedStoreIds,
|
||||
ScopeStoreId = scopeStoreId,
|
||||
StackWithCoupon = request.StackWithCoupon,
|
||||
Description = request.Description,
|
||||
Metrics = MapMetricsRequest(request.Metrics)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FullReductionDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改满减活动状态。
|
||||
/// </summary>
|
||||
[HttpPost("status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<FullReductionDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FullReductionDetailResponse>> ChangeStatus(
|
||||
[FromBody] ChangeFullReductionStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验操作门店
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
// 2. 调用应用层修改状态
|
||||
var result = await mediator.Send(new ChangeFullReductionCampaignStatusCommand
|
||||
{
|
||||
OperationStoreId = operationStoreId,
|
||||
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FullReductionDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除满减活动。
|
||||
/// </summary>
|
||||
[HttpPost("delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Delete(
|
||||
[FromBody] DeleteFullReductionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验操作门店
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
// 2. 调用应用层删除
|
||||
await mediator.Send(new DeleteFullReductionCampaignCommand
|
||||
{
|
||||
OperationStoreId = operationStoreId,
|
||||
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
|
||||
string? storeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
|
||||
// 1. 指定门店:返回单门店
|
||||
if (!string.IsNullOrWhiteSpace(storeId))
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
return [parsedStoreId];
|
||||
}
|
||||
|
||||
// 2. 全部门店:返回当前商户全部可见门店
|
||||
var allStoreIds = await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||
.Select(x => x.Id)
|
||||
.OrderBy(x => x)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (allStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||
}
|
||||
|
||||
return allStoreIds;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<long>> ResolveStoreScopeStoreIdsAsync(
|
||||
string? storeScopeMode,
|
||||
IEnumerable<string>? storeIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
var normalizedMode = NormalizeStoreScopeMode(storeScopeMode);
|
||||
|
||||
// 1. all 模式展开为商户全部门店
|
||||
if (string.Equals(normalizedMode, "all", StringComparison.Ordinal))
|
||||
{
|
||||
var allStoreIds = await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||
.Select(x => x.Id)
|
||||
.OrderBy(x => x)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (allStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||
}
|
||||
|
||||
return allStoreIds;
|
||||
}
|
||||
|
||||
// 2. stores 模式校验传入门店
|
||||
var parsedStoreIds = StoreApiHelpers.ParseSnowflakeList(storeIds);
|
||||
if (parsedStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
|
||||
}
|
||||
|
||||
var accessibleStoreIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
if (accessibleStoreIds.Count != parsedStoreIds.Count)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 存在无权限门店");
|
||||
}
|
||||
|
||||
return accessibleStoreIds.OrderBy(x => x).ToList();
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static string NormalizeStoreScopeMode(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (normalized is not ("all" or "stores"))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeScopeMode 参数不合法");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static FullReductionGiftRuleDto? MapGiftRuleRequest(FullReductionGiftRuleRequest? request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new FullReductionGiftRuleDto
|
||||
{
|
||||
BuyQuantity = request.BuyQuantity,
|
||||
GiftQuantity = request.GiftQuantity,
|
||||
GiftScopeType = request.GiftScopeType,
|
||||
ApplicableScope = MapScopeRequest(request.ApplicableScope),
|
||||
GiftScope = MapScopeRequest(request.GiftScope)
|
||||
};
|
||||
}
|
||||
|
||||
private static FullReductionSecondHalfRuleDto? MapSecondHalfRuleRequest(FullReductionSecondHalfRuleRequest? request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new FullReductionSecondHalfRuleDto
|
||||
{
|
||||
DiscountType = request.DiscountType,
|
||||
ApplicableScope = MapScopeRequest(request.ApplicableScope)
|
||||
};
|
||||
}
|
||||
|
||||
private static FullReductionScopeRuleDto MapScopeRequest(FullReductionScopeRuleRequest request)
|
||||
{
|
||||
return new FullReductionScopeRuleDto
|
||||
{
|
||||
ScopeType = request.ScopeType,
|
||||
CategoryIds = StoreApiHelpers.ParseSnowflakeList(request.CategoryIds),
|
||||
ProductIds = StoreApiHelpers.ParseSnowflakeList(request.ProductIds)
|
||||
};
|
||||
}
|
||||
|
||||
private static FullReductionMetricsDto? MapMetricsRequest(FullReductionMetricsRequest? request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new FullReductionMetricsDto
|
||||
{
|
||||
ParticipatingOrderCount = request.ParticipatingOrderCount,
|
||||
DiscountTotalAmount = request.DiscountTotalAmount,
|
||||
TicketIncreaseAmount = request.TicketIncreaseAmount,
|
||||
GiftedCount = request.GiftedCount,
|
||||
DrivenSalesAmount = request.DrivenSalesAmount,
|
||||
AttachRateIncreasePercent = request.AttachRateIncreasePercent,
|
||||
MonthlyDrivenSalesAmount = request.MonthlyDrivenSalesAmount,
|
||||
AverageTicketIncrease = request.AverageTicketIncrease
|
||||
};
|
||||
}
|
||||
|
||||
private static FullReductionListItemResponse MapListItem(FullReductionListItemDto source)
|
||||
{
|
||||
return new FullReductionListItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
ActivityType = source.Rules.ActivityType,
|
||||
StartDate = ToDateOnly(source.StartAt),
|
||||
EndDate = ToDateOnly(source.EndAt),
|
||||
DisplayStatus = source.DisplayStatus,
|
||||
IsDimmed = source.IsDimmed,
|
||||
ReduceTiers = source.Rules.ReduceTiers.Select(MapTier).ToList(),
|
||||
GiftRule = source.Rules.GiftRule is null ? null : MapGiftRule(source.Rules.GiftRule),
|
||||
SecondHalfRule = source.Rules.SecondHalfRule is null
|
||||
? null
|
||||
: MapSecondHalfRule(source.Rules.SecondHalfRule),
|
||||
Channels = source.Rules.Channels.ToList(),
|
||||
StoreScopeMode = source.Rules.StoreScopeMode,
|
||||
StoreIds = source.Rules.StoreIds.Select(item => item.ToString()).ToList(),
|
||||
ScopeStoreId = source.Rules.ScopeStoreId.ToString(),
|
||||
StackWithCoupon = source.Rules.StackWithCoupon,
|
||||
Description = source.Rules.Description,
|
||||
Metrics = MapMetrics(source.Rules.Metrics),
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static FullReductionDetailResponse MapDetail(FullReductionDetailDto source)
|
||||
{
|
||||
return new FullReductionDetailResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
ActivityType = source.Rules.ActivityType,
|
||||
ReduceTiers = source.Rules.ReduceTiers.Select(MapTier).ToList(),
|
||||
GiftRule = source.Rules.GiftRule is null ? null : MapGiftRule(source.Rules.GiftRule),
|
||||
SecondHalfRule = source.Rules.SecondHalfRule is null
|
||||
? null
|
||||
: MapSecondHalfRule(source.Rules.SecondHalfRule),
|
||||
StartDate = ToDateOnly(source.StartAt),
|
||||
EndDate = ToDateOnly(source.EndAt),
|
||||
DisplayStatus = source.DisplayStatus,
|
||||
Status = source.Status,
|
||||
Channels = source.Rules.Channels.ToList(),
|
||||
StoreScopeMode = source.Rules.StoreScopeMode,
|
||||
StoreIds = source.Rules.StoreIds.Select(item => item.ToString()).ToList(),
|
||||
ScopeStoreId = source.Rules.ScopeStoreId.ToString(),
|
||||
StackWithCoupon = source.Rules.StackWithCoupon,
|
||||
Description = source.Rules.Description,
|
||||
Metrics = MapMetrics(source.Rules.Metrics),
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static FullReductionTierRuleResponse MapTier(FullReductionTierRuleDto source)
|
||||
{
|
||||
return new FullReductionTierRuleResponse
|
||||
{
|
||||
MeetAmount = source.MeetAmount,
|
||||
ReduceAmount = source.ReduceAmount
|
||||
};
|
||||
}
|
||||
|
||||
private static FullReductionGiftRuleResponse MapGiftRule(FullReductionGiftRuleDto source)
|
||||
{
|
||||
return new FullReductionGiftRuleResponse
|
||||
{
|
||||
BuyQuantity = source.BuyQuantity,
|
||||
GiftQuantity = source.GiftQuantity,
|
||||
GiftScopeType = source.GiftScopeType,
|
||||
ApplicableScope = MapScope(source.ApplicableScope),
|
||||
GiftScope = MapScope(source.GiftScope)
|
||||
};
|
||||
}
|
||||
|
||||
private static FullReductionSecondHalfRuleResponse MapSecondHalfRule(FullReductionSecondHalfRuleDto source)
|
||||
{
|
||||
return new FullReductionSecondHalfRuleResponse
|
||||
{
|
||||
DiscountType = source.DiscountType,
|
||||
ApplicableScope = MapScope(source.ApplicableScope)
|
||||
};
|
||||
}
|
||||
|
||||
private static FullReductionScopeRuleResponse MapScope(FullReductionScopeRuleDto source)
|
||||
{
|
||||
return new FullReductionScopeRuleResponse
|
||||
{
|
||||
ScopeType = source.ScopeType,
|
||||
CategoryIds = source.CategoryIds.Select(item => item.ToString()).ToList(),
|
||||
ProductIds = source.ProductIds.Select(item => item.ToString()).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static FullReductionMetricsResponse MapMetrics(FullReductionMetricsDto source)
|
||||
{
|
||||
return new FullReductionMetricsResponse
|
||||
{
|
||||
ParticipatingOrderCount = source.ParticipatingOrderCount,
|
||||
DiscountTotalAmount = source.DiscountTotalAmount,
|
||||
TicketIncreaseAmount = source.TicketIncreaseAmount,
|
||||
GiftedCount = source.GiftedCount,
|
||||
DrivenSalesAmount = source.DrivenSalesAmount,
|
||||
AttachRateIncreasePercent = source.AttachRateIncreasePercent,
|
||||
MonthlyDrivenSalesAmount = source.MonthlyDrivenSalesAmount,
|
||||
AverageTicketIncrease = source.AverageTicketIncrease
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToDateOnly(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string ToDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 营销中心新客有礼管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/marketing/new-customer")]
|
||||
public sealed class MarketingNewCustomerController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:marketing:new-customer:view";
|
||||
private const string ManagePermission = "tenant:marketing:new-customer:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取新客有礼详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<NewCustomerDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<NewCustomerDetailResponse>> Detail(
|
||||
[FromQuery] NewCustomerDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 查询应用层详情
|
||||
var result = await mediator.Send(new GetNewCustomerDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
RecordPage = request.RecordPage,
|
||||
RecordPageSize = request.RecordPageSize
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 返回响应
|
||||
return ApiResponse<NewCustomerDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存新客有礼配置。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<NewCustomerSettingsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<NewCustomerSettingsResponse>> Save(
|
||||
[FromBody] SaveNewCustomerSettingsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 调用应用层保存
|
||||
var result = await mediator.Send(new SaveNewCustomerSettingsCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
GiftEnabled = request.GiftEnabled,
|
||||
GiftType = request.GiftType,
|
||||
DirectReduceAmount = request.DirectReduceAmount,
|
||||
DirectMinimumSpend = request.DirectMinimumSpend,
|
||||
InviteEnabled = request.InviteEnabled,
|
||||
ShareChannels = request.ShareChannels,
|
||||
WelcomeCoupons = request.WelcomeCoupons.Select(MapSaveCouponRule).ToList(),
|
||||
InviterCoupons = request.InviterCoupons.Select(MapSaveCouponRule).ToList(),
|
||||
InviteeCoupons = request.InviteeCoupons.Select(MapSaveCouponRule).ToList()
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 返回响应
|
||||
return ApiResponse<NewCustomerSettingsResponse>.Ok(MapSettings(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取新客邀请记录分页。
|
||||
/// </summary>
|
||||
[HttpGet("invite-record/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<NewCustomerInviteRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<NewCustomerInviteRecordListResultResponse>> InviteRecordList(
|
||||
[FromQuery] NewCustomerInviteRecordListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 查询应用层分页
|
||||
var result = await mediator.Send(new GetNewCustomerInviteRecordListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 返回响应
|
||||
return ApiResponse<NewCustomerInviteRecordListResultResponse>.Ok(MapInviteRecordList(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客邀请记录。
|
||||
/// </summary>
|
||||
[HttpPost("invite-record/write")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<NewCustomerInviteRecordResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<NewCustomerInviteRecordResponse>> WriteInviteRecord(
|
||||
[FromBody] WriteNewCustomerInviteRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 调用应用层写入
|
||||
var result = await mediator.Send(new WriteNewCustomerInviteRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
InviterName = request.InviterName,
|
||||
InviteeName = request.InviteeName,
|
||||
InviteTime = request.InviteTime,
|
||||
OrderStatus = request.OrderStatus,
|
||||
RewardStatus = request.RewardStatus,
|
||||
RewardIssuedAt = request.RewardIssuedAt,
|
||||
SourceChannel = request.SourceChannel
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 返回响应
|
||||
return ApiResponse<NewCustomerInviteRecordResponse>.Ok(MapInviteRecord(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客成长记录。
|
||||
/// </summary>
|
||||
[HttpPost("growth-record/write")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<NewCustomerGrowthRecordResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<NewCustomerGrowthRecordResponse>> WriteGrowthRecord(
|
||||
[FromBody] WriteNewCustomerGrowthRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 调用应用层写入
|
||||
var result = await mediator.Send(new WriteNewCustomerGrowthRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
CustomerKey = request.CustomerKey,
|
||||
CustomerName = request.CustomerName,
|
||||
RegisteredAt = request.RegisteredAt,
|
||||
GiftClaimedAt = request.GiftClaimedAt,
|
||||
FirstOrderAt = request.FirstOrderAt,
|
||||
SourceChannel = request.SourceChannel
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 返回响应
|
||||
return ApiResponse<NewCustomerGrowthRecordResponse>.Ok(MapGrowthRecord(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static NewCustomerSaveCouponRuleInputDto MapSaveCouponRule(NewCustomerSaveCouponRuleRequest source)
|
||||
{
|
||||
return new NewCustomerSaveCouponRuleInputDto
|
||||
{
|
||||
CouponType = source.CouponType,
|
||||
Value = source.Value,
|
||||
MinimumSpend = source.MinimumSpend,
|
||||
ValidDays = source.ValidDays
|
||||
};
|
||||
}
|
||||
|
||||
private static NewCustomerDetailResponse MapDetail(NewCustomerDetailDto source)
|
||||
{
|
||||
return new NewCustomerDetailResponse
|
||||
{
|
||||
Settings = MapSettings(source.Settings),
|
||||
Stats = new NewCustomerStatsResponse
|
||||
{
|
||||
MonthlyNewCustomers = source.Stats.MonthlyNewCustomers,
|
||||
MonthlyGrowthCount = source.Stats.MonthlyGrowthCount,
|
||||
MonthlyGrowthRatePercent = source.Stats.MonthlyGrowthRatePercent,
|
||||
GiftClaimRate = source.Stats.GiftClaimRate,
|
||||
GiftClaimedCount = source.Stats.GiftClaimedCount,
|
||||
FirstOrderConversionRate = source.Stats.FirstOrderConversionRate,
|
||||
FirstOrderedCount = source.Stats.FirstOrderedCount
|
||||
},
|
||||
InviteRecords = MapInviteRecordList(source.InviteRecords)
|
||||
};
|
||||
}
|
||||
|
||||
private static NewCustomerSettingsResponse MapSettings(NewCustomerSettingsDto source)
|
||||
{
|
||||
return new NewCustomerSettingsResponse
|
||||
{
|
||||
StoreId = source.StoreId.ToString(),
|
||||
GiftEnabled = source.GiftEnabled,
|
||||
GiftType = source.GiftType,
|
||||
DirectReduceAmount = source.DirectReduceAmount,
|
||||
DirectMinimumSpend = source.DirectMinimumSpend,
|
||||
InviteEnabled = source.InviteEnabled,
|
||||
ShareChannels = source.ShareChannels.ToList(),
|
||||
WelcomeCoupons = source.WelcomeCoupons.Select(MapCouponRule).ToList(),
|
||||
InviterCoupons = source.InviterCoupons.Select(MapCouponRule).ToList(),
|
||||
InviteeCoupons = source.InviteeCoupons.Select(MapCouponRule).ToList(),
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static NewCustomerCouponRuleResponse MapCouponRule(NewCustomerCouponRuleDto source)
|
||||
{
|
||||
return new NewCustomerCouponRuleResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Scene = source.Scene,
|
||||
CouponType = source.CouponType,
|
||||
Value = source.Value,
|
||||
MinimumSpend = source.MinimumSpend,
|
||||
ValidDays = source.ValidDays,
|
||||
SortOrder = source.SortOrder
|
||||
};
|
||||
}
|
||||
|
||||
private static NewCustomerInviteRecordListResultResponse MapInviteRecordList(
|
||||
NewCustomerInviteRecordListResultDto source)
|
||||
{
|
||||
return new NewCustomerInviteRecordListResultResponse
|
||||
{
|
||||
Items = source.Items.Select(MapInviteRecord).ToList(),
|
||||
Page = source.Page,
|
||||
PageSize = source.PageSize,
|
||||
TotalCount = source.TotalCount
|
||||
};
|
||||
}
|
||||
|
||||
private static NewCustomerInviteRecordResponse MapInviteRecord(NewCustomerInviteRecordDto source)
|
||||
{
|
||||
return new NewCustomerInviteRecordResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
InviterName = source.InviterName,
|
||||
InviteeName = source.InviteeName,
|
||||
InviteTime = ToDateTime(source.InviteTime),
|
||||
OrderStatus = source.OrderStatus,
|
||||
RewardStatus = source.RewardStatus,
|
||||
RewardIssuedAt = source.RewardIssuedAt.HasValue
|
||||
? ToDateTime(source.RewardIssuedAt.Value)
|
||||
: null,
|
||||
SourceChannel = source.SourceChannel
|
||||
};
|
||||
}
|
||||
|
||||
private static NewCustomerGrowthRecordResponse MapGrowthRecord(NewCustomerGrowthRecordDto source)
|
||||
{
|
||||
return new NewCustomerGrowthRecordResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
CustomerKey = source.CustomerKey,
|
||||
CustomerName = source.CustomerName,
|
||||
RegisteredAt = ToDateTime(source.RegisteredAt),
|
||||
GiftClaimedAt = source.GiftClaimedAt.HasValue
|
||||
? ToDateTime(source.GiftClaimedAt.Value)
|
||||
: null,
|
||||
FirstOrderAt = source.FirstOrderAt.HasValue
|
||||
? ToDateTime(source.FirstOrderAt.Value)
|
||||
: null,
|
||||
SourceChannel = source.SourceChannel
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 营销中心次卡管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/marketing/punch-card")]
|
||||
public sealed class MarketingPunchCardController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:marketing:punch-card:view";
|
||||
private const string ManagePermission = "tenant:marketing:punch-card:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取次卡列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardListResultResponse>> List(
|
||||
[FromQuery] PunchCardListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPunchCardTemplateListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardListResultResponse>.Ok(new PunchCardListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount,
|
||||
Stats = MapTemplateStats(result.Stats)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取次卡详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardDetailResponse>> Detail(
|
||||
[FromQuery] PunchCardDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPunchCardTemplateDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId))
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<PunchCardDetailResponse>.Error(ErrorCodes.NotFound, "次卡不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存次卡。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardDetailResponse>> Save(
|
||||
[FromBody] SavePunchCardRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SavePunchCardTemplateCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
|
||||
Name = request.Name,
|
||||
CoverImageUrl = request.CoverImageUrl,
|
||||
SalePrice = request.SalePrice,
|
||||
OriginalPrice = request.OriginalPrice,
|
||||
TotalTimes = request.TotalTimes,
|
||||
ValidityType = request.ValidityType,
|
||||
ValidityDays = request.ValidityDays,
|
||||
ValidFrom = ParseDateOrNull(request.ValidFrom, nameof(request.ValidFrom)),
|
||||
ValidTo = ParseDateOrNull(request.ValidTo, nameof(request.ValidTo)),
|
||||
ScopeType = request.ScopeType,
|
||||
ScopeCategoryIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeCategoryIds),
|
||||
ScopeTagIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeTagIds),
|
||||
ScopeProductIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeProductIds),
|
||||
UsageMode = request.UsageMode,
|
||||
UsageCapAmount = request.UsageCapAmount,
|
||||
DailyLimit = request.DailyLimit,
|
||||
PerOrderLimit = request.PerOrderLimit,
|
||||
PerUserPurchaseLimit = request.PerUserPurchaseLimit,
|
||||
AllowTransfer = request.AllowTransfer,
|
||||
ExpireStrategy = request.ExpireStrategy,
|
||||
Description = request.Description,
|
||||
NotifyChannels = request.NotifyChannels
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改次卡状态。
|
||||
/// </summary>
|
||||
[HttpPost("status")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardDetailResponse>> ChangeStatus(
|
||||
[FromBody] ChangePunchCardStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ChangePunchCardTemplateStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除次卡。
|
||||
/// </summary>
|
||||
[HttpPost("delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Delete(
|
||||
[FromBody] DeletePunchCardRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
await mediator.Send(new DeletePunchCardTemplateCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取次卡使用记录。
|
||||
/// </summary>
|
||||
[HttpGet("usage-record/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardUsageRecordListResultResponse>> UsageRecordList(
|
||||
[FromQuery] PunchCardUsageRecordListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPunchCardUsageRecordListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardId),
|
||||
Status = request.Status,
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardUsageRecordListResultResponse>.Ok(new PunchCardUsageRecordListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapUsageRecord).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount,
|
||||
Stats = new PunchCardUsageStatsResponse
|
||||
{
|
||||
TodayUsedCount = result.Stats.TodayUsedCount,
|
||||
MonthUsedCount = result.Stats.MonthUsedCount,
|
||||
ExpiringSoonCount = result.Stats.ExpiringSoonCount
|
||||
},
|
||||
TemplateOptions = result.TemplateOptions.Select(item => new PunchCardTemplateOptionResponse
|
||||
{
|
||||
TemplateId = item.TemplateId.ToString(),
|
||||
Name = item.Name
|
||||
}).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出次卡使用记录。
|
||||
/// </summary>
|
||||
[HttpGet("usage-record/export")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardUsageRecordExportResponse>> ExportUsageRecord(
|
||||
[FromQuery] ExportPunchCardUsageRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportPunchCardUsageRecordCsvQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardId),
|
||||
Status = request.Status,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardUsageRecordExportResponse>.Ok(new PunchCardUsageRecordExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入次卡使用记录。
|
||||
/// </summary>
|
||||
[HttpPost("usage-record/write")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardUsageRecordResponse>> WriteUsageRecord(
|
||||
[FromBody] WritePunchCardUsageRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new WritePunchCardUsageRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId)),
|
||||
InstanceId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardInstanceId),
|
||||
InstanceNo = request.PunchCardInstanceNo,
|
||||
MemberName = request.MemberName,
|
||||
MemberPhoneMasked = request.MemberPhoneMasked,
|
||||
ProductName = request.ProductName,
|
||||
UsedAt = request.UsedAt,
|
||||
UsedTimes = request.UsedTimes,
|
||||
ExtraPayAmount = request.ExtraPayAmount
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardUsageRecordResponse>.Ok(MapUsageRecord(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value, string fieldName)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: StoreApiHelpers.ParseDateOnly(value, fieldName);
|
||||
}
|
||||
|
||||
private static PunchCardListItemResponse MapListItem(PunchCardListItemDto source)
|
||||
{
|
||||
return new PunchCardListItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
CoverImageUrl = source.CoverImageUrl,
|
||||
SalePrice = source.SalePrice,
|
||||
OriginalPrice = source.OriginalPrice,
|
||||
TotalTimes = source.TotalTimes,
|
||||
ValiditySummary = source.ValiditySummary,
|
||||
ScopeType = source.ScopeType,
|
||||
UsageMode = source.UsageMode,
|
||||
UsageCapAmount = source.UsageCapAmount,
|
||||
DailyLimit = source.DailyLimit,
|
||||
Status = source.Status,
|
||||
IsDimmed = source.IsDimmed,
|
||||
SoldCount = source.SoldCount,
|
||||
ActiveCount = source.ActiveCount,
|
||||
RevenueAmount = source.RevenueAmount,
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static PunchCardStatsResponse MapTemplateStats(PunchCardStatsDto source)
|
||||
{
|
||||
return new PunchCardStatsResponse
|
||||
{
|
||||
OnSaleCount = source.OnSaleCount,
|
||||
TotalSoldCount = source.TotalSoldCount,
|
||||
TotalRevenueAmount = source.TotalRevenueAmount,
|
||||
ActiveInUseCount = source.ActiveInUseCount
|
||||
};
|
||||
}
|
||||
|
||||
private static PunchCardDetailResponse MapDetail(PunchCardDetailDto source)
|
||||
{
|
||||
return new PunchCardDetailResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
StoreId = source.StoreId.ToString(),
|
||||
Name = source.Name,
|
||||
CoverImageUrl = source.CoverImageUrl,
|
||||
SalePrice = source.SalePrice,
|
||||
OriginalPrice = source.OriginalPrice,
|
||||
TotalTimes = source.TotalTimes,
|
||||
ValidityType = source.ValidityType,
|
||||
ValidityDays = source.ValidityDays,
|
||||
ValidFrom = ToDateOnly(source.ValidFrom),
|
||||
ValidTo = ToDateOnly(source.ValidTo),
|
||||
Scope = new PunchCardScopeResponse
|
||||
{
|
||||
ScopeType = source.Scope.ScopeType,
|
||||
CategoryIds = source.Scope.CategoryIds.Select(item => item.ToString()).ToList(),
|
||||
TagIds = source.Scope.TagIds.Select(item => item.ToString()).ToList(),
|
||||
ProductIds = source.Scope.ProductIds.Select(item => item.ToString()).ToList()
|
||||
},
|
||||
UsageMode = source.UsageMode,
|
||||
UsageCapAmount = source.UsageCapAmount,
|
||||
DailyLimit = source.DailyLimit,
|
||||
PerOrderLimit = source.PerOrderLimit,
|
||||
PerUserPurchaseLimit = source.PerUserPurchaseLimit,
|
||||
AllowTransfer = source.AllowTransfer,
|
||||
ExpireStrategy = source.ExpireStrategy,
|
||||
Description = source.Description,
|
||||
NotifyChannels = source.NotifyChannels.ToList(),
|
||||
Status = source.Status,
|
||||
SoldCount = source.SoldCount,
|
||||
ActiveCount = source.ActiveCount,
|
||||
RevenueAmount = source.RevenueAmount,
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static PunchCardUsageRecordResponse MapUsageRecord(PunchCardUsageRecordDto source)
|
||||
{
|
||||
return new PunchCardUsageRecordResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
RecordNo = source.RecordNo,
|
||||
PunchCardId = source.PunchCardTemplateId.ToString(),
|
||||
PunchCardName = source.PunchCardName,
|
||||
PunchCardInstanceId = source.PunchCardInstanceId.ToString(),
|
||||
MemberName = source.MemberName,
|
||||
MemberPhoneMasked = source.MemberPhoneMasked,
|
||||
ProductName = source.ProductName,
|
||||
UsedAt = ToDateTime(source.UsedAt),
|
||||
UsedTimes = source.UsedTimes,
|
||||
RemainingTimesAfterUse = source.RemainingTimesAfterUse,
|
||||
TotalTimes = source.TotalTimes,
|
||||
DisplayStatus = source.DisplayStatus,
|
||||
ExtraPayAmount = source.ExtraPayAmount
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ToDateOnly(DateTime? value)
|
||||
{
|
||||
return value.HasValue
|
||||
? value.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string ToDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Coupons.Seckill.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.Seckill.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
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.Marketing;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 营销中心秒杀活动管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/marketing/seckill")]
|
||||
public sealed class MarketingSeckillController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取秒杀活动列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SeckillListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<SeckillListResultResponse>> List(
|
||||
[FromQuery] SeckillListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetSeckillCampaignListQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<SeckillListResultResponse>.Ok(new SeckillListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.TotalCount,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
Stats = new SeckillStatsResponse
|
||||
{
|
||||
TotalCount = result.Stats.TotalCount,
|
||||
OngoingCount = result.Stats.OngoingCount,
|
||||
MonthlySeckillSalesCount = result.Stats.MonthlySeckillSalesCount,
|
||||
ConversionRate = result.Stats.ConversionRate
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取秒杀活动详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SeckillDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<SeckillDetailResponse>> Detail(
|
||||
[FromQuery] SeckillDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetSeckillCampaignDetailQuery
|
||||
{
|
||||
OperationStoreId = operationStoreId,
|
||||
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId))
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<SeckillDetailResponse>.Error(ErrorCodes.NotFound, "活动不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<SeckillDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存秒杀活动(新增/编辑)。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SeckillDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<SeckillDetailResponse>> Save(
|
||||
[FromBody] SaveSeckillRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
var resolvedStoreIds = await ResolveStoreIdsForSaveAsync(
|
||||
request.StoreIds,
|
||||
operationStoreId,
|
||||
cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SaveSeckillCampaignCommand
|
||||
{
|
||||
OperationStoreId = operationStoreId,
|
||||
CampaignId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
|
||||
Name = request.Name,
|
||||
ActivityType = request.ActivityType,
|
||||
StartDate = ParseDateOnlyOrNull(request.StartDate, nameof(request.StartDate)),
|
||||
EndDate = ParseDateOnlyOrNull(request.EndDate, nameof(request.EndDate)),
|
||||
TimeStart = ParseTimeOrNull(request.TimeStart, nameof(request.TimeStart)),
|
||||
TimeEnd = ParseTimeOrNull(request.TimeEnd, nameof(request.TimeEnd)),
|
||||
Sessions = (request.Sessions ?? [])
|
||||
.Select(item => new SeckillSessionRuleDto
|
||||
{
|
||||
StartTime = StoreApiHelpers.ParseRequiredTime(item.StartTime, "sessions.startTime"),
|
||||
DurationMinutes = item.DurationMinutes
|
||||
})
|
||||
.ToList(),
|
||||
Channels = request.Channels ?? [],
|
||||
PerUserLimit = request.PerUserLimit,
|
||||
PreheatEnabled = request.PreheatEnabled,
|
||||
PreheatHours = request.PreheatHours,
|
||||
StoreIds = resolvedStoreIds,
|
||||
Products = request.Products.Select(item => new SeckillSaveProductInputDto
|
||||
{
|
||||
ProductId = StoreApiHelpers.ParseRequiredSnowflake(item.ProductId, nameof(item.ProductId)),
|
||||
SeckillPrice = item.SeckillPrice,
|
||||
StockLimit = item.StockLimit,
|
||||
PerUserLimit = item.PerUserLimit
|
||||
}).ToList(),
|
||||
Metrics = request.Metrics is null
|
||||
? null
|
||||
: new SeckillMetricsDto
|
||||
{
|
||||
ParticipantCount = request.Metrics.ParticipantCount,
|
||||
DealCount = request.Metrics.DealCount,
|
||||
ConversionRate = request.Metrics.ConversionRate,
|
||||
MonthlySeckillSalesCount = request.Metrics.MonthlySeckillSalesCount
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<SeckillDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改秒杀活动状态。
|
||||
/// </summary>
|
||||
[HttpPost("status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SeckillDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<SeckillDetailResponse>> ChangeStatus(
|
||||
[FromBody] ChangeSeckillStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ChangeSeckillCampaignStatusCommand
|
||||
{
|
||||
OperationStoreId = operationStoreId,
|
||||
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<SeckillDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除秒杀活动。
|
||||
/// </summary>
|
||||
[HttpPost("delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Delete(
|
||||
[FromBody] DeleteSeckillRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
await mediator.Send(new DeleteSeckillCampaignCommand
|
||||
{
|
||||
OperationStoreId = operationStoreId,
|
||||
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取秒杀选品分类。
|
||||
/// </summary>
|
||||
[HttpGet("picker/categories")]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<SeckillPickerCategoryItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<List<SeckillPickerCategoryItemResponse>>> PickerCategories(
|
||||
[FromQuery] SeckillPickerCategoriesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetSeckillPickerCategoriesQuery
|
||||
{
|
||||
OperationStoreId = operationStoreId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<List<SeckillPickerCategoryItemResponse>>.Ok(result
|
||||
.Select(item => new SeckillPickerCategoryItemResponse
|
||||
{
|
||||
Id = item.Id.ToString(),
|
||||
Name = item.Name,
|
||||
ProductCount = item.ProductCount
|
||||
})
|
||||
.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取秒杀选品商品。
|
||||
/// </summary>
|
||||
[HttpGet("picker/products")]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<SeckillPickerProductItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<List<SeckillPickerProductItemResponse>>> PickerProducts(
|
||||
[FromQuery] SeckillPickerProductsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetSeckillPickerProductsQuery
|
||||
{
|
||||
OperationStoreId = operationStoreId,
|
||||
CategoryId = StoreApiHelpers.ParseSnowflakeOrNull(request.CategoryId),
|
||||
Keyword = request.Keyword,
|
||||
Limit = request.Limit
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<List<SeckillPickerProductItemResponse>>.Ok(result
|
||||
.Select(item => new SeckillPickerProductItemResponse
|
||||
{
|
||||
Id = item.Id.ToString(),
|
||||
CategoryId = item.CategoryId.ToString(),
|
||||
CategoryName = item.CategoryName,
|
||||
Name = item.Name,
|
||||
Price = item.Price,
|
||||
Stock = item.Stock,
|
||||
SpuCode = item.SpuCode,
|
||||
Status = item.Status
|
||||
})
|
||||
.ToList());
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
|
||||
string? storeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(storeId))
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
return [parsedStoreId];
|
||||
}
|
||||
|
||||
var allStoreIds = await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
|
||||
.Select(item => item.Id)
|
||||
.OrderBy(item => item)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (allStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||
}
|
||||
|
||||
return allStoreIds;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<long>> ResolveStoreIdsForSaveAsync(
|
||||
IEnumerable<string>? storeIds,
|
||||
long operationStoreId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreIds = StoreApiHelpers.ParseSnowflakeList(storeIds);
|
||||
if (parsedStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
|
||||
}
|
||||
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
var accessibleStoreIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
if (accessibleStoreIds.Count != parsedStoreIds.Count)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 存在无权限门店");
|
||||
}
|
||||
|
||||
if (!accessibleStoreIds.Contains(operationStoreId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 必须包含当前操作门店");
|
||||
}
|
||||
|
||||
return accessibleStoreIds.OrderBy(item => item).ToList();
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOnlyOrNull(string? value, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!DateTime.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 日期格式必须为 yyyy-MM-dd");
|
||||
}
|
||||
|
||||
return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseTimeOrNull(string? value, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return StoreApiHelpers.ParseRequiredTime(value, fieldName);
|
||||
}
|
||||
|
||||
private static SeckillListItemResponse MapListItem(SeckillListItemDto source)
|
||||
{
|
||||
return new SeckillListItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
ActivityType = source.Rules.ActivityType,
|
||||
StartDate = ToDateOnly(source.Rules.StartDate),
|
||||
EndDate = ToDateOnly(source.Rules.EndDate),
|
||||
TimeStart = StoreApiHelpers.ToHHmm(source.Rules.TimeStart),
|
||||
TimeEnd = StoreApiHelpers.ToHHmm(source.Rules.TimeEnd),
|
||||
Sessions = source.Rules.Sessions.Select(MapSession).ToList(),
|
||||
Status = source.Status,
|
||||
DisplayStatus = source.DisplayStatus,
|
||||
IsDimmed = source.IsDimmed,
|
||||
Channels = source.Rules.Channels.ToList(),
|
||||
PerUserLimit = source.Rules.PerUserLimit,
|
||||
PreheatEnabled = source.Rules.PreheatEnabled,
|
||||
PreheatHours = source.Rules.PreheatHours,
|
||||
StoreIds = source.Rules.StoreIds.Select(item => item.ToString()).ToList(),
|
||||
Products = source.Rules.Products.Select(MapProduct).ToList(),
|
||||
Metrics = MapMetrics(source.Rules.Metrics),
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static SeckillDetailResponse MapDetail(SeckillDetailDto source)
|
||||
{
|
||||
return new SeckillDetailResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
ActivityType = source.Rules.ActivityType,
|
||||
StartDate = ToDateOnly(source.Rules.StartDate),
|
||||
EndDate = ToDateOnly(source.Rules.EndDate),
|
||||
TimeStart = StoreApiHelpers.ToHHmm(source.Rules.TimeStart),
|
||||
TimeEnd = StoreApiHelpers.ToHHmm(source.Rules.TimeEnd),
|
||||
Sessions = source.Rules.Sessions.Select(MapSession).ToList(),
|
||||
Status = source.Status,
|
||||
DisplayStatus = source.DisplayStatus,
|
||||
Channels = source.Rules.Channels.ToList(),
|
||||
PerUserLimit = source.Rules.PerUserLimit,
|
||||
PreheatEnabled = source.Rules.PreheatEnabled,
|
||||
PreheatHours = source.Rules.PreheatHours,
|
||||
StoreIds = source.Rules.StoreIds.Select(item => item.ToString()).ToList(),
|
||||
Products = source.Rules.Products.Select(MapProduct).ToList(),
|
||||
Metrics = MapMetrics(source.Rules.Metrics),
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static SeckillSessionResponse MapSession(SeckillSessionRuleDto source)
|
||||
{
|
||||
return new SeckillSessionResponse
|
||||
{
|
||||
StartTime = StoreApiHelpers.ToHHmm(source.StartTime),
|
||||
DurationMinutes = source.DurationMinutes
|
||||
};
|
||||
}
|
||||
|
||||
private static SeckillProductResponse MapProduct(SeckillProductRuleDto source)
|
||||
{
|
||||
return new SeckillProductResponse
|
||||
{
|
||||
ProductId = source.ProductId.ToString(),
|
||||
CategoryId = source.CategoryId.ToString(),
|
||||
CategoryName = source.CategoryName,
|
||||
Name = source.Name,
|
||||
SpuCode = source.SpuCode,
|
||||
Status = source.Status,
|
||||
OriginalPrice = source.OriginalPrice,
|
||||
SeckillPrice = source.SeckillPrice,
|
||||
StockLimit = source.StockLimit,
|
||||
PerUserLimit = source.PerUserLimit,
|
||||
SoldCount = source.SoldCount
|
||||
};
|
||||
}
|
||||
|
||||
private static SeckillMetricsResponse MapMetrics(SeckillMetricsDto source)
|
||||
{
|
||||
return new SeckillMetricsResponse
|
||||
{
|
||||
ParticipantCount = source.ParticipantCount,
|
||||
DealCount = source.DealCount,
|
||||
ConversionRate = source.ConversionRate,
|
||||
MonthlySeckillSalesCount = source.MonthlySeckillSalesCount
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ToDateOnly(DateTime? value)
|
||||
{
|
||||
return value?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string ToDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
251
src/Api/TakeoutSaaS.TenantApi/Controllers/MemberController.cs
Normal file
251
src/Api/TakeoutSaaS.TenantApi/Controllers/MemberController.cs
Normal file
@@ -0,0 +1,251 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Members.Commands;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员管理列表与详情。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/member/list")]
|
||||
public sealed class MemberController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:member:view";
|
||||
private const string ManagePermission = "tenant:member:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取会员列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberListResultResponse>> List(
|
||||
[FromQuery] MemberListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SearchMemberListQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
Keyword = request.Keyword,
|
||||
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId),
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<MemberListResultResponse>.Ok(new MemberListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.TotalCount,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取会员列表统计。
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberListStatsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberListStatsResponse>> Stats(
|
||||
[FromQuery] MemberListFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetMemberListStatsQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
Keyword = request.Keyword,
|
||||
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<MemberListStatsResponse>.Ok(new MemberListStatsResponse
|
||||
{
|
||||
TotalMembers = result.TotalMembers,
|
||||
MonthlyNewMembers = result.MonthlyNewMembers,
|
||||
ActiveMembers = result.ActiveMembers,
|
||||
DormantMembers = result.DormantMembers
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取会员详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberDetailResponse>> Detail(
|
||||
[FromQuery] MemberDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetMemberDetailQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId))
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<MemberDetailResponse>.Error(ErrorCodes.NotFound, "会员不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<MemberDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存会员标签。
|
||||
/// </summary>
|
||||
[HttpPost("tags")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> SaveTags(
|
||||
[FromBody] SaveMemberTagsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_ = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
await mediator.Send(new SaveMemberTagsCommand
|
||||
{
|
||||
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)),
|
||||
Tags = (request.Tags ?? []).ToList()
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出会员列表 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("export")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberExportResponse>> Export(
|
||||
[FromQuery] MemberListFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportMemberCsvQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
Keyword = request.Keyword,
|
||||
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<MemberExportResponse>.Ok(new MemberExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
|
||||
string? storeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(storeId))
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreId,
|
||||
cancellationToken);
|
||||
return [parsedStoreId];
|
||||
}
|
||||
|
||||
var allStoreIds = await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
|
||||
.Select(item => item.Id)
|
||||
.OrderBy(item => item)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (allStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||
}
|
||||
|
||||
return allStoreIds;
|
||||
}
|
||||
|
||||
private static MemberListItemResponse MapListItem(MemberListItemDto source)
|
||||
{
|
||||
return new MemberListItemResponse
|
||||
{
|
||||
MemberId = source.MemberId.ToString(),
|
||||
Name = source.Name,
|
||||
AvatarText = source.AvatarText,
|
||||
AvatarColor = source.AvatarColor,
|
||||
MobileMasked = source.MobileMasked,
|
||||
TierId = source.TierId?.ToString(),
|
||||
TierName = source.TierName,
|
||||
TierColorHex = source.TierColorHex,
|
||||
TotalAmount = source.TotalAmount,
|
||||
OrderCount = source.OrderCount,
|
||||
LastOrderAt = source.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
StoredBalance = source.StoredBalance,
|
||||
PointsBalance = source.PointsBalance,
|
||||
IsDormant = source.IsDormant
|
||||
};
|
||||
}
|
||||
|
||||
private static MemberDetailResponse MapDetail(MemberDetailDto source)
|
||||
{
|
||||
return new MemberDetailResponse
|
||||
{
|
||||
MemberId = source.MemberId.ToString(),
|
||||
Name = source.Name,
|
||||
AvatarText = source.AvatarText,
|
||||
AvatarColor = source.AvatarColor,
|
||||
MobileMasked = source.MobileMasked,
|
||||
JoinedAt = source.JoinedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
TierId = source.TierId?.ToString(),
|
||||
TierName = source.TierName,
|
||||
TierColorHex = source.TierColorHex,
|
||||
TotalAmount = source.TotalAmount,
|
||||
OrderCount = source.OrderCount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
StoredBalance = source.StoredBalance,
|
||||
StoredRechargeBalance = source.StoredRechargeBalance,
|
||||
StoredGiftBalance = source.StoredGiftBalance,
|
||||
PointsBalance = source.PointsBalance,
|
||||
Tags = source.Tags.ToList(),
|
||||
RecentOrders = source.RecentOrders.Select(item => new MemberRecentOrderResponse
|
||||
{
|
||||
OrderedAt = item.OrderedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
OrderNo = item.OrderNo,
|
||||
Amount = item.Amount,
|
||||
StatusText = item.StatusText
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
using System.Globalization;
|
||||
using Asp.Versioning;
|
||||
using Hangfire;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
using TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息触达管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/member/message-reach")]
|
||||
public sealed class MemberMessageReachController(
|
||||
IMemberMessageReachAppService memberMessageReachAppService,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:member:message-reach:view";
|
||||
private const string ManagePermission = "tenant:member:message-reach:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取页面统计。
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachStatsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageReachStatsResponse>> Stats(
|
||||
[FromQuery] MemberMessageReachStatsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = await ResolveTenantIdAsync(request.StoreId, cancellationToken);
|
||||
var result = await memberMessageReachAppService.GetStatsAsync(tenantId, cancellationToken);
|
||||
return ApiResponse<MemberMessageReachStatsResponse>.Ok(new MemberMessageReachStatsResponse
|
||||
{
|
||||
MonthlySentCount = result.MonthlySentCount,
|
||||
ReachMemberCount = result.ReachMemberCount,
|
||||
OpenRate = result.OpenRate,
|
||||
ConversionRate = result.ConversionRate
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询消息列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageReachListResultResponse>> List(
|
||||
[FromQuery] MemberMessageReachListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var result = await memberMessageReachAppService.SearchMessagesAsync(
|
||||
tenantId,
|
||||
new SearchMemberMessageInput
|
||||
{
|
||||
Status = request.Status,
|
||||
Channel = request.Channel,
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<MemberMessageReachListResultResponse>.Ok(new MemberMessageReachListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapMessageListItem).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取消息详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageReachDetailResponse>> Detail(
|
||||
[FromQuery] MemberMessageReachDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var messageId = StoreApiHelpers.ParseRequiredSnowflake(request.MessageId, nameof(request.MessageId));
|
||||
var result = await memberMessageReachAppService.GetMessageDetailAsync(tenantId, messageId, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<MemberMessageReachDetailResponse>.Error(ErrorCodes.NotFound, "消息不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<MemberMessageReachDetailResponse>.Ok(MapMessageDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存消息(草稿/发送)。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageDispatchMetaResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageDispatchMetaResponse>> Save(
|
||||
[FromBody] SaveMemberMessageReachRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = await ResolveTenantIdAsync(request.StoreId, cancellationToken);
|
||||
var messageId = StoreApiHelpers.ParseSnowflakeOrNull(request.MessageId);
|
||||
var previousMeta = messageId.HasValue
|
||||
? await memberMessageReachAppService.GetDispatchMetaAsync(tenantId, messageId.Value, cancellationToken)
|
||||
: null;
|
||||
|
||||
var saved = await memberMessageReachAppService.SaveMessageAsync(
|
||||
tenantId,
|
||||
new SaveMemberMessageInput
|
||||
{
|
||||
MessageId = messageId,
|
||||
StoreId = StoreApiHelpers.ParseSnowflakeOrNull(request.StoreId),
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.TemplateId),
|
||||
Title = request.Title,
|
||||
Content = request.Content,
|
||||
Channels = request.Channels,
|
||||
AudienceType = request.AudienceType,
|
||||
AudienceTags = request.AudienceTags,
|
||||
ScheduleType = request.ScheduleType,
|
||||
ScheduledAt = request.ScheduledAt,
|
||||
SubmitAction = request.SubmitAction
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
// 1. 清理旧任务(若存在)。
|
||||
if (!string.IsNullOrWhiteSpace(previousMeta?.HangfireJobId))
|
||||
{
|
||||
BackgroundJob.Delete(previousMeta.HangfireJobId);
|
||||
}
|
||||
|
||||
// 2. 发送动作创建新任务并回写任务 ID。
|
||||
if (string.Equals(request.SubmitAction, "send", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var newJobId = ScheduleDispatchJob(saved.MessageId, saved.ScheduleType, saved.ScheduledAt);
|
||||
await memberMessageReachAppService.BindDispatchJobAsync(tenantId, saved.MessageId, newJobId, cancellationToken);
|
||||
}
|
||||
|
||||
// 3. 返回最新调度状态。
|
||||
var latest = await memberMessageReachAppService.GetDispatchMetaAsync(tenantId, saved.MessageId, cancellationToken);
|
||||
return ApiResponse<MemberMessageDispatchMetaResponse>.Ok(MapDispatchMeta(latest ?? saved));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除消息。
|
||||
/// </summary>
|
||||
[HttpPost("delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Delete(
|
||||
[FromBody] DeleteMemberMessageReachRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var messageId = StoreApiHelpers.ParseRequiredSnowflake(request.MessageId, nameof(request.MessageId));
|
||||
var oldJobId = await memberMessageReachAppService.DeleteMessageAsync(tenantId, messageId, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(oldJobId))
|
||||
{
|
||||
BackgroundJob.Delete(oldJobId);
|
||||
}
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 估算目标人群。
|
||||
/// </summary>
|
||||
[HttpPost("audience/estimate")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageAudienceEstimateResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageAudienceEstimateResponse>> EstimateAudience(
|
||||
[FromBody] MemberMessageAudienceEstimateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var result = await memberMessageReachAppService.EstimateAudienceAsync(
|
||||
tenantId,
|
||||
new MemberMessageAudienceEstimateInput
|
||||
{
|
||||
AudienceType = request.AudienceType,
|
||||
Tags = request.Tags
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<MemberMessageAudienceEstimateResponse>.Ok(new MemberMessageAudienceEstimateResponse
|
||||
{
|
||||
ReachCount = result.ReachCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询模板。
|
||||
/// </summary>
|
||||
[HttpGet("template/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageTemplateListResultResponse>> TemplateList(
|
||||
[FromQuery] MemberMessageTemplateListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var result = await memberMessageReachAppService.SearchTemplatesAsync(
|
||||
tenantId,
|
||||
new SearchMemberMessageTemplateInput
|
||||
{
|
||||
Category = request.Category,
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<MemberMessageTemplateListResultResponse>.Ok(new MemberMessageTemplateListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapTemplate).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取模板详情。
|
||||
/// </summary>
|
||||
[HttpGet("template/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageTemplateResponse>> TemplateDetail(
|
||||
[FromQuery] MemberMessageTemplateDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var templateId = StoreApiHelpers.ParseRequiredSnowflake(request.TemplateId, nameof(request.TemplateId));
|
||||
var result = await memberMessageReachAppService.GetTemplateAsync(tenantId, templateId, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<MemberMessageTemplateResponse>.Error(ErrorCodes.NotFound, "模板不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<MemberMessageTemplateResponse>.Ok(MapTemplate(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存模板。
|
||||
/// </summary>
|
||||
[HttpPost("template/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageTemplateResponse>> SaveTemplate(
|
||||
[FromBody] SaveMemberMessageTemplateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var result = await memberMessageReachAppService.SaveTemplateAsync(
|
||||
tenantId,
|
||||
new SaveMemberMessageTemplateInput
|
||||
{
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.TemplateId),
|
||||
Name = request.Name,
|
||||
Category = request.Category,
|
||||
Content = request.Content
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<MemberMessageTemplateResponse>.Ok(MapTemplate(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除模板。
|
||||
/// </summary>
|
||||
[HttpPost("template/delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteTemplate(
|
||||
[FromBody] DeleteMemberMessageTemplateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var templateId = StoreApiHelpers.ParseRequiredSnowflake(request.TemplateId, nameof(request.TemplateId));
|
||||
await memberMessageReachAppService.DeleteTemplateAsync(tenantId, templateId, cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
private long ResolveTenantId()
|
||||
{
|
||||
var (tenantId, _) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
private async Task<long> ResolveTenantIdAsync(string? storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
if (string.IsNullOrWhiteSpace(storeId))
|
||||
{
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreId,
|
||||
cancellationToken);
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
private static string ScheduleDispatchJob(long messageId, string scheduleType, DateTime? scheduledAtUtc)
|
||||
{
|
||||
if (string.Equals(scheduleType, "scheduled", StringComparison.OrdinalIgnoreCase) && scheduledAtUtc.HasValue)
|
||||
{
|
||||
var delay = scheduledAtUtc.Value.ToUniversalTime() - DateTime.UtcNow;
|
||||
if (delay < TimeSpan.Zero)
|
||||
{
|
||||
delay = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
return BackgroundJob.Schedule<MemberMessageReachDispatchJobRunner>(
|
||||
runner => runner.ExecuteAsync(messageId),
|
||||
delay);
|
||||
}
|
||||
|
||||
return BackgroundJob.Enqueue<MemberMessageReachDispatchJobRunner>(runner => runner.ExecuteAsync(messageId));
|
||||
}
|
||||
|
||||
private static MemberMessageReachListItemResponse MapMessageListItem(MemberMessageReachListItemDto source)
|
||||
{
|
||||
return new MemberMessageReachListItemResponse
|
||||
{
|
||||
MessageId = source.MessageId.ToString(),
|
||||
Title = source.Title,
|
||||
Channels = source.Channels.ToList(),
|
||||
AudienceText = source.AudienceText,
|
||||
EstimatedReachCount = source.EstimatedReachCount,
|
||||
Status = source.Status,
|
||||
SentAt = FormatDateTime(source.SentAt),
|
||||
ScheduledAt = FormatDateTime(source.ScheduledAt),
|
||||
OpenRate = source.OpenRate,
|
||||
ConversionRate = source.ConversionRate
|
||||
};
|
||||
}
|
||||
|
||||
private static MemberMessageReachDetailResponse MapMessageDetail(MemberMessageReachDetailDto source)
|
||||
{
|
||||
return new MemberMessageReachDetailResponse
|
||||
{
|
||||
MessageId = source.MessageId.ToString(),
|
||||
TemplateId = source.TemplateId?.ToString(),
|
||||
Title = source.Title,
|
||||
Content = source.Content,
|
||||
Channels = source.Channels.ToList(),
|
||||
AudienceType = source.AudienceType,
|
||||
AudienceTags = source.AudienceTags.ToList(),
|
||||
AudienceText = source.AudienceText,
|
||||
EstimatedReachCount = source.EstimatedReachCount,
|
||||
ScheduleType = source.ScheduleType,
|
||||
ScheduledAt = FormatDateTime(source.ScheduledAt),
|
||||
Status = source.Status,
|
||||
SentAt = FormatDateTime(source.SentAt),
|
||||
SentCount = source.SentCount,
|
||||
ReadCount = source.ReadCount,
|
||||
ConvertedCount = source.ConvertedCount,
|
||||
OpenRate = source.OpenRate,
|
||||
ConversionRate = source.ConversionRate,
|
||||
LastError = source.LastError,
|
||||
Recipients = source.Recipients.Select(item => new MemberMessageReachRecipientResponse
|
||||
{
|
||||
MemberId = item.MemberId.ToString(),
|
||||
Channel = item.Channel,
|
||||
Status = item.Status,
|
||||
Mobile = item.Mobile,
|
||||
OpenId = item.OpenId,
|
||||
SentAt = FormatDateTime(item.SentAt),
|
||||
ReadAt = FormatDateTime(item.ReadAt),
|
||||
ConvertedAt = FormatDateTime(item.ConvertedAt),
|
||||
ErrorMessage = item.ErrorMessage
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static MemberMessageDispatchMetaResponse MapDispatchMeta(MemberMessageDispatchMetaDto source)
|
||||
{
|
||||
return new MemberMessageDispatchMetaResponse
|
||||
{
|
||||
MessageId = source.MessageId.ToString(),
|
||||
Status = source.Status,
|
||||
ScheduleType = source.ScheduleType,
|
||||
ScheduledAt = FormatDateTime(source.ScheduledAt),
|
||||
HangfireJobId = source.HangfireJobId
|
||||
};
|
||||
}
|
||||
|
||||
private static MemberMessageTemplateResponse MapTemplate(MemberMessageTemplateDto source)
|
||||
{
|
||||
return new MemberMessageTemplateResponse
|
||||
{
|
||||
TemplateId = source.TemplateId.ToString(),
|
||||
Name = source.Name,
|
||||
Category = source.Category,
|
||||
Content = source.Content,
|
||||
UsageCount = source.UsageCount,
|
||||
LastUsedAt = FormatDateTime(source.LastUsedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FormatDateTime(DateTime? value)
|
||||
{
|
||||
return value.HasValue
|
||||
? value.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Commands;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员中心积分商城管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/member/points-mall")]
|
||||
public sealed class MemberPointsMallController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:member:points-mall:view";
|
||||
private const string ManagePermission = "tenant:member:points-mall:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取积分规则详情。
|
||||
/// </summary>
|
||||
[HttpGet("rule/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRuleDetailResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRuleDetailResultResponse>> RuleDetail(
|
||||
[FromQuery] PointMallRuleDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallRuleDetailQuery
|
||||
{
|
||||
StoreId = storeId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRuleDetailResultResponse>.Ok(new PointMallRuleDetailResultResponse
|
||||
{
|
||||
Rule = MapRule(result.Rule),
|
||||
Stats = new PointMallRuleStatsResponse
|
||||
{
|
||||
TotalIssuedPoints = result.Stats.TotalIssuedPoints,
|
||||
RedeemedPoints = result.Stats.RedeemedPoints,
|
||||
PointMembers = result.Stats.PointMembers,
|
||||
RedeemRate = result.Stats.RedeemRate
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分规则。
|
||||
/// </summary>
|
||||
[HttpPost("rule/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRuleResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRuleResponse>> SaveRule(
|
||||
[FromBody] SavePointMallRuleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SavePointMallRuleCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
IsConsumeRewardEnabled = request.IsConsumeRewardEnabled,
|
||||
ConsumeAmountPerStep = request.ConsumeAmountPerStep,
|
||||
ConsumeRewardPointsPerStep = request.ConsumeRewardPointsPerStep,
|
||||
IsReviewRewardEnabled = request.IsReviewRewardEnabled,
|
||||
ReviewRewardPoints = request.ReviewRewardPoints,
|
||||
IsRegisterRewardEnabled = request.IsRegisterRewardEnabled,
|
||||
RegisterRewardPoints = request.RegisterRewardPoints,
|
||||
IsSigninRewardEnabled = request.IsSigninRewardEnabled,
|
||||
SigninRewardPoints = request.SigninRewardPoints,
|
||||
ExpiryMode = request.ExpiryMode
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRuleResponse>.Ok(MapRule(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换商品列表。
|
||||
/// </summary>
|
||||
[HttpGet("product/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductListResultResponse>> ProductList(
|
||||
[FromQuery] PointMallProductListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallProductListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Status = request.Status,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallProductListResultResponse>.Ok(new PointMallProductListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapProduct).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换商品详情。
|
||||
/// </summary>
|
||||
[HttpGet("product/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductResponse>> ProductDetail(
|
||||
[FromQuery] PointMallProductDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallProductDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存兑换商品。
|
||||
/// </summary>
|
||||
[HttpPost("product/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductResponse>> SaveProduct(
|
||||
[FromBody] SavePointMallProductRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SavePointMallProductCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseSnowflakeOrNull(request.PointMallProductId),
|
||||
Name = request.Name,
|
||||
ImageUrl = request.ImageUrl,
|
||||
RedeemType = request.RedeemType,
|
||||
ProductId = StoreApiHelpers.ParseSnowflakeOrNull(request.ProductId),
|
||||
CouponTemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.CouponTemplateId),
|
||||
PhysicalName = request.PhysicalName,
|
||||
PickupMethod = request.PickupMethod,
|
||||
Description = request.Description,
|
||||
ExchangeType = request.ExchangeType,
|
||||
RequiredPoints = request.RequiredPoints,
|
||||
CashAmount = request.CashAmount,
|
||||
StockTotal = request.StockTotal,
|
||||
PerMemberLimit = request.PerMemberLimit,
|
||||
NotifyChannels = request.NotifyChannels,
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改兑换商品状态。
|
||||
/// </summary>
|
||||
[HttpPost("product/status")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductResponse>> ChangeProductStatus(
|
||||
[FromBody] ChangePointMallProductStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ChangePointMallProductStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除兑换商品。
|
||||
/// </summary>
|
||||
[HttpPost("product/delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteProduct(
|
||||
[FromBody] DeletePointMallProductRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
await mediator.Send(new DeletePointMallProductCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录分页。
|
||||
/// </summary>
|
||||
[HttpGet("record/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordListResultResponse>> RecordList(
|
||||
[FromQuery] PointMallRecordListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallRecordListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
RedeemType = request.RedeemType,
|
||||
Status = request.Status,
|
||||
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
|
||||
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRecordListResultResponse>.Ok(new PointMallRecordListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapRecord).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount,
|
||||
Stats = new PointMallRecordStatsResponse
|
||||
{
|
||||
TodayRedeemCount = result.Stats.TodayRedeemCount,
|
||||
PendingPhysicalCount = result.Stats.PendingPhysicalCount,
|
||||
CurrentMonthUsedPoints = result.Stats.CurrentMonthUsedPoints
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录详情。
|
||||
/// </summary>
|
||||
[HttpGet("record/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordDetailResponse>> RecordDetail(
|
||||
[FromQuery] PointMallRecordDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallRecordDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出兑换记录 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("record/export")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordExportResponse>> ExportRecord(
|
||||
[FromQuery] ExportPointMallRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportPointMallRecordCsvQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
RedeemType = request.RedeemType,
|
||||
Status = request.Status,
|
||||
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
|
||||
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRecordExportResponse>.Ok(new PointMallRecordExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入兑换记录。
|
||||
/// </summary>
|
||||
[HttpPost("record/write")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordResponse>> WriteRecord(
|
||||
[FromBody] WritePointMallRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new WritePointMallRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId)),
|
||||
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)),
|
||||
RedeemedAt = request.RedeemedAt
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRecordResponse>.Ok(MapRecord(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 核销兑换记录。
|
||||
/// </summary>
|
||||
[HttpPost("record/verify")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordDetailResponse>> VerifyRecord(
|
||||
[FromBody] VerifyPointMallRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new VerifyPointMallRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
|
||||
VerifyMethod = request.VerifyMethod,
|
||||
VerifyRemark = request.VerifyRemark
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value, string fieldName)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: StoreApiHelpers.ParseDateOnly(value, fieldName);
|
||||
}
|
||||
|
||||
private static PointMallRuleResponse MapRule(MemberPointMallRuleDto source)
|
||||
{
|
||||
return new PointMallRuleResponse
|
||||
{
|
||||
StoreId = source.StoreId.ToString(),
|
||||
IsConsumeRewardEnabled = source.IsConsumeRewardEnabled,
|
||||
ConsumeAmountPerStep = source.ConsumeAmountPerStep,
|
||||
ConsumeRewardPointsPerStep = source.ConsumeRewardPointsPerStep,
|
||||
IsReviewRewardEnabled = source.IsReviewRewardEnabled,
|
||||
ReviewRewardPoints = source.ReviewRewardPoints,
|
||||
IsRegisterRewardEnabled = source.IsRegisterRewardEnabled,
|
||||
RegisterRewardPoints = source.RegisterRewardPoints,
|
||||
IsSigninRewardEnabled = source.IsSigninRewardEnabled,
|
||||
SigninRewardPoints = source.SigninRewardPoints,
|
||||
ExpiryMode = source.ExpiryMode
|
||||
};
|
||||
}
|
||||
|
||||
private static PointMallProductResponse MapProduct(MemberPointMallProductDto source)
|
||||
{
|
||||
return new PointMallProductResponse
|
||||
{
|
||||
PointMallProductId = source.PointMallProductId.ToString(),
|
||||
StoreId = source.StoreId.ToString(),
|
||||
Name = source.Name,
|
||||
ImageUrl = source.ImageUrl,
|
||||
RedeemType = source.RedeemType,
|
||||
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
|
||||
ProductId = source.ProductId?.ToString(),
|
||||
CouponTemplateId = source.CouponTemplateId?.ToString(),
|
||||
PhysicalName = source.PhysicalName,
|
||||
PickupMethod = source.PickupMethod,
|
||||
Description = source.Description,
|
||||
ExchangeType = source.ExchangeType,
|
||||
RequiredPoints = source.RequiredPoints,
|
||||
CashAmount = source.CashAmount,
|
||||
StockTotal = source.StockTotal,
|
||||
StockAvailable = source.StockAvailable,
|
||||
RedeemedCount = source.RedeemedCount,
|
||||
PerMemberLimit = source.PerMemberLimit,
|
||||
NotifyChannels = source.NotifyChannels.ToList(),
|
||||
Status = source.Status,
|
||||
StatusText = ResolveProductStatusText(source.Status),
|
||||
UpdatedAt = source.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
private static PointMallRecordResponse MapRecord(MemberPointMallRecordDto source)
|
||||
{
|
||||
return new PointMallRecordResponse
|
||||
{
|
||||
RecordId = source.RecordId.ToString(),
|
||||
RecordNo = source.RecordNo,
|
||||
PointMallProductId = source.PointMallProductId.ToString(),
|
||||
ProductName = source.ProductName,
|
||||
RedeemType = source.RedeemType,
|
||||
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
|
||||
ExchangeType = source.ExchangeType,
|
||||
MemberId = source.MemberId.ToString(),
|
||||
MemberName = source.MemberName,
|
||||
MemberMobileMasked = source.MemberMobileMasked,
|
||||
UsedPoints = source.UsedPoints,
|
||||
CashAmount = source.CashAmount,
|
||||
Status = source.Status,
|
||||
StatusText = ResolveRecordStatusText(source.Status),
|
||||
RedeemedAt = source.RedeemedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
VerifiedAt = source.VerifiedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
private static PointMallRecordDetailResponse MapRecordDetail(MemberPointMallRecordDetailDto source)
|
||||
{
|
||||
var response = new PointMallRecordDetailResponse
|
||||
{
|
||||
RecordId = source.RecordId.ToString(),
|
||||
RecordNo = source.RecordNo,
|
||||
PointMallProductId = source.PointMallProductId.ToString(),
|
||||
ProductName = source.ProductName,
|
||||
RedeemType = source.RedeemType,
|
||||
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
|
||||
ExchangeType = source.ExchangeType,
|
||||
MemberId = source.MemberId.ToString(),
|
||||
MemberName = source.MemberName,
|
||||
MemberMobileMasked = source.MemberMobileMasked,
|
||||
UsedPoints = source.UsedPoints,
|
||||
CashAmount = source.CashAmount,
|
||||
Status = source.Status,
|
||||
StatusText = ResolveRecordStatusText(source.Status),
|
||||
RedeemedAt = source.RedeemedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
VerifiedAt = source.VerifiedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
VerifyMethod = source.VerifyMethod,
|
||||
VerifyMethodText = ResolveVerifyMethodText(source.VerifyMethod),
|
||||
VerifyRemark = source.VerifyRemark,
|
||||
VerifiedBy = source.VerifiedBy?.ToString()
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string ResolveRedeemTypeText(string value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"product" => "商品",
|
||||
"coupon" => "优惠券",
|
||||
"physical" => "实物",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveProductStatusText(string value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"enabled" => "上架",
|
||||
"disabled" => "下架",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveRecordStatusText(string value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"pending_pickup" => "待领取",
|
||||
"issued" => "已发放",
|
||||
"completed" => "已完成",
|
||||
"canceled" => "已取消",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveVerifyMethodText(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"scan" => "扫码核销",
|
||||
"manual" => "手动核销",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Members.StoredCard.Commands;
|
||||
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.StoredCard.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员中心储值卡管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/member/stored-card")]
|
||||
public sealed class MemberStoredCardController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:member:stored-card:view";
|
||||
private const string ManagePermission = "tenant:member:stored-card:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取储值卡方案列表。
|
||||
/// </summary>
|
||||
[HttpGet("plan/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoredCardPlanListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoredCardPlanListResultResponse>> PlanList(
|
||||
[FromQuery] StoredCardPlanListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetStoredCardPlanListQuery
|
||||
{
|
||||
StoreId = storeId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<StoredCardPlanListResultResponse>.Ok(new StoredCardPlanListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapPlan).ToList(),
|
||||
Stats = new StoredCardPlanStatsResponse
|
||||
{
|
||||
TotalRechargeAmount = result.Stats.TotalRechargeAmount,
|
||||
TotalGiftAmount = result.Stats.TotalGiftAmount,
|
||||
CurrentMonthRechargeAmount = result.Stats.CurrentMonthRechargeAmount,
|
||||
RechargeMemberCount = result.Stats.RechargeMemberCount
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存储值卡方案。
|
||||
/// </summary>
|
||||
[HttpPost("plan/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoredCardPlanResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoredCardPlanResponse>> SavePlan(
|
||||
[FromBody] SaveStoredCardPlanRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SaveStoredCardPlanCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PlanId = StoreApiHelpers.ParseSnowflakeOrNull(request.PlanId),
|
||||
RechargeAmount = request.RechargeAmount,
|
||||
GiftAmount = request.GiftAmount,
|
||||
SortOrder = request.SortOrder,
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<StoredCardPlanResponse>.Ok(MapPlan(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改储值卡方案状态。
|
||||
/// </summary>
|
||||
[HttpPost("plan/status")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoredCardPlanResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoredCardPlanResponse>> ChangePlanStatus(
|
||||
[FromBody] ChangeStoredCardPlanStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ChangeStoredCardPlanStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PlanId = StoreApiHelpers.ParseRequiredSnowflake(request.PlanId, nameof(request.PlanId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<StoredCardPlanResponse>.Ok(MapPlan(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除储值卡方案。
|
||||
/// </summary>
|
||||
[HttpPost("plan/delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeletePlan(
|
||||
[FromBody] DeleteStoredCardPlanRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
await mediator.Send(new DeleteStoredCardPlanCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PlanId = StoreApiHelpers.ParseRequiredSnowflake(request.PlanId, nameof(request.PlanId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取充值记录分页。
|
||||
/// </summary>
|
||||
[HttpGet("record/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoredCardRechargeRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoredCardRechargeRecordListResultResponse>> RecordList(
|
||||
[FromQuery] StoredCardRechargeRecordListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetStoredCardRechargeRecordListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
|
||||
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<StoredCardRechargeRecordListResultResponse>.Ok(new StoredCardRechargeRecordListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapRecord).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出充值记录 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("record/export")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoredCardRechargeRecordExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoredCardRechargeRecordExportResponse>> ExportRecord(
|
||||
[FromQuery] ExportStoredCardRechargeRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportStoredCardRechargeRecordCsvQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
|
||||
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<StoredCardRechargeRecordExportResponse>.Ok(new StoredCardRechargeRecordExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入充值记录。
|
||||
/// </summary>
|
||||
[HttpPost("record/write")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoredCardRechargeRecordResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoredCardRechargeRecordResponse>> WriteRecord(
|
||||
[FromBody] WriteStoredCardRechargeRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new WriteStoredCardRechargeRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)),
|
||||
PlanId = StoreApiHelpers.ParseSnowflakeOrNull(request.PlanId),
|
||||
RechargeAmount = request.RechargeAmount,
|
||||
GiftAmount = request.GiftAmount,
|
||||
PaymentMethod = request.PaymentMethod,
|
||||
RechargedAt = request.RechargedAt,
|
||||
Remark = request.Remark
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<StoredCardRechargeRecordResponse>.Ok(MapRecord(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value, string fieldName)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: StoreApiHelpers.ParseDateOnly(value, fieldName);
|
||||
}
|
||||
|
||||
private static StoredCardPlanResponse MapPlan(MemberStoredCardPlanDto source)
|
||||
{
|
||||
return new StoredCardPlanResponse
|
||||
{
|
||||
PlanId = source.PlanId.ToString(),
|
||||
RechargeAmount = source.RechargeAmount,
|
||||
GiftAmount = source.GiftAmount,
|
||||
ArrivedAmount = source.ArrivedAmount,
|
||||
SortOrder = source.SortOrder,
|
||||
Status = source.Status,
|
||||
RechargeCount = source.RechargeCount,
|
||||
TotalRechargeAmount = source.TotalRechargeAmount
|
||||
};
|
||||
}
|
||||
|
||||
private static StoredCardRechargeRecordResponse MapRecord(MemberStoredCardRechargeRecordDto source)
|
||||
{
|
||||
return new StoredCardRechargeRecordResponse
|
||||
{
|
||||
RecordId = source.RecordId.ToString(),
|
||||
RecordNo = source.RecordNo,
|
||||
MemberId = source.MemberId.ToString(),
|
||||
MemberName = source.MemberName,
|
||||
MemberMobileMasked = source.MemberMobileMasked,
|
||||
RechargeAmount = source.RechargeAmount,
|
||||
GiftAmount = source.GiftAmount,
|
||||
ArrivedAmount = source.ArrivedAmount,
|
||||
PaymentMethod = source.PaymentMethod,
|
||||
PaymentMethodText = ResolvePaymentMethodText(source.PaymentMethod),
|
||||
RechargedAt = source.RechargedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
PlanId = source.PlanId?.ToString(),
|
||||
Remark = source.Remark
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolvePaymentMethodText(string paymentMethod)
|
||||
{
|
||||
return (paymentMethod ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"wechat" => "微信支付",
|
||||
"alipay" => "支付宝",
|
||||
"cash" => "现金",
|
||||
"card" => "刷卡",
|
||||
"balance" => "余额",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Members.Commands;
|
||||
using TakeoutSaaS.Application.App.Members.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级体系与会员日配置。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/member/tier")]
|
||||
public sealed class MemberTierController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:member:view";
|
||||
private const string ManagePermission = "tenant:member:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取会员等级列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<MemberTierListItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<List<MemberTierListItemResponse>>> List(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetMemberTierListQuery(), cancellationToken);
|
||||
return ApiResponse<List<MemberTierListItemResponse>>.Ok(result.Select(MapTierListItem).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取会员等级详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberTierDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberTierDetailResponse>> Detail(
|
||||
[FromQuery] MemberTierDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetMemberTierDetailQuery
|
||||
{
|
||||
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<MemberTierDetailResponse>.Ok(MapTierDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存会员等级。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberTierDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberTierDetailResponse>> Save(
|
||||
[FromBody] SaveMemberTierRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new SaveMemberTierCommand
|
||||
{
|
||||
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId),
|
||||
SortOrder = request.SortOrder,
|
||||
Name = request.Name,
|
||||
IconKey = request.IconKey,
|
||||
ColorHex = request.ColorHex,
|
||||
IsDefault = request.IsDefault,
|
||||
Rule = new MemberTierRuleDto
|
||||
{
|
||||
UpgradeRuleType = request.Rule.UpgradeRuleType,
|
||||
UpgradeAmountThreshold = request.Rule.UpgradeAmountThreshold,
|
||||
UpgradeOrderCountThreshold = request.Rule.UpgradeOrderCountThreshold,
|
||||
DowngradeWindowDays = request.Rule.DowngradeWindowDays
|
||||
},
|
||||
Benefits = MapBenefits(request.Benefits)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<MemberTierDetailResponse>.Ok(MapTierDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除会员等级。
|
||||
/// </summary>
|
||||
[HttpPost("delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Delete(
|
||||
[FromBody] DeleteMemberTierRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await mediator.Send(new DeleteMemberTierCommand
|
||||
{
|
||||
TierId = StoreApiHelpers.ParseRequiredSnowflake(request.TierId, nameof(request.TierId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取会员日配置。
|
||||
/// </summary>
|
||||
[HttpGet("day-setting")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberDaySettingResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberDaySettingResponse>> GetDaySetting(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetMemberDaySettingQuery(), cancellationToken);
|
||||
return ApiResponse<MemberDaySettingResponse>.Ok(new MemberDaySettingResponse
|
||||
{
|
||||
IsEnabled = result.IsEnabled,
|
||||
Weekday = result.Weekday,
|
||||
ExtraDiscountRate = result.ExtraDiscountRate
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存会员日配置。
|
||||
/// </summary>
|
||||
[HttpPost("day-setting")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberDaySettingResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberDaySettingResponse>> SaveDaySetting(
|
||||
[FromBody] SaveMemberDaySettingRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new SaveMemberDaySettingCommand
|
||||
{
|
||||
IsEnabled = request.IsEnabled,
|
||||
Weekday = request.Weekday,
|
||||
ExtraDiscountRate = request.ExtraDiscountRate
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<MemberDaySettingResponse>.Ok(new MemberDaySettingResponse
|
||||
{
|
||||
IsEnabled = result.IsEnabled,
|
||||
Weekday = result.Weekday,
|
||||
ExtraDiscountRate = result.ExtraDiscountRate
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询可选优惠券列表。
|
||||
/// </summary>
|
||||
[HttpGet("coupon-picker")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<MemberCouponPickerItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<List<MemberCouponPickerItemResponse>>> CouponPicker(
|
||||
[FromQuery] MemberCouponPickerRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SearchMemberCouponPickerQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<List<MemberCouponPickerItemResponse>>.Ok(result.Select(item => new MemberCouponPickerItemResponse
|
||||
{
|
||||
CouponTemplateId = item.CouponTemplateId.ToString(),
|
||||
Name = item.Name,
|
||||
CouponType = item.CouponType,
|
||||
Value = item.Value,
|
||||
MinimumSpend = item.MinimumSpend,
|
||||
DisplayText = item.DisplayText
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
|
||||
string? storeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(storeId))
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreId,
|
||||
cancellationToken);
|
||||
return [parsedStoreId];
|
||||
}
|
||||
|
||||
var allStoreIds = await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
|
||||
.Select(item => item.Id)
|
||||
.OrderBy(item => item)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (allStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||
}
|
||||
|
||||
return allStoreIds;
|
||||
}
|
||||
|
||||
private static MemberTierListItemResponse MapTierListItem(MemberTierListItemDto source)
|
||||
{
|
||||
return new MemberTierListItemResponse
|
||||
{
|
||||
TierId = source.TierId.ToString(),
|
||||
SortOrder = source.SortOrder,
|
||||
Name = source.Name,
|
||||
IconKey = source.IconKey,
|
||||
ColorHex = source.ColorHex,
|
||||
ConditionText = source.ConditionText,
|
||||
Perks = source.Perks.ToList(),
|
||||
MemberCount = source.MemberCount,
|
||||
IsDefault = source.IsDefault,
|
||||
CanDelete = source.CanDelete
|
||||
};
|
||||
}
|
||||
|
||||
private static MemberTierDetailResponse MapTierDetail(MemberTierDetailDto source)
|
||||
{
|
||||
return new MemberTierDetailResponse
|
||||
{
|
||||
TierId = source.TierId?.ToString(),
|
||||
SortOrder = source.SortOrder,
|
||||
Name = source.Name,
|
||||
IconKey = source.IconKey,
|
||||
ColorHex = source.ColorHex,
|
||||
IsDefault = source.IsDefault,
|
||||
Rule = new MemberTierRuleResponse
|
||||
{
|
||||
UpgradeRuleType = source.Rule.UpgradeRuleType,
|
||||
UpgradeAmountThreshold = source.Rule.UpgradeAmountThreshold,
|
||||
UpgradeOrderCountThreshold = source.Rule.UpgradeOrderCountThreshold,
|
||||
DowngradeWindowDays = source.Rule.DowngradeWindowDays
|
||||
},
|
||||
Benefits = new MemberTierBenefitsResponse
|
||||
{
|
||||
Discount = new MemberTierDiscountBenefitResponse
|
||||
{
|
||||
Enabled = source.Benefits.Discount.Enabled,
|
||||
DiscountRate = source.Benefits.Discount.DiscountRate
|
||||
},
|
||||
PointMultiplier = new MemberTierPointMultiplierBenefitResponse
|
||||
{
|
||||
Enabled = source.Benefits.PointMultiplier.Enabled,
|
||||
Multiplier = source.Benefits.PointMultiplier.Multiplier
|
||||
},
|
||||
Birthday = new MemberTierBirthdayBenefitResponse
|
||||
{
|
||||
Enabled = source.Benefits.Birthday.Enabled,
|
||||
DoublePointsEnabled = source.Benefits.Birthday.DoublePointsEnabled,
|
||||
CouponTemplateIds = source.Benefits.Birthday.CouponTemplateIds.Select(item => item.ToString()).ToList()
|
||||
},
|
||||
MonthlyCoupon = new MemberTierMonthlyCouponBenefitResponse
|
||||
{
|
||||
Enabled = source.Benefits.MonthlyCoupon.Enabled,
|
||||
GrantDay = source.Benefits.MonthlyCoupon.GrantDay,
|
||||
CouponTemplateIds = source.Benefits.MonthlyCoupon.CouponTemplateIds.Select(item => item.ToString()).ToList()
|
||||
},
|
||||
FreeDelivery = new MemberTierFreeDeliveryBenefitResponse
|
||||
{
|
||||
Enabled = source.Benefits.FreeDelivery.Enabled,
|
||||
MonthlyFreeTimes = source.Benefits.FreeDelivery.MonthlyFreeTimes
|
||||
},
|
||||
PriorityDeliveryEnabled = source.Benefits.PriorityDeliveryEnabled,
|
||||
ExclusiveServiceEnabled = source.Benefits.ExclusiveServiceEnabled
|
||||
},
|
||||
CanDelete = source.CanDelete
|
||||
};
|
||||
}
|
||||
|
||||
private static MemberTierBenefitsDto MapBenefits(MemberTierBenefitsResponse source)
|
||||
{
|
||||
return new MemberTierBenefitsDto
|
||||
{
|
||||
Discount = new MemberTierDiscountBenefitDto
|
||||
{
|
||||
Enabled = source.Discount.Enabled,
|
||||
DiscountRate = source.Discount.DiscountRate
|
||||
},
|
||||
PointMultiplier = new MemberTierPointMultiplierBenefitDto
|
||||
{
|
||||
Enabled = source.PointMultiplier.Enabled,
|
||||
Multiplier = source.PointMultiplier.Multiplier
|
||||
},
|
||||
Birthday = new MemberTierBirthdayBenefitDto
|
||||
{
|
||||
Enabled = source.Birthday.Enabled,
|
||||
DoublePointsEnabled = source.Birthday.DoublePointsEnabled,
|
||||
CouponTemplateIds = source.Birthday.CouponTemplateIds
|
||||
.Select(StoreApiHelpers.ParseSnowflakeOrNull)
|
||||
.Where(item => item.HasValue)
|
||||
.Select(item => item!.Value)
|
||||
.ToList()
|
||||
},
|
||||
MonthlyCoupon = new MemberTierMonthlyCouponBenefitDto
|
||||
{
|
||||
Enabled = source.MonthlyCoupon.Enabled,
|
||||
GrantDay = source.MonthlyCoupon.GrantDay,
|
||||
CouponTemplateIds = source.MonthlyCoupon.CouponTemplateIds
|
||||
.Select(StoreApiHelpers.ParseSnowflakeOrNull)
|
||||
.Where(item => item.HasValue)
|
||||
.Select(item => item!.Value)
|
||||
.ToList()
|
||||
},
|
||||
FreeDelivery = new MemberTierFreeDeliveryBenefitDto
|
||||
{
|
||||
Enabled = source.FreeDelivery.Enabled,
|
||||
MonthlyFreeTimes = source.FreeDelivery.MonthlyFreeTimes
|
||||
},
|
||||
PriorityDeliveryEnabled = source.PriorityDeliveryEnabled,
|
||||
ExclusiveServiceEnabled = source.ExclusiveServiceEnabled
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
@@ -14,7 +18,10 @@ namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/merchant")]
|
||||
public sealed class MerchantController(IMediator mediator) : BaseApiController
|
||||
public sealed class MerchantController(
|
||||
IMediator mediator,
|
||||
ITenantProvider tenantProvider,
|
||||
GeoLocationOrchestrator geoLocationOrchestrator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前登录用户对应的商户中心信息。
|
||||
@@ -32,4 +39,106 @@ public sealed class MerchantController(IMediator mediator) : BaseApiController
|
||||
// 2. 返回聚合信息
|
||||
return ApiResponse<CurrentMerchantCenterDto>.Ok(info);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新当前商户基础信息。
|
||||
/// </summary>
|
||||
/// <param name="request">更新请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新结果。</returns>
|
||||
[HttpPost("update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<UpdateMerchantResultDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<UpdateMerchantResultDto>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<UpdateMerchantResultDto>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<UpdateMerchantResultDto>> Update(
|
||||
[FromBody] UpdateCurrentMerchantRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var merchantId = StoreApiHelpers.ParseRequiredSnowflake(request.Id, nameof(request.Id));
|
||||
var result = await mediator.Send(new UpdateMerchantCommand
|
||||
{
|
||||
MerchantId = merchantId,
|
||||
Name = request.Name,
|
||||
LicenseNumber = request.LicenseNumber,
|
||||
LegalRepresentative = request.LegalRepresentative,
|
||||
RegisteredAddress = request.RegisteredAddress,
|
||||
ContactPhone = request.ContactPhone,
|
||||
ContactEmail = request.ContactEmail
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<UpdateMerchantResultDto>.Error(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<UpdateMerchantResultDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动重试当前商户地理定位。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>重试结果。</returns>
|
||||
[HttpPost("geocode/retry")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ApiResponse<object>> RetryCurrentMerchantGeocode(CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (tenantId <= 0)
|
||||
{
|
||||
return ApiResponse<object>.Error(ErrorCodes.BadRequest, "缺少租户标识");
|
||||
}
|
||||
|
||||
var currentMerchant = await mediator.Send(new GetCurrentMerchantCenterQuery(), cancellationToken);
|
||||
var merchantId = currentMerchant.Merchant.Id;
|
||||
var success = await geoLocationOrchestrator.RetryMerchantAsync(tenantId, merchantId, cancellationToken);
|
||||
if (!success)
|
||||
{
|
||||
return ApiResponse<object>.Error(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新当前商户请求。
|
||||
/// </summary>
|
||||
public sealed class UpdateCurrentMerchantRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 商户标识。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商户名称。
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 营业执照号。
|
||||
/// </summary>
|
||||
public string? LicenseNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 法人/负责人。
|
||||
/// </summary>
|
||||
public string? LegalRepresentative { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册地址。
|
||||
/// </summary>
|
||||
public string? RegisteredAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
public string? ContactPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系邮箱。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Orders.Commands;
|
||||
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||
using TakeoutSaaS.Application.App.Orders.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.OrderBoard;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 订单大厅(实时看板)接口。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/order-board")]
|
||||
public sealed class OrderBoardController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取完整看板数据(四列)。
|
||||
/// </summary>
|
||||
[HttpGet("board")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OrderBoardResultDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<OrderBoardResultDto>> GetBoard(
|
||||
[FromQuery] string storeId,
|
||||
[FromQuery] string? channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店权限
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
await EnsureStoreAccessibleAsync(parsedStoreId, cancellationToken);
|
||||
|
||||
// 2. 解析渠道筛选
|
||||
var channelFilter = ParseChannel(channel);
|
||||
|
||||
// 3. 查询看板数据
|
||||
var (_, tenantId, _) = storeContextService.GetRequiredContext();
|
||||
var result = await mediator.Send(new GetOrderBoardQuery
|
||||
{
|
||||
StoreId = parsedStoreId,
|
||||
TenantId = tenantId,
|
||||
Channel = channelFilter
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<OrderBoardResultDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取看板统计数据。
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OrderBoardStatsDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<OrderBoardStatsDto>> GetStats(
|
||||
[FromQuery] string storeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
await EnsureStoreAccessibleAsync(parsedStoreId, cancellationToken);
|
||||
|
||||
var (_, tenantId, _) = storeContextService.GetRequiredContext();
|
||||
var result = await mediator.Send(new GetOrderBoardStatsQuery
|
||||
{
|
||||
StoreId = parsedStoreId,
|
||||
TenantId = tenantId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<OrderBoardStatsDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重连补偿拉取(获取指定时间后的订单变更)。
|
||||
/// </summary>
|
||||
[HttpGet("pending-since")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<OrderBoardCardDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<OrderBoardCardDto>>> GetPendingSince(
|
||||
[FromQuery] string storeId,
|
||||
[FromQuery] DateTime since,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
await EnsureStoreAccessibleAsync(parsedStoreId, cancellationToken);
|
||||
|
||||
var (_, tenantId, _) = storeContextService.GetRequiredContext();
|
||||
var result = await mediator.Send(new GetPendingOrdersSinceQuery
|
||||
{
|
||||
StoreId = parsedStoreId,
|
||||
TenantId = tenantId,
|
||||
Since = since
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<IReadOnlyList<OrderBoardCardDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 接单。
|
||||
/// </summary>
|
||||
[HttpPost("{orderId}/accept")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OrderBoardCardDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<OrderBoardCardDto>> Accept(
|
||||
string orderId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedOrderId = StoreApiHelpers.ParseRequiredSnowflake(orderId, nameof(orderId));
|
||||
var (userId, tenantId, _) = storeContextService.GetRequiredContext();
|
||||
|
||||
var result = await mediator.Send(new AcceptOrderCommand
|
||||
{
|
||||
OrderId = parsedOrderId,
|
||||
TenantId = tenantId,
|
||||
OperatorId = userId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<OrderBoardCardDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拒单。
|
||||
/// </summary>
|
||||
[HttpPost("{orderId}/reject")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OrderBoardCardDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<OrderBoardCardDto>> Reject(
|
||||
string orderId,
|
||||
[FromBody] RejectOrderRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedOrderId = StoreApiHelpers.ParseRequiredSnowflake(orderId, nameof(orderId));
|
||||
var (userId, tenantId, _) = storeContextService.GetRequiredContext();
|
||||
|
||||
var result = await mediator.Send(new RejectOrderCommand
|
||||
{
|
||||
OrderId = parsedOrderId,
|
||||
TenantId = tenantId,
|
||||
Reason = request.Reason,
|
||||
OperatorId = userId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<OrderBoardCardDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 出餐完成。
|
||||
/// </summary>
|
||||
[HttpPost("{orderId}/complete-preparation")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OrderBoardCardDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<OrderBoardCardDto>> CompletePreparation(
|
||||
string orderId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedOrderId = StoreApiHelpers.ParseRequiredSnowflake(orderId, nameof(orderId));
|
||||
var (userId, tenantId, _) = storeContextService.GetRequiredContext();
|
||||
|
||||
var result = await mediator.Send(new CompletePreparationCommand
|
||||
{
|
||||
OrderId = parsedOrderId,
|
||||
TenantId = tenantId,
|
||||
OperatorId = userId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<OrderBoardCardDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确认送达/取餐。
|
||||
/// </summary>
|
||||
[HttpPost("{orderId}/confirm-delivery")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OrderBoardCardDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<OrderBoardCardDto>> ConfirmDelivery(
|
||||
string orderId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedOrderId = StoreApiHelpers.ParseRequiredSnowflake(orderId, nameof(orderId));
|
||||
var (userId, tenantId, _) = storeContextService.GetRequiredContext();
|
||||
|
||||
var result = await mediator.Send(new ConfirmDeliveryCommand
|
||||
{
|
||||
OrderId = parsedOrderId,
|
||||
TenantId = tenantId,
|
||||
OperatorId = userId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<OrderBoardCardDto>.Ok(result);
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static OrderChannel? ParseChannel(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"miniprogram" => OrderChannel.MiniProgram,
|
||||
"scan" => OrderChannel.ScanToOrder,
|
||||
"staff" => OrderChannel.StaffConsole,
|
||||
"phone" => OrderChannel.PhoneReservation,
|
||||
"thirdparty" => OrderChannel.ThirdPartyDelivery,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
373
src/Api/TakeoutSaaS.TenantApi/Controllers/OrderController.cs
Normal file
373
src/Api/TakeoutSaaS.TenantApi/Controllers/OrderController.cs
Normal file
@@ -0,0 +1,373 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||
using TakeoutSaaS.Application.App.Orders.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.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.Order;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 租户端订单管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/order")]
|
||||
public sealed class OrderController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 全部订单列表。
|
||||
/// </summary>
|
||||
[HttpGet("all/list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OrderAllListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<OrderAllListResultResponse>> List(
|
||||
[FromQuery] OrderAllListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (storeId, startAt, endAt, status, refundedOnly, deliveryType, paymentMethod) =
|
||||
await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SearchOrderAllListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
StartAt = startAt,
|
||||
EndAt = endAt,
|
||||
Status = status,
|
||||
RefundedOnly = refundedOnly,
|
||||
DeliveryType = deliveryType,
|
||||
PaymentMethod = paymentMethod,
|
||||
Keyword = request.Keyword,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200),
|
||||
SortBy = "createdAt",
|
||||
SortDescending = true
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<OrderAllListResultResponse>.Ok(new OrderAllListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.TotalCount,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单统计。
|
||||
/// </summary>
|
||||
[HttpGet("all/stats")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OrderAllStatsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<OrderAllStatsResponse>> Stats(
|
||||
[FromQuery] OrderAllFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (storeId, startAt, endAt, status, refundedOnly, deliveryType, paymentMethod) =
|
||||
await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetOrderAllStatsQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
StartAt = startAt,
|
||||
EndAt = endAt,
|
||||
Status = status,
|
||||
RefundedOnly = refundedOnly,
|
||||
DeliveryType = deliveryType,
|
||||
PaymentMethod = paymentMethod,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<OrderAllStatsResponse>.Ok(new OrderAllStatsResponse
|
||||
{
|
||||
TotalOrders = result.TotalOrders,
|
||||
TotalAmount = result.TotalAmount,
|
||||
AverageAmount = result.AverageAmount,
|
||||
RefundCount = result.RefundCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单详情。
|
||||
/// </summary>
|
||||
[HttpGet("all/detail")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OrderAllDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<OrderAllDetailResponse>> Detail(
|
||||
[FromQuery] OrderAllDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var orderNo = request.OrderNo?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(orderNo))
|
||||
{
|
||||
return ApiResponse<OrderAllDetailResponse>.Error(ErrorCodes.BadRequest, "orderNo 非法");
|
||||
}
|
||||
|
||||
var result = await mediator.Send(new GetOrderAllDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
OrderNo = orderNo
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<OrderAllDetailResponse>.Error(ErrorCodes.NotFound, "订单不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<OrderAllDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单导出。
|
||||
/// </summary>
|
||||
[HttpGet("all/export")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OrderAllExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<OrderAllExportResponse>> Export(
|
||||
[FromQuery] OrderAllFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (storeId, startAt, endAt, status, refundedOnly, deliveryType, paymentMethod) =
|
||||
await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportOrderAllCsvQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
StartAt = startAt,
|
||||
EndAt = endAt,
|
||||
Status = status,
|
||||
RefundedOnly = refundedOnly,
|
||||
DeliveryType = deliveryType,
|
||||
PaymentMethod = paymentMethod,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<OrderAllExportResponse>.Ok(new OrderAllExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(long StoreId, DateTime? StartAt, DateTime? EndAt, OrderStatus? Status, bool RefundedOnly, DeliveryType? DeliveryType, PaymentMethod? PaymentMethod)> ParseFilterAsync(
|
||||
OrderAllFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var startAt = ParseDateOrNull(request.StartDate);
|
||||
var endAt = ParseDateOrNull(request.EndDate)?.AddDays(1);
|
||||
if (startAt.HasValue && endAt.HasValue && startAt >= endAt)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
|
||||
}
|
||||
|
||||
var (status, refundedOnly) = ParseStatus(request.Status);
|
||||
var deliveryType = ParseDeliveryType(request.Channel);
|
||||
if (deliveryType is null)
|
||||
{
|
||||
var normalizedStatus = (request.Status ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (normalizedStatus == "pickup")
|
||||
{
|
||||
deliveryType = Domain.Orders.Enums.DeliveryType.Pickup;
|
||||
}
|
||||
else if (normalizedStatus == "delivering")
|
||||
{
|
||||
deliveryType = Domain.Orders.Enums.DeliveryType.Delivery;
|
||||
}
|
||||
}
|
||||
var paymentMethod = ParsePaymentMethod(request.PaymentMethod);
|
||||
|
||||
return (storeId, startAt, endAt, status, refundedOnly, deliveryType, paymentMethod);
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
value,
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var parsed))
|
||||
{
|
||||
return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "日期格式必须为 yyyy-MM-dd");
|
||||
}
|
||||
|
||||
private static (OrderStatus? Status, bool RefundedOnly) ParseStatus(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"pending" => (OrderStatus.AwaitingPreparation, false),
|
||||
"making" => (OrderStatus.InProgress, false),
|
||||
"delivering" => (OrderStatus.Ready, false),
|
||||
"pickup" => (OrderStatus.Ready, false),
|
||||
"completed" => (OrderStatus.Completed, false),
|
||||
"cancelled" => (OrderStatus.Cancelled, false),
|
||||
"refunded" => (null, true),
|
||||
_ => (null, false)
|
||||
};
|
||||
}
|
||||
|
||||
private static DeliveryType? ParseDeliveryType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"delivery" => Domain.Orders.Enums.DeliveryType.Delivery,
|
||||
"pickup" => Domain.Orders.Enums.DeliveryType.Pickup,
|
||||
"dine_in" => Domain.Orders.Enums.DeliveryType.DineIn,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static PaymentMethod? ParsePaymentMethod(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"wechat" => Domain.Payments.Enums.PaymentMethod.WeChatPay,
|
||||
"alipay" => Domain.Payments.Enums.PaymentMethod.Alipay,
|
||||
"balance" => Domain.Payments.Enums.PaymentMethod.Balance,
|
||||
"cash" => Domain.Payments.Enums.PaymentMethod.Cash,
|
||||
"card" => Domain.Payments.Enums.PaymentMethod.Card,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static OrderAllListItemResponse MapListItem(OrderAllListItemDto source)
|
||||
{
|
||||
return new OrderAllListItemResponse
|
||||
{
|
||||
OrderNo = source.OrderNo,
|
||||
OrderedAt = source.OrderedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
Channel = ToDeliveryTypeText(source.DeliveryType),
|
||||
Customer = source.CustomerName,
|
||||
ItemsSummary = source.ItemsSummary,
|
||||
Amount = source.Amount,
|
||||
Status = ToStatusText(source.Status, source.IsRefunded, source.DeliveryType),
|
||||
IsDimmed = source.IsDimmed
|
||||
};
|
||||
}
|
||||
|
||||
private static OrderAllDetailResponse MapDetail(OrderAllDetailDto source)
|
||||
{
|
||||
return new OrderAllDetailResponse
|
||||
{
|
||||
OrderNo = source.OrderNo,
|
||||
Channel = ToDeliveryTypeText(source.DeliveryType),
|
||||
Status = ToStatusText(source.Status, false, source.DeliveryType),
|
||||
PaymentMethod = ToPaymentMethodText(source.PaymentMethod),
|
||||
OrderedAt = source.OrderedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
PaidAt = source.PaidAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
FinishedAt = source.FinishedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
CustomerName = source.CustomerName,
|
||||
CustomerPhone = source.CustomerPhone,
|
||||
CustomerAddress = source.CustomerAddress,
|
||||
ItemsAmount = source.ItemsAmount,
|
||||
DeliveryFee = source.DeliveryFee,
|
||||
DiscountAmount = source.DiscountAmount,
|
||||
PaidAmount = source.PaidAmount,
|
||||
Remark = source.Remark,
|
||||
Items = source.Items
|
||||
.Select(item => new OrderAllDetailItemResponse
|
||||
{
|
||||
Name = item.Name,
|
||||
Spec = item.Spec,
|
||||
Quantity = item.Quantity,
|
||||
UnitPrice = item.UnitPrice,
|
||||
SubTotal = item.SubTotal
|
||||
})
|
||||
.ToList(),
|
||||
Timeline = source.Timeline
|
||||
.OrderBy(item => item.OccurredAt)
|
||||
.Select(item => new OrderAllTimelineResponse
|
||||
{
|
||||
Label = item.Label,
|
||||
Time = item.OccurredAt.ToString("HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToDeliveryTypeText(Domain.Orders.Enums.DeliveryType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
Domain.Orders.Enums.DeliveryType.Delivery => "外卖",
|
||||
Domain.Orders.Enums.DeliveryType.Pickup => "自提",
|
||||
Domain.Orders.Enums.DeliveryType.DineIn => "堂食",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToPaymentMethodText(Domain.Payments.Enums.PaymentMethod value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
Domain.Payments.Enums.PaymentMethod.WeChatPay => "微信支付",
|
||||
Domain.Payments.Enums.PaymentMethod.Alipay => "支付宝",
|
||||
Domain.Payments.Enums.PaymentMethod.Balance => "余额支付",
|
||||
Domain.Payments.Enums.PaymentMethod.Cash => "现金",
|
||||
Domain.Payments.Enums.PaymentMethod.Card => "刷卡",
|
||||
_ => "--"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToStatusText(OrderStatus status, bool refunded, DeliveryType deliveryType)
|
||||
{
|
||||
if (refunded)
|
||||
{
|
||||
return "已退款";
|
||||
}
|
||||
|
||||
return status switch
|
||||
{
|
||||
OrderStatus.PendingPayment => "待接单",
|
||||
OrderStatus.AwaitingPreparation => "待接单",
|
||||
OrderStatus.InProgress => "制作中",
|
||||
OrderStatus.Ready => ToReadyStatusText(deliveryType),
|
||||
OrderStatus.Completed => "已完成",
|
||||
OrderStatus.Cancelled => "已取消",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToReadyStatusText(DeliveryType deliveryType)
|
||||
{
|
||||
return deliveryType switch
|
||||
{
|
||||
DeliveryType.Delivery => "配送中",
|
||||
DeliveryType.Pickup => "待取餐",
|
||||
DeliveryType.DineIn => "待取餐",
|
||||
_ => "待处理"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Payments.Commands;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 支付回调接口。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/tenant/v{version:apiVersion}/payment/callback")]
|
||||
public sealed class PaymentCallbackController(
|
||||
IMediator mediator,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 微信支付回调(预留,签名验证后续接入 SDK)。
|
||||
/// </summary>
|
||||
[HttpPost("wechat")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> WeChatCallback(CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: 接入微信支付 V3 SDK 后实现签名验证与报文解析
|
||||
return Ok(new { code = "SUCCESS", message = "成功" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 支付宝回调(预留,签名验证后续接入 SDK)。
|
||||
/// </summary>
|
||||
[HttpPost("alipay")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> AlipayCallback(CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: 接入支付宝 RSA2 SDK 后实现签名验证与报文解析
|
||||
return Ok("success");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 内部模拟支付回调(仅开发环境)。
|
||||
/// </summary>
|
||||
[HttpPost("internal")]
|
||||
[Authorize]
|
||||
public async Task<ApiResponse<bool>> InternalCallback(
|
||||
[FromBody] InternalPaymentCallbackRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (_, tenantId, _) = storeContextService.GetRequiredContext();
|
||||
|
||||
var result = await mediator.Send(new ProcessPaymentCallbackCommand
|
||||
{
|
||||
OrderNo = request.OrderNo,
|
||||
ChannelTransactionId = request.TransactionId ?? $"internal-{Guid.NewGuid():N}",
|
||||
Method = request.Method ?? PaymentMethod.WeChatPay,
|
||||
Amount = request.Amount,
|
||||
PaidAt = request.PaidAt ?? DateTime.UtcNow,
|
||||
TenantId = tenantId,
|
||||
RawPayload = "internal-test"
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<bool>.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 内部模拟支付回调请求体。
|
||||
/// </summary>
|
||||
public sealed record InternalPaymentCallbackRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public required string OrderNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付金额。
|
||||
/// </summary>
|
||||
public required decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付流水号(可选)。
|
||||
/// </summary>
|
||||
public string? TransactionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式(可选,默认微信)。
|
||||
/// </summary>
|
||||
public PaymentMethod? Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间(可选,默认当前时间)。
|
||||
/// </summary>
|
||||
public DateTime? PaidAt { get; init; }
|
||||
}
|
||||
245
src/Api/TakeoutSaaS.TenantApi/Controllers/PersonalController.cs
Normal file
245
src/Api/TakeoutSaaS.TenantApi/Controllers/PersonalController.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 租户端个人中心。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 提供个人总览、角色概览、配额、账单、支付、操作记录与消息摘要能力。
|
||||
/// </remarks>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant/v{version:apiVersion}/personal")]
|
||||
public sealed class PersonalController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取个人中心总览。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>总览结果。</returns>
|
||||
[HttpGet("overview")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalOverviewDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalOverviewDto>), StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ApiResponse<PersonalOverviewDto>> GetOverview(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询总览
|
||||
var overview = await mediator.Send(new GetPersonalOverviewQuery(), cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<PersonalOverviewDto>.Ok(overview);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取我的角色与权限概览。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色权限概览。</returns>
|
||||
[HttpGet("roles")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalRolePermissionSummaryDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalRolePermissionSummaryDto>), StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ApiResponse<PersonalRolePermissionSummaryDto>> GetRoles(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询角色权限概览
|
||||
var summary = await mediator.Send(new GetPersonalRolesQuery(), cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<PersonalRolePermissionSummaryDto>.Ok(summary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取套餐与配额摘要。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配额摘要。</returns>
|
||||
[HttpGet("quota")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalQuotaUsageSummaryDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalQuotaUsageSummaryDto>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalQuotaUsageSummaryDto>), StatusCodes.Status403Forbidden)]
|
||||
public async Task<ApiResponse<PersonalQuotaUsageSummaryDto>> GetQuota(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询配额摘要
|
||||
var summary = await mediator.Send(new GetPersonalQuotaQuery(), cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<PersonalQuotaUsageSummaryDto>.Ok(summary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询账单记录。
|
||||
/// </summary>
|
||||
/// <param name="page">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">每页条数。</param>
|
||||
/// <param name="from">开始时间(UTC)。</param>
|
||||
/// <param name="to">结束时间(UTC)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单分页结果。</returns>
|
||||
[HttpGet("billing/statements")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalBillingStatementDto>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalBillingStatementDto>>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalBillingStatementDto>>), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalBillingStatementDto>>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<PagedResult<PersonalBillingStatementDto>>> SearchBillingStatements(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] DateTime? from = null,
|
||||
[FromQuery] DateTime? to = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 发送查询
|
||||
var result = await mediator.Send(new SearchPersonalBillingStatementsQuery
|
||||
{
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
From = from,
|
||||
To = to
|
||||
}, cancellationToken);
|
||||
|
||||
// 2. 返回分页结果
|
||||
return ApiResponse<PagedResult<PersonalBillingStatementDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询支付记录。
|
||||
/// </summary>
|
||||
/// <param name="page">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">每页条数。</param>
|
||||
/// <param name="from">开始时间(UTC)。</param>
|
||||
/// <param name="to">结束时间(UTC)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录分页结果。</returns>
|
||||
[HttpGet("billing/payments")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalPaymentRecordDto>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalPaymentRecordDto>>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalPaymentRecordDto>>), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalPaymentRecordDto>>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<PagedResult<PersonalPaymentRecordDto>>> SearchPayments(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] DateTime? from = null,
|
||||
[FromQuery] DateTime? to = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 发送查询
|
||||
var result = await mediator.Send(new SearchPersonalPaymentsQuery
|
||||
{
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
From = from,
|
||||
To = to
|
||||
}, cancellationToken);
|
||||
|
||||
// 2. 返回分页结果
|
||||
return ApiResponse<PagedResult<PersonalPaymentRecordDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取账单/配额可见角色配置。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>可见角色配置。</returns>
|
||||
[HttpGet("visibility/roles")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status403Forbidden)]
|
||||
public async Task<ApiResponse<PersonalVisibilityRoleConfigDto>> GetVisibilityRoles(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询配置
|
||||
var config = await mediator.Send(new GetPersonalVisibilityRoleConfigQuery(), cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<PersonalVisibilityRoleConfigDto>.Ok(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新账单/配额可见角色配置。
|
||||
/// </summary>
|
||||
/// <param name="command">更新请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新后的配置。</returns>
|
||||
[HttpPut("visibility/roles")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<PersonalVisibilityRoleConfigDto>> UpdateVisibilityRoles(
|
||||
[FromBody] UpdatePersonalVisibilityRoleConfigCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 更新配置
|
||||
var config = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<PersonalVisibilityRoleConfigDto>.Ok(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人操作记录。
|
||||
/// </summary>
|
||||
/// <param name="page">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">每页条数(默认 50,最大 50)。</param>
|
||||
/// <param name="from">开始时间(UTC)。</param>
|
||||
/// <param name="to">结束时间(UTC)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>操作记录分页结果。</returns>
|
||||
[HttpGet("operations")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalOperationLogDto>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalOperationLogDto>>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalOperationLogDto>>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<PagedResult<PersonalOperationLogDto>>> SearchOperations(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] DateTime? from = null,
|
||||
[FromQuery] DateTime? to = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 发送查询
|
||||
var result = await mediator.Send(new SearchPersonalOperationsQuery
|
||||
{
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
From = from,
|
||||
To = to
|
||||
}, cancellationToken);
|
||||
|
||||
// 2. 返回分页结果
|
||||
return ApiResponse<PagedResult<PersonalOperationLogDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人消息摘要。
|
||||
/// </summary>
|
||||
/// <param name="page">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">每页条数。</param>
|
||||
/// <param name="unreadOnly">是否仅返回未读。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>消息摘要分页结果。</returns>
|
||||
[HttpGet("notifications")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalNotificationDto>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalNotificationDto>>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalNotificationDto>>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<PagedResult<PersonalNotificationDto>>> SearchNotifications(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] bool unreadOnly = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 发送查询
|
||||
var result = await mediator.Send(new SearchPersonalNotificationsQuery
|
||||
{
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
UnreadOnly = unreadOnly
|
||||
}, cancellationToken);
|
||||
|
||||
// 2. 返回分页结果
|
||||
return ApiResponse<PagedResult<PersonalNotificationDto>>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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.Infrastructure.App.Persistence;
|
||||
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 ProductAddonController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 加料组列表。
|
||||
/// </summary>
|
||||
[HttpGet("addon/group/list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductAddonGroupItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductAddonGroupItemResponse>>> GetAddonGroupList(
|
||||
[FromQuery] ProductAddonGroupListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 查询并返回列表。
|
||||
var result = await mediator.Send(new GetProductAddonGroupListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<IReadOnlyList<ProductAddonGroupItemResponse>>.Ok(result.Select(MapAddonGroupItem).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存加料组。
|
||||
/// </summary>
|
||||
[HttpPost("addon/group/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductAddonGroupItemResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ProductAddonGroupItemResponse>> SaveAddonGroup(
|
||||
[FromBody] SaveProductAddonGroupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 提交保存命令。
|
||||
var result = await mediator.Send(new SaveProductAddonGroupCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
GroupId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
Required = request.Required,
|
||||
MinSelect = request.MinSelect,
|
||||
MaxSelect = request.MaxSelect,
|
||||
Sort = request.Sort,
|
||||
Status = request.Status,
|
||||
ProductIds = StoreApiHelpers.ParseSnowflakeList(request.ProductIds),
|
||||
Items = (request.Items ?? [])
|
||||
.Select(item => new SaveProductAddonItemCommand
|
||||
{
|
||||
Id = StoreApiHelpers.ParseSnowflakeOrNull(item.Id),
|
||||
Name = item.Name,
|
||||
Price = item.Price,
|
||||
Stock = item.Stock,
|
||||
Sort = item.Sort,
|
||||
Status = item.Status
|
||||
})
|
||||
.ToList()
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<ProductAddonGroupItemResponse>.Ok(MapAddonGroupItem(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除加料组。
|
||||
/// </summary>
|
||||
[HttpPost("addon/group/delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteAddonGroup(
|
||||
[FromBody] DeleteProductAddonGroupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 提交删除命令。
|
||||
await mediator.Send(new DeleteProductAddonGroupCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
GroupId = StoreApiHelpers.ParseRequiredSnowflake(request.GroupId, nameof(request.GroupId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改加料组状态。
|
||||
/// </summary>
|
||||
[HttpPost("addon/group/status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductAddonGroupItemResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ProductAddonGroupItemResponse>> ChangeAddonGroupStatus(
|
||||
[FromBody] ChangeProductAddonGroupStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 提交状态命令并返回更新后的快照。
|
||||
var result = await mediator.Send(new ChangeProductAddonGroupStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
GroupId = StoreApiHelpers.ParseRequiredSnowflake(request.GroupId, nameof(request.GroupId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<ProductAddonGroupItemResponse>.Ok(MapAddonGroupItem(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 绑定加料组商品。
|
||||
/// </summary>
|
||||
[HttpPost("addon/group/products/bind")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductAddonGroupItemResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ProductAddonGroupItemResponse>> BindAddonGroupProducts(
|
||||
[FromBody] BindProductAddonGroupProductsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 提交绑定命令并返回更新后的快照。
|
||||
var result = await mediator.Send(new BindProductAddonGroupProductsCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
GroupId = StoreApiHelpers.ParseRequiredSnowflake(request.GroupId, nameof(request.GroupId)),
|
||||
ProductIds = StoreApiHelpers.ParseSnowflakeList(request.ProductIds)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<ProductAddonGroupItemResponse>.Ok(MapAddonGroupItem(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取当前租户上下文。
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
|
||||
// 2. 校验门店是否属于当前租户商户。
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static ProductAddonGroupItemResponse MapAddonGroupItem(ProductAddonTemplateItemDto source)
|
||||
{
|
||||
// 1. 映射加料项列表。
|
||||
var items = source.Items
|
||||
.Select(item => new ProductAddonItemResponse
|
||||
{
|
||||
Id = item.Id.ToString(),
|
||||
Name = item.Name,
|
||||
Price = item.Price,
|
||||
Stock = item.Stock,
|
||||
Sort = item.Sort,
|
||||
Status = item.Status
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// 2. 映射加料组响应。
|
||||
return new ProductAddonGroupItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
Description = source.Description,
|
||||
Required = source.Required,
|
||||
MinSelect = source.MinSelect,
|
||||
MaxSelect = source.MaxSelect,
|
||||
Sort = source.Sort,
|
||||
Status = source.Status,
|
||||
ProductCount = source.ProductCount,
|
||||
ProductIds = source.ProductIds.Select(item => item.ToString()).ToList(),
|
||||
Items = items,
|
||||
UpdatedAt = source.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,282 @@
|
||||
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.Infrastructure.App.Persistence;
|
||||
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 ProductCategoryController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类列表(侧栏)。
|
||||
/// </summary>
|
||||
[HttpGet("category/list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductCategoryListItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductCategoryListItemResponse>>> GetCategoryList(
|
||||
[FromQuery] ProductCategoryListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetProductCategoryListQuery
|
||||
{
|
||||
StoreId = storeId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<IReadOnlyList<ProductCategoryListItemResponse>>.Ok(result.Select(MapCategoryListItem).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类管理列表。
|
||||
/// </summary>
|
||||
[HttpGet("category/manage/list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductCategoryManageItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductCategoryManageItemResponse>>> GetCategoryManageList(
|
||||
[FromQuery] ProductCategoryManageListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetProductCategoryManageListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<IReadOnlyList<ProductCategoryManageItemResponse>>.Ok(result.Select(MapCategoryManageItem).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存分类。
|
||||
/// </summary>
|
||||
[HttpPost("category/manage/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductCategoryManageItemResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ProductCategoryManageItemResponse>> SaveCategory(
|
||||
[FromBody] SaveProductCategoryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SaveProductCategoryCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
CategoryId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
Icon = request.Icon,
|
||||
Channels = request.Channels,
|
||||
Sort = request.Sort,
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<ProductCategoryManageItemResponse>.Ok(MapCategoryManageItem(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除分类。
|
||||
/// </summary>
|
||||
[HttpPost("category/manage/delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteCategory(
|
||||
[FromBody] DeleteProductCategoryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
await mediator.Send(new DeleteProductCategoryCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
CategoryId = StoreApiHelpers.ParseRequiredSnowflake(request.CategoryId, nameof(request.CategoryId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改分类状态。
|
||||
/// </summary>
|
||||
[HttpPost("category/manage/status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductCategoryManageItemResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ProductCategoryManageItemResponse>> ChangeCategoryStatus(
|
||||
[FromBody] ChangeProductCategoryStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ChangeProductCategoryStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
CategoryId = StoreApiHelpers.ParseRequiredSnowflake(request.CategoryId, nameof(request.CategoryId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<ProductCategoryManageItemResponse>.Ok(MapCategoryManageItem(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类排序。
|
||||
/// </summary>
|
||||
[HttpPost("category/manage/sort")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductCategoryManageItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductCategoryManageItemResponse>>> SortCategory(
|
||||
[FromBody] SortProductCategoryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SortProductCategoryCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
Items = (request.Items ?? [])
|
||||
.Select(item => new SortProductCategoryItem
|
||||
{
|
||||
CategoryId = StoreApiHelpers.ParseRequiredSnowflake(item.CategoryId, nameof(item.CategoryId)),
|
||||
Sort = item.Sort
|
||||
})
|
||||
.ToList()
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<IReadOnlyList<ProductCategoryManageItemResponse>>.Ok(result.Select(MapCategoryManageItem).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 绑定分类商品。
|
||||
/// </summary>
|
||||
[HttpPost("category/manage/products/bind")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductBindResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ProductBindResultResponse>> BindCategoryProducts(
|
||||
[FromBody] BindCategoryProductsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new BindCategoryProductsCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
CategoryId = StoreApiHelpers.ParseRequiredSnowflake(request.CategoryId, nameof(request.CategoryId)),
|
||||
ProductIds = StoreApiHelpers.ParseSnowflakeList(request.ProductIds)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<ProductBindResultResponse>.Ok(new ProductBindResultResponse
|
||||
{
|
||||
TotalCount = result.TotalCount,
|
||||
SuccessCount = result.SuccessCount,
|
||||
FailedCount = result.FailedCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解绑分类商品。
|
||||
/// </summary>
|
||||
[HttpPost("category/manage/products/unbind")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> UnbindCategoryProduct(
|
||||
[FromBody] UnbindCategoryProductRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
await mediator.Send(new UnbindCategoryProductCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
CategoryId = StoreApiHelpers.ParseRequiredSnowflake(request.CategoryId, nameof(request.CategoryId)),
|
||||
ProductId = StoreApiHelpers.ParseRequiredSnowflake(request.ProductId, nameof(request.ProductId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 商品选择器。
|
||||
/// </summary>
|
||||
[HttpGet("picker/list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductPickerItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductPickerItemResponse>>> PickerList(
|
||||
[FromQuery] ProductPickerListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SearchProductPickerQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
CategoryId = StoreApiHelpers.ParseSnowflakeOrNull(request.CategoryId),
|
||||
Keyword = request.Keyword,
|
||||
Limit = request.Limit ?? 200
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<IReadOnlyList<ProductPickerItemResponse>>.Ok(result.Select(MapPickerItem).ToList());
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static ProductCategoryListItemResponse MapCategoryListItem(ProductCategoryListItemDto source)
|
||||
{
|
||||
return new ProductCategoryListItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
ProductCount = source.ProductCount,
|
||||
Sort = source.Sort
|
||||
};
|
||||
}
|
||||
|
||||
private static ProductCategoryManageItemResponse MapCategoryManageItem(ProductCategoryManageItemDto source)
|
||||
{
|
||||
return new ProductCategoryManageItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
ProductCount = source.ProductCount,
|
||||
Sort = source.Sort,
|
||||
Description = source.Description,
|
||||
Icon = source.Icon,
|
||||
Status = source.Status,
|
||||
Channels = source.Channels.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static ProductPickerItemResponse MapPickerItem(ProductPickerItemDto source)
|
||||
{
|
||||
return new ProductPickerItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
CategoryId = source.CategoryId.ToString(),
|
||||
CategoryName = source.CategoryName,
|
||||
Name = source.Name,
|
||||
Price = source.Price,
|
||||
SpuCode = source.SpuCode,
|
||||
Status = source.Status
|
||||
};
|
||||
}
|
||||
}
|
||||
2059
src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs
Normal file
2059
src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
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.Infrastructure.App.Persistence;
|
||||
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 ProductLabelController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品标签列表。
|
||||
/// </summary>
|
||||
[HttpGet("label/list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductLabelItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductLabelItemResponse>>> GetLabelList(
|
||||
[FromQuery] ProductLabelListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetProductLabelListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<IReadOnlyList<ProductLabelItemResponse>>.Ok(result.Select(MapLabelItem).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存商品标签。
|
||||
/// </summary>
|
||||
[HttpPost("label/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductLabelItemResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ProductLabelItemResponse>> SaveLabel(
|
||||
[FromBody] SaveProductLabelRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SaveProductLabelCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
LabelId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
|
||||
Name = request.Name,
|
||||
Color = request.Color,
|
||||
Sort = request.Sort,
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<ProductLabelItemResponse>.Ok(MapLabelItem(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除商品标签。
|
||||
/// </summary>
|
||||
[HttpPost("label/delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteLabel(
|
||||
[FromBody] DeleteProductLabelRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
await mediator.Send(new DeleteProductLabelCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
LabelId = StoreApiHelpers.ParseRequiredSnowflake(request.LabelId, nameof(request.LabelId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改商品标签状态。
|
||||
/// </summary>
|
||||
[HttpPost("label/status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductLabelItemResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ProductLabelItemResponse>> ChangeLabelStatus(
|
||||
[FromBody] ChangeProductLabelStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ChangeProductLabelStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
LabelId = StoreApiHelpers.ParseRequiredSnowflake(request.LabelId, nameof(request.LabelId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<ProductLabelItemResponse>.Ok(MapLabelItem(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static ProductLabelItemResponse MapLabelItem(ProductLabelItemDto source)
|
||||
{
|
||||
return new ProductLabelItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
Color = source.Color,
|
||||
Sort = source.Sort,
|
||||
Status = source.Status,
|
||||
ProductCount = source.ProductCount,
|
||||
UpdatedAt = source.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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.Infrastructure.App.Persistence;
|
||||
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 ProductScheduleController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品时段规则列表。
|
||||
/// </summary>
|
||||
[HttpGet("schedule/list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductScheduleItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductScheduleItemResponse>>> GetScheduleList(
|
||||
[FromQuery] ProductScheduleListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetProductScheduleListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<IReadOnlyList<ProductScheduleItemResponse>>.Ok(result.Select(MapScheduleItem).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存商品时段规则。
|
||||
/// </summary>
|
||||
[HttpPost("schedule/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductScheduleItemResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ProductScheduleItemResponse>> SaveSchedule(
|
||||
[FromBody] SaveProductScheduleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SaveProductScheduleCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
ScheduleId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
|
||||
Name = request.Name,
|
||||
StartTime = request.StartTime,
|
||||
EndTime = request.EndTime,
|
||||
WeekDays = request.WeekDays ?? [],
|
||||
ProductIds = StoreApiHelpers.ParseSnowflakeList(request.ProductIds),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<ProductScheduleItemResponse>.Ok(MapScheduleItem(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除商品时段规则。
|
||||
/// </summary>
|
||||
[HttpPost("schedule/delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteSchedule(
|
||||
[FromBody] DeleteProductScheduleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
await mediator.Send(new DeleteProductScheduleCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
ScheduleId = StoreApiHelpers.ParseRequiredSnowflake(request.ScheduleId, nameof(request.ScheduleId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改商品时段规则状态。
|
||||
/// </summary>
|
||||
[HttpPost("schedule/status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductScheduleItemResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ProductScheduleItemResponse>> ChangeScheduleStatus(
|
||||
[FromBody] ChangeProductScheduleStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ChangeProductScheduleStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
ScheduleId = StoreApiHelpers.ParseRequiredSnowflake(request.ScheduleId, nameof(request.ScheduleId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<ProductScheduleItemResponse>.Ok(MapScheduleItem(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static ProductScheduleItemResponse MapScheduleItem(ProductScheduleItemDto source)
|
||||
{
|
||||
return new ProductScheduleItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
StartTime = source.StartTime,
|
||||
EndTime = source.EndTime,
|
||||
WeekDays = source.WeekDays.ToList(),
|
||||
Status = source.Status,
|
||||
ProductCount = source.ProductCount,
|
||||
ProductIds = source.ProductIds.Select(item => item.ToString()).ToList(),
|
||||
UpdatedAt = source.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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.Infrastructure.App.Persistence;
|
||||
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 ProductSpecController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 规格做法模板列表。
|
||||
/// </summary>
|
||||
[HttpGet("spec/list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductSpecItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductSpecItemResponse>>> GetSpecList(
|
||||
[FromQuery] ProductSpecListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetProductSpecTemplateListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Keyword = request.Keyword,
|
||||
Type = request.Type,
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<IReadOnlyList<ProductSpecItemResponse>>.Ok(result.Select(MapSpecItem).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存规格做法模板。
|
||||
/// </summary>
|
||||
[HttpPost("spec/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductSpecItemResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ProductSpecItemResponse>> SaveSpec(
|
||||
[FromBody] SaveProductSpecRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SaveProductSpecTemplateCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
SpecId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
|
||||
Name = request.Name,
|
||||
Type = request.Type,
|
||||
SelectionType = request.SelectionType,
|
||||
IsRequired = request.IsRequired,
|
||||
Sort = request.Sort,
|
||||
Status = request.Status,
|
||||
ProductIds = StoreApiHelpers.ParseSnowflakeList(request.ProductIds),
|
||||
Values = (request.Values ?? [])
|
||||
.Select(item => new SaveProductSpecTemplateValueCommand
|
||||
{
|
||||
Id = StoreApiHelpers.ParseSnowflakeOrNull(item.Id),
|
||||
Name = item.Name,
|
||||
ExtraPrice = item.ExtraPrice,
|
||||
Sort = item.Sort
|
||||
})
|
||||
.ToList()
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<ProductSpecItemResponse>.Ok(MapSpecItem(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除规格做法模板。
|
||||
/// </summary>
|
||||
[HttpPost("spec/delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteSpec(
|
||||
[FromBody] DeleteProductSpecRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
await mediator.Send(new DeleteProductSpecTemplateCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
SpecId = StoreApiHelpers.ParseRequiredSnowflake(request.SpecId, nameof(request.SpecId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改规格做法模板状态。
|
||||
/// </summary>
|
||||
[HttpPost("spec/status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductSpecItemResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ProductSpecItemResponse>> ChangeSpecStatus(
|
||||
[FromBody] ChangeProductSpecStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ChangeProductSpecTemplateStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
SpecId = StoreApiHelpers.ParseRequiredSnowflake(request.SpecId, nameof(request.SpecId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<ProductSpecItemResponse>.Ok(MapSpecItem(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制规格做法模板。
|
||||
/// </summary>
|
||||
[HttpPost("spec/copy")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductSpecItemResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ProductSpecItemResponse>> CopySpec(
|
||||
[FromBody] CopyProductSpecRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new CopyProductSpecTemplateCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
SpecId = StoreApiHelpers.ParseRequiredSnowflake(request.SpecId, nameof(request.SpecId)),
|
||||
NewName = request.NewName
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<ProductSpecItemResponse>.Ok(MapSpecItem(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static ProductSpecItemResponse MapSpecItem(ProductSpecTemplateItemDto source)
|
||||
{
|
||||
return new ProductSpecItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
Type = source.Type,
|
||||
SelectionType = source.SelectionType,
|
||||
IsRequired = source.IsRequired,
|
||||
Sort = source.Sort,
|
||||
Status = source.Status,
|
||||
ProductCount = source.ProductCount,
|
||||
ProductIds = source.ProductIds.Select(item => item.ToString()).ToList(),
|
||||
Values = source.Values
|
||||
.Select(item => new ProductSpecValueResponse
|
||||
{
|
||||
Id = item.Id.ToString(),
|
||||
Name = item.Name,
|
||||
ExtraPrice = item.ExtraPrice,
|
||||
Sort = item.Sort
|
||||
})
|
||||
.ToList(),
|
||||
UpdatedAt = source.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
};
|
||||
}
|
||||
}
|
||||
380
src/Api/TakeoutSaaS.TenantApi/Controllers/StoreApiHelpers.cs
Normal file
380
src/Api/TakeoutSaaS.TenantApi/Controllers/StoreApiHelpers.cs
Normal file
@@ -0,0 +1,380 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
internal static class StoreApiHelpers
|
||||
{
|
||||
private static readonly string[] AvatarColors =
|
||||
[
|
||||
"#f56a00",
|
||||
"#7265e6",
|
||||
"#52c41a",
|
||||
"#fa8c16",
|
||||
"#1890ff",
|
||||
"#bfbfbf",
|
||||
"#13c2c2",
|
||||
"#eb2f96"
|
||||
];
|
||||
|
||||
public static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static (long TenantId, long MerchantId) GetTenantMerchantContext(StoreContextService storeContextService)
|
||||
{
|
||||
var (_, tenantId, merchantId) = storeContextService.GetRequiredContext();
|
||||
return (tenantId, merchantId);
|
||||
}
|
||||
|
||||
public static long ParseRequiredSnowflake(string? value, string fieldName)
|
||||
{
|
||||
if (!long.TryParse(value, out var id) || id <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 非法");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public static long? ParseSnowflakeOrNull(string? value)
|
||||
{
|
||||
return long.TryParse(value, out var id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
public static List<long> ParseSnowflakeList(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return values
|
||||
.Select(ParseSnowflakeOrNull)
|
||||
.Where(id => id.HasValue)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static TimeSpan ParseRequiredTime(string? value, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) ||
|
||||
!TimeSpan.TryParseExact(value, "hh\\:mm", CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 时间格式必须为 HH:mm");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
public static string ToHHmm(TimeSpan value)
|
||||
{
|
||||
return value.ToString("hh\\:mm", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static string? ToHHmm(TimeSpan? value)
|
||||
{
|
||||
return value.HasValue ? ToHHmm(value.Value) : null;
|
||||
}
|
||||
|
||||
public static DateTime ParseDateOnly(string? value, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) ||
|
||||
!DateTime.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 日期格式必须为 yyyy-MM-dd");
|
||||
}
|
||||
|
||||
// PostgreSQL timestamptz 仅接受 UTC,日期输入统一落盘为 UTC 零点。
|
||||
return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
public static string ToDateOnly(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static DayOfWeek UiDayOfWeekToDotNet(int uiDayOfWeek)
|
||||
{
|
||||
return uiDayOfWeek switch
|
||||
{
|
||||
0 => DayOfWeek.Monday,
|
||||
1 => DayOfWeek.Tuesday,
|
||||
2 => DayOfWeek.Wednesday,
|
||||
3 => DayOfWeek.Thursday,
|
||||
4 => DayOfWeek.Friday,
|
||||
5 => DayOfWeek.Saturday,
|
||||
6 => DayOfWeek.Sunday,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "dayOfWeek 必须在 0-6 之间")
|
||||
};
|
||||
}
|
||||
|
||||
public static int DotNetDayOfWeekToUi(DayOfWeek dayOfWeek)
|
||||
{
|
||||
return dayOfWeek switch
|
||||
{
|
||||
DayOfWeek.Monday => 0,
|
||||
DayOfWeek.Tuesday => 1,
|
||||
DayOfWeek.Wednesday => 2,
|
||||
DayOfWeek.Thursday => 3,
|
||||
DayOfWeek.Friday => 4,
|
||||
DayOfWeek.Saturday => 5,
|
||||
DayOfWeek.Sunday => 6,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
public static string SerializeWeekdays(IEnumerable<int>? uiDaysOfWeek)
|
||||
{
|
||||
var normalized = (uiDaysOfWeek ?? [])
|
||||
.Distinct()
|
||||
.Where(day => day is >= 0 and <= 6)
|
||||
.OrderBy(day => day)
|
||||
.ToList();
|
||||
|
||||
return normalized.Count == 0 ? string.Empty : string.Join(',', normalized);
|
||||
}
|
||||
|
||||
public static List<int> DeserializeWeekdays(string? storedValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storedValue))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var values = storedValue
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(item => int.TryParse(item, out var parsed) ? parsed : -1)
|
||||
.Select(day =>
|
||||
{
|
||||
// 兼容旧数据(1-7)
|
||||
if (day is >= 1 and <= 7)
|
||||
{
|
||||
return day == 7 ? 6 : day - 1;
|
||||
}
|
||||
|
||||
return day;
|
||||
})
|
||||
.Where(day => day is >= 0 and <= 6)
|
||||
.Distinct()
|
||||
.OrderBy(day => day)
|
||||
.ToList();
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
public static BusinessHourType ToBusinessHourType(int slotType)
|
||||
{
|
||||
return slotType switch
|
||||
{
|
||||
1 => BusinessHourType.Normal,
|
||||
2 => BusinessHourType.PickupOrDelivery,
|
||||
3 => BusinessHourType.ReservationOnly,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "slot.type 非法")
|
||||
};
|
||||
}
|
||||
|
||||
public static int ToSlotType(BusinessHourType hourType)
|
||||
{
|
||||
return hourType switch
|
||||
{
|
||||
BusinessHourType.Normal => 1,
|
||||
BusinessHourType.PickupOrDelivery => 2,
|
||||
BusinessHourType.ReservationOnly => 3,
|
||||
_ => 1
|
||||
};
|
||||
}
|
||||
|
||||
public static StaffRoleType ToStaffRoleType(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"manager" => StaffRoleType.Admin,
|
||||
"cashier" => StaffRoleType.FrontDesk,
|
||||
"chef" => StaffRoleType.Kitchen,
|
||||
"courier" => StaffRoleType.Courier,
|
||||
_ => StaffRoleType.FrontDesk
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToStaffRoleTypeText(StaffRoleType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
StaffRoleType.Admin => "manager",
|
||||
StaffRoleType.FrontDesk => "cashier",
|
||||
StaffRoleType.Kitchen => "chef",
|
||||
StaffRoleType.Courier => "courier",
|
||||
StaffRoleType.Operator => "manager",
|
||||
_ => "cashier"
|
||||
};
|
||||
}
|
||||
|
||||
public static StaffStatus ToStaffStatus(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"active" => StaffStatus.Active,
|
||||
"leave" => StaffStatus.Disabled,
|
||||
"resigned" => StaffStatus.Resigned,
|
||||
_ => StaffStatus.Active
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToStaffStatusText(StaffStatus value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
StaffStatus.Active => "active",
|
||||
StaffStatus.Disabled => "leave",
|
||||
StaffStatus.Resigned => "resigned",
|
||||
_ => "active"
|
||||
};
|
||||
}
|
||||
|
||||
public static StoreTableStatus ToTableStatus(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"free" => StoreTableStatus.Idle,
|
||||
"disabled" => StoreTableStatus.Disabled,
|
||||
"dining" => StoreTableStatus.Occupied,
|
||||
"reserved" => StoreTableStatus.Cleaning,
|
||||
_ => StoreTableStatus.Idle
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToTableStatusText(StoreTableStatus value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
StoreTableStatus.Idle => "free",
|
||||
StoreTableStatus.Disabled => "disabled",
|
||||
StoreTableStatus.Occupied => "dining",
|
||||
StoreTableStatus.Cleaning => "reserved",
|
||||
_ => "free"
|
||||
};
|
||||
}
|
||||
|
||||
public static StorePickupMode ToPickupMode(string? value)
|
||||
{
|
||||
return string.Equals(value, "fine", StringComparison.OrdinalIgnoreCase)
|
||||
? StorePickupMode.Fine
|
||||
: StorePickupMode.Big;
|
||||
}
|
||||
|
||||
public static string ToPickupModeText(StorePickupMode value)
|
||||
{
|
||||
return value == StorePickupMode.Fine ? "fine" : "big";
|
||||
}
|
||||
|
||||
public static StoreDeliveryMode ToDeliveryMode(string? value)
|
||||
{
|
||||
return string.Equals(value, "polygon", StringComparison.OrdinalIgnoreCase)
|
||||
? StoreDeliveryMode.Polygon
|
||||
: StoreDeliveryMode.Radius;
|
||||
}
|
||||
|
||||
public static string ToDeliveryModeText(StoreDeliveryMode value)
|
||||
{
|
||||
return value == StoreDeliveryMode.Polygon ? "polygon" : "radius";
|
||||
}
|
||||
|
||||
public static StoreStaffShiftType ToShiftType(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"morning" => StoreStaffShiftType.Morning,
|
||||
"evening" => StoreStaffShiftType.Evening,
|
||||
"full" => StoreStaffShiftType.Full,
|
||||
"off" => StoreStaffShiftType.Off,
|
||||
_ => StoreStaffShiftType.Off
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToShiftTypeText(StoreStaffShiftType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
StoreStaffShiftType.Morning => "morning",
|
||||
StoreStaffShiftType.Evening => "evening",
|
||||
StoreStaffShiftType.Full => "full",
|
||||
StoreStaffShiftType.Off => "off",
|
||||
_ => "off"
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<Store> EnsureStoreAccessibleAsync(
|
||||
TakeoutAppDbContext dbContext,
|
||||
long tenantId,
|
||||
long merchantId,
|
||||
long storeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var store = await dbContext.Stores
|
||||
.FirstOrDefaultAsync(x => x.Id == storeId && x.TenantId == tenantId && x.MerchantId == merchantId, cancellationToken);
|
||||
|
||||
if (store is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "门店不存在或无权限访问");
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
public static async Task<HashSet<long>> FilterAccessibleStoreIdsAsync(
|
||||
TakeoutAppDbContext dbContext,
|
||||
long tenantId,
|
||||
long merchantId,
|
||||
IEnumerable<long> storeIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ids = storeIds.Distinct().ToList();
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId && ids.Contains(x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToHashSetAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public static string ResolveAvatarColor(string? seed)
|
||||
{
|
||||
var source = string.IsNullOrWhiteSpace(seed) ? "store-staff" : seed;
|
||||
var hash = 0;
|
||||
foreach (var ch in source)
|
||||
{
|
||||
hash = (hash * 31 + ch) & int.MaxValue;
|
||||
}
|
||||
|
||||
return AvatarColors[hash % AvatarColors.Length];
|
||||
}
|
||||
|
||||
public static string ResolveWeekStartDate(string? requestedWeekStartDate)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(requestedWeekStartDate) &&
|
||||
DateTime.TryParseExact(requestedWeekStartDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
|
||||
{
|
||||
return ToDateOnly(parsed);
|
||||
}
|
||||
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var diff = today.DayOfWeek == DayOfWeek.Sunday ? -6 : 1 - (int)today.DayOfWeek;
|
||||
var monday = today.AddDays(diff);
|
||||
return ToDateOnly(monday);
|
||||
}
|
||||
}
|
||||
166
src/Api/TakeoutSaaS.TenantApi/Controllers/StoreController.cs
Normal file
166
src/Api/TakeoutSaaS.TenantApi/Controllers/StoreController.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Stores.Commands;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
using TakeoutSaaS.Application.App.Stores.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 租户端门店管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||
public sealed class StoreController(
|
||||
IMediator mediator,
|
||||
StoreContextService storeContextService,
|
||||
GeoLocationOrchestrator geoLocationOrchestrator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询门店列表。
|
||||
/// </summary>
|
||||
/// <param name="query">查询参数。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页列表。</returns>
|
||||
[HttpGet("list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreListResultDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreListResultDto>), StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ApiResponse<StoreListResultDto>> List([FromQuery] SearchStoresQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询门店分页
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
// 2. 返回分页数据
|
||||
return ApiResponse<StoreListResultDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询门店统计。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>统计结果。</returns>
|
||||
[HttpGet("stats")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreStatsDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreStatsDto>), StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ApiResponse<StoreStatsDto>> Stats(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询门店统计
|
||||
var result = await mediator.Send(new GetStoreStatsQuery(), cancellationToken);
|
||||
|
||||
// 2. 返回统计结果
|
||||
return ApiResponse<StoreStatsDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建门店。
|
||||
/// </summary>
|
||||
/// <param name="command">创建命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>创建结果。</returns>
|
||||
[HttpPost("create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<object>> Create([FromBody] CreateStoreCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行创建
|
||||
await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回成功响应
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新门店。
|
||||
/// </summary>
|
||||
/// <param name="command">更新命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新结果。</returns>
|
||||
[HttpPost("update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<object>> Update([FromBody] UpdateStoreCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行更新
|
||||
await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回成功响应
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除门店。
|
||||
/// </summary>
|
||||
/// <param name="command">删除命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>删除结果。</returns>
|
||||
[HttpPost("delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<object>> Delete([FromBody] DeleteStoreCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行删除
|
||||
await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回成功响应
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 快速切换门店经营状态。
|
||||
/// </summary>
|
||||
/// <param name="command">切换命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>切换后的门店信息。</returns>
|
||||
[HttpPost("toggle-business-status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreDto>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreDto>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<StoreDto>> ToggleBusinessStatus(
|
||||
[FromBody] ToggleBusinessStatusCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行状态切换
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回切换结果
|
||||
return ApiResponse<StoreDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动重试门店地理定位。
|
||||
/// </summary>
|
||||
/// <param name="storeId">门店 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>重试结果。</returns>
|
||||
[HttpPost("{storeId}/geocode/retry")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<object>> RetryGeocode(string storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
var success = await geoLocationOrchestrator.RetryStoreAsync(
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreId,
|
||||
cancellationToken);
|
||||
if (!success)
|
||||
{
|
||||
return ApiResponse<object>.Error(ErrorCodes.NotFound, "门店不存在或无权限访问");
|
||||
}
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.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.Store;
|
||||
using TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 门店配送设置模块。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||
public sealed class StoreDeliveryController(
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService,
|
||||
TencentMapGeocodingService tencentMapGeocodingService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取门店配送设置。
|
||||
/// </summary>
|
||||
[HttpGet("delivery")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreDeliverySettingsDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreDeliverySettingsDto>> Get([FromQuery] string storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var setting = await dbContext.StoreDeliverySettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
var polygonZones = await dbContext.StoreDeliveryZones
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.Priority)
|
||||
.ThenBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var radiusTiers = ParseRadiusTiers(setting?.RadiusTiersJson);
|
||||
var isConfigured = setting is not null || polygonZones.Count > 0 || radiusTiers.Count > 0;
|
||||
return ApiResponse<StoreDeliverySettingsDto>.Ok(new StoreDeliverySettingsDto
|
||||
{
|
||||
StoreId = parsedStoreId.ToString(),
|
||||
IsConfigured = isConfigured,
|
||||
Mode = setting is null ? null : StoreApiHelpers.ToDeliveryModeText(setting.Mode),
|
||||
RadiusCenterLatitude = setting?.RadiusCenterLatitude,
|
||||
RadiusCenterLongitude = setting?.RadiusCenterLongitude,
|
||||
RadiusTiers = radiusTiers,
|
||||
PolygonZones = polygonZones.Select(MapPolygonZone).ToList(),
|
||||
GeneralSettings = setting is null
|
||||
? null
|
||||
: new DeliveryGeneralSettingsDto
|
||||
{
|
||||
EtaAdjustmentMinutes = setting.EtaAdjustmentMinutes,
|
||||
FreeDeliveryThreshold = setting.FreeDeliveryThreshold,
|
||||
HourlyCapacityLimit = setting.HourlyCapacityLimit,
|
||||
MaxDeliveryDistance = setting.MaxDeliveryDistance
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 地址地理编码(服务端签名)。
|
||||
/// </summary>
|
||||
[HttpGet("delivery/geocode")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreDeliveryGeocodeDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreDeliveryGeocodeDto>> Geocode(
|
||||
[FromQuery] string address,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedAddress = address?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(normalizedAddress))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "address 不能为空");
|
||||
}
|
||||
|
||||
var result = await tencentMapGeocodingService.GeocodeAsync(normalizedAddress, cancellationToken);
|
||||
return ApiResponse<StoreDeliveryGeocodeDto>.Ok(new StoreDeliveryGeocodeDto
|
||||
{
|
||||
Address = normalizedAddress,
|
||||
Latitude = result?.Latitude,
|
||||
Longitude = result?.Longitude
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存门店配送设置。
|
||||
/// </summary>
|
||||
[HttpPost("delivery/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Save([FromBody] StoreDeliverySettingsDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
if (string.IsNullOrWhiteSpace(request.Mode))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "mode 不能为空");
|
||||
}
|
||||
|
||||
if (request.GeneralSettings is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "generalSettings 不能为空");
|
||||
}
|
||||
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var setting = await dbContext.StoreDeliverySettings
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
if (setting is null)
|
||||
{
|
||||
setting = new StoreDeliverySetting
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.StoreDeliverySettings.AddAsync(setting, cancellationToken);
|
||||
}
|
||||
|
||||
setting.Mode = StoreApiHelpers.ToDeliveryMode(request.Mode);
|
||||
var (radiusCenterLatitude, radiusCenterLongitude) = NormalizeRadiusCenter(
|
||||
request.RadiusCenterLatitude,
|
||||
request.RadiusCenterLongitude);
|
||||
setting.EtaAdjustmentMinutes = Math.Clamp(request.GeneralSettings.EtaAdjustmentMinutes, 0, 240);
|
||||
setting.FreeDeliveryThreshold = request.GeneralSettings.FreeDeliveryThreshold;
|
||||
setting.HourlyCapacityLimit = Math.Clamp(request.GeneralSettings.HourlyCapacityLimit, 1, 9999);
|
||||
setting.MaxDeliveryDistance = Math.Max(0m, request.GeneralSettings.MaxDeliveryDistance);
|
||||
setting.RadiusCenterLatitude = radiusCenterLatitude;
|
||||
setting.RadiusCenterLongitude = radiusCenterLongitude;
|
||||
setting.RadiusTiersJson = JsonSerializer.Serialize(NormalizeRadiusTiers(request.RadiusTiers), StoreApiHelpers.JsonOptions);
|
||||
|
||||
var existingZones = await dbContext.StoreDeliveryZones
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingZoneMap = existingZones.ToDictionary(x => x.Id);
|
||||
var retainedIds = new HashSet<long>();
|
||||
|
||||
foreach (var zone in request.PolygonZones ?? [])
|
||||
{
|
||||
var zoneId = StoreApiHelpers.ParseSnowflakeOrNull(zone.Id);
|
||||
StoreDeliveryZone? entity = null;
|
||||
if (zoneId.HasValue && existingZoneMap.TryGetValue(zoneId.Value, out var existing))
|
||||
{
|
||||
entity = existing;
|
||||
retainedIds.Add(existing.Id);
|
||||
}
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
entity = new StoreDeliveryZone
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.StoreDeliveryZones.AddAsync(entity, cancellationToken);
|
||||
}
|
||||
|
||||
entity.ZoneName = zone.Name?.Trim() ?? string.Empty;
|
||||
entity.PolygonGeoJson = NormalizePolygonGeoJson(zone.PolygonGeoJson);
|
||||
entity.Color = string.IsNullOrWhiteSpace(zone.Color) ? "#1677ff" : zone.Color.Trim();
|
||||
entity.MinimumOrderAmount = Math.Max(0m, zone.MinOrderAmount);
|
||||
entity.DeliveryFee = Math.Max(0m, zone.DeliveryFee);
|
||||
entity.EstimatedMinutes = Math.Max(1, zone.EtaMinutes);
|
||||
entity.Priority = Math.Max(1, zone.Priority);
|
||||
entity.SortOrder = entity.Priority;
|
||||
}
|
||||
|
||||
var toDelete = existingZones
|
||||
.Where(x => !retainedIds.Contains(x.Id))
|
||||
.ToList();
|
||||
dbContext.StoreDeliveryZones.RemoveRange(toDelete);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制门店配送设置。
|
||||
/// </summary>
|
||||
[HttpPost("delivery/copy")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CopyStoreDeliverySettingsResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CopyStoreDeliverySettingsResult>> Copy([FromBody] CopyStoreDeliverySettingsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.SourceStoreId, "sourceStoreId");
|
||||
var targetStoreIds = StoreApiHelpers.ParseSnowflakeList(request.TargetStoreIds);
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, sourceStoreId, cancellationToken);
|
||||
|
||||
var accessibleTargetIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
targetStoreIds,
|
||||
cancellationToken);
|
||||
accessibleTargetIds.Remove(sourceStoreId);
|
||||
|
||||
if (accessibleTargetIds.Count == 0)
|
||||
{
|
||||
return ApiResponse<CopyStoreDeliverySettingsResult>.Ok(new CopyStoreDeliverySettingsResult
|
||||
{
|
||||
CopiedCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
var sourceSetting = await dbContext.StoreDeliverySettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == sourceStoreId, cancellationToken);
|
||||
var sourceZones = await dbContext.StoreDeliveryZones
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (sourceSetting is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "源门店未配置配送设置,无法复制");
|
||||
}
|
||||
|
||||
var targetSettings = await dbContext.StoreDeliverySettings
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
var targetSettingMap = targetSettings.ToDictionary(x => x.StoreId);
|
||||
|
||||
foreach (var targetStoreId in accessibleTargetIds)
|
||||
{
|
||||
if (!targetSettingMap.TryGetValue(targetStoreId, out var targetSetting))
|
||||
{
|
||||
targetSetting = new StoreDeliverySetting
|
||||
{
|
||||
StoreId = targetStoreId
|
||||
};
|
||||
await dbContext.StoreDeliverySettings.AddAsync(targetSetting, cancellationToken);
|
||||
}
|
||||
|
||||
targetSetting.Mode = sourceSetting.Mode;
|
||||
targetSetting.EtaAdjustmentMinutes = sourceSetting.EtaAdjustmentMinutes;
|
||||
targetSetting.FreeDeliveryThreshold = sourceSetting.FreeDeliveryThreshold;
|
||||
targetSetting.HourlyCapacityLimit = sourceSetting.HourlyCapacityLimit;
|
||||
targetSetting.MaxDeliveryDistance = sourceSetting.MaxDeliveryDistance;
|
||||
targetSetting.RadiusCenterLatitude = sourceSetting.RadiusCenterLatitude;
|
||||
targetSetting.RadiusCenterLongitude = sourceSetting.RadiusCenterLongitude;
|
||||
targetSetting.RadiusTiersJson = sourceSetting.RadiusTiersJson;
|
||||
}
|
||||
|
||||
var targetZones = await dbContext.StoreDeliveryZones
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StoreDeliveryZones.RemoveRange(targetZones);
|
||||
|
||||
var clonedZones = accessibleTargetIds
|
||||
.SelectMany(targetStoreId => sourceZones.Select(zone => new StoreDeliveryZone
|
||||
{
|
||||
StoreId = targetStoreId,
|
||||
ZoneName = zone.ZoneName,
|
||||
PolygonGeoJson = zone.PolygonGeoJson,
|
||||
MinimumOrderAmount = zone.MinimumOrderAmount,
|
||||
DeliveryFee = zone.DeliveryFee,
|
||||
EstimatedMinutes = zone.EstimatedMinutes,
|
||||
Color = zone.Color,
|
||||
Priority = zone.Priority,
|
||||
SortOrder = zone.SortOrder
|
||||
}))
|
||||
.ToList();
|
||||
if (clonedZones.Count > 0)
|
||||
{
|
||||
await dbContext.StoreDeliveryZones.AddRangeAsync(clonedZones, cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ApiResponse<CopyStoreDeliverySettingsResult>.Ok(new CopyStoreDeliverySettingsResult
|
||||
{
|
||||
CopiedCount = accessibleTargetIds.Count
|
||||
});
|
||||
}
|
||||
|
||||
private static List<RadiusTierDto> ParseRadiusTiers(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<List<RadiusTierDto>>(raw, StoreApiHelpers.JsonOptions);
|
||||
return NormalizeRadiusTiers(parsed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static List<RadiusTierDto> NormalizeRadiusTiers(IEnumerable<RadiusTierDto>? source)
|
||||
{
|
||||
return (source ?? [])
|
||||
.Select((tier, index) =>
|
||||
{
|
||||
var minDistance = Math.Max(0m, tier.MinDistance);
|
||||
var maxDistance = Math.Max(minDistance + 0.01m, tier.MaxDistance);
|
||||
return new RadiusTierDto
|
||||
{
|
||||
Id = string.IsNullOrWhiteSpace(tier.Id) ? $"tier-{index + 1}" : tier.Id,
|
||||
MinDistance = minDistance,
|
||||
MaxDistance = maxDistance,
|
||||
DeliveryFee = Math.Max(0m, tier.DeliveryFee),
|
||||
EtaMinutes = Math.Max(1, tier.EtaMinutes),
|
||||
MinOrderAmount = Math.Max(0m, tier.MinOrderAmount),
|
||||
Color = string.IsNullOrWhiteSpace(tier.Color) ? "#1677ff" : tier.Color
|
||||
};
|
||||
})
|
||||
.OrderBy(x => x.MinDistance)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static PolygonZoneDto MapPolygonZone(StoreDeliveryZone source)
|
||||
{
|
||||
return new PolygonZoneDto
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.ZoneName,
|
||||
Color = source.Color ?? string.Empty,
|
||||
DeliveryFee = source.DeliveryFee ?? 0m,
|
||||
EtaMinutes = source.EstimatedMinutes ?? 0,
|
||||
MinOrderAmount = source.MinimumOrderAmount ?? 0m,
|
||||
Priority = source.Priority,
|
||||
PolygonGeoJson = source.PolygonGeoJson
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizePolygonGeoJson(string? polygonGeoJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(polygonGeoJson))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "请先绘制配送区域");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(polygonGeoJson);
|
||||
var root = document.RootElement;
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "区域图形格式非法");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("type", out var typeElement) ||
|
||||
!string.Equals(typeElement.GetString(), "FeatureCollection", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "区域图形必须为 FeatureCollection");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("features", out var featuresElement) ||
|
||||
featuresElement.ValueKind != JsonValueKind.Array ||
|
||||
featuresElement.GetArrayLength() == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "至少绘制一个配送区域");
|
||||
}
|
||||
|
||||
var polygonCount = 0;
|
||||
foreach (var feature in featuresElement.EnumerateArray())
|
||||
{
|
||||
if (feature.ValueKind != JsonValueKind.Object ||
|
||||
!feature.TryGetProperty("geometry", out var geometryElement) ||
|
||||
geometryElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "区域图形格式非法");
|
||||
}
|
||||
|
||||
if (!geometryElement.TryGetProperty("type", out var geometryTypeElement) ||
|
||||
!string.Equals(geometryTypeElement.GetString(), "Polygon", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "仅支持 Polygon 多边形区域");
|
||||
}
|
||||
|
||||
if (!geometryElement.TryGetProperty("coordinates", out var coordinatesElement) ||
|
||||
coordinatesElement.ValueKind != JsonValueKind.Array ||
|
||||
coordinatesElement.GetArrayLength() == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "多边形坐标不能为空");
|
||||
}
|
||||
|
||||
ValidatePolygonCoordinates(coordinatesElement);
|
||||
polygonCount++;
|
||||
}
|
||||
|
||||
if (polygonCount == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "至少绘制一个配送区域");
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(root, StoreApiHelpers.JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "区域图形格式非法");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidatePolygonCoordinates(JsonElement coordinatesElement)
|
||||
{
|
||||
foreach (var ringElement in coordinatesElement.EnumerateArray())
|
||||
{
|
||||
if (ringElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "多边形坐标格式非法");
|
||||
}
|
||||
|
||||
double? firstLng = null;
|
||||
double? firstLat = null;
|
||||
double? lastLng = null;
|
||||
double? lastLat = null;
|
||||
var pointCount = 0;
|
||||
|
||||
foreach (var pointElement in ringElement.EnumerateArray())
|
||||
{
|
||||
if (!TryReadLngLat(pointElement, out var lng, out var lat))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "多边形坐标格式非法");
|
||||
}
|
||||
|
||||
if (lng is < -180 or > 180 || lat is < -90 or > 90)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "多边形坐标越界");
|
||||
}
|
||||
|
||||
if (pointCount == 0)
|
||||
{
|
||||
firstLng = lng;
|
||||
firstLat = lat;
|
||||
}
|
||||
|
||||
lastLng = lng;
|
||||
lastLat = lat;
|
||||
pointCount++;
|
||||
}
|
||||
|
||||
if (pointCount < 4)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "多边形顶点数量不足");
|
||||
}
|
||||
|
||||
if (firstLng is null || firstLat is null || lastLng is null || lastLat is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "多边形坐标格式非法");
|
||||
}
|
||||
|
||||
if (Math.Abs(firstLng.Value - lastLng.Value) > 1e-8 || Math.Abs(firstLat.Value - lastLat.Value) > 1e-8)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "多边形首尾坐标必须闭合");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadLngLat(JsonElement pointElement, out double lng, out double lat)
|
||||
{
|
||||
lng = 0;
|
||||
lat = 0;
|
||||
if (pointElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var coordinateEnumerator = pointElement.EnumerateArray();
|
||||
if (!coordinateEnumerator.MoveNext() ||
|
||||
coordinateEnumerator.Current.ValueKind != JsonValueKind.Number ||
|
||||
!coordinateEnumerator.Current.TryGetDouble(out lng))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!coordinateEnumerator.MoveNext() ||
|
||||
coordinateEnumerator.Current.ValueKind != JsonValueKind.Number ||
|
||||
!coordinateEnumerator.Current.TryGetDouble(out lat))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static (decimal? Latitude, decimal? Longitude) NormalizeRadiusCenter(
|
||||
decimal? latitude,
|
||||
decimal? longitude)
|
||||
{
|
||||
if (latitude is null && longitude is null)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
if (latitude is null || longitude is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "半径配送中心点经纬度必须同时填写");
|
||||
}
|
||||
|
||||
if (latitude < -90m || latitude > 90m)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "纬度必须在 -90 到 90 之间");
|
||||
}
|
||||
|
||||
if (longitude < -180m || longitude > 180m)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "经度必须在 -180 到 180 之间");
|
||||
}
|
||||
|
||||
return (decimal.Round(latitude.Value, 7), decimal.Round(longitude.Value, 7));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
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.Store;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 门店堂食管理模块。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||
public sealed class StoreDineInController(
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取门店堂食设置。
|
||||
/// </summary>
|
||||
[HttpGet("dinein")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreDineInSettingsDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreDineInSettingsDto>> Get([FromQuery] string storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var basic = await dbContext.StoreDineInSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
var areas = await dbContext.StoreTableAreas
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
var tables = await dbContext.StoreTables
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.TableCode)
|
||||
.ToListAsync(cancellationToken);
|
||||
var isConfigured = basic is not null || areas.Count > 0 || tables.Count > 0;
|
||||
|
||||
return ApiResponse<StoreDineInSettingsDto>.Ok(new StoreDineInSettingsDto
|
||||
{
|
||||
StoreId = parsedStoreId.ToString(),
|
||||
IsConfigured = isConfigured,
|
||||
BasicSettings = basic is null
|
||||
? null
|
||||
: new DineInBasicSettingsDto
|
||||
{
|
||||
Enabled = basic.Enabled,
|
||||
DefaultDiningMinutes = basic.DefaultDiningMinutes,
|
||||
OvertimeReminderMinutes = basic.OvertimeReminderMinutes
|
||||
},
|
||||
Areas = areas.Select(MapArea).ToList(),
|
||||
Tables = tables.Select(MapTable).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存门店堂食基础设置。
|
||||
/// </summary>
|
||||
[HttpPost("dinein/basic/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> SaveBasic([FromBody] SaveStoreDineInBasicSettingsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var basic = await dbContext.StoreDineInSettings
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
if (basic is null)
|
||||
{
|
||||
basic = new StoreDineInSetting
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.StoreDineInSettings.AddAsync(basic, cancellationToken);
|
||||
}
|
||||
|
||||
basic.Enabled = request.BasicSettings.Enabled;
|
||||
basic.DefaultDiningMinutes = Math.Clamp(request.BasicSettings.DefaultDiningMinutes, 1, 999);
|
||||
basic.OvertimeReminderMinutes = Math.Clamp(request.BasicSettings.OvertimeReminderMinutes, 0, 999);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存堂食区域。
|
||||
/// </summary>
|
||||
[HttpPost("dinein/area/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DineInAreaDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DineInAreaDto>> SaveArea([FromBody] SaveDineInAreaRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var areaName = request.Area.Name?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(areaName))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "区域名称不能为空");
|
||||
}
|
||||
|
||||
var areaId = StoreApiHelpers.ParseSnowflakeOrNull(request.Area.Id);
|
||||
StoreTableArea? area = null;
|
||||
if (areaId.HasValue)
|
||||
{
|
||||
area = await dbContext.StoreTableAreas.FirstOrDefaultAsync(
|
||||
x => x.Id == areaId.Value && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (area is null)
|
||||
{
|
||||
area = new StoreTableArea
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.StoreTableAreas.AddAsync(area, cancellationToken);
|
||||
}
|
||||
|
||||
area.Name = areaName;
|
||||
area.Description = request.Area.Description?.Trim();
|
||||
area.SortOrder = Math.Max(1, request.Area.Sort);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<DineInAreaDto>.Ok(MapArea(area));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除堂食区域。
|
||||
/// </summary>
|
||||
[HttpPost("dinein/area/delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteArea([FromBody] DeleteDineInAreaRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var parsedAreaId = StoreApiHelpers.ParseRequiredSnowflake(request.AreaId, "areaId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var hasTables = await dbContext.StoreTables
|
||||
.AnyAsync(x => x.TenantId == tenantId && x.StoreId == parsedStoreId && x.AreaId == parsedAreaId, cancellationToken);
|
||||
if (hasTables)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "该区域仍有桌位,请先迁移或删除桌位");
|
||||
}
|
||||
|
||||
var area = await dbContext.StoreTableAreas.FirstOrDefaultAsync(
|
||||
x => x.Id == parsedAreaId && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
if (area is null)
|
||||
{
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
dbContext.StoreTableAreas.Remove(area);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存堂食桌位。
|
||||
/// </summary>
|
||||
[HttpPost("dinein/table/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DineInTableDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DineInTableDto>> SaveTable([FromBody] SaveDineInTableRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var parsedAreaId = StoreApiHelpers.ParseRequiredSnowflake(request.Table.AreaId, "table.areaId");
|
||||
var areaExists = await dbContext.StoreTableAreas
|
||||
.AnyAsync(x => x.Id == parsedAreaId && x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
if (!areaExists)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "区域不存在");
|
||||
}
|
||||
|
||||
var tableCode = (request.Table.Code ?? string.Empty).Trim().ToUpperInvariant();
|
||||
if (string.IsNullOrWhiteSpace(tableCode))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "桌位编号不能为空");
|
||||
}
|
||||
|
||||
var tableId = StoreApiHelpers.ParseSnowflakeOrNull(request.Table.Id);
|
||||
StoreTable? table = null;
|
||||
if (tableId.HasValue)
|
||||
{
|
||||
table = await dbContext.StoreTables.FirstOrDefaultAsync(
|
||||
x => x.Id == tableId.Value && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var duplicateCode = await dbContext.StoreTables.AnyAsync(
|
||||
x => x.TenantId == tenantId
|
||||
&& x.StoreId == parsedStoreId
|
||||
&& x.TableCode == tableCode
|
||||
&& (!tableId.HasValue || x.Id != tableId.Value),
|
||||
cancellationToken);
|
||||
if (duplicateCode)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "桌位编号已存在");
|
||||
}
|
||||
|
||||
if (table is null)
|
||||
{
|
||||
table = new StoreTable
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.StoreTables.AddAsync(table, cancellationToken);
|
||||
}
|
||||
|
||||
table.AreaId = parsedAreaId;
|
||||
table.TableCode = tableCode;
|
||||
table.Capacity = Math.Clamp(request.Table.Seats, 1, 20);
|
||||
table.Status = StoreApiHelpers.ToTableStatus(request.Table.Status);
|
||||
table.Tags = string.Join(',', (request.Table.Tags ?? [])
|
||||
.Select(x => x.Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct());
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<DineInTableDto>.Ok(MapTable(table));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除堂食桌位。
|
||||
/// </summary>
|
||||
[HttpPost("dinein/table/delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteTable([FromBody] DeleteDineInTableRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var parsedTableId = StoreApiHelpers.ParseRequiredSnowflake(request.TableId, "tableId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var table = await dbContext.StoreTables.FirstOrDefaultAsync(
|
||||
x => x.Id == parsedTableId && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
if (table is null)
|
||||
{
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
dbContext.StoreTables.Remove(table);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量生成堂食桌位。
|
||||
/// </summary>
|
||||
[HttpPost("dinein/table/batch-create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchCreateDineInTablesResultDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BatchCreateDineInTablesResultDto>> BatchCreateTables(
|
||||
[FromBody] BatchCreateDineInTablesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var parsedAreaId = StoreApiHelpers.ParseRequiredSnowflake(request.AreaId, "areaId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var areaExists = await dbContext.StoreTableAreas
|
||||
.AnyAsync(x => x.Id == parsedAreaId && x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
if (!areaExists)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "区域不存在");
|
||||
}
|
||||
|
||||
var count = Math.Clamp(request.Count, 1, 50);
|
||||
var startNumber = Math.Clamp(request.StartNumber, 1, 9999);
|
||||
var seats = Math.Clamp(request.Seats, 1, 20);
|
||||
var prefix = string.IsNullOrWhiteSpace(request.CodePrefix) ? "A" : request.CodePrefix.Trim().ToUpperInvariant();
|
||||
var width = Math.Max(2, (startNumber + count - 1).ToString().Length);
|
||||
|
||||
var existingCodes = await dbContext.StoreTables
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.Select(x => x.TableCode)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingCodeSet = new HashSet<string>(existingCodes, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var created = new List<StoreTable>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var code = $"{prefix}{(startNumber + i).ToString().PadLeft(width, '0')}";
|
||||
if (existingCodeSet.Contains(code))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var table = new StoreTable
|
||||
{
|
||||
StoreId = parsedStoreId,
|
||||
AreaId = parsedAreaId,
|
||||
TableCode = code,
|
||||
Capacity = seats,
|
||||
Status = StoreApiHelpers.ToTableStatus("free"),
|
||||
Tags = null
|
||||
};
|
||||
created.Add(table);
|
||||
existingCodeSet.Add(code);
|
||||
}
|
||||
|
||||
if (created.Count > 0)
|
||||
{
|
||||
await dbContext.StoreTables.AddRangeAsync(created, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return ApiResponse<BatchCreateDineInTablesResultDto>.Ok(new BatchCreateDineInTablesResultDto
|
||||
{
|
||||
CreatedTables = created.Select(MapTable).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制堂食设置。
|
||||
/// </summary>
|
||||
[HttpPost("dinein/copy")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CopyStoreDineInSettingsResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CopyStoreDineInSettingsResult>> Copy([FromBody] CopyStoreDineInSettingsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.SourceStoreId, "sourceStoreId");
|
||||
var targetStoreIds = StoreApiHelpers.ParseSnowflakeList(request.TargetStoreIds);
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, sourceStoreId, cancellationToken);
|
||||
|
||||
var accessibleTargetIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
targetStoreIds,
|
||||
cancellationToken);
|
||||
accessibleTargetIds.Remove(sourceStoreId);
|
||||
|
||||
if (accessibleTargetIds.Count == 0)
|
||||
{
|
||||
return ApiResponse<CopyStoreDineInSettingsResult>.Ok(new CopyStoreDineInSettingsResult
|
||||
{
|
||||
CopiedCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
var sourceBasic = await dbContext.StoreDineInSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == sourceStoreId, cancellationToken);
|
||||
var sourceAreas = await dbContext.StoreTableAreas
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var sourceTables = await dbContext.StoreTables
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.OrderBy(x => x.TableCode)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (sourceBasic is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "源门店未配置堂食基础设置,无法复制");
|
||||
}
|
||||
|
||||
foreach (var targetStoreId in accessibleTargetIds)
|
||||
{
|
||||
var targetBasic = await dbContext.StoreDineInSettings
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == targetStoreId, cancellationToken);
|
||||
if (targetBasic is null)
|
||||
{
|
||||
targetBasic = new StoreDineInSetting
|
||||
{
|
||||
StoreId = targetStoreId
|
||||
};
|
||||
await dbContext.StoreDineInSettings.AddAsync(targetBasic, cancellationToken);
|
||||
}
|
||||
|
||||
targetBasic.Enabled = sourceBasic.Enabled;
|
||||
targetBasic.DefaultDiningMinutes = sourceBasic.DefaultDiningMinutes;
|
||||
targetBasic.OvertimeReminderMinutes = sourceBasic.OvertimeReminderMinutes;
|
||||
|
||||
var targetTables = await dbContext.StoreTables
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == targetStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var targetAreas = await dbContext.StoreTableAreas
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == targetStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StoreTables.RemoveRange(targetTables);
|
||||
dbContext.StoreTableAreas.RemoveRange(targetAreas);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var createdAreas = sourceAreas.Select(area => new StoreTableArea
|
||||
{
|
||||
StoreId = targetStoreId,
|
||||
Name = area.Name,
|
||||
Description = area.Description,
|
||||
SortOrder = area.SortOrder
|
||||
}).ToList();
|
||||
if (createdAreas.Count > 0)
|
||||
{
|
||||
await dbContext.StoreTableAreas.AddRangeAsync(createdAreas, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var areaIdMap = new Dictionary<long, long>();
|
||||
for (var index = 0; index < sourceAreas.Count && index < createdAreas.Count; index++)
|
||||
{
|
||||
areaIdMap[sourceAreas[index].Id] = createdAreas[index].Id;
|
||||
}
|
||||
|
||||
var createdTables = sourceTables.Select(table => new StoreTable
|
||||
{
|
||||
StoreId = targetStoreId,
|
||||
AreaId = table.AreaId.HasValue && areaIdMap.TryGetValue(table.AreaId.Value, out var mappedAreaId)
|
||||
? mappedAreaId
|
||||
: null,
|
||||
TableCode = table.TableCode,
|
||||
Capacity = table.Capacity,
|
||||
Status = table.Status,
|
||||
Tags = table.Tags
|
||||
}).ToList();
|
||||
if (createdTables.Count > 0)
|
||||
{
|
||||
await dbContext.StoreTables.AddRangeAsync(createdTables, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return ApiResponse<CopyStoreDineInSettingsResult>.Ok(new CopyStoreDineInSettingsResult
|
||||
{
|
||||
CopiedCount = accessibleTargetIds.Count
|
||||
});
|
||||
}
|
||||
|
||||
private static DineInAreaDto MapArea(StoreTableArea source)
|
||||
{
|
||||
return new DineInAreaDto
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
Description = source.Description ?? string.Empty,
|
||||
Sort = source.SortOrder
|
||||
};
|
||||
}
|
||||
|
||||
private static DineInTableDto MapTable(StoreTable source)
|
||||
{
|
||||
return new DineInTableDto
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
AreaId = source.AreaId?.ToString() ?? string.Empty,
|
||||
Code = source.TableCode,
|
||||
Seats = source.Capacity,
|
||||
Status = StoreApiHelpers.ToTableStatusText(source.Status),
|
||||
Tags = string.IsNullOrWhiteSpace(source.Tags)
|
||||
? []
|
||||
: source.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
280
src/Api/TakeoutSaaS.TenantApi/Controllers/StoreFeesController.cs
Normal file
280
src/Api/TakeoutSaaS.TenantApi/Controllers/StoreFeesController.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Stores.Commands;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
using TakeoutSaaS.Application.App.Stores.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.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.Store;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 门店费用设置模块。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||
public sealed class StoreFeesController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取门店费用设置。
|
||||
/// </summary>
|
||||
[HttpGet("fees")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreFeesSettingsDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreFeesSettingsDto>> Get([FromQuery] string storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var fee = await mediator.Send(new GetStoreFeeQuery
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
}, cancellationToken);
|
||||
|
||||
var response = MapFeeSettings(parsedStoreId, fee);
|
||||
return ApiResponse<StoreFeesSettingsDto>.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存门店费用设置。
|
||||
/// </summary>
|
||||
[HttpPost("fees/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreFeesSettingsDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreFeesSettingsDto>> Save([FromBody] StoreFeesSettingsDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new UpdateStoreFeeCommand
|
||||
{
|
||||
StoreId = parsedStoreId,
|
||||
MinimumOrderAmount = request.MinimumOrderAmount,
|
||||
DeliveryFee = request.BaseDeliveryFee,
|
||||
PlatformServiceRate = request.PlatformServiceRate,
|
||||
FreeDeliveryThreshold = request.FreeDeliveryThreshold,
|
||||
PackagingFeeMode = ParsePackagingFeeMode(request.PackagingFeeMode),
|
||||
OrderPackagingFeeMode = ParseOrderPackagingFeeMode(request.OrderPackagingFeeMode),
|
||||
FixedPackagingFee = request.FixedPackagingFee,
|
||||
PackagingFeeTiers = (request.PackagingFeeTiers ?? [])
|
||||
.OrderBy(x => x.Sort)
|
||||
.ThenBy(x => x.MinAmount)
|
||||
.Select(x => new StoreFeeTierDto
|
||||
{
|
||||
MinPrice = x.MinAmount,
|
||||
MaxPrice = x.MaxAmount,
|
||||
Fee = x.Fee
|
||||
})
|
||||
.ToList(),
|
||||
CutleryFeeEnabled = request.OtherFees.Cutlery.Enabled,
|
||||
CutleryFeeAmount = request.OtherFees.Cutlery.Amount,
|
||||
RushFeeEnabled = request.OtherFees.Rush.Enabled,
|
||||
RushFeeAmount = request.OtherFees.Rush.Amount
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<StoreFeesSettingsDto>.Ok(MapFeeSettings(parsedStoreId, result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存包装费收取方式。
|
||||
/// </summary>
|
||||
[HttpPost("fees/mode/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> SaveMode([FromBody] SaveStoreFeesModeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var packagingFeeMode = ParsePackagingFeeMode(request.PackagingFeeMode);
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
var store = await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var fee = await dbContext.StoreFees
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
if (fee is null)
|
||||
{
|
||||
fee = new StoreFee
|
||||
{
|
||||
StoreId = parsedStoreId,
|
||||
TenantId = store.TenantId
|
||||
};
|
||||
await dbContext.StoreFees.AddAsync(fee, cancellationToken);
|
||||
}
|
||||
|
||||
fee.PackagingFeeMode = packagingFeeMode;
|
||||
if (packagingFeeMode == PackagingFeeMode.PerItem)
|
||||
{
|
||||
fee.OrderPackagingFeeMode = OrderPackagingFeeMode.Fixed;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制费用设置。
|
||||
/// </summary>
|
||||
[HttpPost("fees/copy")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CopyStoreFeesSettingsResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CopyStoreFeesSettingsResult>> Copy([FromBody] CopyStoreFeesSettingsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.SourceStoreId, "sourceStoreId");
|
||||
var targetStoreIds = StoreApiHelpers.ParseSnowflakeList(request.TargetStoreIds);
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, sourceStoreId, cancellationToken);
|
||||
|
||||
var accessibleTargetIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
targetStoreIds,
|
||||
cancellationToken);
|
||||
accessibleTargetIds.Remove(sourceStoreId);
|
||||
|
||||
if (accessibleTargetIds.Count == 0)
|
||||
{
|
||||
return ApiResponse<CopyStoreFeesSettingsResult>.Ok(new CopyStoreFeesSettingsResult
|
||||
{
|
||||
CopiedCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
var sourceFee = await dbContext.StoreFees
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == sourceStoreId, cancellationToken);
|
||||
if (sourceFee is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "源门店未配置费用设置,无法复制");
|
||||
}
|
||||
|
||||
var targetFees = await dbContext.StoreFees
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
var targetFeeMap = targetFees.ToDictionary(x => x.StoreId);
|
||||
|
||||
foreach (var targetStoreId in accessibleTargetIds)
|
||||
{
|
||||
if (!targetFeeMap.TryGetValue(targetStoreId, out var targetFee))
|
||||
{
|
||||
targetFee = new StoreFee
|
||||
{
|
||||
StoreId = targetStoreId
|
||||
};
|
||||
await dbContext.StoreFees.AddAsync(targetFee, cancellationToken);
|
||||
}
|
||||
|
||||
targetFee.MinimumOrderAmount = sourceFee.MinimumOrderAmount;
|
||||
targetFee.BaseDeliveryFee = sourceFee.BaseDeliveryFee;
|
||||
targetFee.PlatformServiceRate = sourceFee.PlatformServiceRate;
|
||||
targetFee.FreeDeliveryThreshold = sourceFee.FreeDeliveryThreshold;
|
||||
targetFee.PackagingFeeMode = sourceFee.PackagingFeeMode;
|
||||
targetFee.OrderPackagingFeeMode = sourceFee.OrderPackagingFeeMode;
|
||||
targetFee.FixedPackagingFee = sourceFee.FixedPackagingFee;
|
||||
targetFee.PackagingFeeTiersJson = sourceFee.PackagingFeeTiersJson;
|
||||
targetFee.CutleryFeeEnabled = sourceFee.CutleryFeeEnabled;
|
||||
targetFee.CutleryFeeAmount = sourceFee.CutleryFeeAmount;
|
||||
targetFee.RushFeeEnabled = sourceFee.RushFeeEnabled;
|
||||
targetFee.RushFeeAmount = sourceFee.RushFeeAmount;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ApiResponse<CopyStoreFeesSettingsResult>.Ok(new CopyStoreFeesSettingsResult
|
||||
{
|
||||
CopiedCount = accessibleTargetIds.Count
|
||||
});
|
||||
}
|
||||
|
||||
private static StoreFeesSettingsDto MapFeeSettings(long storeId, StoreFeeDto? source)
|
||||
{
|
||||
var tiers = (source?.PackagingFeeTiers ?? [])
|
||||
.OrderBy(x => x.MinPrice)
|
||||
.Select((tier, index) => new PackagingFeeTierDto
|
||||
{
|
||||
Id = $"tier-{index + 1}",
|
||||
MinAmount = tier.MinPrice,
|
||||
MaxAmount = tier.MaxPrice,
|
||||
Fee = tier.Fee,
|
||||
Sort = index + 1
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new StoreFeesSettingsDto
|
||||
{
|
||||
StoreId = storeId.ToString(),
|
||||
IsConfigured = source is not null,
|
||||
MinimumOrderAmount = source?.MinimumOrderAmount ?? 0m,
|
||||
BaseDeliveryFee = source?.DeliveryFee ?? 0m,
|
||||
PlatformServiceRate = source?.PlatformServiceRate ?? 0m,
|
||||
FreeDeliveryThreshold = source?.FreeDeliveryThreshold,
|
||||
PackagingFeeMode = ToPackagingFeeModeText(source?.PackagingFeeMode ?? PackagingFeeMode.Fixed),
|
||||
OrderPackagingFeeMode = ToOrderPackagingFeeModeText(source?.OrderPackagingFeeMode ?? OrderPackagingFeeMode.Fixed),
|
||||
FixedPackagingFee = source?.FixedPackagingFee ?? 0m,
|
||||
PackagingFeeTiers = tiers,
|
||||
OtherFees = new StoreOtherFeesDto
|
||||
{
|
||||
Cutlery = new AdditionalFeeItemDto
|
||||
{
|
||||
Enabled = source?.CutleryFeeEnabled ?? false,
|
||||
Amount = source?.CutleryFeeAmount ?? 0m
|
||||
},
|
||||
Rush = new AdditionalFeeItemDto
|
||||
{
|
||||
Enabled = source?.RushFeeEnabled ?? false,
|
||||
Amount = source?.RushFeeAmount ?? 0m
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static PackagingFeeMode ParsePackagingFeeMode(string? value)
|
||||
{
|
||||
if (string.Equals(value, "item", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PackagingFeeMode.PerItem;
|
||||
}
|
||||
|
||||
if (string.Equals(value, "order", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PackagingFeeMode.Fixed;
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "packagingFeeMode 非法");
|
||||
}
|
||||
|
||||
private static string ToPackagingFeeModeText(PackagingFeeMode value)
|
||||
{
|
||||
return value == PackagingFeeMode.PerItem ? "item" : "order";
|
||||
}
|
||||
|
||||
private static OrderPackagingFeeMode ParseOrderPackagingFeeMode(string? value)
|
||||
{
|
||||
if (string.Equals(value, "tiered", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return OrderPackagingFeeMode.Tiered;
|
||||
}
|
||||
|
||||
if (string.Equals(value, "fixed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return OrderPackagingFeeMode.Fixed;
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "orderPackagingFeeMode 非法");
|
||||
}
|
||||
|
||||
private static string ToOrderPackagingFeeModeText(OrderPackagingFeeMode value)
|
||||
{
|
||||
return value == OrderPackagingFeeMode.Tiered ? "tiered" : "fixed";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.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.Store;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 门店营业时间模块。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||
public sealed class StoreHoursController(
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取门店营业时间。
|
||||
/// </summary>
|
||||
[HttpGet("hours")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreHoursDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreHoursDto>> Get([FromQuery] string storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var hours = await dbContext.StoreBusinessHours
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.DayOfWeek)
|
||||
.ThenBy(x => x.StartTime)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var holidays = await dbContext.StoreHolidays
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.Date)
|
||||
.ThenBy(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var weeklyHours = Enumerable.Range(0, 7)
|
||||
.Select(day =>
|
||||
{
|
||||
var slots = hours
|
||||
.Where(x => StoreApiHelpers.DotNetDayOfWeekToUi(x.DayOfWeek) == day)
|
||||
.Select(MapSlot)
|
||||
.ToList();
|
||||
|
||||
return new StoreHourDayHoursDto
|
||||
{
|
||||
DayOfWeek = day,
|
||||
IsOpen = slots.Count > 0,
|
||||
Slots = slots
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var result = new StoreHoursDto
|
||||
{
|
||||
StoreId = parsedStoreId.ToString(),
|
||||
WeeklyHours = weeklyHours,
|
||||
Holidays = holidays.Select(MapHoliday).ToList()
|
||||
};
|
||||
|
||||
return ApiResponse<StoreHoursDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存每周营业时间。
|
||||
/// </summary>
|
||||
[HttpPost("hours/weekly")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> SaveWeekly([FromBody] SaveWeeklyHoursRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var existingHours = await dbContext.StoreBusinessHours
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
dbContext.StoreBusinessHours.RemoveRange(existingHours);
|
||||
|
||||
var toCreate = new List<StoreBusinessHour>();
|
||||
foreach (var day in request.WeeklyHours ?? [])
|
||||
{
|
||||
if (day.DayOfWeek is < 0 or > 6 || !day.IsOpen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var slot in day.Slots ?? [])
|
||||
{
|
||||
var startTime = StoreApiHelpers.ParseRequiredTime(slot.StartTime, "slot.startTime");
|
||||
var endTime = StoreApiHelpers.ParseRequiredTime(slot.EndTime, "slot.endTime");
|
||||
if (startTime >= endTime)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "营业时段开始时间必须早于结束时间");
|
||||
}
|
||||
|
||||
var hourType = StoreApiHelpers.ToBusinessHourType(slot.Type);
|
||||
toCreate.Add(new StoreBusinessHour
|
||||
{
|
||||
StoreId = parsedStoreId,
|
||||
DayOfWeek = StoreApiHelpers.UiDayOfWeekToDotNet(day.DayOfWeek),
|
||||
HourType = hourType,
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
CapacityLimit = hourType == BusinessHourType.PickupOrDelivery ? slot.Capacity : null,
|
||||
Notes = string.IsNullOrWhiteSpace(slot.Remark) ? null : slot.Remark.Trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (toCreate.Count > 0)
|
||||
{
|
||||
await dbContext.StoreBusinessHours.AddRangeAsync(toCreate, cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存特殊日期。
|
||||
/// </summary>
|
||||
[HttpPost("hours/holiday")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreHourHolidayDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreHourHolidayDto>> SaveHoliday([FromBody] SaveHolidayRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var holidayInput = request.Holiday;
|
||||
var startDate = StoreApiHelpers.ParseDateOnly(holidayInput.StartDate, "holiday.startDate");
|
||||
var endDate = StoreApiHelpers.ParseDateOnly(string.IsNullOrWhiteSpace(holidayInput.EndDate) ? holidayInput.StartDate : holidayInput.EndDate, "holiday.endDate");
|
||||
if (startDate > endDate)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "holiday.startDate 不能晚于 holiday.endDate");
|
||||
}
|
||||
|
||||
var type = holidayInput.Type == 2 ? StoreHolidayType.Special : StoreHolidayType.Closed;
|
||||
var hasTimeRange = type == StoreHolidayType.Special
|
||||
&& !string.IsNullOrWhiteSpace(holidayInput.StartTime)
|
||||
&& !string.IsNullOrWhiteSpace(holidayInput.EndTime);
|
||||
|
||||
var startTime = hasTimeRange ? StoreApiHelpers.ParseRequiredTime(holidayInput.StartTime, "holiday.startTime") : (TimeSpan?)null;
|
||||
var endTime = hasTimeRange ? StoreApiHelpers.ParseRequiredTime(holidayInput.EndTime, "holiday.endTime") : (TimeSpan?)null;
|
||||
if (hasTimeRange && startTime >= endTime)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "holiday.startTime 必须早于 holiday.endTime");
|
||||
}
|
||||
|
||||
var holidayId = StoreApiHelpers.ParseSnowflakeOrNull(holidayInput.Id);
|
||||
|
||||
// 同一门店的特殊日期范围不允许重叠(编辑时排除自身)。
|
||||
var hasOverlap = await dbContext.StoreHolidays
|
||||
.AsNoTracking()
|
||||
.AnyAsync(
|
||||
x => x.TenantId == tenantId
|
||||
&& x.StoreId == parsedStoreId
|
||||
&& (!holidayId.HasValue || x.Id != holidayId.Value)
|
||||
&& x.Date <= endDate
|
||||
&& (x.EndDate ?? x.Date) >= startDate,
|
||||
cancellationToken);
|
||||
if (hasOverlap)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "日期范围与已有节假日/特殊日期冲突,请勿重复设置");
|
||||
}
|
||||
|
||||
var entity = holidayId.HasValue
|
||||
? await dbContext.StoreHolidays.FirstOrDefaultAsync(
|
||||
x => x.Id == holidayId.Value && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken)
|
||||
: null;
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
entity = new StoreHoliday
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.StoreHolidays.AddAsync(entity, cancellationToken);
|
||||
}
|
||||
|
||||
entity.Date = startDate;
|
||||
entity.EndDate = endDate;
|
||||
entity.IsAllDay = !hasTimeRange;
|
||||
entity.StartTime = hasTimeRange ? startTime : null;
|
||||
entity.EndTime = hasTimeRange ? endTime : null;
|
||||
entity.OverrideType = type == StoreHolidayType.Closed
|
||||
? OverrideType.Closed
|
||||
: hasTimeRange
|
||||
? OverrideType.ModifiedHours
|
||||
: OverrideType.TemporaryOpen;
|
||||
entity.IsClosed = type == StoreHolidayType.Closed;
|
||||
entity.Reason = string.IsNullOrWhiteSpace(holidayInput.Reason) ? null : holidayInput.Reason.Trim();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var result = MapHoliday(entity);
|
||||
result.Remark = holidayInput.Remark;
|
||||
return ApiResponse<StoreHourHolidayDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除特殊日期。
|
||||
/// </summary>
|
||||
[HttpPost("hours/holiday/delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteHoliday([FromBody] DeleteHolidayRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var holidayId = StoreApiHelpers.ParseRequiredSnowflake(request.Id, "id");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
|
||||
var holiday = await dbContext.StoreHolidays
|
||||
.FirstOrDefaultAsync(x => x.Id == holidayId && x.TenantId == tenantId, cancellationToken);
|
||||
|
||||
if (holiday is null)
|
||||
{
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
var hasAccess = await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.AnyAsync(x => x.Id == holiday.StoreId && x.TenantId == tenantId && x.MerchantId == merchantId, cancellationToken);
|
||||
if (!hasAccess)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "特殊日期不存在或无权限访问");
|
||||
}
|
||||
|
||||
dbContext.StoreHolidays.Remove(holiday);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制营业时间到其他门店。
|
||||
/// </summary>
|
||||
[HttpPost("hours/copy")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CopyStoreHoursResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CopyStoreHoursResult>> Copy([FromBody] CopyStoreHoursRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.SourceStoreId, "sourceStoreId");
|
||||
var targetStoreIds = StoreApiHelpers.ParseSnowflakeList(request.TargetStoreIds);
|
||||
var includeWeeklyHours = request.IncludeWeeklyHours ?? true;
|
||||
var includeHolidays = request.IncludeHolidays ?? true;
|
||||
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, sourceStoreId, cancellationToken);
|
||||
|
||||
var accessibleTargetIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
targetStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
accessibleTargetIds.Remove(sourceStoreId);
|
||||
if (accessibleTargetIds.Count == 0)
|
||||
{
|
||||
return ApiResponse<CopyStoreHoursResult>.Ok(new CopyStoreHoursResult
|
||||
{
|
||||
CopiedCount = 0,
|
||||
IncludeWeeklyHours = includeWeeklyHours,
|
||||
IncludeHolidays = includeHolidays
|
||||
});
|
||||
}
|
||||
|
||||
if (includeWeeklyHours)
|
||||
{
|
||||
var sourceHours = await dbContext.StoreBusinessHours
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var targetHours = await dbContext.StoreBusinessHours
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StoreBusinessHours.RemoveRange(targetHours);
|
||||
|
||||
var clonedHours = accessibleTargetIds
|
||||
.SelectMany(targetStoreId => sourceHours.Select(hour => new StoreBusinessHour
|
||||
{
|
||||
StoreId = targetStoreId,
|
||||
DayOfWeek = hour.DayOfWeek,
|
||||
HourType = hour.HourType,
|
||||
StartTime = hour.StartTime,
|
||||
EndTime = hour.EndTime,
|
||||
CapacityLimit = hour.CapacityLimit,
|
||||
Notes = hour.Notes
|
||||
}))
|
||||
.ToList();
|
||||
|
||||
if (clonedHours.Count > 0)
|
||||
{
|
||||
await dbContext.StoreBusinessHours.AddRangeAsync(clonedHours, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeHolidays)
|
||||
{
|
||||
var sourceHolidays = await dbContext.StoreHolidays
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var targetHolidays = await dbContext.StoreHolidays
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StoreHolidays.RemoveRange(targetHolidays);
|
||||
|
||||
var clonedHolidays = accessibleTargetIds
|
||||
.SelectMany(targetStoreId => sourceHolidays.Select(holiday => new StoreHoliday
|
||||
{
|
||||
StoreId = targetStoreId,
|
||||
Date = holiday.Date,
|
||||
EndDate = holiday.EndDate,
|
||||
IsAllDay = holiday.IsAllDay,
|
||||
StartTime = holiday.StartTime,
|
||||
EndTime = holiday.EndTime,
|
||||
OverrideType = holiday.OverrideType,
|
||||
IsClosed = holiday.IsClosed,
|
||||
Reason = holiday.Reason
|
||||
}))
|
||||
.ToList();
|
||||
|
||||
if (clonedHolidays.Count > 0)
|
||||
{
|
||||
await dbContext.StoreHolidays.AddRangeAsync(clonedHolidays, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ApiResponse<CopyStoreHoursResult>.Ok(new CopyStoreHoursResult
|
||||
{
|
||||
CopiedCount = accessibleTargetIds.Count,
|
||||
IncludeWeeklyHours = includeWeeklyHours,
|
||||
IncludeHolidays = includeHolidays
|
||||
});
|
||||
}
|
||||
|
||||
private static StoreHourTimeSlotDto MapSlot(StoreBusinessHour source)
|
||||
{
|
||||
return new StoreHourTimeSlotDto
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Type = StoreApiHelpers.ToSlotType(source.HourType),
|
||||
StartTime = StoreApiHelpers.ToHHmm(source.StartTime),
|
||||
EndTime = StoreApiHelpers.ToHHmm(source.EndTime),
|
||||
Capacity = source.CapacityLimit,
|
||||
Remark = source.Notes
|
||||
};
|
||||
}
|
||||
|
||||
private static StoreHourHolidayDto MapHoliday(StoreHoliday source)
|
||||
{
|
||||
var type = source.OverrideType == OverrideType.Closed || source.IsClosed
|
||||
? StoreHolidayType.Closed
|
||||
: StoreHolidayType.Special;
|
||||
|
||||
return new StoreHourHolidayDto
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
StartDate = StoreApiHelpers.ToDateOnly(source.Date),
|
||||
EndDate = StoreApiHelpers.ToDateOnly(source.EndDate ?? source.Date),
|
||||
Type = (int)type,
|
||||
StartTime = type == StoreHolidayType.Special ? StoreApiHelpers.ToHHmm(source.StartTime) : null,
|
||||
EndTime = type == StoreHolidayType.Special ? StoreApiHelpers.ToHHmm(source.EndTime) : null,
|
||||
Reason = source.Reason ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private enum StoreHolidayType
|
||||
{
|
||||
Closed = 1,
|
||||
Special = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.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.Store;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 门店自提设置模块。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||
public sealed class StorePickupController(
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取门店自提设置。
|
||||
/// </summary>
|
||||
[HttpGet("pickup")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StorePickupSettingsDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StorePickupSettingsDto>> Get([FromQuery] string storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var setting = await dbContext.StorePickupSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
|
||||
var slots = await dbContext.StorePickupSlots
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.StartTime)
|
||||
.ThenBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var fineRule = ParseFineRule(setting?.FineRuleJson);
|
||||
var previewDays = fineRule is null ? [] : BuildPreviewDays(fineRule);
|
||||
var isConfigured = setting is not null || slots.Count > 0;
|
||||
|
||||
var response = new StorePickupSettingsDto
|
||||
{
|
||||
StoreId = parsedStoreId.ToString(),
|
||||
IsConfigured = isConfigured,
|
||||
Mode = setting is null ? null : StoreApiHelpers.ToPickupModeText(setting.Mode),
|
||||
BasicSettings = setting is null
|
||||
? null
|
||||
: new PickupBasicSettingsDto
|
||||
{
|
||||
AllowSameDayPickup = setting.AllowToday,
|
||||
BookingDays = setting.AllowDaysAhead,
|
||||
MaxItemsPerOrder = setting.MaxQuantityPerOrder
|
||||
},
|
||||
BigSlots = slots.Select(slot => new PickupSlotDto
|
||||
{
|
||||
Id = slot.Id.ToString(),
|
||||
Name = slot.Name,
|
||||
StartTime = StoreApiHelpers.ToHHmm(slot.StartTime),
|
||||
EndTime = StoreApiHelpers.ToHHmm(slot.EndTime),
|
||||
CutoffMinutes = slot.CutoffMinutes,
|
||||
Capacity = slot.Capacity,
|
||||
ReservedCount = slot.ReservedCount,
|
||||
DayOfWeeks = StoreApiHelpers.DeserializeWeekdays(slot.Weekdays),
|
||||
Enabled = slot.IsEnabled
|
||||
}).ToList(),
|
||||
FineRule = fineRule,
|
||||
PreviewDays = previewDays
|
||||
};
|
||||
|
||||
return ApiResponse<StorePickupSettingsDto>.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存自提基础设置。
|
||||
/// </summary>
|
||||
[HttpPost("pickup/basic/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> SaveBasic([FromBody] SavePickupBasicSettingsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var setting = await EnsurePickupSettingAsync(tenantId, parsedStoreId, cancellationToken);
|
||||
setting.AllowToday = request.BasicSettings.AllowSameDayPickup;
|
||||
setting.AllowDaysAhead = Math.Clamp(request.BasicSettings.BookingDays, 1, 30);
|
||||
setting.MaxQuantityPerOrder = request.BasicSettings.MaxItemsPerOrder;
|
||||
setting.Mode = request.Mode is null ? setting.Mode : StoreApiHelpers.ToPickupMode(request.Mode);
|
||||
setting.RowVersion = CreateRowVersion();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存自提大时段。
|
||||
/// </summary>
|
||||
[HttpPost("pickup/slots/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> SaveSlots([FromBody] SavePickupSlotsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var setting = await EnsurePickupSettingAsync(tenantId, parsedStoreId, cancellationToken);
|
||||
setting.Mode = request.Mode is null ? setting.Mode : StoreApiHelpers.ToPickupMode(request.Mode);
|
||||
setting.RowVersion = CreateRowVersion();
|
||||
|
||||
var existingSlots = await dbContext.StorePickupSlots
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StorePickupSlots.RemoveRange(existingSlots);
|
||||
|
||||
var toCreate = new List<StorePickupSlot>();
|
||||
foreach (var slot in request.Slots ?? [])
|
||||
{
|
||||
var startTime = StoreApiHelpers.ParseRequiredTime(slot.StartTime, "slot.startTime");
|
||||
var endTime = StoreApiHelpers.ParseRequiredTime(slot.EndTime, "slot.endTime");
|
||||
if (startTime >= endTime)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var capacity = Math.Max(0, slot.Capacity);
|
||||
toCreate.Add(new StorePickupSlot
|
||||
{
|
||||
StoreId = parsedStoreId,
|
||||
Name = string.IsNullOrWhiteSpace(slot.Name) ? "时段" : slot.Name.Trim(),
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
CutoffMinutes = Math.Max(0, slot.CutoffMinutes),
|
||||
Capacity = capacity,
|
||||
ReservedCount = Math.Clamp(slot.ReservedCount, 0, capacity),
|
||||
Weekdays = StoreApiHelpers.SerializeWeekdays(slot.DayOfWeeks),
|
||||
IsEnabled = slot.Enabled,
|
||||
RowVersion = CreateRowVersion()
|
||||
});
|
||||
}
|
||||
|
||||
if (toCreate.Count > 0)
|
||||
{
|
||||
await dbContext.StorePickupSlots.AddRangeAsync(toCreate, cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存自提精细规则。
|
||||
/// </summary>
|
||||
[HttpPost("pickup/fine-rule/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> SaveFineRule([FromBody] SavePickupFineRuleRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var setting = await EnsurePickupSettingAsync(tenantId, parsedStoreId, cancellationToken);
|
||||
setting.Mode = request.Mode is null ? setting.Mode : StoreApiHelpers.ToPickupMode(request.Mode);
|
||||
setting.RowVersion = CreateRowVersion();
|
||||
|
||||
var normalizedRule = NormalizeFineRule(request.FineRule);
|
||||
setting.FineRuleJson = JsonSerializer.Serialize(normalizedRule, StoreApiHelpers.JsonOptions);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存自提模式。
|
||||
/// </summary>
|
||||
[HttpPost("pickup/mode/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> SaveMode([FromBody] SavePickupModeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var mode = ParseRequiredPickupMode(request.Mode);
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var setting = await EnsurePickupSettingAsync(tenantId, parsedStoreId, cancellationToken);
|
||||
setting.Mode = mode;
|
||||
setting.RowVersion = CreateRowVersion();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制自提设置。
|
||||
/// </summary>
|
||||
[HttpPost("pickup/copy")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CopyStorePickupSettingsResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CopyStorePickupSettingsResult>> Copy([FromBody] CopyStorePickupSettingsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.SourceStoreId, "sourceStoreId");
|
||||
var targetStoreIds = StoreApiHelpers.ParseSnowflakeList(request.TargetStoreIds);
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, sourceStoreId, cancellationToken);
|
||||
|
||||
var accessibleTargetIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
targetStoreIds,
|
||||
cancellationToken);
|
||||
accessibleTargetIds.Remove(sourceStoreId);
|
||||
|
||||
if (accessibleTargetIds.Count == 0)
|
||||
{
|
||||
return ApiResponse<CopyStorePickupSettingsResult>.Ok(new CopyStorePickupSettingsResult
|
||||
{
|
||||
CopiedCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
var sourceSetting = await dbContext.StorePickupSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == sourceStoreId, cancellationToken);
|
||||
var sourceSlots = await dbContext.StorePickupSlots
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (sourceSetting is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "源门店未配置自提设置,无法复制");
|
||||
}
|
||||
|
||||
var targetSettings = await dbContext.StorePickupSettings
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
var targetSettingMap = targetSettings.ToDictionary(x => x.StoreId);
|
||||
|
||||
foreach (var targetStoreId in accessibleTargetIds)
|
||||
{
|
||||
if (!targetSettingMap.TryGetValue(targetStoreId, out var targetSetting))
|
||||
{
|
||||
targetSetting = new StorePickupSetting
|
||||
{
|
||||
StoreId = targetStoreId,
|
||||
RowVersion = CreateRowVersion()
|
||||
};
|
||||
await dbContext.StorePickupSettings.AddAsync(targetSetting, cancellationToken);
|
||||
}
|
||||
|
||||
targetSetting.AllowToday = sourceSetting.AllowToday;
|
||||
targetSetting.AllowDaysAhead = sourceSetting.AllowDaysAhead;
|
||||
targetSetting.DefaultCutoffMinutes = sourceSetting.DefaultCutoffMinutes;
|
||||
targetSetting.MaxQuantityPerOrder = sourceSetting.MaxQuantityPerOrder;
|
||||
targetSetting.Mode = sourceSetting.Mode;
|
||||
targetSetting.FineRuleJson = sourceSetting.FineRuleJson;
|
||||
targetSetting.RowVersion = CreateRowVersion();
|
||||
}
|
||||
|
||||
var targetSlots = await dbContext.StorePickupSlots
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StorePickupSlots.RemoveRange(targetSlots);
|
||||
|
||||
var clonedSlots = accessibleTargetIds
|
||||
.SelectMany(targetStoreId => sourceSlots.Select(slot => new StorePickupSlot
|
||||
{
|
||||
StoreId = targetStoreId,
|
||||
Name = slot.Name,
|
||||
StartTime = slot.StartTime,
|
||||
EndTime = slot.EndTime,
|
||||
CutoffMinutes = slot.CutoffMinutes,
|
||||
Capacity = slot.Capacity,
|
||||
ReservedCount = slot.ReservedCount,
|
||||
Weekdays = slot.Weekdays,
|
||||
IsEnabled = slot.IsEnabled,
|
||||
RowVersion = CreateRowVersion()
|
||||
}))
|
||||
.ToList();
|
||||
|
||||
if (clonedSlots.Count > 0)
|
||||
{
|
||||
await dbContext.StorePickupSlots.AddRangeAsync(clonedSlots, cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ApiResponse<CopyStorePickupSettingsResult>.Ok(new CopyStorePickupSettingsResult
|
||||
{
|
||||
CopiedCount = accessibleTargetIds.Count
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<StorePickupSetting> EnsurePickupSettingAsync(long tenantId, long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var setting = await dbContext.StorePickupSettings
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == storeId, cancellationToken);
|
||||
if (setting is not null)
|
||||
{
|
||||
return setting;
|
||||
}
|
||||
|
||||
setting = new StorePickupSetting
|
||||
{
|
||||
StoreId = storeId,
|
||||
RowVersion = CreateRowVersion()
|
||||
};
|
||||
await dbContext.StorePickupSettings.AddAsync(setting, cancellationToken);
|
||||
return setting;
|
||||
}
|
||||
|
||||
private static byte[] CreateRowVersion()
|
||||
{
|
||||
return RandomNumberGenerator.GetBytes(16);
|
||||
}
|
||||
|
||||
private static StorePickupMode ParseRequiredPickupMode(string? mode)
|
||||
{
|
||||
return (mode ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"big" => StorePickupMode.Big,
|
||||
"fine" => StorePickupMode.Fine,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "mode 非法")
|
||||
};
|
||||
}
|
||||
|
||||
private static PickupFineRuleDto? ParseFineRule(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<PickupFineRuleDto>(raw, StoreApiHelpers.JsonOptions);
|
||||
return parsed is null ? null : NormalizeFineRule(parsed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static PickupFineRuleDto NormalizeFineRule(PickupFineRuleDto source)
|
||||
{
|
||||
var start = StoreApiHelpers.ParseRequiredTime(source.DayStartTime, "fineRule.dayStartTime");
|
||||
var end = StoreApiHelpers.ParseRequiredTime(source.DayEndTime, "fineRule.dayEndTime");
|
||||
|
||||
return new PickupFineRuleDto
|
||||
{
|
||||
IntervalMinutes = Math.Clamp(source.IntervalMinutes, 5, 180),
|
||||
SlotCapacity = Math.Clamp(source.SlotCapacity, 1, 999),
|
||||
DayStartTime = StoreApiHelpers.ToHHmm(start),
|
||||
DayEndTime = StoreApiHelpers.ToHHmm(end),
|
||||
MinAdvanceHours = Math.Clamp(source.MinAdvanceHours, 0, 72),
|
||||
DayOfWeeks = (source.DayOfWeeks ?? [])
|
||||
.Distinct()
|
||||
.Where(x => x is >= 0 and <= 6)
|
||||
.OrderBy(x => x)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static List<PickupPreviewDayDto> BuildPreviewDays(PickupFineRuleDto fineRule)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var startMinutes = StoreApiHelpers.ParseRequiredTime(fineRule.DayStartTime, "fineRule.dayStartTime").Hours * 60
|
||||
+ StoreApiHelpers.ParseRequiredTime(fineRule.DayStartTime, "fineRule.dayStartTime").Minutes;
|
||||
var endMinutes = StoreApiHelpers.ParseRequiredTime(fineRule.DayEndTime, "fineRule.dayEndTime").Hours * 60
|
||||
+ StoreApiHelpers.ParseRequiredTime(fineRule.DayEndTime, "fineRule.dayEndTime").Minutes;
|
||||
if (fineRule.IntervalMinutes <= 0 || endMinutes <= startMinutes)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var results = new List<PickupPreviewDayDto>();
|
||||
for (var offset = 0; offset < 3; offset++)
|
||||
{
|
||||
var date = now.Date.AddDays(offset);
|
||||
var uiDayOfWeek = StoreApiHelpers.DotNetDayOfWeekToUi(date.DayOfWeek);
|
||||
var enabled = fineRule.DayOfWeeks.Contains(uiDayOfWeek);
|
||||
var dateText = StoreApiHelpers.ToDateOnly(date);
|
||||
|
||||
var subLabel = $"{GetWeekdayLabel(uiDayOfWeek)} {(offset == 0 ? "今天" : offset == 1 ? "明天" : "后天")}";
|
||||
var slots = enabled
|
||||
? BuildPreviewSlots(date, startMinutes, endMinutes, fineRule.IntervalMinutes, fineRule.SlotCapacity, fineRule.MinAdvanceHours)
|
||||
: [];
|
||||
|
||||
results.Add(new PickupPreviewDayDto
|
||||
{
|
||||
Date = dateText,
|
||||
Label = $"{date.Month}/{date.Day}",
|
||||
SubLabel = subLabel,
|
||||
Slots = slots
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static List<PickupPreviewSlotDto> BuildPreviewSlots(
|
||||
DateTime date,
|
||||
int startMinutes,
|
||||
int endMinutes,
|
||||
int intervalMinutes,
|
||||
int slotCapacity,
|
||||
int minAdvanceHours)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var normalizedCapacity = Math.Max(0, slotCapacity);
|
||||
var results = new List<PickupPreviewSlotDto>();
|
||||
for (var minutes = startMinutes; minutes <= endMinutes; minutes += intervalMinutes)
|
||||
{
|
||||
var slotHour = minutes / 60;
|
||||
var slotMinute = minutes % 60;
|
||||
var slotTime = new TimeSpan(slotHour, slotMinute, 0);
|
||||
var slotDateTime = date.Date.Add(slotTime);
|
||||
// 当前阶段仅做规则预览,不再伪造预约占用数据。
|
||||
var remaining = normalizedCapacity;
|
||||
var status = slotDateTime <= now.AddHours(minAdvanceHours)
|
||||
? "expired"
|
||||
: remaining == 0
|
||||
? "full"
|
||||
: remaining <= 1
|
||||
? "almost"
|
||||
: "available";
|
||||
|
||||
results.Add(new PickupPreviewSlotDto
|
||||
{
|
||||
Time = $"{slotHour:00}:{slotMinute:00}",
|
||||
RemainingCount = remaining,
|
||||
Status = status
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static string GetWeekdayLabel(int dayOfWeek)
|
||||
{
|
||||
return dayOfWeek switch
|
||||
{
|
||||
0 => "周一",
|
||||
1 => "周二",
|
||||
2 => "周三",
|
||||
3 => "周四",
|
||||
4 => "周五",
|
||||
5 => "周六",
|
||||
6 => "周日",
|
||||
_ => "周一"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,893 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.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.Store;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 门店员工与排班模块。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||
public sealed class StoreStaffController(
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取员工分页列表。
|
||||
/// </summary>
|
||||
[HttpGet("staff")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PaginatedResultDto<StoreStaffItemDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PaginatedResultDto<StoreStaffItemDto>>> List(
|
||||
[FromQuery] string storeId,
|
||||
[FromQuery] string? keyword,
|
||||
[FromQuery] string? roleType,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 10,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var normalizedKeyword = keyword?.Trim();
|
||||
StaffRoleType? normalizedRoleType = string.IsNullOrWhiteSpace(roleType) ? null : StoreApiHelpers.ToStaffRoleType(roleType);
|
||||
StaffStatus? normalizedStatus = string.IsNullOrWhiteSpace(status) ? null : StoreApiHelpers.ToStaffStatus(status);
|
||||
var normalizedPage = Math.Max(1, page);
|
||||
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
|
||||
|
||||
var query = dbContext.MerchantStaff
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
{
|
||||
var lowered = normalizedKeyword.ToLowerInvariant();
|
||||
query = query.Where(x =>
|
||||
x.Name.ToLower().Contains(lowered) ||
|
||||
x.Phone.Contains(normalizedKeyword) ||
|
||||
(x.Email != null && x.Email.ToLower().Contains(lowered)));
|
||||
}
|
||||
|
||||
if (normalizedRoleType.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.RoleType == normalizedRoleType.Value);
|
||||
}
|
||||
|
||||
if (normalizedStatus.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == normalizedStatus.Value);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var staffs = await query
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ThenBy(x => x.Name)
|
||||
.Skip((normalizedPage - 1) * normalizedPageSize)
|
||||
.Take(normalizedPageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var result = new PaginatedResultDto<StoreStaffItemDto>
|
||||
{
|
||||
Items = staffs.Select(MapStaff).ToList(),
|
||||
Total = total,
|
||||
Page = normalizedPage,
|
||||
PageSize = normalizedPageSize
|
||||
};
|
||||
|
||||
return ApiResponse<PaginatedResultDto<StoreStaffItemDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存员工。
|
||||
/// </summary>
|
||||
[HttpPost("staff/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreStaffItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreStaffItemDto>> Save([FromBody] SaveStoreStaffRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
var store = await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var staffId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id);
|
||||
var roleType = StoreApiHelpers.ToStaffRoleType(request.RoleType);
|
||||
var staffStatus = StoreApiHelpers.ToStaffStatus(request.Status);
|
||||
var permissions = NormalizePermissions(request.Permissions, roleType);
|
||||
|
||||
MerchantStaff? entity = null;
|
||||
if (staffId.HasValue)
|
||||
{
|
||||
entity = await dbContext.MerchantStaff.FirstOrDefaultAsync(
|
||||
x => x.Id == staffId.Value && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
entity = new MerchantStaff
|
||||
{
|
||||
MerchantId = store.MerchantId,
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.MerchantStaff.AddAsync(entity, cancellationToken);
|
||||
}
|
||||
|
||||
entity.Name = request.Name.Trim();
|
||||
entity.Phone = request.Phone.Trim();
|
||||
entity.Email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim();
|
||||
entity.RoleType = roleType;
|
||||
entity.Status = staffStatus;
|
||||
entity.PermissionsJson = JsonSerializer.Serialize(permissions, StoreApiHelpers.JsonOptions);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ApiResponse<StoreStaffItemDto>.Ok(MapStaff(entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除员工。
|
||||
/// </summary>
|
||||
[HttpPost("staff/delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Delete([FromBody] DeleteStoreStaffRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var staffId = StoreApiHelpers.ParseRequiredSnowflake(request.StaffId, "staffId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var staff = await dbContext.MerchantStaff.FirstOrDefaultAsync(
|
||||
x => x.Id == staffId && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
if (staff is null)
|
||||
{
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
dbContext.MerchantStaff.Remove(staff);
|
||||
|
||||
var schedules = await dbContext.StoreStaffWeeklySchedules
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId && x.StaffId == staffId)
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StoreStaffWeeklySchedules.RemoveRange(schedules);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取门店排班配置。
|
||||
/// </summary>
|
||||
[HttpGet("staff/schedule")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreStaffScheduleDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreStaffScheduleDto>> GetSchedule(
|
||||
[FromQuery] string storeId,
|
||||
[FromQuery] string? weekStartDate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var template = await GetTemplateDtoAsync(tenantId, parsedStoreId, cancellationToken);
|
||||
var staffs = await dbContext.MerchantStaff
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ThenBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var scheduleRows = await dbContext.StoreStaffWeeklySchedules
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var scheduleMap = scheduleRows
|
||||
.GroupBy(x => x.StaffId)
|
||||
.ToDictionary(x => x.Key, x => x.ToList());
|
||||
|
||||
var schedules = staffs.Select(staff =>
|
||||
{
|
||||
var shifts = scheduleMap.TryGetValue(staff.Id, out var rows)
|
||||
? template is null
|
||||
? []
|
||||
: NormalizeRowsToShifts(rows, template)
|
||||
: [];
|
||||
|
||||
if (staff.Status == StaffStatus.Resigned)
|
||||
{
|
||||
shifts = CreateOffWeekShifts();
|
||||
}
|
||||
|
||||
return new StaffScheduleDto
|
||||
{
|
||||
StaffId = staff.Id.ToString(),
|
||||
Shifts = shifts
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return ApiResponse<StoreStaffScheduleDto>.Ok(new StoreStaffScheduleDto
|
||||
{
|
||||
StoreId = parsedStoreId.ToString(),
|
||||
WeekStartDate = StoreApiHelpers.ResolveWeekStartDate(weekStartDate),
|
||||
Templates = template,
|
||||
IsTemplateConfigured = template is not null,
|
||||
IsScheduleConfigured = scheduleRows.Count > 0,
|
||||
Schedules = schedules
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存班次模板。
|
||||
/// </summary>
|
||||
[HttpPost("staff/template/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreShiftTemplatesDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreShiftTemplatesDto>> SaveTemplate(
|
||||
[FromBody] SaveStoreStaffTemplatesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var normalizedTemplate = NormalizeTemplate(request.Templates);
|
||||
var templateEntity = await dbContext.StoreStaffTemplates.FirstOrDefaultAsync(
|
||||
x => x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
if (templateEntity is null)
|
||||
{
|
||||
templateEntity = new StoreStaffTemplate
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.StoreStaffTemplates.AddAsync(templateEntity, cancellationToken);
|
||||
}
|
||||
|
||||
templateEntity.MorningStartTime = StoreApiHelpers.ParseRequiredTime(normalizedTemplate.Morning.StartTime, "templates.morning.startTime");
|
||||
templateEntity.MorningEndTime = StoreApiHelpers.ParseRequiredTime(normalizedTemplate.Morning.EndTime, "templates.morning.endTime");
|
||||
templateEntity.EveningStartTime = StoreApiHelpers.ParseRequiredTime(normalizedTemplate.Evening.StartTime, "templates.evening.startTime");
|
||||
templateEntity.EveningEndTime = StoreApiHelpers.ParseRequiredTime(normalizedTemplate.Evening.EndTime, "templates.evening.endTime");
|
||||
templateEntity.FullStartTime = StoreApiHelpers.ParseRequiredTime(normalizedTemplate.Full.StartTime, "templates.full.startTime");
|
||||
templateEntity.FullEndTime = StoreApiHelpers.ParseRequiredTime(normalizedTemplate.Full.EndTime, "templates.full.endTime");
|
||||
|
||||
var scheduleRows = await dbContext.StoreStaffWeeklySchedules
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var row in scheduleRows)
|
||||
{
|
||||
var (start, end) = ResolveShiftTimeRange(row.ShiftType, normalizedTemplate);
|
||||
row.StartTime = start;
|
||||
row.EndTime = end;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<StoreShiftTemplatesDto>.Ok(normalizedTemplate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存单员工排班。
|
||||
/// </summary>
|
||||
[HttpPost("staff/schedule/personal/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StaffScheduleDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StaffScheduleDto>> SavePersonalSchedule(
|
||||
[FromBody] SaveStoreStaffPersonalScheduleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var parsedStaffId = StoreApiHelpers.ParseRequiredSnowflake(request.StaffId, "staffId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var staff = await dbContext.MerchantStaff.FirstOrDefaultAsync(
|
||||
x => x.Id == parsedStaffId && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
if (staff is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "员工不存在");
|
||||
}
|
||||
|
||||
var template = await GetTemplateDtoAsync(tenantId, parsedStoreId, cancellationToken);
|
||||
if (template is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "请先配置班次模板");
|
||||
}
|
||||
var existingRows = await dbContext.StoreStaffWeeklySchedules
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId && x.StaffId == parsedStaffId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var fallback = NormalizeRowsToShifts(existingRows, template);
|
||||
var shifts = staff.Status == StaffStatus.Resigned
|
||||
? CreateOffWeekShifts()
|
||||
: NormalizeShifts(request.Shifts, fallback, template);
|
||||
|
||||
var nextRows = ToWeeklyEntities(parsedStoreId, parsedStaffId, shifts, template).ToList();
|
||||
await ReplaceWeeklySchedulesAsync(
|
||||
dbContext.StoreStaffWeeklySchedules
|
||||
.Where(x =>
|
||||
x.TenantId == tenantId &&
|
||||
x.StoreId == parsedStoreId &&
|
||||
x.StaffId == parsedStaffId),
|
||||
nextRows,
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<StaffScheduleDto>.Ok(new StaffScheduleDto
|
||||
{
|
||||
StaffId = parsedStaffId.ToString(),
|
||||
Shifts = shifts
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存门店周排班。
|
||||
/// </summary>
|
||||
[HttpPost("staff/schedule/weekly/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreStaffScheduleDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreStaffScheduleDto>> SaveWeeklySchedule(
|
||||
[FromBody] SaveStoreStaffWeeklyScheduleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var staffs = await dbContext.MerchantStaff
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ThenBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
var staffMap = staffs.ToDictionary(x => x.Id);
|
||||
|
||||
var template = await GetTemplateDtoAsync(tenantId, parsedStoreId, cancellationToken);
|
||||
if (template is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "请先配置班次模板");
|
||||
}
|
||||
var existingRows = await dbContext.StoreStaffWeeklySchedules
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingMap = existingRows
|
||||
.GroupBy(x => x.StaffId)
|
||||
.ToDictionary(
|
||||
x => x.Key,
|
||||
x => NormalizeRowsToShifts(x.ToList(), template));
|
||||
|
||||
var incomingMap = new Dictionary<long, List<StaffDayShiftDto>>();
|
||||
foreach (var schedule in request.Schedules ?? [])
|
||||
{
|
||||
var staffId = StoreApiHelpers.ParseSnowflakeOrNull(schedule.StaffId);
|
||||
if (!staffId.HasValue || !staffMap.TryGetValue(staffId.Value, out var staff))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (staff.Status == StaffStatus.Resigned)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fallback = existingMap.GetValueOrDefault(staffId.Value) ?? [];
|
||||
incomingMap[staffId.Value] = NormalizeShifts(schedule.Shifts, fallback, template);
|
||||
}
|
||||
|
||||
var finalSchedules = new List<StaffScheduleDto>();
|
||||
foreach (var staff in staffs)
|
||||
{
|
||||
List<StaffDayShiftDto> shifts;
|
||||
if (staff.Status == StaffStatus.Resigned)
|
||||
{
|
||||
shifts = CreateOffWeekShifts();
|
||||
}
|
||||
else if (incomingMap.TryGetValue(staff.Id, out var incomingShifts))
|
||||
{
|
||||
shifts = incomingShifts;
|
||||
}
|
||||
else
|
||||
{
|
||||
shifts = existingMap.GetValueOrDefault(staff.Id) ?? [];
|
||||
}
|
||||
|
||||
finalSchedules.Add(new StaffScheduleDto
|
||||
{
|
||||
StaffId = staff.Id.ToString(),
|
||||
Shifts = shifts
|
||||
});
|
||||
}
|
||||
|
||||
var entities = finalSchedules
|
||||
.SelectMany(x => ToWeeklyEntities(parsedStoreId, long.Parse(x.StaffId), x.Shifts, template))
|
||||
.ToList();
|
||||
await ReplaceWeeklySchedulesAsync(
|
||||
dbContext.StoreStaffWeeklySchedules
|
||||
.Where(x =>
|
||||
x.TenantId == tenantId &&
|
||||
x.StoreId == parsedStoreId),
|
||||
entities,
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<StoreStaffScheduleDto>.Ok(new StoreStaffScheduleDto
|
||||
{
|
||||
StoreId = parsedStoreId.ToString(),
|
||||
WeekStartDate = StoreApiHelpers.ResolveWeekStartDate(null),
|
||||
Templates = template,
|
||||
IsTemplateConfigured = true,
|
||||
IsScheduleConfigured = entities.Count > 0,
|
||||
Schedules = finalSchedules
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制班次模板与排班。
|
||||
/// </summary>
|
||||
[HttpPost("staff/copy")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CopyStoreStaffScheduleResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CopyStoreStaffScheduleResult>> Copy([FromBody] CopyStoreStaffScheduleRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.SourceStoreId, "sourceStoreId");
|
||||
var targetStoreIds = StoreApiHelpers.ParseSnowflakeList(request.TargetStoreIds);
|
||||
var copyScope = string.IsNullOrWhiteSpace(request.CopyScope) ? "template_and_schedule" : request.CopyScope;
|
||||
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, sourceStoreId, cancellationToken);
|
||||
|
||||
if (!string.Equals(copyScope, "template_and_schedule", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ApiResponse<CopyStoreStaffScheduleResult>.Ok(new CopyStoreStaffScheduleResult
|
||||
{
|
||||
CopiedCount = 0,
|
||||
CopyScope = "template_and_schedule"
|
||||
});
|
||||
}
|
||||
|
||||
var accessibleTargetIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
targetStoreIds,
|
||||
cancellationToken);
|
||||
accessibleTargetIds.Remove(sourceStoreId);
|
||||
|
||||
if (accessibleTargetIds.Count == 0)
|
||||
{
|
||||
return ApiResponse<CopyStoreStaffScheduleResult>.Ok(new CopyStoreStaffScheduleResult
|
||||
{
|
||||
CopiedCount = 0,
|
||||
CopyScope = "template_and_schedule"
|
||||
});
|
||||
}
|
||||
|
||||
var sourceTemplate = await GetTemplateDtoAsync(tenantId, sourceStoreId, cancellationToken);
|
||||
if (sourceTemplate is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "源门店未配置班次模板,无法复制");
|
||||
}
|
||||
var sourceRows = await dbContext.StoreStaffWeeklySchedules
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var sourceStaffs = await dbContext.MerchantStaff
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var sourceStaffMap = sourceStaffs.ToDictionary(x => x.Id);
|
||||
var sourceScheduleMap = sourceRows
|
||||
.GroupBy(x => x.StaffId)
|
||||
.ToDictionary(
|
||||
x => x.Key,
|
||||
x => NormalizeRowsToShifts(x.ToList(), sourceTemplate));
|
||||
var sourceScheduleSequence = sourceStaffs
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ThenBy(x => x.Name)
|
||||
.ThenBy(x => x.Id)
|
||||
.Select(staff => staff.Status == StaffStatus.Resigned
|
||||
? CreateOffWeekShifts()
|
||||
: sourceScheduleMap.GetValueOrDefault(staff.Id) ?? [])
|
||||
.ToList();
|
||||
|
||||
var targetTemplates = await dbContext.StoreStaffTemplates
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
var targetTemplateMap = targetTemplates.ToDictionary(x => x.StoreId);
|
||||
|
||||
foreach (var targetStoreId in accessibleTargetIds)
|
||||
{
|
||||
if (!targetTemplateMap.TryGetValue(targetStoreId, out var targetTemplateEntity))
|
||||
{
|
||||
targetTemplateEntity = new StoreStaffTemplate
|
||||
{
|
||||
StoreId = targetStoreId
|
||||
};
|
||||
await dbContext.StoreStaffTemplates.AddAsync(targetTemplateEntity, cancellationToken);
|
||||
}
|
||||
|
||||
targetTemplateEntity.MorningStartTime = StoreApiHelpers.ParseRequiredTime(sourceTemplate.Morning.StartTime, "templates.morning.startTime");
|
||||
targetTemplateEntity.MorningEndTime = StoreApiHelpers.ParseRequiredTime(sourceTemplate.Morning.EndTime, "templates.morning.endTime");
|
||||
targetTemplateEntity.EveningStartTime = StoreApiHelpers.ParseRequiredTime(sourceTemplate.Evening.StartTime, "templates.evening.startTime");
|
||||
targetTemplateEntity.EveningEndTime = StoreApiHelpers.ParseRequiredTime(sourceTemplate.Evening.EndTime, "templates.evening.endTime");
|
||||
targetTemplateEntity.FullStartTime = StoreApiHelpers.ParseRequiredTime(sourceTemplate.Full.StartTime, "templates.full.startTime");
|
||||
targetTemplateEntity.FullEndTime = StoreApiHelpers.ParseRequiredTime(sourceTemplate.Full.EndTime, "templates.full.endTime");
|
||||
}
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var targetStaffs = await dbContext.MerchantStaff
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId.HasValue && accessibleTargetIds.Contains(x.StoreId.Value))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var entities = new List<StoreStaffWeeklySchedule>();
|
||||
foreach (var targetStoreId in accessibleTargetIds)
|
||||
{
|
||||
var targetStoreStaffs = targetStaffs
|
||||
.Where(x => x.StoreId == targetStoreId)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ThenBy(x => x.Name)
|
||||
.ThenBy(x => x.Id)
|
||||
.ToList();
|
||||
|
||||
for (var index = 0; index < targetStoreStaffs.Count; index++)
|
||||
{
|
||||
var staff = targetStoreStaffs[index];
|
||||
var shifts = staff.Status == StaffStatus.Resigned
|
||||
? CreateOffWeekShifts()
|
||||
: sourceScheduleSequence.Count > 0
|
||||
? sourceScheduleSequence[index % sourceScheduleSequence.Count]
|
||||
: [];
|
||||
|
||||
entities.AddRange(ToWeeklyEntities(targetStoreId, staff.Id, shifts, sourceTemplate));
|
||||
}
|
||||
}
|
||||
|
||||
await ReplaceWeeklySchedulesAsync(
|
||||
dbContext.StoreStaffWeeklySchedules
|
||||
.Where(x =>
|
||||
x.TenantId == tenantId &&
|
||||
accessibleTargetIds.Contains(x.StoreId)),
|
||||
entities,
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<CopyStoreStaffScheduleResult>.Ok(new CopyStoreStaffScheduleResult
|
||||
{
|
||||
CopiedCount = accessibleTargetIds.Count,
|
||||
CopyScope = "template_and_schedule"
|
||||
});
|
||||
}
|
||||
private static StoreStaffItemDto MapStaff(MerchantStaff source)
|
||||
{
|
||||
var permissions = ParsePermissions(source.PermissionsJson, source.RoleType);
|
||||
var hiredAt = source.CreatedAt == default ? DateTime.UtcNow : source.CreatedAt;
|
||||
|
||||
return new StoreStaffItemDto
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
Phone = source.Phone,
|
||||
Email = source.Email ?? string.Empty,
|
||||
RoleType = StoreApiHelpers.ToStaffRoleTypeText(source.RoleType),
|
||||
Status = StoreApiHelpers.ToStaffStatusText(source.Status),
|
||||
Permissions = permissions,
|
||||
AvatarColor = StoreApiHelpers.ResolveAvatarColor($"{source.Name}-{source.Id}"),
|
||||
HiredAt = StoreApiHelpers.ToDateOnly(hiredAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string> ParsePermissions(string? rawJson, StaffRoleType roleType)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(rawJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<List<string>>(rawJson, StoreApiHelpers.JsonOptions);
|
||||
if (parsed is not null)
|
||||
{
|
||||
return NormalizePermissions(parsed, roleType);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略权限反序列化异常并回落默认值
|
||||
}
|
||||
}
|
||||
|
||||
return roleType is StaffRoleType.Admin or StaffRoleType.Operator ? ["全部权限"] : [];
|
||||
}
|
||||
|
||||
private static List<string> NormalizePermissions(IEnumerable<string>? source, StaffRoleType roleType)
|
||||
{
|
||||
var normalized = (source ?? [])
|
||||
.Select(x => x?.Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(x => x!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (roleType is StaffRoleType.Admin or StaffRoleType.Operator && normalized.Count == 0)
|
||||
{
|
||||
normalized.Add("全部权限");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private async Task<StoreShiftTemplatesDto?> GetTemplateDtoAsync(long tenantId, long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await dbContext.StoreStaffTemplates
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == storeId, cancellationToken);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new StoreShiftTemplatesDto
|
||||
{
|
||||
Morning = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = StoreApiHelpers.ToHHmm(entity.MorningStartTime),
|
||||
EndTime = StoreApiHelpers.ToHHmm(entity.MorningEndTime)
|
||||
},
|
||||
Evening = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = StoreApiHelpers.ToHHmm(entity.EveningStartTime),
|
||||
EndTime = StoreApiHelpers.ToHHmm(entity.EveningEndTime)
|
||||
},
|
||||
Full = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = StoreApiHelpers.ToHHmm(entity.FullStartTime),
|
||||
EndTime = StoreApiHelpers.ToHHmm(entity.FullEndTime)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static StoreShiftTemplatesDto NormalizeTemplate(StoreShiftTemplatesDto source)
|
||||
{
|
||||
source ??= new StoreShiftTemplatesDto();
|
||||
return new StoreShiftTemplatesDto
|
||||
{
|
||||
Morning = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = NormalizeTime(source.Morning.StartTime, string.Empty),
|
||||
EndTime = NormalizeTime(source.Morning.EndTime, string.Empty)
|
||||
},
|
||||
Evening = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = NormalizeTime(source.Evening.StartTime, string.Empty),
|
||||
EndTime = NormalizeTime(source.Evening.EndTime, string.Empty)
|
||||
},
|
||||
Full = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = NormalizeTime(source.Full.StartTime, string.Empty),
|
||||
EndTime = NormalizeTime(source.Full.EndTime, string.Empty)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static List<StaffDayShiftDto> NormalizeRowsToShifts(
|
||||
List<StoreStaffWeeklySchedule> rows,
|
||||
StoreShiftTemplatesDto template)
|
||||
{
|
||||
var results = new List<StaffDayShiftDto>();
|
||||
foreach (var row in rows
|
||||
.Where(x => x.DayOfWeek is >= 0 and <= 6)
|
||||
.OrderBy(x => x.DayOfWeek))
|
||||
{
|
||||
var shiftType = StoreApiHelpers.ToShiftTypeText(row.ShiftType);
|
||||
if (row.ShiftType == StoreStaffShiftType.Off)
|
||||
{
|
||||
results.Add(new StaffDayShiftDto
|
||||
{
|
||||
DayOfWeek = row.DayOfWeek,
|
||||
ShiftType = shiftType,
|
||||
StartTime = string.Empty,
|
||||
EndTime = string.Empty
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var (defaultStart, defaultEnd) = ResolveShiftTimeRange(row.ShiftType, template);
|
||||
results.Add(new StaffDayShiftDto
|
||||
{
|
||||
DayOfWeek = row.DayOfWeek,
|
||||
ShiftType = shiftType,
|
||||
StartTime = StoreApiHelpers.ToHHmm(row.StartTime ?? defaultStart ?? TimeSpan.Zero),
|
||||
EndTime = StoreApiHelpers.ToHHmm(row.EndTime ?? defaultEnd ?? TimeSpan.Zero)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static List<StaffDayShiftDto> NormalizeShifts(
|
||||
IEnumerable<StaffDayShiftDto>? source,
|
||||
List<StaffDayShiftDto> fallback,
|
||||
StoreShiftTemplatesDto template)
|
||||
{
|
||||
var inputMap = (source ?? [])
|
||||
.Where(x => x.DayOfWeek is >= 0 and <= 6)
|
||||
.GroupBy(x => x.DayOfWeek)
|
||||
.ToDictionary(x => x.Key, x => x.Last());
|
||||
|
||||
var normalized = new List<StaffDayShiftDto>();
|
||||
for (var day = 0; day < 7; day++)
|
||||
{
|
||||
var fallbackShift = fallback.FirstOrDefault(x => x.DayOfWeek == day);
|
||||
|
||||
if (!inputMap.TryGetValue(day, out var input))
|
||||
{
|
||||
if (fallbackShift is null) continue;
|
||||
normalized.Add(new StaffDayShiftDto
|
||||
{
|
||||
DayOfWeek = day,
|
||||
ShiftType = fallbackShift.ShiftType,
|
||||
StartTime = fallbackShift.StartTime,
|
||||
EndTime = fallbackShift.EndTime
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var shiftType = StoreApiHelpers.ToShiftType(input.ShiftType);
|
||||
if (shiftType == StoreStaffShiftType.Off)
|
||||
{
|
||||
normalized.Add(new StaffDayShiftDto
|
||||
{
|
||||
DayOfWeek = day,
|
||||
ShiftType = "off",
|
||||
StartTime = string.Empty,
|
||||
EndTime = string.Empty
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var (defaultStart, defaultEnd) = ResolveShiftTimeRange(shiftType, template);
|
||||
normalized.Add(new StaffDayShiftDto
|
||||
{
|
||||
DayOfWeek = day,
|
||||
ShiftType = StoreApiHelpers.ToShiftTypeText(shiftType),
|
||||
StartTime = NormalizeTime(
|
||||
input.StartTime,
|
||||
defaultStart.HasValue
|
||||
? StoreApiHelpers.ToHHmm(defaultStart.Value)
|
||||
: fallbackShift?.StartTime ?? string.Empty),
|
||||
EndTime = NormalizeTime(
|
||||
input.EndTime,
|
||||
defaultEnd.HasValue
|
||||
? StoreApiHelpers.ToHHmm(defaultEnd.Value)
|
||||
: fallbackShift?.EndTime ?? string.Empty)
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IEnumerable<StoreStaffWeeklySchedule> ToWeeklyEntities(
|
||||
long storeId,
|
||||
long staffId,
|
||||
IEnumerable<StaffDayShiftDto> shifts,
|
||||
StoreShiftTemplatesDto template)
|
||||
{
|
||||
foreach (var shift in shifts
|
||||
.Where(x => x.DayOfWeek is >= 0 and <= 6)
|
||||
.GroupBy(x => x.DayOfWeek)
|
||||
.Select(x => x.Last())
|
||||
.OrderBy(x => x.DayOfWeek))
|
||||
{
|
||||
var shiftType = StoreApiHelpers.ToShiftType(shift.ShiftType);
|
||||
var (defaultStart, defaultEnd) = ResolveShiftTimeRange(shiftType, template);
|
||||
var startTime = shiftType == StoreStaffShiftType.Off
|
||||
? null
|
||||
: (TimeSpan?)StoreApiHelpers.ParseRequiredTime(
|
||||
NormalizeTime(shift.StartTime, defaultStart.HasValue ? StoreApiHelpers.ToHHmm(defaultStart.Value) : string.Empty),
|
||||
"shift.startTime");
|
||||
var endTime = shiftType == StoreStaffShiftType.Off
|
||||
? null
|
||||
: (TimeSpan?)StoreApiHelpers.ParseRequiredTime(
|
||||
NormalizeTime(shift.EndTime, defaultEnd.HasValue ? StoreApiHelpers.ToHHmm(defaultEnd.Value) : string.Empty),
|
||||
"shift.endTime");
|
||||
|
||||
yield return new StoreStaffWeeklySchedule
|
||||
{
|
||||
StoreId = storeId,
|
||||
StaffId = staffId,
|
||||
DayOfWeek = shift.DayOfWeek,
|
||||
ShiftType = shiftType,
|
||||
StartTime = startTime,
|
||||
EndTime = endTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReplaceWeeklySchedulesAsync(
|
||||
IQueryable<StoreStaffWeeklySchedule> deleteQuery,
|
||||
IReadOnlyCollection<StoreStaffWeeklySchedule> nextRows,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var nextRowPayloads = nextRows
|
||||
.Select(x => new
|
||||
{
|
||||
x.StoreId,
|
||||
x.StaffId,
|
||||
x.DayOfWeek,
|
||||
x.ShiftType,
|
||||
x.StartTime,
|
||||
x.EndTime
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var executionStrategy = dbContext.Database.CreateExecutionStrategy();
|
||||
await executionStrategy.ExecuteAsync(async () =>
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
await deleteQuery.IgnoreQueryFilters().ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
if (nextRowPayloads.Count > 0)
|
||||
{
|
||||
var insertRows = nextRowPayloads.Select(x => new StoreStaffWeeklySchedule
|
||||
{
|
||||
StoreId = x.StoreId,
|
||||
StaffId = x.StaffId,
|
||||
DayOfWeek = x.DayOfWeek,
|
||||
ShiftType = x.ShiftType,
|
||||
StartTime = x.StartTime,
|
||||
EndTime = x.EndTime
|
||||
}).ToList();
|
||||
|
||||
await dbContext.StoreStaffWeeklySchedules.AddRangeAsync(insertRows, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
});
|
||||
}
|
||||
|
||||
private static List<StaffDayShiftDto> CreateOffWeekShifts()
|
||||
{
|
||||
return Enumerable.Range(0, 7).Select(day => new StaffDayShiftDto
|
||||
{
|
||||
DayOfWeek = day,
|
||||
ShiftType = "off",
|
||||
StartTime = string.Empty,
|
||||
EndTime = string.Empty
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static (TimeSpan? Start, TimeSpan? End) ResolveShiftTimeRange(StoreStaffShiftType shiftType, StoreShiftTemplatesDto template)
|
||||
{
|
||||
return shiftType switch
|
||||
{
|
||||
StoreStaffShiftType.Morning => (
|
||||
StoreApiHelpers.ParseRequiredTime(template.Morning.StartTime, "templates.morning.startTime"),
|
||||
StoreApiHelpers.ParseRequiredTime(template.Morning.EndTime, "templates.morning.endTime")),
|
||||
StoreStaffShiftType.Evening => (
|
||||
StoreApiHelpers.ParseRequiredTime(template.Evening.StartTime, "templates.evening.startTime"),
|
||||
StoreApiHelpers.ParseRequiredTime(template.Evening.EndTime, "templates.evening.endTime")),
|
||||
StoreStaffShiftType.Full => (
|
||||
StoreApiHelpers.ParseRequiredTime(template.Full.StartTime, "templates.full.startTime"),
|
||||
StoreApiHelpers.ParseRequiredTime(template.Full.EndTime, "templates.full.endTime")),
|
||||
_ => (null, null)
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeTime(string? value, string fallback)
|
||||
{
|
||||
return TimeSpan.TryParseExact(value, "hh\\:mm", null, out var parsed)
|
||||
? StoreApiHelpers.ToHHmm(parsed)
|
||||
: fallback;
|
||||
}
|
||||
}
|
||||
66
src/Api/TakeoutSaaS.TenantApi/Hubs/OrderBoardHub.cs
Normal file
66
src/Api/TakeoutSaaS.TenantApi/Hubs/OrderBoardHub.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// 订单大厅实时推送 Hub(只读,所有写操作走 HTTP API)。
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public sealed class OrderBoardHub : Hub
|
||||
{
|
||||
/// <summary>
|
||||
/// 连接建立时,自动加入门店 Group。
|
||||
/// </summary>
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
// 1. 从 JWT claims 提取 tenant_id
|
||||
var tenantId = Context.User?.FindFirst("tenant_id")?.Value;
|
||||
var storeId = Context.GetHttpContext()?.Request.Query["storeId"].ToString();
|
||||
|
||||
// 2. 加入门店 Group
|
||||
if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(storeId))
|
||||
{
|
||||
var group = $"store:{tenantId}:{storeId}";
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, group);
|
||||
Context.Items["currentGroup"] = group;
|
||||
}
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 连接断开时,移出 Group。
|
||||
/// </summary>
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
// 1. 移出当前 Group
|
||||
if (Context.Items.TryGetValue("currentGroup", out var groupObj) && groupObj is string group)
|
||||
{
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, group);
|
||||
}
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 切换门店时调用,移出旧 Group 加入新 Group。
|
||||
/// </summary>
|
||||
public async Task JoinStore(string storeId)
|
||||
{
|
||||
// 1. 移出旧 Group
|
||||
if (Context.Items.TryGetValue("currentGroup", out var oldGroupObj) && oldGroupObj is string oldGroup)
|
||||
{
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, oldGroup);
|
||||
}
|
||||
|
||||
// 2. 加入新 Group
|
||||
var tenantId = Context.User?.FindFirst("tenant_id")?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(storeId))
|
||||
{
|
||||
var newGroup = $"store:{tenantId}:{storeId}";
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, newGroup);
|
||||
Context.Items["currentGroup"] = newGroup;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/Api/TakeoutSaaS.TenantApi/Options/TencentMapOptions.cs
Normal file
32
src/Api/TakeoutSaaS.TenantApi/Options/TencentMapOptions.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.TenantApi.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯地图 WebService 配置。
|
||||
/// </summary>
|
||||
public sealed class TencentMapOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置节名称。
|
||||
/// </summary>
|
||||
public const string SectionName = "TencentMap";
|
||||
|
||||
/// <summary>
|
||||
/// WebService 基础地址。
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = "https://apis.map.qq.com";
|
||||
|
||||
/// <summary>
|
||||
/// 地理编码路径。
|
||||
/// </summary>
|
||||
public string GeocoderPath { get; set; } = "/ws/geocoder/v1/";
|
||||
|
||||
/// <summary>
|
||||
/// WebService Key。
|
||||
/// </summary>
|
||||
public string WebServiceKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// WebService SecretKey(SK)。
|
||||
/// </summary>
|
||||
public string WebServiceSecret { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,27 +1,43 @@
|
||||
using Asp.Versioning;
|
||||
using Asp.Versioning.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
using TakeoutSaaS.Shared.Web.Filters;
|
||||
using TakeoutSaaS.Shared.Web.Security;
|
||||
using MassTransit;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
using StackExchange.Redis;
|
||||
using TakeoutSaaS.Application.App.Common.Geo;
|
||||
using TakeoutSaaS.Application.App.Extensions;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Options;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
using TakeoutSaaS.Application.Dictionary.Extensions;
|
||||
using TakeoutSaaS.Application.Identity.Extensions;
|
||||
using TakeoutSaaS.Application.Messaging.Extensions;
|
||||
using TakeoutSaaS.Application.Sms.Extensions;
|
||||
using TakeoutSaaS.Application.Storage.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.App.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Extensions;
|
||||
using TakeoutSaaS.Module.Messaging.Extensions;
|
||||
using TakeoutSaaS.Module.Messaging.Options;
|
||||
using TakeoutSaaS.Module.Scheduler.Extensions;
|
||||
using TakeoutSaaS.Module.Sms.Extensions;
|
||||
using TakeoutSaaS.Module.Storage.Extensions;
|
||||
using TakeoutSaaS.Module.Tenancy.Extensions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
using TakeoutSaaS.Shared.Web.Extensions;
|
||||
using TakeoutSaaS.Shared.Web.Filters;
|
||||
using TakeoutSaaS.Shared.Web.Security;
|
||||
using TakeoutSaaS.Shared.Web.Swagger;
|
||||
using TakeoutSaaS.TenantApi.Consumers;
|
||||
using TakeoutSaaS.TenantApi.Hubs;
|
||||
using TakeoutSaaS.TenantApi.Options;
|
||||
using TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
// 1. 创建构建器与日志模板
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -91,8 +107,22 @@ builder.Services.AddAuthorization();
|
||||
builder.Services.AddPermissionAuthorization();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// 5.1 注册 SignalR + Redis Backplane
|
||||
var signalRBuilder = builder.Services.AddSignalR()
|
||||
.AddJsonProtocol(options =>
|
||||
{
|
||||
options.PayloadSerializerOptions.Converters.Add(new SnowflakeIdJsonConverter());
|
||||
});
|
||||
var redisConn = builder.Configuration.GetConnectionString("Redis");
|
||||
if (!string.IsNullOrWhiteSpace(redisConn))
|
||||
{
|
||||
signalRBuilder.AddStackExchangeRedis(redisConn, opt =>
|
||||
opt.Configuration.ChannelPrefix = RedisChannel.Literal("takeout-signalr"));
|
||||
}
|
||||
|
||||
// 6. 注册应用层与基础设施(仅租户侧所需)
|
||||
builder.Services.AddAppApplication();
|
||||
builder.Services.AddSmsApplication(builder.Configuration);
|
||||
builder.Services.AddIdentityApplication(enableMiniSupport: false);
|
||||
builder.Services.AddAppInfrastructure(builder.Configuration);
|
||||
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
|
||||
@@ -100,15 +130,74 @@ builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeat
|
||||
// 7. 注册多租户解析(依赖 ITenantRepository,需在 Infrastructure 之后)
|
||||
builder.Services.AddTenantResolution(builder.Configuration);
|
||||
|
||||
// 6. (空行后) 注册字典模块(系统参数、字典项、缓存等)
|
||||
// 8. 注册字典模块(系统参数、字典项、缓存等)
|
||||
builder.Services.AddDictionaryApplication();
|
||||
builder.Services.AddDictionaryInfrastructure(builder.Configuration);
|
||||
|
||||
// 6. (空行后) 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现)
|
||||
// 9. 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现)
|
||||
builder.Services.AddMessagingApplication();
|
||||
builder.Services.AddMessagingModule(builder.Configuration);
|
||||
builder.Services.AddSmsModule(builder.Configuration);
|
||||
builder.Services.AddMassTransit(configurator =>
|
||||
{
|
||||
// 注册 SignalR 推送消费者
|
||||
configurator.AddConsumer<OrderCreatedConsumer>();
|
||||
configurator.AddConsumer<OrderStatusChangedConsumer>();
|
||||
configurator.AddConsumer<OrderUrgeConsumer>();
|
||||
configurator.AddConsumer<PaymentSucceededConsumer>();
|
||||
|
||||
// 7. 配置 OpenTelemetry 采集
|
||||
configurator.AddEntityFrameworkOutbox<TakeoutAppDbContext>(outbox =>
|
||||
{
|
||||
outbox.UsePostgres();
|
||||
outbox.UseBusOutbox();
|
||||
});
|
||||
|
||||
configurator.UsingRabbitMq((context, cfg) =>
|
||||
{
|
||||
var options = builder.Configuration.GetSection("RabbitMQ").Get<RabbitMqOptions>()
|
||||
?? throw new InvalidOperationException("缺少 RabbitMQ 配置。");
|
||||
|
||||
var virtualHost = string.IsNullOrWhiteSpace(options.VirtualHost) ? "/" : options.VirtualHost.Trim();
|
||||
var virtualHostPath = virtualHost == "/" ? "/" : $"/{virtualHost.TrimStart('/')}";
|
||||
var hostUri = new Uri($"rabbitmq://{options.Host}:{options.Port}{virtualHostPath}");
|
||||
cfg.Host(hostUri, host =>
|
||||
{
|
||||
host.Username(options.Username);
|
||||
host.Password(options.Password);
|
||||
});
|
||||
|
||||
cfg.PrefetchCount = options.PrefetchCount;
|
||||
cfg.ConfigureEndpoints(context);
|
||||
});
|
||||
});
|
||||
builder.Services.AddStorageModule(builder.Configuration);
|
||||
builder.Services.AddStorageApplication();
|
||||
builder.Services.AddSchedulerModule(builder.Configuration);
|
||||
builder.Services.AddOptions<MemberMessagingOptions>()
|
||||
.Bind(builder.Configuration.GetSection("MemberMessaging"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddHttpClient<IMemberMessageWeChatSender, MemberMessageWeChatSender>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.weixin.qq.com/");
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
});
|
||||
builder.Services.AddScoped<MemberMessageReachDispatchJobRunner>();
|
||||
|
||||
// 9.1 注册腾讯地图地理编码服务(服务端签名)
|
||||
builder.Services.Configure<TencentMapOptions>(builder.Configuration.GetSection(TencentMapOptions.SectionName));
|
||||
builder.Services.AddHttpClient(TencentMapGeocodingService.HttpClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(8);
|
||||
});
|
||||
builder.Services.AddScoped<TencentMapGeocodingService>();
|
||||
builder.Services.AddScoped<IAddressGeocodingService>(provider => provider.GetRequiredService<TencentMapGeocodingService>());
|
||||
builder.Services.AddScoped<GeoLocationOrchestrator>();
|
||||
builder.Services.AddScoped<ProductSkuSaveService>();
|
||||
builder.Services.AddScoped<ProductSkuSaveJobRunner>();
|
||||
builder.Services.AddHostedService<GeoLocationRetryBackgroundService>();
|
||||
|
||||
// 10. 配置 OpenTelemetry 采集
|
||||
var otelSection = builder.Configuration.GetSection("Otel");
|
||||
var otelEndpoint = otelSection.GetValue<string>("Endpoint");
|
||||
var useConsoleExporter = otelSection.GetValue<bool?>("UseConsoleExporter") ?? builder.Environment.IsDevelopment();
|
||||
@@ -124,7 +213,6 @@ builder.Services.AddOpenTelemetry()
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddEntityFrameworkCoreInstrumentation();
|
||||
// 1. (空行后) 配置 OTLP 导出
|
||||
if (!string.IsNullOrWhiteSpace(otelEndpoint))
|
||||
{
|
||||
tracing.AddOtlpExporter(exporter =>
|
||||
@@ -132,7 +220,6 @@ builder.Services.AddOpenTelemetry()
|
||||
exporter.Endpoint = new Uri(otelEndpoint);
|
||||
});
|
||||
}
|
||||
// 2. (空行后) 配置 Console 导出
|
||||
if (useConsoleExporter)
|
||||
{
|
||||
tracing.AddConsoleExporter();
|
||||
@@ -145,7 +232,6 @@ builder.Services.AddOpenTelemetry()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation()
|
||||
.AddPrometheusExporter();
|
||||
// 1. (空行后) 配置 OTLP 导出
|
||||
if (!string.IsNullOrWhiteSpace(otelEndpoint))
|
||||
{
|
||||
metrics.AddOtlpExporter(exporter =>
|
||||
@@ -153,14 +239,13 @@ builder.Services.AddOpenTelemetry()
|
||||
exporter.Endpoint = new Uri(otelEndpoint);
|
||||
});
|
||||
}
|
||||
// 2. (空行后) 配置 Console 导出
|
||||
if (useConsoleExporter)
|
||||
{
|
||||
metrics.AddConsoleExporter();
|
||||
}
|
||||
});
|
||||
|
||||
// 8. 配置 CORS
|
||||
// 11. 配置 CORS
|
||||
var tenantOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Tenant");
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
@@ -170,7 +255,7 @@ builder.Services.AddCors(options =>
|
||||
});
|
||||
});
|
||||
|
||||
// 9. 构建应用并配置中间件管道
|
||||
// 12. 构建应用并配置中间件管道
|
||||
var app = builder.Build();
|
||||
app.UseCors("TenantApiCors");
|
||||
|
||||
@@ -185,6 +270,7 @@ app.UseSharedWebCore();
|
||||
|
||||
// 4. (空行后) 执行授权
|
||||
app.UseAuthorization();
|
||||
app.UseSchedulerDashboard(builder.Configuration);
|
||||
|
||||
// 5. (空行后) 开发环境启用 Swagger
|
||||
if (app.Environment.IsDevelopment())
|
||||
@@ -193,10 +279,11 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
app.MapHealthChecks("/healthz");
|
||||
app.MapPrometheusScrapingEndpoint();
|
||||
app.MapHub<OrderBoardHub>("/hubs/order-board");
|
||||
app.MapControllers();
|
||||
app.Run();
|
||||
|
||||
// 10. 解析配置中的 CORS 域名
|
||||
// 13. 解析配置中的 CORS 域名
|
||||
static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey)
|
||||
{
|
||||
var origins = configuration.GetSection(sectionKey).Get<string[]>();
|
||||
@@ -206,19 +293,22 @@ static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionK
|
||||
.ToArray() ?? [];
|
||||
}
|
||||
|
||||
// 10. 构建 CORS 策略
|
||||
// 14. 构建 CORS 策略
|
||||
static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
|
||||
{
|
||||
if (origins.Length == 0)
|
||||
{
|
||||
policy.AllowAnyOrigin();
|
||||
// SignalR 需要 AllowCredentials,与 AllowAnyOrigin 互斥,
|
||||
// 因此无配置时使用 SetIsOriginAllowed 替代。
|
||||
policy.SetIsOriginAllowed(_ => true)
|
||||
.AllowCredentials();
|
||||
}
|
||||
else
|
||||
{
|
||||
policy.WithOrigins(origins)
|
||||
.AllowCredentials();
|
||||
}
|
||||
// 1. (空行后) 放行通用 Header 与 Method
|
||||
|
||||
policy
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Common.Geo;
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 商户/门店/租户地理定位编排服务。
|
||||
/// </summary>
|
||||
public sealed class GeoLocationOrchestrator(
|
||||
TakeoutAppDbContext dbContext,
|
||||
IAddressGeocodingService geocodingService,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
ILogger<GeoLocationOrchestrator> logger)
|
||||
{
|
||||
private const int BatchSizePerEntity = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 手动重试门店地理编码。
|
||||
/// </summary>
|
||||
public async Task<bool> RetryStoreAsync(
|
||||
long tenantId,
|
||||
long merchantId,
|
||||
long storeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var store = await dbContext.Stores
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == storeId && x.TenantId == tenantId && x.MerchantId == merchantId,
|
||||
cancellationToken);
|
||||
if (store is null)
|
||||
{
|
||||
logger.LogWarning("手动重试门店定位失败:Store={StoreId}, Tenant={TenantId}, Merchant={MerchantId}", storeId, tenantId, merchantId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var merchant = await dbContext.Merchants
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == merchantId && x.TenantId == tenantId, cancellationToken);
|
||||
store.GeoRetryCount = 0;
|
||||
await GeocodeStoreAsync(store, merchant, DateTime.UtcNow, isRetry: true, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("手动重试门店定位已执行:Store={StoreId}", storeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动重试商户地理编码。
|
||||
/// </summary>
|
||||
public async Task<bool> RetryMerchantAsync(
|
||||
long tenantId,
|
||||
long merchantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var merchant = await dbContext.Merchants
|
||||
.FirstOrDefaultAsync(x => x.Id == merchantId && x.TenantId == tenantId, cancellationToken);
|
||||
if (merchant is null)
|
||||
{
|
||||
logger.LogWarning("手动重试商户定位失败:Merchant={MerchantId}, Tenant={TenantId}", merchantId, tenantId);
|
||||
return false;
|
||||
}
|
||||
|
||||
merchant.GeoRetryCount = 0;
|
||||
await GeocodeMerchantAsync(merchant, DateTime.UtcNow, isRetry: true, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("手动重试商户定位已执行:Merchant={MerchantId}", merchantId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 后台批量处理待定位记录。
|
||||
/// </summary>
|
||||
public async Task<int> ProcessPendingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var processedCount = 0;
|
||||
var tenantIds = await CollectPendingTenantIdsAsync(now, cancellationToken);
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
processedCount += await ProcessTenantEntitiesAsync(tenantId, now, cancellationToken);
|
||||
}
|
||||
|
||||
var pendingTenants = await dbContext.Tenants
|
||||
.Where(x =>
|
||||
x.GeoStatus != GeoLocationStatus.Success &&
|
||||
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
|
||||
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
|
||||
.OrderBy(x => x.GeoNextRetryAt)
|
||||
.ThenBy(x => x.Id)
|
||||
.Take(BatchSizePerEntity)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var tenant in pendingTenants)
|
||||
{
|
||||
await GeocodeTenantAsync(tenant, now, isRetry: true, cancellationToken);
|
||||
}
|
||||
|
||||
processedCount += pendingTenants.Count;
|
||||
|
||||
if (processedCount > 0)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogDebug("地理定位批处理完成,处理记录数:{Count}", processedCount);
|
||||
}
|
||||
|
||||
return processedCount;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<long>> CollectPendingTenantIdsAsync(DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var storeTenantIds = await dbContext.Stores
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.DeletedAt == null &&
|
||||
x.GeoStatus != GeoLocationStatus.Success &&
|
||||
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
|
||||
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
|
||||
.Select(x => x.TenantId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var merchantTenantIds = await dbContext.Merchants
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.DeletedAt == null &&
|
||||
x.GeoStatus != GeoLocationStatus.Success &&
|
||||
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
|
||||
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
|
||||
.Select(x => x.TenantId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return storeTenantIds
|
||||
.Concat(merchantTenantIds)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<int> ProcessTenantEntitiesAsync(
|
||||
long tenantId,
|
||||
DateTime now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var previousContext = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(tenantId, $"t{tenantId}", "geo-location-retry");
|
||||
try
|
||||
{
|
||||
var processedCount = 0;
|
||||
var pendingStores = await dbContext.Stores
|
||||
.Where(x =>
|
||||
x.GeoStatus != GeoLocationStatus.Success &&
|
||||
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
|
||||
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
|
||||
.OrderBy(x => x.GeoNextRetryAt)
|
||||
.ThenBy(x => x.Id)
|
||||
.Take(BatchSizePerEntity)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var store in pendingStores)
|
||||
{
|
||||
var merchant = await dbContext.Merchants
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == store.MerchantId, cancellationToken);
|
||||
await GeocodeStoreAsync(store, merchant, now, isRetry: true, cancellationToken);
|
||||
}
|
||||
|
||||
processedCount += pendingStores.Count;
|
||||
|
||||
var pendingMerchants = await dbContext.Merchants
|
||||
.Where(x =>
|
||||
x.GeoStatus != GeoLocationStatus.Success &&
|
||||
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
|
||||
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
|
||||
.OrderBy(x => x.GeoNextRetryAt)
|
||||
.ThenBy(x => x.Id)
|
||||
.Take(BatchSizePerEntity)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var merchant in pendingMerchants)
|
||||
{
|
||||
await GeocodeMerchantAsync(merchant, now, isRetry: true, cancellationToken);
|
||||
}
|
||||
|
||||
processedCount += pendingMerchants.Count;
|
||||
|
||||
if (processedCount > 0)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return processedCount;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
tenantContextAccessor.Current = previousContext;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GeocodeStoreAsync(
|
||||
Store store,
|
||||
Merchant? merchant,
|
||||
DateTime now,
|
||||
bool isRetry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = GeoAddressBuilder.BuildStoreCandidates(store, merchant);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
WriteFailedState(store, "缺少地址信息,无法定位", now, isRetry);
|
||||
return;
|
||||
}
|
||||
|
||||
var geocodeResult = await ResolveCandidatesAsync(candidates, cancellationToken);
|
||||
if (geocodeResult.Succeeded && geocodeResult.Latitude is not null && geocodeResult.Longitude is not null)
|
||||
{
|
||||
GeoLocationStateHelper.MarkSuccess(
|
||||
store,
|
||||
(double)geocodeResult.Latitude.Value,
|
||||
(double)geocodeResult.Longitude.Value,
|
||||
now);
|
||||
return;
|
||||
}
|
||||
|
||||
WriteFailedState(store, geocodeResult.Message, now, isRetry);
|
||||
}
|
||||
|
||||
private async Task GeocodeMerchantAsync(
|
||||
Merchant merchant,
|
||||
DateTime now,
|
||||
bool isRetry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = GeoAddressBuilder.BuildMerchantCandidates(merchant);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
WriteFailedState(merchant, "缺少地址信息,无法定位", now, isRetry);
|
||||
return;
|
||||
}
|
||||
|
||||
var geocodeResult = await ResolveCandidatesAsync(candidates, cancellationToken);
|
||||
if (geocodeResult.Succeeded && geocodeResult.Latitude is not null && geocodeResult.Longitude is not null)
|
||||
{
|
||||
GeoLocationStateHelper.MarkSuccess(
|
||||
merchant,
|
||||
(double)geocodeResult.Latitude.Value,
|
||||
(double)geocodeResult.Longitude.Value,
|
||||
now);
|
||||
return;
|
||||
}
|
||||
|
||||
WriteFailedState(merchant, geocodeResult.Message, now, isRetry);
|
||||
}
|
||||
|
||||
private async Task GeocodeTenantAsync(
|
||||
Tenant tenant,
|
||||
DateTime now,
|
||||
bool isRetry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = GeoAddressBuilder.BuildTenantCandidates(tenant);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
WriteFailedState(tenant, "缺少地址信息,无法定位", now, isRetry);
|
||||
return;
|
||||
}
|
||||
|
||||
var geocodeResult = await ResolveCandidatesAsync(candidates, cancellationToken);
|
||||
if (geocodeResult.Succeeded && geocodeResult.Latitude is not null && geocodeResult.Longitude is not null)
|
||||
{
|
||||
GeoLocationStateHelper.MarkSuccess(
|
||||
tenant,
|
||||
(double)geocodeResult.Latitude.Value,
|
||||
(double)geocodeResult.Longitude.Value,
|
||||
now);
|
||||
return;
|
||||
}
|
||||
|
||||
WriteFailedState(tenant, geocodeResult.Message, now, isRetry);
|
||||
}
|
||||
|
||||
private async Task<AddressGeocodingResult> ResolveCandidatesAsync(
|
||||
IReadOnlyList<string> candidates,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? lastError = null;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var result = await geocodingService.GeocodeAsync(candidate, cancellationToken);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
lastError = result.Message;
|
||||
}
|
||||
|
||||
return AddressGeocodingResult.Failed(lastError ?? "地址地理编码失败");
|
||||
}
|
||||
|
||||
private static void WriteFailedState(Store store, string? reason, DateTime now, bool isRetry)
|
||||
{
|
||||
if (isRetry)
|
||||
{
|
||||
GeoLocationStateHelper.MarkRetryFailure(store, reason, now);
|
||||
return;
|
||||
}
|
||||
|
||||
GeoLocationStateHelper.MarkPending(store, reason, now);
|
||||
}
|
||||
|
||||
private static void WriteFailedState(Merchant merchant, string? reason, DateTime now, bool isRetry)
|
||||
{
|
||||
if (isRetry)
|
||||
{
|
||||
GeoLocationStateHelper.MarkRetryFailure(merchant, reason, now);
|
||||
return;
|
||||
}
|
||||
|
||||
GeoLocationStateHelper.MarkPending(merchant, reason, now);
|
||||
}
|
||||
|
||||
private static void WriteFailedState(Tenant tenant, string? reason, DateTime now, bool isRetry)
|
||||
{
|
||||
if (isRetry)
|
||||
{
|
||||
GeoLocationStateHelper.MarkRetryFailure(tenant, reason, now);
|
||||
return;
|
||||
}
|
||||
|
||||
GeoLocationStateHelper.MarkPending(tenant, reason, now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位自动重试后台服务。
|
||||
/// </summary>
|
||||
public sealed class GeoLocationRetryBackgroundService(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
ILogger<GeoLocationRetryBackgroundService> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan PollingInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = serviceScopeFactory.CreateScope();
|
||||
var orchestrator = scope.ServiceProvider.GetRequiredService<GeoLocationOrchestrator>();
|
||||
var processedCount = await orchestrator.ProcessPendingAsync(stoppingToken);
|
||||
if (processedCount > 0)
|
||||
{
|
||||
logger.LogInformation("地理定位重试任务完成,处理记录数:{Count}", processedCount);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "地理定位重试任务执行失败");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(PollingInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Hangfire;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息触达发送任务执行器。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachDispatchJobRunner(
|
||||
TakeoutAppDbContext dbContext,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
IMemberMessageReachAppService memberMessageReachAppService,
|
||||
ILogger<MemberMessageReachDispatchJobRunner> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行消息发送任务。
|
||||
/// </summary>
|
||||
[AutomaticRetry(Attempts = 0)]
|
||||
public async Task ExecuteAsync(long messageId)
|
||||
{
|
||||
// 1. 查询任务所属租户,避免跨租户执行。
|
||||
var jobMeta = await dbContext.MemberReachMessages
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Id == messageId)
|
||||
.Select(item => new JobMeta(item.Id, item.TenantId))
|
||||
.SingleOrDefaultAsync();
|
||||
if (jobMeta is null || jobMeta.TenantId <= 0)
|
||||
{
|
||||
logger.LogWarning("会员消息任务不存在或租户无效,MessageId={MessageId}", messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 切换租户作用域并执行发送逻辑。
|
||||
using var _ = tenantContextAccessor.EnterTenantScope(jobMeta.TenantId, "scheduler", $"tenant-{jobMeta.TenantId}");
|
||||
try
|
||||
{
|
||||
await memberMessageReachAppService.ExecuteDispatchAsync(jobMeta.TenantId, jobMeta.Id, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "会员消息任务执行失败,TenantId={TenantId} MessageId={MessageId}", jobMeta.TenantId, jobMeta.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record JobMeta(long Id, long TenantId);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text.Json;
|
||||
using Hangfire;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Products.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 商品 SKU 异步保存任务执行器。
|
||||
/// </summary>
|
||||
public sealed class ProductSkuSaveJobRunner(
|
||||
TakeoutAppDbContext dbContext,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
ProductSkuSaveService productSkuSaveService,
|
||||
ILogger<ProductSkuSaveJobRunner> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行指定任务。
|
||||
/// </summary>
|
||||
[AutomaticRetry(Attempts = 0)]
|
||||
public async Task ExecuteAsync(long jobId)
|
||||
{
|
||||
var jobMeta = await dbContext.ProductSkuSaveJobs
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Id == jobId)
|
||||
.Select(item => new JobMeta(item.Id, item.TenantId))
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
if (jobMeta is null || jobMeta.TenantId <= 0)
|
||||
{
|
||||
logger.LogWarning("SKU 异步保存任务不存在或租户无效,JobId={JobId}", jobId);
|
||||
return;
|
||||
}
|
||||
|
||||
using var _ = tenantContextAccessor.EnterTenantScope(jobMeta.TenantId, "scheduler", $"tenant-{jobMeta.TenantId}");
|
||||
try
|
||||
{
|
||||
await RunWithExecutionStrategyAsync(jobMeta.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "SKU 异步保存任务执行失败,JobId={JobId}", jobId);
|
||||
await MarkFailedAsync(jobMeta.Id, BuildDetailedErrorMessage(ex));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunWithExecutionStrategyAsync(long jobId)
|
||||
{
|
||||
var executionStrategy = dbContext.Database.CreateExecutionStrategy();
|
||||
await executionStrategy.ExecuteAsync(async () =>
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||
var job = await dbContext.ProductSkuSaveJobs
|
||||
.SingleOrDefaultAsync(item => item.Id == jobId);
|
||||
if (job is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.Status == ProductSkuSaveJobStatus.Succeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
job.Status = ProductSkuSaveJobStatus.Running;
|
||||
job.StartedAt ??= DateTime.UtcNow;
|
||||
job.FinishedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
job.FailedCount = 0;
|
||||
job.ProgressProcessed = 0;
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// 以租户+商品维度申请事务级咨询锁,保证同商品串行落库。
|
||||
var advisoryLockKey = BuildAdvisoryLockKey(job.TenantId, job.ProductId);
|
||||
await dbContext.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"SELECT pg_advisory_xact_lock({advisoryLockKey});");
|
||||
|
||||
var payload = DeserializePayload(job.PayloadJson);
|
||||
if (payload.Skus.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("SKU 保存任务缺少有效数据。");
|
||||
}
|
||||
|
||||
var productExists = await dbContext.Products
|
||||
.AsNoTracking()
|
||||
.AnyAsync(item => item.Id == job.ProductId && item.StoreId == job.StoreId);
|
||||
if (!productExists)
|
||||
{
|
||||
throw new InvalidOperationException($"商品不存在或不属于门店,ProductId={job.ProductId}");
|
||||
}
|
||||
|
||||
await productSkuSaveService.ReplaceSkusAsync(
|
||||
job.ProductId,
|
||||
job.StoreId,
|
||||
payload.Skus,
|
||||
payload.SpecTemplateIds,
|
||||
CancellationToken.None);
|
||||
|
||||
job.Status = ProductSkuSaveJobStatus.Succeeded;
|
||||
job.ProgressProcessed = job.ProgressTotal;
|
||||
job.FailedCount = 0;
|
||||
job.FinishedAt = DateTime.UtcNow;
|
||||
job.ErrorMessage = null;
|
||||
await dbContext.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task MarkFailedAsync(long jobId, string errorMessage)
|
||||
{
|
||||
var executionStrategy = dbContext.Database.CreateExecutionStrategy();
|
||||
await executionStrategy.ExecuteAsync(async () =>
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
|
||||
var job = await dbContext.ProductSkuSaveJobs
|
||||
.SingleOrDefaultAsync(item => item.Id == jobId);
|
||||
if (job is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
job.Status = ProductSkuSaveJobStatus.Failed;
|
||||
job.FinishedAt = DateTime.UtcNow;
|
||||
job.ErrorMessage = Truncate(errorMessage, 2000);
|
||||
job.FailedCount = Math.Max(1, job.ProgressTotal - job.ProgressProcessed);
|
||||
await dbContext.SaveChangesAsync();
|
||||
});
|
||||
}
|
||||
|
||||
private static ProductSkuSaveJobPayload DeserializePayload(string payloadJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payloadJson))
|
||||
{
|
||||
throw new InvalidOperationException("SKU 保存任务负载为空。");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<ProductSkuSaveJobPayload>(payloadJson, StoreApiHelpers.JsonOptions);
|
||||
if (payload is null)
|
||||
{
|
||||
throw new InvalidOperationException("SKU 保存任务负载解析结果为空。");
|
||||
}
|
||||
|
||||
payload.Skus ??= [];
|
||||
payload.SpecTemplateIds ??= [];
|
||||
return payload;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException("SKU 保存任务负载解析失败。", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return value.Length <= maxLength ? value : value[..maxLength];
|
||||
}
|
||||
|
||||
private static string BuildDetailedErrorMessage(Exception ex)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var current = ex;
|
||||
var depth = 0;
|
||||
const int maxDepth = 8;
|
||||
|
||||
while (current is not null && depth < maxDepth)
|
||||
{
|
||||
var prefix = depth == 0 ? "Exception" : $"Inner[{depth}]";
|
||||
var message = string.IsNullOrWhiteSpace(current.Message)
|
||||
? "(no message)"
|
||||
: current.Message.Trim();
|
||||
lines.Add($"{prefix} {current.GetType().Name}: {message}");
|
||||
current = current.InnerException;
|
||||
depth++;
|
||||
}
|
||||
|
||||
if (current is not null)
|
||||
{
|
||||
lines.Add($"Inner[{depth}] ...");
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
private static long BuildAdvisoryLockKey(long tenantId, long productId)
|
||||
{
|
||||
Span<byte> raw = stackalloc byte[16];
|
||||
BinaryPrimitives.WriteInt64LittleEndian(raw, tenantId);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(raw[8..], productId);
|
||||
|
||||
const ulong fnvOffsetBasis = 14695981039346656037UL;
|
||||
const ulong fnvPrime = 1099511628211UL;
|
||||
var hash = fnvOffsetBasis;
|
||||
foreach (var b in raw)
|
||||
{
|
||||
hash ^= b;
|
||||
hash *= fnvPrime;
|
||||
}
|
||||
|
||||
return unchecked((long)hash);
|
||||
}
|
||||
|
||||
private sealed record JobMeta(long Id, long TenantId);
|
||||
}
|
||||
476
src/Api/TakeoutSaaS.TenantApi/Services/ProductSkuSaveService.cs
Normal file
476
src/Api/TakeoutSaaS.TenantApi/Services/ProductSkuSaveService.cs
Normal file
@@ -0,0 +1,476 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using TakeoutSaaS.Domain.Products.Entities;
|
||||
using TakeoutSaaS.Domain.Products.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 商品 SKU 保存服务(replace 语义)。
|
||||
/// </summary>
|
||||
public sealed class ProductSkuSaveService(TakeoutAppDbContext dbContext)
|
||||
{
|
||||
/// <summary>
|
||||
/// 按 replace 语义保存 SKU:
|
||||
/// 1. 命中的 SKU 更新并恢复启用。
|
||||
/// 2. 未命中的 SKU 新增。
|
||||
/// 3. 缺失的历史 SKU 软禁用(IsEnabled=false, Stock=0)。
|
||||
/// </summary>
|
||||
public async Task ReplaceSkusAsync(
|
||||
long productId,
|
||||
long storeId,
|
||||
IReadOnlyList<ProductSkuUpsertInput> skus,
|
||||
IReadOnlyCollection<long> specTemplateIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedSkus = skus ?? [];
|
||||
if (normalizedSkus.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "skus 不能为空");
|
||||
}
|
||||
|
||||
await ValidateSkuTemplateRefsAsync(storeId, normalizedSkus, specTemplateIds, cancellationToken);
|
||||
|
||||
var explicitSkuCodes = normalizedSkus
|
||||
.Select(item => NormalizeSkuCode(item.SkuCode))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
var duplicateSkuCode = explicitSkuCodes
|
||||
.GroupBy(item => item, StringComparer.Ordinal)
|
||||
.FirstOrDefault(group => group.Count() > 1)?
|
||||
.Key;
|
||||
if (!string.IsNullOrWhiteSpace(duplicateSkuCode))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"SKU 编码重复: {duplicateSkuCode}");
|
||||
}
|
||||
|
||||
if (explicitSkuCodes.Count > 0)
|
||||
{
|
||||
string? codeConflict;
|
||||
using (dbContext.DisableSoftDeleteFilter())
|
||||
{
|
||||
codeConflict = await dbContext.ProductSkus
|
||||
.AsNoTracking()
|
||||
.Where(item => item.ProductId != productId && explicitSkuCodes.Contains(item.SkuCode))
|
||||
.Select(item => item.SkuCode)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(codeConflict))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"SKU 编码已存在: {codeConflict}");
|
||||
}
|
||||
}
|
||||
|
||||
List<ProductSku> existingSkus;
|
||||
using (dbContext.DisableSoftDeleteFilter())
|
||||
{
|
||||
existingSkus = await dbContext.ProductSkus
|
||||
.Where(item => item.ProductId == productId)
|
||||
.OrderBy(item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var existingBySkuCode = existingSkus
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.SkuCode))
|
||||
.GroupBy(item => item.SkuCode, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => new Queue<ProductSku>(group), StringComparer.Ordinal);
|
||||
|
||||
var existingByAttrKey = existingSkus
|
||||
.GroupBy(item => BuildSkuAttributeKey(ParseSkuAttributes(item.AttributesJson)), StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => new Queue<ProductSku>(group), StringComparer.Ordinal);
|
||||
|
||||
var usedExistingSkuIds = new HashSet<long>();
|
||||
var plannedSkuCodes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var currentProductSkuCodes = existingSkus
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.SkuCode))
|
||||
.Select(item => item.SkuCode)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var explicitSkuCode in explicitSkuCodes)
|
||||
{
|
||||
currentProductSkuCodes.Add(explicitSkuCode);
|
||||
}
|
||||
var createdSkus = new List<ProductSku>();
|
||||
|
||||
foreach (var sku in normalizedSkus)
|
||||
{
|
||||
var normalizedSkuCode = NormalizeSkuCode(sku.SkuCode);
|
||||
var attrKey = BuildSkuAttributeKey(sku.Attributes);
|
||||
|
||||
ProductSku? matched = null;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedSkuCode) &&
|
||||
existingBySkuCode.TryGetValue(normalizedSkuCode, out var byCodeQueue))
|
||||
{
|
||||
matched = PickUnmatchedSku(byCodeQueue, usedExistingSkuIds);
|
||||
}
|
||||
|
||||
if (matched is null && existingByAttrKey.TryGetValue(attrKey, out var byAttrQueue))
|
||||
{
|
||||
matched = PickUnmatchedSku(byAttrQueue, usedExistingSkuIds);
|
||||
}
|
||||
|
||||
if (matched is not null)
|
||||
{
|
||||
usedExistingSkuIds.Add(matched.Id);
|
||||
var targetSkuCode = !string.IsNullOrWhiteSpace(normalizedSkuCode)
|
||||
? normalizedSkuCode
|
||||
: NormalizeSkuCode(matched.SkuCode);
|
||||
if (string.IsNullOrWhiteSpace(targetSkuCode))
|
||||
{
|
||||
targetSkuCode = GenerateUniqueSkuCode(productId, currentProductSkuCodes);
|
||||
}
|
||||
|
||||
if (!plannedSkuCodes.Add(targetSkuCode))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"SKU 编码冲突: {targetSkuCode}");
|
||||
}
|
||||
|
||||
currentProductSkuCodes.Add(targetSkuCode);
|
||||
matched.Price = sku.Price;
|
||||
matched.OriginalPrice = sku.OriginalPrice;
|
||||
matched.StockQuantity = Math.Max(0, sku.Stock);
|
||||
matched.AttributesJson = SerializeSkuAttributes(sku.Attributes);
|
||||
matched.SortOrder = sku.SortOrder;
|
||||
matched.IsEnabled = sku.IsEnabled;
|
||||
matched.DeletedAt = null;
|
||||
matched.DeletedBy = null;
|
||||
matched.SkuCode = targetSkuCode;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var generatedCode = !string.IsNullOrWhiteSpace(normalizedSkuCode)
|
||||
? normalizedSkuCode
|
||||
: GenerateUniqueSkuCode(productId, currentProductSkuCodes);
|
||||
if (!plannedSkuCodes.Add(generatedCode))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"SKU 编码冲突: {generatedCode}");
|
||||
}
|
||||
|
||||
currentProductSkuCodes.Add(generatedCode);
|
||||
createdSkus.Add(new ProductSku
|
||||
{
|
||||
ProductId = productId,
|
||||
SkuCode = generatedCode,
|
||||
Price = sku.Price,
|
||||
OriginalPrice = sku.OriginalPrice,
|
||||
StockQuantity = Math.Max(0, sku.Stock),
|
||||
AttributesJson = SerializeSkuAttributes(sku.Attributes),
|
||||
SortOrder = sku.SortOrder,
|
||||
IsEnabled = sku.IsEnabled
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var existing in existingSkus)
|
||||
{
|
||||
if (usedExistingSkuIds.Contains(existing.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(existing.SkuCode) && plannedSkuCodes.Contains(existing.SkuCode))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"SKU 编码已存在: {existing.SkuCode}");
|
||||
}
|
||||
|
||||
existing.IsEnabled = false;
|
||||
existing.StockQuantity = 0;
|
||||
}
|
||||
|
||||
if (createdSkus.Count > 0)
|
||||
{
|
||||
await dbContext.ProductSkus.AddRangeAsync(createdSkus, cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsSkuCodeUniqueViolation(ex))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "SKU 编码冲突,请刷新后重试");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ValidateSkuTemplateRefsAsync(
|
||||
long storeId,
|
||||
IReadOnlyList<ProductSkuUpsertInput> skus,
|
||||
IReadOnlyCollection<long> specTemplateIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var allowedSpecTemplateIds = specTemplateIds.ToHashSet();
|
||||
var templateIdsInSkus = skus
|
||||
.SelectMany(item => item.Attributes)
|
||||
.Select(item => item.TemplateId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var outOfSelectedTemplateId = templateIdsInSkus.FirstOrDefault(item => !allowedSpecTemplateIds.Contains(item));
|
||||
if (outOfSelectedTemplateId > 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"SKU 使用了未关联的规格模板: {outOfSelectedTemplateId}");
|
||||
}
|
||||
|
||||
if (templateIdsInSkus.Count > 0)
|
||||
{
|
||||
var templateTypeLookup = await dbContext.ProductSpecTemplates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.StoreId == storeId && templateIdsInSkus.Contains(item.Id))
|
||||
.ToDictionaryAsync(item => item.Id, item => item.TemplateType, cancellationToken);
|
||||
|
||||
var missingTemplateId = templateIdsInSkus.FirstOrDefault(item => !templateTypeLookup.ContainsKey(item));
|
||||
if (missingTemplateId > 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"SKU 规格模板不存在: {missingTemplateId}");
|
||||
}
|
||||
|
||||
var invalidTemplateId = templateTypeLookup
|
||||
.FirstOrDefault(item => item.Value == ProductSpecTemplateType.Addon)
|
||||
.Key;
|
||||
if (invalidTemplateId > 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"SKU 规格模板类型错误: {invalidTemplateId}");
|
||||
}
|
||||
}
|
||||
|
||||
var optionIdsInSkus = skus
|
||||
.SelectMany(item => item.Attributes)
|
||||
.Select(item => item.OptionId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var optionTemplateLookup = optionIdsInSkus.Count == 0
|
||||
? new Dictionary<long, long>()
|
||||
: await dbContext.ProductSpecTemplateOptions
|
||||
.AsNoTracking()
|
||||
.Where(item => optionIdsInSkus.Contains(item.Id))
|
||||
.ToDictionaryAsync(item => item.Id, item => item.TemplateId, cancellationToken);
|
||||
|
||||
var missingOptionId = optionIdsInSkus.FirstOrDefault(item => !optionTemplateLookup.ContainsKey(item));
|
||||
if (missingOptionId > 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"SKU 规格选项不存在: {missingOptionId}");
|
||||
}
|
||||
|
||||
foreach (var sku in skus)
|
||||
{
|
||||
foreach (var attr in sku.Attributes)
|
||||
{
|
||||
if (optionTemplateLookup[attr.OptionId] != attr.TemplateId)
|
||||
{
|
||||
throw new BusinessException(
|
||||
ErrorCodes.BadRequest,
|
||||
$"SKU 规格选项与模板不匹配: templateId={attr.TemplateId}, optionId={attr.OptionId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ProductSku? PickUnmatchedSku(Queue<ProductSku> queue, HashSet<long> usedExistingSkuIds)
|
||||
{
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Peek();
|
||||
if (usedExistingSkuIds.Contains(current.Id))
|
||||
{
|
||||
queue.Dequeue();
|
||||
continue;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string BuildSkuAttributeKey(IReadOnlyList<ProductSkuUpsertAttributeInput> attributes)
|
||||
{
|
||||
if (attributes.Count == 0)
|
||||
{
|
||||
return "default";
|
||||
}
|
||||
|
||||
return string.Join('|', attributes
|
||||
.OrderBy(item => item.TemplateId)
|
||||
.ThenBy(item => item.OptionId)
|
||||
.Select(item => $"{item.TemplateId}:{item.OptionId}"));
|
||||
}
|
||||
|
||||
private static List<ProductSkuUpsertAttributeInput> ParseSkuAttributes(string? attributesJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attributesJson))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<List<SkuAttributePayload>>(attributesJson, StoreApiHelpers.JsonOptions) ?? [];
|
||||
return parsed
|
||||
.Where(item => item.TemplateId > 0 && item.OptionId > 0)
|
||||
.Select(item => new ProductSkuUpsertAttributeInput
|
||||
{
|
||||
TemplateId = item.TemplateId,
|
||||
OptionId = item.OptionId
|
||||
})
|
||||
.DistinctBy(item => $"{item.TemplateId}:{item.OptionId}")
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeSkuAttributes(IReadOnlyList<ProductSkuUpsertAttributeInput> attributes)
|
||||
{
|
||||
if (attributes.Count == 0)
|
||||
{
|
||||
return "[]";
|
||||
}
|
||||
|
||||
var payload = attributes
|
||||
.OrderBy(item => item.TemplateId)
|
||||
.ThenBy(item => item.OptionId)
|
||||
.Select(item => new SkuAttributePayload(item.TemplateId, item.OptionId))
|
||||
.ToList();
|
||||
return JsonSerializer.Serialize(payload);
|
||||
}
|
||||
|
||||
private static string NormalizeSkuCode(string? skuCode)
|
||||
{
|
||||
var normalized = (skuCode ?? string.Empty).Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized) ? string.Empty : normalized;
|
||||
}
|
||||
|
||||
private static string GenerateUniqueSkuCode(long productId, IReadOnlySet<string> currentProductSkuCodes)
|
||||
{
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
var random = RandomNumberGenerator.GetInt32(0, 60_466_176);
|
||||
var candidate = $"SKU{productId}{ToBase36((ulong)random).PadLeft(5, '0')}";
|
||||
if (!currentProductSkuCodes.Contains(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCodes.InternalServerError, "SKU 编码生成失败,请稍后重试");
|
||||
}
|
||||
|
||||
private static string ToBase36(ulong value)
|
||||
{
|
||||
const string chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
if (value == 0)
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
Span<char> buffer = stackalloc char[16];
|
||||
var pos = buffer.Length;
|
||||
while (value > 0)
|
||||
{
|
||||
buffer[--pos] = chars[(int)(value % 36)];
|
||||
value /= 36;
|
||||
}
|
||||
|
||||
return new string(buffer[pos..]);
|
||||
}
|
||||
|
||||
private static bool IsSkuCodeUniqueViolation(DbUpdateException exception)
|
||||
{
|
||||
if (exception.InnerException is not PostgresException postgresException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return postgresException.SqlState == PostgresErrorCodes.UniqueViolation &&
|
||||
string.Equals(
|
||||
postgresException.ConstraintName,
|
||||
"IX_product_skus_TenantId_SkuCode",
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed record SkuAttributePayload(long TemplateId, long OptionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SKU replace 输入模型。
|
||||
/// </summary>
|
||||
public sealed class ProductSkuUpsertInput
|
||||
{
|
||||
/// <summary>
|
||||
/// SKU 编码(可选)。
|
||||
/// </summary>
|
||||
public string? SkuCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal Price { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 划线价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存。
|
||||
/// </summary>
|
||||
public int Stock { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 规格属性组合。
|
||||
/// </summary>
|
||||
public List<ProductSkuUpsertAttributeInput> Attributes { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SKU replace 属性输入模型。
|
||||
/// </summary>
|
||||
public sealed class ProductSkuUpsertAttributeInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 规格模板 ID。
|
||||
/// </summary>
|
||||
public long TemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板选项 ID。
|
||||
/// </summary>
|
||||
public long OptionId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SKU 异步保存任务负载。
|
||||
/// </summary>
|
||||
public sealed class ProductSkuSaveJobPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// SKU 列表快照。
|
||||
/// </summary>
|
||||
public List<ProductSkuUpsertInput> Skus { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 允许使用的规格模板 ID 列表。
|
||||
/// </summary>
|
||||
public List<long> SpecTemplateIds { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.App.Common.Geo;
|
||||
using TakeoutSaaS.TenantApi.Options;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯地图地理编码服务(服务端签名版)。
|
||||
/// </summary>
|
||||
public sealed class TencentMapGeocodingService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptionsMonitor<TencentMapOptions> optionsMonitor,
|
||||
ILogger<TencentMapGeocodingService> logger) : IAddressGeocodingService
|
||||
{
|
||||
/// <summary>
|
||||
/// HttpClient 名称。
|
||||
/// </summary>
|
||||
public const string HttpClientName = "TencentMapWebService";
|
||||
|
||||
/// <summary>
|
||||
/// 根据地址解析经纬度。
|
||||
/// </summary>
|
||||
/// <param name="rawAddress">地址文本。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>解析结果;失败时返回 <c>null</c>。</returns>
|
||||
public async Task<(decimal Latitude, decimal Longitude)?> GeocodeAsync(
|
||||
string rawAddress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await GeocodeWithDetailsAsync(rawAddress, cancellationToken);
|
||||
return result.Succeeded && result.Latitude is not null && result.Longitude is not null
|
||||
? (result.Latitude.Value, result.Longitude.Value)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据地址解析经纬度(含失败原因)。
|
||||
/// </summary>
|
||||
/// <param name="rawAddress">地址文本。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>解析结果。</returns>
|
||||
public async Task<AddressGeocodingResult> GeocodeWithDetailsAsync(
|
||||
string rawAddress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 预处理地址文本,空值直接返回。
|
||||
var address = rawAddress?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
{
|
||||
return AddressGeocodingResult.Failed("地址为空,无法定位");
|
||||
}
|
||||
|
||||
// 2. 读取腾讯地图配置并做兜底校验。
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
var key = options.WebServiceKey?.Trim() ?? string.Empty;
|
||||
var secret = options.WebServiceSecret?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
logger.LogWarning("腾讯地图 WebService 未配置 key/sk,已跳过地址解析。");
|
||||
return AddressGeocodingResult.Failed("地图服务未配置");
|
||||
}
|
||||
|
||||
// 3. 同时尝试两种签名方式,兼容不同编码策略。
|
||||
var baseUrl = NormalizeBaseUrl(options.BaseUrl);
|
||||
var geocoderPath = NormalizePath(options.GeocoderPath);
|
||||
string? lastMessage = null;
|
||||
foreach (var useEncodedValueInSignature in new[] { false, true })
|
||||
{
|
||||
var query = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["address"] = address,
|
||||
["key"] = key
|
||||
};
|
||||
query["sig"] = BuildSignature(
|
||||
geocoderPath,
|
||||
query,
|
||||
secret,
|
||||
useEncodedValueInSignature);
|
||||
|
||||
var requestUri = BuildRequestUri(baseUrl, geocoderPath, query);
|
||||
var response = await RequestAsync(requestUri, cancellationToken);
|
||||
if (response is null)
|
||||
{
|
||||
lastMessage = "地图服务响应无法解析";
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. 成功状态直接返回经纬度。
|
||||
if (response.Status == 0 &&
|
||||
response.Latitude is not null &&
|
||||
response.Longitude is not null)
|
||||
{
|
||||
return AddressGeocodingResult.Success(response.Latitude.Value, response.Longitude.Value);
|
||||
}
|
||||
|
||||
lastMessage = string.IsNullOrWhiteSpace(response.Message)
|
||||
? $"地理编码失败,状态码:{response.Status}"
|
||||
: response.Message;
|
||||
|
||||
// 5. 仅在签名错误时继续下一轮重试,其他状态直接终止。
|
||||
if (response.Status != 111)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return AddressGeocodingResult.Failed(lastMessage ?? "地理编码失败");
|
||||
}
|
||||
|
||||
Task<AddressGeocodingResult> IAddressGeocodingService.GeocodeAsync(
|
||||
string address,
|
||||
CancellationToken cancellationToken)
|
||||
=> GeocodeWithDetailsAsync(address, cancellationToken);
|
||||
|
||||
private async Task<TencentGeocodeResponse?> RequestAsync(
|
||||
string requestUri,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 发起请求并获取响应文本。
|
||||
using var httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 2. 解析响应,失败时返回 null。
|
||||
if (!TryParseGeocodeResponse(responseBody, out var parsed))
|
||||
{
|
||||
logger.LogWarning("腾讯地图地理编码响应无法解析。");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 非 0 状态记录诊断信息(不包含敏感密钥)。
|
||||
if (parsed.Status != 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"腾讯地图地理编码失败,Status={Status}, Message={Message}",
|
||||
parsed.Status,
|
||||
parsed.Message);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static string BuildRequestUri(
|
||||
string baseUrl,
|
||||
string geocoderPath,
|
||||
IReadOnlyDictionary<string, string> query)
|
||||
{
|
||||
var canonicalQuery = string.Join(
|
||||
"&",
|
||||
query
|
||||
.OrderBy(x => x.Key, StringComparer.Ordinal)
|
||||
.Select(x => $"{x.Key}={Uri.EscapeDataString(x.Value)}"));
|
||||
|
||||
var requestUri = new Uri(new Uri(baseUrl, UriKind.Absolute), geocoderPath);
|
||||
return $"{requestUri}?{canonicalQuery}";
|
||||
}
|
||||
|
||||
private static string BuildSignature(
|
||||
string geocoderPath,
|
||||
IReadOnlyDictionary<string, string> query,
|
||||
string secret,
|
||||
bool useEncodedValue)
|
||||
{
|
||||
var canonicalQuery = string.Join(
|
||||
"&",
|
||||
query
|
||||
.OrderBy(x => x.Key, StringComparer.Ordinal)
|
||||
.Select(x =>
|
||||
{
|
||||
var value = useEncodedValue
|
||||
? Uri.EscapeDataString(x.Value)
|
||||
: x.Value;
|
||||
return $"{x.Key}={value}";
|
||||
}));
|
||||
|
||||
var payload = $"{geocoderPath}?{canonicalQuery}{secret}";
|
||||
var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(payload));
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool TryParseGeocodeResponse(
|
||||
string responseBody,
|
||||
out TencentGeocodeResponse response)
|
||||
{
|
||||
response = new TencentGeocodeResponse();
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseBody);
|
||||
var root = document.RootElement;
|
||||
|
||||
response.Status = ReadStatus(root);
|
||||
response.Message = root.TryGetProperty("message", out var messageElement)
|
||||
? messageElement.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
if (!root.TryGetProperty("result", out var resultElement) ||
|
||||
resultElement.ValueKind != JsonValueKind.Object ||
|
||||
!resultElement.TryGetProperty("location", out var locationElement) ||
|
||||
locationElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!TryReadDecimal(locationElement, "lat", out var latitude) ||
|
||||
!TryReadDecimal(locationElement, "lng", out var longitude))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
response.Latitude = decimal.Round(latitude, 7);
|
||||
response.Longitude = decimal.Round(longitude, 7);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static int ReadStatus(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("status", out var statusElement))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return statusElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when statusElement.TryGetInt32(out var numeric) => numeric,
|
||||
JsonValueKind.String when int.TryParse(
|
||||
statusElement.GetString(),
|
||||
NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var parsed) => parsed,
|
||||
_ => -1
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryReadDecimal(
|
||||
JsonElement parent,
|
||||
string propertyName,
|
||||
out decimal value)
|
||||
{
|
||||
value = 0m;
|
||||
if (!parent.TryGetProperty(propertyName, out var element))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when element.TryGetDecimal(out var numericValue)
|
||||
=> Assign(out value, numericValue),
|
||||
JsonValueKind.String when decimal.TryParse(
|
||||
element.GetString(),
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var parsedValue) => Assign(out value, parsedValue),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool Assign(out decimal target, decimal value)
|
||||
{
|
||||
target = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string NormalizeBaseUrl(string? baseUrl)
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(baseUrl)
|
||||
? "https://apis.map.qq.com"
|
||||
: baseUrl.Trim();
|
||||
return normalized.EndsWith('/') ? normalized : $"{normalized}/";
|
||||
}
|
||||
|
||||
private static string NormalizePath(string? path)
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(path)
|
||||
? "/ws/geocoder/v1/"
|
||||
: path.Trim();
|
||||
|
||||
if (!normalized.StartsWith('/'))
|
||||
{
|
||||
normalized = $"/{normalized}";
|
||||
}
|
||||
|
||||
if (!normalized.EndsWith('/'))
|
||||
{
|
||||
normalized = $"{normalized}/";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private sealed class TencentGeocodeResponse
|
||||
{
|
||||
public decimal? Latitude { get; set; }
|
||||
|
||||
public decimal? Longitude { get; set; }
|
||||
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public int Status { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.14.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.14.0-beta.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -25,6 +26,7 @@
|
||||
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Authorization\TakeoutSaaS.Module.Authorization.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Scheduler\TakeoutSaaS.Module.Scheduler.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -60,9 +60,135 @@
|
||||
"CodeTenantMap": {},
|
||||
"ThrowIfUnresolved": false
|
||||
},
|
||||
"TencentMap": {
|
||||
"BaseUrl": "https://apis.map.qq.com",
|
||||
"GeocoderPath": "/ws/geocoder/v1/",
|
||||
"WebServiceKey": "DGIBZ-YUM64-5ONUT-F4RIA-MPUP2-XFFXZ",
|
||||
"WebServiceSecret": "6ztzMqwtuOyaJLuBs1koRDyqNpnVyda8"
|
||||
},
|
||||
"Cors": {
|
||||
"Tenant": []
|
||||
},
|
||||
"Storage": {
|
||||
"Provider": "TencentCos",
|
||||
"CdnBaseUrl": "https://image-admin.laosankeji.com",
|
||||
"TencentCos": {
|
||||
"SecretId": "AKID52mHageV8ZnnY5NRL3Xq270fAcw2vb5R",
|
||||
"SecretKey": "B8sPitsiEXcS4ScaMvGMErFOL3ZqsgFa",
|
||||
"Region": "ap-beijing",
|
||||
"Bucket": "saas-admin-1388556178",
|
||||
"Endpoint": "https://cos.ap-beijing.myqcloud.com",
|
||||
"CdnBaseUrl": "https://image-admin.laosankeji.com",
|
||||
"UseHttps": true,
|
||||
"ForcePathStyle": false
|
||||
},
|
||||
"QiniuKodo": {
|
||||
"AccessKey": "QINIU_ACCESS_KEY",
|
||||
"SecretKey": "QINIU_SECRET_KEY",
|
||||
"Bucket": "takeout-files",
|
||||
"DownloadDomain": "",
|
||||
"Endpoint": "",
|
||||
"UseHttps": true,
|
||||
"SignedUrlExpirationMinutes": 30
|
||||
},
|
||||
"AliyunOss": {
|
||||
"AccessKeyId": "OSS_ACCESS_KEY_ID",
|
||||
"AccessKeySecret": "OSS_ACCESS_KEY_SECRET",
|
||||
"Endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
|
||||
"Bucket": "takeout-files",
|
||||
"CdnBaseUrl": "",
|
||||
"UseHttps": true
|
||||
},
|
||||
"Security": {
|
||||
"MaxFileSizeBytes": 10485760,
|
||||
"AllowedImageExtensions": [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".gif"
|
||||
],
|
||||
"AllowedFileExtensions": [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".gif",
|
||||
".pdf"
|
||||
],
|
||||
"DefaultUrlExpirationMinutes": 30,
|
||||
"EnableRefererValidation": false,
|
||||
"AllowedReferers": [
|
||||
"https://admin.example.com",
|
||||
"https://miniapp.example.com"
|
||||
],
|
||||
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
|
||||
}
|
||||
},
|
||||
"Sms": {
|
||||
"Provider": "Tencent",
|
||||
"DefaultSignName": "外卖SaaS",
|
||||
"UseMock": true,
|
||||
"Tencent": {
|
||||
"SecretId": "TENCENT_SMS_SECRET_ID",
|
||||
"SecretKey": "TENCENT_SMS_SECRET_KEY",
|
||||
"SdkAppId": "1400000000",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "ap-beijing",
|
||||
"Endpoint": "https://sms.tencentcloudapi.com"
|
||||
},
|
||||
"Aliyun": {
|
||||
"AccessKeyId": "ALIYUN_SMS_AK",
|
||||
"AccessKeySecret": "ALIYUN_SMS_SK",
|
||||
"Endpoint": "dysmsapi.aliyuncs.com",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "cn-hangzhou"
|
||||
},
|
||||
"SceneTemplates": {
|
||||
"login": "LOGIN_TEMPLATE_ID",
|
||||
"register": "REGISTER_TEMPLATE_ID",
|
||||
"reset": "RESET_TEMPLATE_ID",
|
||||
"member_message": "MEMBER_MESSAGE_TEMPLATE_ID"
|
||||
},
|
||||
"VerificationCode": {
|
||||
"CodeLength": 6,
|
||||
"ExpireMinutes": 5,
|
||||
"CooldownSeconds": 60,
|
||||
"CachePrefix": "sms:code"
|
||||
}
|
||||
},
|
||||
"MemberMessaging": {
|
||||
"SmsScene": "member_message",
|
||||
"WeChatMini": {
|
||||
"AppId": "WECHAT_MINI_APP_ID",
|
||||
"AppSecret": "WECHAT_MINI_APP_SECRET",
|
||||
"SubscribeTemplateId": "WECHAT_SUBSCRIBE_TEMPLATE_ID",
|
||||
"PagePath": "pages/member/message-center/index",
|
||||
"TitleDataKey": "thing1",
|
||||
"ContentDataKey": "thing2"
|
||||
}
|
||||
},
|
||||
"Scheduler": {
|
||||
"ConnectionString": "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",
|
||||
"WorkerCount": 10,
|
||||
"DashboardEnabled": false,
|
||||
"DashboardPath": "/hangfire",
|
||||
"SubscriptionAutomation": {
|
||||
"AutoRenewalExecuteHourUtc": 1,
|
||||
"AutoRenewalDaysBeforeExpiry": 3,
|
||||
"RenewalReminderExecuteHourUtc": 10,
|
||||
"ReminderDaysBeforeExpiry": [
|
||||
7,
|
||||
3,
|
||||
1
|
||||
],
|
||||
"SubscriptionExpiryCheckExecuteHourUtc": 2,
|
||||
"GracePeriodDays": 7
|
||||
},
|
||||
"BillingAutomation": {
|
||||
"OverdueBillingProcessCron": "*/10 * * * *"
|
||||
}
|
||||
},
|
||||
"Otel": {
|
||||
"Endpoint": "",
|
||||
"Sampling": "ParentBasedAlwaysOn",
|
||||
|
||||
@@ -56,11 +56,137 @@
|
||||
"/health",
|
||||
"/healthz"
|
||||
],
|
||||
"RootDomain": ""
|
||||
"RootDomain": "tenant.laosankeji.com"
|
||||
},
|
||||
"TencentMap": {
|
||||
"BaseUrl": "https://apis.map.qq.com",
|
||||
"GeocoderPath": "/ws/geocoder/v1/",
|
||||
"WebServiceKey": "DGIBZ-YUM64-5ONUT-F4RIA-MPUP2-XFFXZ",
|
||||
"WebServiceSecret": "6ztzMqwtuOyaJLuBs1koRDyqNpnVyda8"
|
||||
},
|
||||
"Cors": {
|
||||
"Tenant": []
|
||||
},
|
||||
"Storage": {
|
||||
"Provider": "TencentCos",
|
||||
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
|
||||
"TencentCos": {
|
||||
"SecretId": "AKID52mHageV8ZnnY5NRL3Xq270fAcw2vb5R",
|
||||
"SecretKey": "B8sPitsiEXcS4ScaMvGMErFOL3ZqsgFa",
|
||||
"Region": "ap-beijing",
|
||||
"Bucket": "saas2025-1388556178",
|
||||
"Endpoint": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
|
||||
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
|
||||
"UseHttps": true,
|
||||
"ForcePathStyle": false
|
||||
},
|
||||
"QiniuKodo": {
|
||||
"AccessKey": "QINIU_ACCESS_KEY",
|
||||
"SecretKey": "QINIU_SECRET_KEY",
|
||||
"Bucket": "takeout-files",
|
||||
"DownloadDomain": "",
|
||||
"Endpoint": "",
|
||||
"UseHttps": true,
|
||||
"SignedUrlExpirationMinutes": 30
|
||||
},
|
||||
"AliyunOss": {
|
||||
"AccessKeyId": "OSS_ACCESS_KEY_ID",
|
||||
"AccessKeySecret": "OSS_ACCESS_KEY_SECRET",
|
||||
"Endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
|
||||
"Bucket": "takeout-files",
|
||||
"CdnBaseUrl": "",
|
||||
"UseHttps": true
|
||||
},
|
||||
"Security": {
|
||||
"MaxFileSizeBytes": 10485760,
|
||||
"AllowedImageExtensions": [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".gif"
|
||||
],
|
||||
"AllowedFileExtensions": [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".gif",
|
||||
".pdf"
|
||||
],
|
||||
"DefaultUrlExpirationMinutes": 30,
|
||||
"EnableRefererValidation": true,
|
||||
"AllowedReferers": [
|
||||
"https://admin.example.com",
|
||||
"https://miniapp.example.com"
|
||||
],
|
||||
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
|
||||
}
|
||||
},
|
||||
"Sms": {
|
||||
"Provider": "Tencent",
|
||||
"DefaultSignName": "外卖SaaS",
|
||||
"UseMock": false,
|
||||
"Tencent": {
|
||||
"SecretId": "TENCENT_SMS_SECRET_ID",
|
||||
"SecretKey": "TENCENT_SMS_SECRET_KEY",
|
||||
"SdkAppId": "1400000000",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "ap-beijing",
|
||||
"Endpoint": "https://sms.tencentcloudapi.com"
|
||||
},
|
||||
"Aliyun": {
|
||||
"AccessKeyId": "ALIYUN_SMS_AK",
|
||||
"AccessKeySecret": "ALIYUN_SMS_SK",
|
||||
"Endpoint": "dysmsapi.aliyuncs.com",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "cn-hangzhou"
|
||||
},
|
||||
"SceneTemplates": {
|
||||
"login": "LOGIN_TEMPLATE_ID",
|
||||
"register": "REGISTER_TEMPLATE_ID",
|
||||
"reset": "RESET_TEMPLATE_ID",
|
||||
"member_message": "MEMBER_MESSAGE_TEMPLATE_ID"
|
||||
},
|
||||
"VerificationCode": {
|
||||
"CodeLength": 6,
|
||||
"ExpireMinutes": 5,
|
||||
"CooldownSeconds": 60,
|
||||
"CachePrefix": "sms:code"
|
||||
}
|
||||
},
|
||||
"MemberMessaging": {
|
||||
"SmsScene": "member_message",
|
||||
"WeChatMini": {
|
||||
"AppId": "WECHAT_MINI_APP_ID",
|
||||
"AppSecret": "WECHAT_MINI_APP_SECRET",
|
||||
"SubscribeTemplateId": "WECHAT_SUBSCRIBE_TEMPLATE_ID",
|
||||
"PagePath": "pages/member/message-center/index",
|
||||
"TitleDataKey": "thing1",
|
||||
"ContentDataKey": "thing2"
|
||||
}
|
||||
},
|
||||
"Scheduler": {
|
||||
"ConnectionString": "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",
|
||||
"WorkerCount": 10,
|
||||
"DashboardEnabled": false,
|
||||
"DashboardPath": "/hangfire",
|
||||
"SubscriptionAutomation": {
|
||||
"AutoRenewalExecuteHourUtc": 1,
|
||||
"AutoRenewalDaysBeforeExpiry": 3,
|
||||
"RenewalReminderExecuteHourUtc": 10,
|
||||
"ReminderDaysBeforeExpiry": [
|
||||
7,
|
||||
3,
|
||||
1
|
||||
],
|
||||
"SubscriptionExpiryCheckExecuteHourUtc": 2,
|
||||
"GracePeriodDays": 7
|
||||
},
|
||||
"BillingAutomation": {
|
||||
"OverdueBillingProcessCron": "*/10 * * * *"
|
||||
}
|
||||
},
|
||||
"Otel": {
|
||||
"Endpoint": "",
|
||||
"Sampling": "ParentBasedAlwaysOn",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace TakeoutSaaS.Application.App.Common.Geo;
|
||||
|
||||
/// <summary>
|
||||
/// 地址地理编码结果。
|
||||
/// </summary>
|
||||
public sealed record AddressGeocodingResult(
|
||||
bool Succeeded,
|
||||
decimal? Latitude,
|
||||
decimal? Longitude,
|
||||
string? Message)
|
||||
{
|
||||
/// <summary>
|
||||
/// 构建成功结果。
|
||||
/// </summary>
|
||||
public static AddressGeocodingResult Success(decimal latitude, decimal longitude)
|
||||
=> new(true, latitude, longitude, null);
|
||||
|
||||
/// <summary>
|
||||
/// 构建失败结果。
|
||||
/// </summary>
|
||||
public static AddressGeocodingResult Failed(string? message)
|
||||
=> new(false, null, null, message);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user