Compare commits

..

85 Commits

Author SHA1 Message Date
2ba8c0732b Merge pull request 'feat(finance): 财务概览模块 1:1 还原' (#10) from feature/finance-overview-1to1 into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 2m21s
2026-03-05 03:12:11 +00:00
f7eba55039 feat(finance): add overview dashboard and platform fee rate 2026-03-05 10:47:15 +08:00
fdbefca650 merge: bring finance report api changes into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 26s
2026-03-04 22:03:57 +08:00
4a7d012a58 merge: bring member points mall changes into dev 2026-03-04 22:02:45 +08:00
d330db84fc Merge pull request #6 from msumshk/feature/finance-invoice-1to1
Feature/finance invoice 1to1
2026-03-04 21:23:17 +08:00
c79e9bd6e8 feat(finance): 完成发票管理模块后端实现 2026-03-04 21:13:33 +08:00
3f308c2d0c chore(docs): update submodule with finance scripts 2026-03-04 17:07:43 +08:00
5dfaac01fd feat(finance): implement invoice and business report backend modules 2026-03-04 16:57:06 +08:00
21a689edec Merge pull request #5 from msumshk/feature/finance-cost-1to1
feat(finance): add cost management backend module
2026-03-04 16:15:02 +08:00
fa6e376b86 feat(finance): add cost management backend module 2026-03-04 16:07:16 +08:00
76366cbc30 Merge pull request #4 from msumshk/feature/finance-report-1to1
feat(finance): add tenant settlement query backend
2026-03-04 16:00:02 +08:00
b0bb87d97c feat(finance): add tenant settlement query backend 2026-03-04 15:48:37 +08:00
b5aa060faf feat(member): add message reach backend module and docs seeds 2026-03-04 13:35:22 +08:00
39e28c1a62 Merge pull request #3 from msumshk/feature/member-points-mall-1to1
feat(member): implement points mall backend module
2026-03-04 12:32:58 +08:00
dd2ac79d48 Merge pull request #2 from msumshk/feature/member-message-reach-module
feat: 完成会员消息触达后端模块
2026-03-04 12:17:33 +08:00
bd418c5927 feat(member): implement points mall backend module 2026-03-04 12:15:18 +08:00
a8cfda88f7 feat: 完成会员消息触达后端模块 2026-03-04 11:53:52 +08:00
e4f7ceeaa7 Merge pull request #1 from msumshk/feature/finance-transaction-module
feat: 新增财务交易流水后端模块
2026-03-04 11:38:34 +08:00
d437b146d1 feat: 新增财务交易流水后端模块 2026-03-04 11:33:29 +08:00
2970134200 feat: implement tenant member stored card module
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m24s
2026-03-04 09:14:57 +08:00
d96ca4971a feat(member): implement member center management module
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 1m54s
2026-03-03 20:38:31 +08:00
c2821202c7 chore(docs): bump docs submodule for customer analysis seed 2026-03-03 16:46:22 +08:00
26afffd874 feat(customer): add customer analysis query APIs 2026-03-03 16:45:39 +08:00
a993b81aeb feat(customer): 完成客户画像会员摘要与权限链路
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m2s
2026-03-03 14:39:33 +08:00
1b28fa6db4 feat(marketing): implement marketing calendar backend
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m59s
2026-03-03 10:10:41 +08:00
decfa4fa12 fix: resolve ProductBatchToolController xml doc warnings
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m6s
2026-03-03 08:38:42 +08:00
3b3bdcee71 feat: implement marketing punch card backend module 2026-03-02 21:43:09 +08:00
6588c85f27 feat(marketing): add new customer gift backend module 2026-03-02 15:58:06 +08:00
c9e2226b48 feat(marketing): implement tenant seckill backend module
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m55s
2026-03-02 13:08:56 +08:00
0f3542f33f feat(marketing): implement flash sale module api and app layer
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m4s
2026-03-02 11:08:14 +08:00
5a6da9be0c feat(marketing): add full reduction campaign api module
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m50s
2026-02-28 15:46:21 +08:00
dda3f96d28 feat: 完成营销中心优惠券后端模块
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m54s
2026-02-28 11:14:55 +08:00
04e76cd519 feat: 添加订单超时自动取消与 SMS 告警任务
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m50s
- OrderTimeoutJob 替换占位实现为真实逻辑(查询超时订单 → 批量取消 → 发布事件)
- 新增 OrderEscalationJob(每 2 分钟检查超时未接单订单,预留 SMS 告警)
- 新增 OrderTimeoutOptions(AutoCancelMinutes=15, SmsEscalationMinutes=10)
- RecurringJobRegistrar 注册 OrderEscalationJob
- SchedulerServiceCollectionExtensions 注册 Job + Options
2026-02-27 13:11:01 +08:00
3c423f87d4 feat: 添加订单大厅看板 API(四列数据 + 统计 + 重连补偿 + 操作端点)
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Has been cancelled
- 新增 OrderBoardCardDto、OrderBoardResultDto、OrderBoardStatsDto
- 新增看板查询 Query + Handler(board/stats/pending-since)
- IOrderRepository 扩展 GetActiveOrdersAsync、GetOrdersChangedSinceAsync
- EfOrderRepository 实现看板查询方法
- 新增 OrderBoardController(GET board/stats/pending-since + POST accept/reject/complete/confirm)
- 新增 RejectOrderRequest 契约
2026-02-27 13:10:13 +08:00
1e5f0b2f93 feat: 添加支付回调链路(支付成功 → 订单状态流转 → SignalR 推送)
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 12s
- 新增 ProcessPaymentCallbackCommand + Handler(幂等处理)
- 丰富 PaymentSucceededEvent(StoreId、OrderNo、Channel 等字段)
- 新增 PaymentSucceededConsumer(支付成功 → NewOrder 推送)
- 新增 PaymentCallbackController(微信/支付宝预留 + 内部测试端点)
- Program.cs 注册 PaymentSucceededConsumer
2026-02-27 13:09:25 +08:00
bb2ff6167e feat: 添加订单事件管道与状态流转命令
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 12s
- 新增 OrderStatusChangedEvent、OrderUrgeEvent 事件类型
- 扩展 EventRoutingKeys(orders.status-changed、orders.urged)
- 丰富 OrderCreatedEvent(StoreId、Channel、DeliveryType 等字段)
- CreateOrderCommandHandler 注入 IEventPublisher 并发布事件
- 新增接单/拒单/出餐完成/确认送达 Command + Handler(4对)
- 新增 MassTransit Consumer(OrderCreated/StatusChanged/Urge → SignalR)
- Program.cs 注册 3 个 Consumer
2026-02-27 13:08:41 +08:00
7c06ac3e29 feat: 添加 SignalR 基础设施(Hub + Redis Backplane + JWT 认证)
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Has been cancelled
- 添加 Microsoft.AspNetCore.SignalR.StackExchangeRedis NuGet 包
- 新建 OrderBoardHub(连接/断开/切换门店 Group)
- 新建 SignalRJwtEvents(WebSocket query string JWT 提取)
- Program.cs 注册 SignalR + Redis Backplane + MapHub
- JwtAuthenticationExtensions 添加 OnMessageReceived 事件
- CORS 修复 AllowAnyOrigin 与 AllowCredentials 互斥问题
2026-02-27 13:07:51 +08:00
502f80473e fix(order): move all-orders list to database pagination
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m50s
2026-02-27 10:32:21 +08:00
b1c51c7712 fix(order): tighten payment method filter by paid status
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m49s
2026-02-27 10:24:53 +08:00
11a1521b6a feat(order): add all-orders APIs and query workflow
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m51s
2026-02-27 10:09:19 +08:00
dd90bdfe0f feat: 完成商品批量工具后端接口与Excel导入导出
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m49s
2026-02-26 12:07:48 +08:00
3f5ca9c3ee feat: 商品列表查询改为数据库分页过滤
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m53s
2026-02-26 10:50:20 +08:00
8f64eb897b fix: 修复PostgreSQL咨询锁函数参数不兼容
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m0s
2026-02-25 11:55:30 +08:00
7d3542735b ci: 流水线新增SkuWorker容器并自动执行数据库迁移
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m55s
2026-02-25 11:37:27 +08:00
77caac3af9 feat: SKU保存链路切换到RabbitMQ Outbox并新增独立Worker
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 51s
2026-02-25 11:20:38 +08:00
aeef4ca649 feat: 新增商品单接口异步保存并内置SKU入队
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 50s
2026-02-25 10:51:04 +08:00
18af62e111 fix: 空SKU编码时改为自动生成避免冲突
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 50s
2026-02-25 10:00:48 +08:00
474c0c88c0 fix: 提前校验SKU最终编码冲突并返回明确错误
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 52s
2026-02-25 09:55:38 +08:00
b970cd4ba7 fix: 修复SKU软删除冲突导致唯一索引报错
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 50s
2026-02-25 09:49:20 +08:00
21d7be5a4f fix: 增强SKU异步任务失败错误明细
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 51s
2026-02-25 09:39:21 +08:00
5fcc1e1484 feat: 商品SKU支持异步保存与软禁用替换策略
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 56s
2026-02-25 09:23:15 +08:00
c2a6cf7b1e fix(api): 修复商品保存事务重试冲突并兼容kind缺省
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 46s
2026-02-24 16:51:45 +08:00
2c7981b6d0 chore(tenantapi): set production tenancy root domain
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 49s
2026-02-24 15:59:19 +08:00
d66879f5cf feat(product): complete combo and detail editing data model
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 47s
2026-02-22 09:35:57 +08:00
f7c2ae4bac feat(product): add product list/detail/save/soldout/batch api support
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 47s
2026-02-21 17:23:48 +08:00
d41f69045f feat(product): add product schedule management api
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 46s
2026-02-21 11:46:55 +08:00
ad65ef3bf6 feat(product): add product label management backend
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s
2026-02-21 10:18:48 +08:00
93bc072b8d feat: 新增加料管理接口与模板能力
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s
2026-02-21 08:44:26 +08:00
848778b8b5 fix: 修复规格模板查询并发DbContext异常
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 44s
2026-02-21 07:42:02 +08:00
392d9f03a1 feat: 新增规格做法模板管理接口与数据模型
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 45s
2026-02-20 20:08:34 +08:00
1b3525862a feat: implement tenant product category management APIs
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 44s
2026-02-20 18:45:48 +08:00
eea8a53da3 chore(api): align tenant storage config with admin
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 46s
2026-02-20 16:45:54 +08:00
237e0a1a9f fix(api): register storage services in tenant api
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 46s
2026-02-20 16:33:46 +08:00
750346fdb2 feat(api): add tenant files upload endpoint
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 49s
2026-02-20 16:23:29 +08:00
48cf852d46 feat(project): 新增包装费模式切换接口
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 44s
2026-02-20 10:39:34 +08:00
adac05521a feat(store): add dedicated pickup mode save endpoint
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 46s
2026-02-20 10:09:33 +08:00
8a6fc867c2 refactor(store): remove fallback config behavior and expose isConfigured
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s
2026-02-20 09:55:11 +08:00
938da7469c fix: 排班替换删除时忽略软删过滤
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 42s
2026-02-20 09:21:43 +08:00
f2142de407 fix: 排班事务兼容ef重试策略
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 42s
2026-02-20 09:17:48 +08:00
9651022261 refactor: 排班替换改为事务内先删后增
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s
2026-02-20 09:14:39 +08:00
669db14f64 fix: 修复员工排班保存唯一键冲突
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s
2026-02-20 09:12:21 +08:00
069d7de05e fix: 员工排班移除mock与兜底逻辑
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s
2026-02-20 09:02:52 +08:00
145be01bfc fix(store-fees): 保留包装费配置避免阶梯刷新丢失
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 45s
2026-02-20 08:28:41 +08:00
787ebe6b73 fix(pickup): remove mocked fine-slot reservation preview
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s
2026-02-19 18:21:06 +08:00
7ecf069efd fix(pickup): prevent null rowversion on settings and slots save
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s
2026-02-19 17:57:52 +08:00
53f7c54c82 feat(geo): add tenant/merchant/store geocode fallback and retry workflow
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s
2026-02-19 17:13:00 +08:00
ad245078a2 feat: 提交配送中心点与门店地址映射全量变更
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 42s
2026-02-19 16:12:36 +08:00
d5b22a8d85 fix: 配送地图地理编码改为后端签名代理
Some checks failed
Build and Deploy TenantApi / build-and-deploy (push) Failing after 36s
2026-02-19 16:05:57 +08:00
2c086d1149 fix(infra): auto-apply app and dictionary migrations on startup
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 41s
2026-02-18 20:41:22 +08:00
c853fd8edf fix(store-hours): reject overlapping holiday date ranges
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 42s
2026-02-18 20:36:33 +08:00
8139c08b35 fix(store): enforce UTC holiday dates and exclude deleted stores
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 42s
2026-02-18 17:25:34 +08:00
dfa2b3ee52 feat(store): add quick business toggle endpoint and auto-approve creation
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 42s
2026-02-18 10:03:27 +08:00
be701d8cf6 fix(store): default new store business status to resting
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 42s
2026-02-18 08:44:14 +08:00
b3429e2a0d feat(store): auto-generate store code on create and make update code optional
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 44s
2026-02-18 08:27:37 +08:00
1b185af718 feat: 完成门店管理剩余接口并补齐文档注释
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 46s
2026-02-17 14:54:35 +08:00
817 changed files with 247094 additions and 254 deletions

View File

@@ -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

View File

@@ -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

View 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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; } = [];
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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";
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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";
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}

View 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; }
}

View File

@@ -0,0 +1,12 @@
namespace TakeoutSaaS.TenantApi.Contracts.OrderBoard;
/// <summary>
/// 拒单请求体。
/// </summary>
public sealed record RejectOrderRequest
{
/// <summary>
/// 拒单原因。
/// </summary>
public required string Reason { get; init; }
}

View File

@@ -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;
}

View File

@@ -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; } = [];
}

View File

@@ -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";
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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";
}

View File

@@ -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);
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -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()
};
}
}

View File

@@ -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
};
}
}

View File

@@ -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
};
}
}

View File

@@ -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>
/// 批量导出报表 ZIPPDF + 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
};
}
}

View File

@@ -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)
};
}
}

View File

@@ -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
};
}
}

View File

@@ -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()
};
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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()
};
}
}

View File

@@ -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;
}
}

View File

@@ -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" => "手动核销",
_ => "未知"
};
}
}

View File

@@ -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" => "余额",
_ => "未知"
};
}
}

View File

@@ -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
};
}
}

View File

@@ -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; }
} }

View File

@@ -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
};
}
}

View 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 => "待取餐",
_ => "待处理"
};
}
}

View File

@@ -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; }
}

View File

@@ -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

View File

@@ -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
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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")
};
}
}

View File

@@ -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")
};
}
}

View File

@@ -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")
};
}
}

View 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);
}
}

View File

@@ -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);
}
} }

View File

@@ -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));
}
}

View File

@@ -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()
};
}
}

View 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";
}
}

View File

@@ -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
}
}

View File

@@ -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 => "周日",
_ => "周一"
};
}
}

View File

@@ -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;
}
}

View 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;
}
}
}

View 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 SecretKeySK
/// </summary>
public string WebServiceSecret { get; set; } = string.Empty;
}

View File

@@ -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 需要 AllowCredentialsAllowAnyOrigin 互斥,
// 因此无配置时使用 SetIsOriginAllowed 替代。
policy.SetIsOriginAllowed(_ => true)
.AllowCredentials();
} }
else else
{ {

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View 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; } = [];
}

View File

@@ -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; }
}
}

View File

@@ -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>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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