Compare commits
85 Commits
3a94348cca
...
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 |
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Deploy TenantApi
|
name: Build and Deploy TenantApi + SkuWorker
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -30,18 +30,34 @@ jobs:
|
|||||||
- name: Build on host
|
- name: Build on host
|
||||||
run: |
|
run: |
|
||||||
cd /opt/deploy/tenantapi
|
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/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: |
|
run: |
|
||||||
cd /opt/deploy/tenantapi
|
cd /opt/deploy/tenantapi
|
||||||
docker build -t takeoutsaas-tenantapi:latest -f src/Api/TakeoutSaaS.TenantApi/Dockerfile .
|
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: |
|
run: |
|
||||||
docker stop tenantapi || true
|
docker stop tenantapi || true
|
||||||
docker rm tenantapi || true
|
docker rm tenantapi || true
|
||||||
|
docker stop skuworker || true
|
||||||
|
docker rm skuworker || true
|
||||||
|
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name tenantapi \
|
--name tenantapi \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
@@ -49,6 +65,12 @@ jobs:
|
|||||||
-e ASPNETCORE_ENVIRONMENT=Development \
|
-e ASPNETCORE_ENVIRONMENT=Development \
|
||||||
takeoutsaas-tenantapi:latest
|
takeoutsaas-tenantapi:latest
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name skuworker \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-e ASPNETCORE_ENVIRONMENT=Development \
|
||||||
|
takeoutsaas-skuworker:latest
|
||||||
|
|
||||||
- name: Clean up old images
|
- name: Clean up old images
|
||||||
run: |
|
run: |
|
||||||
docker image prune -f
|
docker image prune -f
|
||||||
|
|||||||
Submodule TakeoutSaaS.Docs updated: de7aefd0ff...6daa444c5e
@@ -47,6 +47,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Sms", "s
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.TenantApi", "src\Api\TakeoutSaaS.TenantApi\TakeoutSaaS.TenantApi.csproj", "{F53E274A-838A-477A-8D29-6EEB0DBD62CD}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.TenantApi", "src\Api\TakeoutSaaS.TenantApi\TakeoutSaaS.TenantApi.csproj", "{F53E274A-838A-477A-8D29-6EEB0DBD62CD}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -56,7 +60,7 @@ Global
|
|||||||
Release|x64 = Release|x64
|
Release|x64 = Release|x64
|
||||||
Release|x86 = Release|x86
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
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.ActiveCfg = Debug|Any CPU
|
||||||
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Debug|Any CPU.Build.0 = 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
|
{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.ActiveCfg = Release|Any CPU
|
||||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x64.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x86.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|x64.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|x86.Build.0 = 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|x64.Build.0 = Release|Any CPU
|
||||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -259,9 +275,11 @@ Global
|
|||||||
{05058F44-6FB7-43AF-8648-8BF538E283EF} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
{05058F44-6FB7-43AF-8648-8BF538E283EF} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||||
{5C12177E-6C25-4F78-BFD4-AA073CFC0650} = {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}
|
{5ADA37B6-09A0-48F7-8633-C266FD5BBFD1} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||||
{FE49A9E7-1228-45BA-9B71-337AA353FE98} = {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}
|
{9C2F510E-4054-482D-AFD3-D2E374D60304} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B} = {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}
|
{F53E274A-838A-477A-8D29-6EEB0DBD62CD} = {81034408-37C8-1011-444E-4C15C2FADA8E}
|
||||||
EndGlobalSection
|
{89BA21D6-604E-9DA1-5F6C-9062FD58212E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{1482D41E-4F00-4B21-8CC0-9025FA41E5E3} = {89BA21D6-604E-9DA1-5F6C-9062FD58212E}
|
||||||
|
EndGlobalSection
|
||||||
EndGlobal
|
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 MediatR;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TakeoutSaaS.Application.App.Merchants.Commands;
|
||||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||||
using TakeoutSaaS.Application.App.Merchants.Queries;
|
using TakeoutSaaS.Application.App.Merchants.Queries;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
using TakeoutSaaS.Shared.Web.Api;
|
using TakeoutSaaS.Shared.Web.Api;
|
||||||
|
using TakeoutSaaS.TenantApi.Services;
|
||||||
|
|
||||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||||
|
|
||||||
@@ -14,7 +18,10 @@ namespace TakeoutSaaS.TenantApi.Controllers;
|
|||||||
[ApiVersion("1.0")]
|
[ApiVersion("1.0")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/tenant/v{version:apiVersion}/merchant")]
|
[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>
|
/// <summary>
|
||||||
/// 获取当前登录用户对应的商户中心信息。
|
/// 获取当前登录用户对应的商户中心信息。
|
||||||
@@ -32,4 +39,106 @@ public sealed class MerchantController(IMediator mediator) : BaseApiController
|
|||||||
// 2. 返回聚合信息
|
// 2. 返回聚合信息
|
||||||
return ApiResponse<CurrentMerchantCenterDto>.Ok(info);
|
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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,11 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using TakeoutSaaS.Application.App.Stores.Commands;
|
using TakeoutSaaS.Application.App.Stores.Commands;
|
||||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||||
using TakeoutSaaS.Application.App.Stores.Queries;
|
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.Abstractions.Results;
|
||||||
using TakeoutSaaS.Shared.Web.Api;
|
using TakeoutSaaS.Shared.Web.Api;
|
||||||
|
using TakeoutSaaS.TenantApi.Services;
|
||||||
|
|
||||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||||
|
|
||||||
@@ -15,7 +18,10 @@ namespace TakeoutSaaS.TenantApi.Controllers;
|
|||||||
[ApiVersion("1.0")]
|
[ApiVersion("1.0")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||||
public sealed class StoreController(IMediator mediator) : BaseApiController
|
public sealed class StoreController(
|
||||||
|
IMediator mediator,
|
||||||
|
StoreContextService storeContextService,
|
||||||
|
GeoLocationOrchestrator geoLocationOrchestrator) : BaseApiController
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查询门店列表。
|
/// 查询门店列表。
|
||||||
@@ -108,5 +114,53 @@ public sealed class StoreController(IMediator mediator) : BaseApiController
|
|||||||
// 2. 返回成功响应
|
// 2. 返回成功响应
|
||||||
return ApiResponse<object>.Ok(null);
|
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,20 +1,32 @@
|
|||||||
using Asp.Versioning;
|
using Asp.Versioning;
|
||||||
using Asp.Versioning.ApiExplorer;
|
using Asp.Versioning.ApiExplorer;
|
||||||
|
using MassTransit;
|
||||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using OpenTelemetry.Metrics;
|
using OpenTelemetry.Metrics;
|
||||||
using OpenTelemetry.Resources;
|
using OpenTelemetry.Resources;
|
||||||
using OpenTelemetry.Trace;
|
using OpenTelemetry.Trace;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
using TakeoutSaaS.Application.App.Common.Geo;
|
||||||
using TakeoutSaaS.Application.App.Extensions;
|
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.Dictionary.Extensions;
|
||||||
using TakeoutSaaS.Application.Identity.Extensions;
|
using TakeoutSaaS.Application.Identity.Extensions;
|
||||||
using TakeoutSaaS.Application.Messaging.Extensions;
|
using TakeoutSaaS.Application.Messaging.Extensions;
|
||||||
|
using TakeoutSaaS.Application.Sms.Extensions;
|
||||||
|
using TakeoutSaaS.Application.Storage.Extensions;
|
||||||
using TakeoutSaaS.Infrastructure.App.Extensions;
|
using TakeoutSaaS.Infrastructure.App.Extensions;
|
||||||
using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
|
using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
|
||||||
using TakeoutSaaS.Infrastructure.Identity.Extensions;
|
using TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||||
using TakeoutSaaS.Module.Authorization.Extensions;
|
using TakeoutSaaS.Module.Authorization.Extensions;
|
||||||
using TakeoutSaaS.Module.Messaging.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.Module.Tenancy.Extensions;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||||
@@ -22,6 +34,10 @@ using TakeoutSaaS.Shared.Web.Extensions;
|
|||||||
using TakeoutSaaS.Shared.Web.Filters;
|
using TakeoutSaaS.Shared.Web.Filters;
|
||||||
using TakeoutSaaS.Shared.Web.Security;
|
using TakeoutSaaS.Shared.Web.Security;
|
||||||
using TakeoutSaaS.Shared.Web.Swagger;
|
using TakeoutSaaS.Shared.Web.Swagger;
|
||||||
|
using TakeoutSaaS.TenantApi.Consumers;
|
||||||
|
using TakeoutSaaS.TenantApi.Hubs;
|
||||||
|
using TakeoutSaaS.TenantApi.Options;
|
||||||
|
using TakeoutSaaS.TenantApi.Services;
|
||||||
|
|
||||||
// 1. 创建构建器与日志模板
|
// 1. 创建构建器与日志模板
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -91,8 +107,22 @@ builder.Services.AddAuthorization();
|
|||||||
builder.Services.AddPermissionAuthorization();
|
builder.Services.AddPermissionAuthorization();
|
||||||
builder.Services.AddHealthChecks();
|
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. 注册应用层与基础设施(仅租户侧所需)
|
// 6. 注册应用层与基础设施(仅租户侧所需)
|
||||||
builder.Services.AddAppApplication();
|
builder.Services.AddAppApplication();
|
||||||
|
builder.Services.AddSmsApplication(builder.Configuration);
|
||||||
builder.Services.AddIdentityApplication(enableMiniSupport: false);
|
builder.Services.AddIdentityApplication(enableMiniSupport: false);
|
||||||
builder.Services.AddAppInfrastructure(builder.Configuration);
|
builder.Services.AddAppInfrastructure(builder.Configuration);
|
||||||
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
|
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
|
||||||
@@ -107,6 +137,65 @@ builder.Services.AddDictionaryInfrastructure(builder.Configuration);
|
|||||||
// 9. 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现)
|
// 9. 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现)
|
||||||
builder.Services.AddMessagingApplication();
|
builder.Services.AddMessagingApplication();
|
||||||
builder.Services.AddMessagingModule(builder.Configuration);
|
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>();
|
||||||
|
|
||||||
|
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 采集
|
// 10. 配置 OpenTelemetry 采集
|
||||||
var otelSection = builder.Configuration.GetSection("Otel");
|
var otelSection = builder.Configuration.GetSection("Otel");
|
||||||
@@ -181,6 +270,7 @@ app.UseSharedWebCore();
|
|||||||
|
|
||||||
// 4. (空行后) 执行授权
|
// 4. (空行后) 执行授权
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
app.UseSchedulerDashboard(builder.Configuration);
|
||||||
|
|
||||||
// 5. (空行后) 开发环境启用 Swagger
|
// 5. (空行后) 开发环境启用 Swagger
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
@@ -189,6 +279,7 @@ if (app.Environment.IsDevelopment())
|
|||||||
}
|
}
|
||||||
app.MapHealthChecks("/healthz");
|
app.MapHealthChecks("/healthz");
|
||||||
app.MapPrometheusScrapingEndpoint();
|
app.MapPrometheusScrapingEndpoint();
|
||||||
|
app.MapHub<OrderBoardHub>("/hubs/order-board");
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
@@ -207,7 +298,10 @@ static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
|
|||||||
{
|
{
|
||||||
if (origins.Length == 0)
|
if (origins.Length == 0)
|
||||||
{
|
{
|
||||||
policy.AllowAnyOrigin();
|
// SignalR 需要 AllowCredentials,与 AllowAnyOrigin 互斥,
|
||||||
|
// 因此无配置时使用 SetIsOriginAllowed 替代。
|
||||||
|
policy.SetIsOriginAllowed(_ => true)
|
||||||
|
.AllowCredentials();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.OpenTelemetryProtocol" Version="1.14.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.Console" 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="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.14.0-beta.1" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
|
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Authorization\TakeoutSaaS.Module.Authorization.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" />
|
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -60,9 +60,135 @@
|
|||||||
"CodeTenantMap": {},
|
"CodeTenantMap": {},
|
||||||
"ThrowIfUnresolved": false
|
"ThrowIfUnresolved": false
|
||||||
},
|
},
|
||||||
|
"TencentMap": {
|
||||||
|
"BaseUrl": "https://apis.map.qq.com",
|
||||||
|
"GeocoderPath": "/ws/geocoder/v1/",
|
||||||
|
"WebServiceKey": "DGIBZ-YUM64-5ONUT-F4RIA-MPUP2-XFFXZ",
|
||||||
|
"WebServiceSecret": "6ztzMqwtuOyaJLuBs1koRDyqNpnVyda8"
|
||||||
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"Tenant": []
|
"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": {
|
"Otel": {
|
||||||
"Endpoint": "",
|
"Endpoint": "",
|
||||||
"Sampling": "ParentBasedAlwaysOn",
|
"Sampling": "ParentBasedAlwaysOn",
|
||||||
|
|||||||
@@ -56,11 +56,137 @@
|
|||||||
"/health",
|
"/health",
|
||||||
"/healthz"
|
"/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": {
|
"Cors": {
|
||||||
"Tenant": []
|
"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": {
|
"Otel": {
|
||||||
"Endpoint": "",
|
"Endpoint": "",
|
||||||
"Sampling": "ParentBasedAlwaysOn",
|
"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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Stores.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Common.Geo;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 地理定位地址构建器。
|
||||||
|
/// </summary>
|
||||||
|
public static class GeoAddressBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 构建门店地理编码候选地址。
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string> BuildStoreCandidates(Store store, Merchant? merchant = null)
|
||||||
|
{
|
||||||
|
var candidates = new List<string>();
|
||||||
|
AddCandidate(candidates, BuildAddress(store.Province, store.City, store.District, store.Address));
|
||||||
|
AddCandidate(candidates, store.Address);
|
||||||
|
|
||||||
|
if (merchant is not null)
|
||||||
|
{
|
||||||
|
AddCandidate(candidates, BuildAddress(merchant.Province, merchant.City, merchant.District, merchant.Address));
|
||||||
|
AddCandidate(candidates, BuildAddress(merchant.Province, merchant.City, merchant.District));
|
||||||
|
AddCandidate(candidates, BuildAddress(merchant.City, merchant.District));
|
||||||
|
AddCandidate(candidates, merchant.Address);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构建商户地理编码候选地址。
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string> BuildMerchantCandidates(Merchant merchant)
|
||||||
|
{
|
||||||
|
var candidates = new List<string>();
|
||||||
|
AddCandidate(candidates, BuildAddress(merchant.Province, merchant.City, merchant.District, merchant.Address));
|
||||||
|
AddCandidate(candidates, merchant.Address);
|
||||||
|
AddCandidate(candidates, BuildAddress(merchant.Province, merchant.City, merchant.District));
|
||||||
|
AddCandidate(candidates, BuildAddress(merchant.City, merchant.District));
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构建租户地理编码候选地址。
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string> BuildTenantCandidates(Tenant tenant)
|
||||||
|
{
|
||||||
|
var candidates = new List<string>();
|
||||||
|
AddCandidate(candidates, BuildAddress(tenant.Country, tenant.Province, tenant.City, tenant.Address));
|
||||||
|
AddCandidate(candidates, tenant.Address);
|
||||||
|
AddCandidate(candidates, BuildAddress(tenant.Country, tenant.Province, tenant.City));
|
||||||
|
AddCandidate(candidates, BuildAddress(tenant.Province, tenant.City));
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildAddress(params string?[] parts)
|
||||||
|
{
|
||||||
|
var normalized = parts
|
||||||
|
.Select(part => part?.Trim())
|
||||||
|
.Where(part => !string.IsNullOrWhiteSpace(part))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return normalized.Length == 0 ? string.Empty : string.Join(string.Empty, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddCandidate(ICollection<string> candidates, string? candidate)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(candidate))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.Any(existing => string.Equals(existing, candidate, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.Add(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using TakeoutSaaS.Domain.Common.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Stores.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Common.Geo;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 地理定位状态写入助手。
|
||||||
|
/// </summary>
|
||||||
|
public static class GeoLocationStateHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 最大重试次数。
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxRetryCount = 5;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取重试间隔。
|
||||||
|
/// </summary>
|
||||||
|
public static TimeSpan GetRetryDelay(int retryCount)
|
||||||
|
{
|
||||||
|
return retryCount switch
|
||||||
|
{
|
||||||
|
<= 1 => TimeSpan.FromMinutes(1),
|
||||||
|
2 => TimeSpan.FromMinutes(5),
|
||||||
|
3 => TimeSpan.FromMinutes(30),
|
||||||
|
4 => TimeSpan.FromHours(2),
|
||||||
|
_ => TimeSpan.FromHours(12)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入门店定位成功状态。
|
||||||
|
/// </summary>
|
||||||
|
public static void MarkSuccess(Store store, double latitude, double longitude, DateTime now)
|
||||||
|
{
|
||||||
|
store.Latitude = latitude;
|
||||||
|
store.Longitude = longitude;
|
||||||
|
store.GeoStatus = GeoLocationStatus.Success;
|
||||||
|
store.GeoFailReason = null;
|
||||||
|
store.GeoRetryCount = 0;
|
||||||
|
store.GeoUpdatedAt = now;
|
||||||
|
store.GeoNextRetryAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入商户定位成功状态。
|
||||||
|
/// </summary>
|
||||||
|
public static void MarkSuccess(Merchant merchant, double latitude, double longitude, DateTime now)
|
||||||
|
{
|
||||||
|
merchant.Latitude = latitude;
|
||||||
|
merchant.Longitude = longitude;
|
||||||
|
merchant.GeoStatus = GeoLocationStatus.Success;
|
||||||
|
merchant.GeoFailReason = null;
|
||||||
|
merchant.GeoRetryCount = 0;
|
||||||
|
merchant.GeoUpdatedAt = now;
|
||||||
|
merchant.GeoNextRetryAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入租户定位成功状态。
|
||||||
|
/// </summary>
|
||||||
|
public static void MarkSuccess(Tenant tenant, double latitude, double longitude, DateTime now)
|
||||||
|
{
|
||||||
|
tenant.Latitude = latitude;
|
||||||
|
tenant.Longitude = longitude;
|
||||||
|
tenant.GeoStatus = GeoLocationStatus.Success;
|
||||||
|
tenant.GeoFailReason = null;
|
||||||
|
tenant.GeoRetryCount = 0;
|
||||||
|
tenant.GeoUpdatedAt = now;
|
||||||
|
tenant.GeoNextRetryAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入门店待重试状态。
|
||||||
|
/// </summary>
|
||||||
|
public static void MarkPending(Store store, string? reason, DateTime now)
|
||||||
|
{
|
||||||
|
store.GeoStatus = GeoLocationStatus.Pending;
|
||||||
|
store.GeoFailReason = reason;
|
||||||
|
store.GeoRetryCount = 0;
|
||||||
|
store.GeoNextRetryAt = now.Add(GetRetryDelay(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入商户待重试状态。
|
||||||
|
/// </summary>
|
||||||
|
public static void MarkPending(Merchant merchant, string? reason, DateTime now)
|
||||||
|
{
|
||||||
|
merchant.GeoStatus = GeoLocationStatus.Pending;
|
||||||
|
merchant.GeoFailReason = reason;
|
||||||
|
merchant.GeoRetryCount = 0;
|
||||||
|
merchant.GeoNextRetryAt = now.Add(GetRetryDelay(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入租户待重试状态。
|
||||||
|
/// </summary>
|
||||||
|
public static void MarkPending(Tenant tenant, string? reason, DateTime now)
|
||||||
|
{
|
||||||
|
tenant.GeoStatus = GeoLocationStatus.Pending;
|
||||||
|
tenant.GeoFailReason = reason;
|
||||||
|
tenant.GeoRetryCount = 0;
|
||||||
|
tenant.GeoNextRetryAt = now.Add(GetRetryDelay(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入门店重试失败状态。
|
||||||
|
/// </summary>
|
||||||
|
public static void MarkRetryFailure(Store store, string? reason, DateTime now)
|
||||||
|
{
|
||||||
|
var nextRetryCount = store.GeoRetryCount + 1;
|
||||||
|
store.GeoRetryCount = nextRetryCount;
|
||||||
|
store.GeoFailReason = reason;
|
||||||
|
if (nextRetryCount >= MaxRetryCount)
|
||||||
|
{
|
||||||
|
store.GeoStatus = GeoLocationStatus.Failed;
|
||||||
|
store.GeoNextRetryAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.GeoStatus = GeoLocationStatus.Pending;
|
||||||
|
store.GeoNextRetryAt = now.Add(GetRetryDelay(nextRetryCount + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入商户重试失败状态。
|
||||||
|
/// </summary>
|
||||||
|
public static void MarkRetryFailure(Merchant merchant, string? reason, DateTime now)
|
||||||
|
{
|
||||||
|
var nextRetryCount = merchant.GeoRetryCount + 1;
|
||||||
|
merchant.GeoRetryCount = nextRetryCount;
|
||||||
|
merchant.GeoFailReason = reason;
|
||||||
|
if (nextRetryCount >= MaxRetryCount)
|
||||||
|
{
|
||||||
|
merchant.GeoStatus = GeoLocationStatus.Failed;
|
||||||
|
merchant.GeoNextRetryAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
merchant.GeoStatus = GeoLocationStatus.Pending;
|
||||||
|
merchant.GeoNextRetryAt = now.Add(GetRetryDelay(nextRetryCount + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入租户重试失败状态。
|
||||||
|
/// </summary>
|
||||||
|
public static void MarkRetryFailure(Tenant tenant, string? reason, DateTime now)
|
||||||
|
{
|
||||||
|
var nextRetryCount = tenant.GeoRetryCount + 1;
|
||||||
|
tenant.GeoRetryCount = nextRetryCount;
|
||||||
|
tenant.GeoFailReason = reason;
|
||||||
|
if (nextRetryCount >= MaxRetryCount)
|
||||||
|
{
|
||||||
|
tenant.GeoStatus = GeoLocationStatus.Failed;
|
||||||
|
tenant.GeoNextRetryAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant.GeoStatus = GeoLocationStatus.Pending;
|
||||||
|
tenant.GeoNextRetryAt = now.Add(GetRetryDelay(nextRetryCount + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user