From 4b53862ded338fe69c023d02bce12b3cceec5e4c Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 18 Dec 2025 11:24:44 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20=E5=AE=8C=E6=88=90=E8=B4=A6?= =?UTF-8?q?=E5=8D=95=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=BC=80=E5=8F=91=E5=8F=8AAPI=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心功能: - 账单CRUD操作(创建、查询、详情、更新状态、删除) - 支付记录管理(创建支付、审核支付) - 批量操作支持(批量更新账单状态) - 统计分析功能(账单统计、逾期账单查询) - 导出功能(Excel/PDF/CSV) API端点 (16个): - GET /api/admin/v1/billings - 账单列表(分页、筛选、排序) - POST /api/admin/v1/billings - 创建账单 - GET /api/admin/v1/billings/{id} - 账单详情 - DELETE /api/admin/v1/billings/{id} - 删除账单 - PUT /api/admin/v1/billings/{id}/status - 更新状态 - POST /api/admin/v1/billings/batch/status - 批量更新 - GET /api/admin/v1/billings/{id}/payments - 支付记录 - POST /api/admin/v1/billings/{id}/payments - 创建支付 - PUT /api/admin/v1/billings/payments/{paymentId}/verify - 审核支付 - GET /api/admin/v1/billings/statistics - 统计数据 - GET /api/admin/v1/billings/overdue - 逾期账单 - POST /api/admin/v1/billings/export - 导出账单 架构优化: - 采用CQRS模式分离读写(MediatR + Dapper + EF Core) - 完整的领域模型设计(TenantBillingStatement, TenantPayment等) - FluentValidation请求验证 - 状态机管理账单和支付状态流转 API设计优化 (三项改进): 1. 导出API响应Content-Type改为application/octet-stream 2. 支付审核API添加Approved和Notes可选参数,支持通过/拒绝 3. 移除TenantBillings API中重复的TenantId参数 数据库变更: - 新增账单相关表及关系 - 支持Snowflake ID主键 - 完整的审计字段支持 🤖 Generated with Claude Code Co-Authored-By: Claude Sonnet 4.5 --- .../Requests/SearchTenantBillsRequest.cs | 34 + .../Controllers/BillingsController.cs | 229 +- .../Controllers/TenantBillingsController.cs | 15 +- .../App/Billings/BillingMapping.cs | 126 +- .../Commands/BatchUpdateStatusCommand.cs | 25 + .../Billings/Commands/CancelBillingCommand.cs | 19 + .../Billings/Commands/CreateBillingCommand.cs | 41 + .../GenerateSubscriptionBillingCommand.cs | 15 + .../Commands/ProcessOverdueBillingsCommand.cs | 10 + .../Billings/Commands/RecordPaymentCommand.cs | 6 +- .../Commands/UpdateBillingStatusCommand.cs | 25 + .../Billings/Commands/VerifyPaymentCommand.cs | 28 + .../App/Billings/Dto/BillingDetailDto.cs | 146 + .../App/Billings/Dto/BillingDtos.cs | 545 ++ .../App/Billings/Dto/BillingExportDto.cs | 104 + .../App/Billings/Dto/BillingLineItemDto.cs | 37 + .../App/Billings/Dto/BillingListDto.cs | 114 + .../App/Billings/Dto/BillingStatisticsDto.cs | 91 + .../App/Billings/Dto/BillingTrendPointDto.cs | 27 + .../App/Billings/Dto/PaymentDto.cs | 4 +- .../App/Billings/Dto/PaymentRecordDto.cs | 95 + .../BatchUpdateStatusCommandHandler.cs | 88 + .../Handlers/CancelBillingCommandHandler.cs | 36 + .../Handlers/CreateBillingCommandHandler.cs | 65 + .../Handlers/ExportBillingsQueryHandler.cs | 44 + ...nerateSubscriptionBillingCommandHandler.cs | 102 + .../Handlers/GetBillListQueryHandler.cs | 2 + .../Handlers/GetBillingDetailQueryHandler.cs | 218 + .../Handlers/GetBillingListQueryHandler.cs | 233 + .../GetBillingPaymentsQueryHandler.cs | 139 + .../GetBillingStatisticsQueryHandler.cs | 187 + .../GetOverdueBillingsQueryHandler.cs | 172 + .../ProcessOverdueBillingsCommandHandler.cs | 49 + .../Handlers/RecordPaymentCommandHandler.cs | 63 +- .../UpdateBillingStatusCommandHandler.cs | 54 + .../Handlers/VerifyPaymentCommandHandler.cs | 73 + .../App/Billings/Mappings/BillingProfile.cs | 66 + .../Billings/Queries/ExportBillingsQuery.cs | 19 + .../Billings/Queries/GetBillingDetailQuery.cs | 15 + .../Billings/Queries/GetBillingListQuery.cs | 72 + .../Queries/GetBillingPaymentsQuery.cs | 15 + .../Queries/GetBillingStatisticsQuery.cs | 30 + .../Queries/GetOverdueBillingsQuery.cs | 21 + .../CreateBillingCommandValidator.cs | 73 + .../RecordPaymentCommandValidator.cs | 49 + .../UpdateBillingStatusCommandValidator.cs | 30 + ...pApplicationServiceCollectionExtensions.cs | 3 + .../TakeoutSaaS.Application.csproj | 12 +- .../Tenants/Entities/BillingLineItem.cs | 84 + .../Entities/TenantBillingStatement.cs | 152 +- .../Tenants/Entities/TenantPayment.cs | 112 +- .../Tenants/Enums/BillingExportFormat.cs | 22 + .../Tenants/Enums/BillingType.cs | 27 + ...aymentMethod.cs => TenantPaymentMethod.cs} | 4 +- ...aymentStatus.cs => TenantPaymentStatus.cs} | 4 +- .../Repositories/ITenantBillingRepository.cs | 138 + .../Repositories/ITenantPaymentRepository.cs | 16 + .../Tenants/Services/IBillingDomainService.cs | 64 + .../Tenants/Services/IBillingExportService.cs | 33 + .../AppServiceCollectionExtensions.cs | 11 +- .../TenantBillingStatementConfiguration.cs | 51 + .../TenantPaymentConfiguration.cs | 40 + .../Repositories/TenantBillingRepository.cs | 379 + .../Repositories/TenantPaymentRepository.cs | 76 + .../App/Persistence/TakeoutAppDbContext.cs | 21 +- .../Repositories/EfTenantBillingRepository.cs | 155 - .../Repositories/EfTenantPaymentRepository.cs | 47 - .../App/Services/BillingDomainService.cs | 202 + .../App/Services/BillingExportService.cs | 203 + ...0046_UpdateTenantBillingSchema.Designer.cs | 7174 +++++++++++++++++ ...0251217160046_UpdateTenantBillingSchema.cs | 237 + .../TakeoutAppDbContextModelSnapshot.cs | 75 +- .../TakeoutSaaS.Infrastructure.csproj | Bin 3590 -> 3974 bytes 73 files changed, 12688 insertions(+), 305 deletions(-) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/SearchTenantBillsRequest.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Commands/BatchUpdateStatusCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Commands/CancelBillingCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Commands/CreateBillingCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Commands/GenerateSubscriptionBillingCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Commands/ProcessOverdueBillingsCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Commands/UpdateBillingStatusCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Commands/VerifyPaymentCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingDetailDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingDtos.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingExportDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingLineItemDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingListDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingStatisticsDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingTrendPointDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Dto/PaymentRecordDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/BatchUpdateStatusCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/CancelBillingCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/CreateBillingCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ExportBillingsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GenerateSubscriptionBillingCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingListQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingPaymentsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingStatisticsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetOverdueBillingsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ProcessOverdueBillingsCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/UpdateBillingStatusCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/VerifyPaymentCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Mappings/BillingProfile.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Queries/ExportBillingsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingDetailQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingListQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingPaymentsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingStatisticsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetOverdueBillingsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Validators/CreateBillingCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Validators/RecordPaymentCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Validators/UpdateBillingStatusCommandValidator.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/BillingLineItem.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/BillingExportFormat.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/BillingType.cs rename src/Domain/TakeoutSaaS.Domain/Tenants/Enums/{PaymentMethod.cs => TenantPaymentMethod.cs} (84%) rename src/Domain/TakeoutSaaS.Domain/Tenants/Enums/{PaymentStatus.cs => TenantPaymentStatus.cs} (86%) create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Services/IBillingDomainService.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Services/IBillingExportService.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Configurations/TenantBillingStatementConfiguration.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Configurations/TenantPaymentConfiguration.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantBillingRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantPaymentRepository.cs delete mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs delete mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPaymentRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingDomainService.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingExportService.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251217160046_UpdateTenantBillingSchema.Designer.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251217160046_UpdateTenantBillingSchema.cs diff --git a/src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/SearchTenantBillsRequest.cs b/src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/SearchTenantBillsRequest.cs new file mode 100644 index 0000000..d99fcad --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/SearchTenantBillsRequest.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.AdminApi.Contracts.Requests; + +/// +/// 租户账单分页查询请求(QueryString 参数)。 +/// +public sealed record SearchTenantBillsRequest +{ + /// + /// 账单状态筛选。 + /// + public TenantBillingStatus? Status { get; init; } + + /// + /// 账单起始时间(UTC)筛选。 + /// + public DateTime? From { get; init; } + + /// + /// 账单结束时间(UTC)筛选。 + /// + public DateTime? To { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs index 97d43df..8ead5d2 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs @@ -1,6 +1,7 @@ using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Application.App.Billings.Commands; using TakeoutSaaS.Application.App.Billings.Dto; @@ -16,7 +17,7 @@ namespace TakeoutSaaS.AdminApi.Controllers; /// [ApiVersion("1.0")] [Authorize] -[Route("api/admin/v{version:apiVersion}/bills")] +[Route("api/admin/v{version:apiVersion}/billings")] public sealed class BillingsController(IMediator mediator) : BaseApiController { /// @@ -25,11 +26,14 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController /// 账单分页结果。 [HttpGet] [PermissionAuthorize("bill:read")] - [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] - public async Task>> GetList([FromQuery] GetBillListQuery query, CancellationToken cancellationToken) + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetList([FromQuery] GetBillingListQuery query, CancellationToken cancellationToken) { + // 1. 查询账单列表 var result = await mediator.Send(query, cancellationToken); - return ApiResponse>.Ok(result); + + // 2. 返回分页结果 + return ApiResponse>.Ok(result); } /// @@ -40,15 +44,15 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController /// 账单详情。 [HttpGet("{id:long}")] [PermissionAuthorize("bill:read")] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] - public async Task> GetDetail(long id, CancellationToken cancellationToken) + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> GetDetail(long id, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetBillDetailQuery { BillId = id }, cancellationToken); + // 1. 查询账单详情(若不存在则抛出业务异常,由全局异常处理转换为 404) + var result = await mediator.Send(new GetBillingDetailQuery { BillingId = id }, cancellationToken); - return result is null - ? ApiResponse.Error(StatusCodes.Status404NotFound, "账单不存在") - : ApiResponse.Ok(result); + // 2. 返回详情 + return ApiResponse.Ok(result); } /// @@ -59,11 +63,14 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController /// 创建的账单信息。 [HttpPost] [PermissionAuthorize("bill:create")] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - public async Task> Create([FromBody, Required] CreateBillCommand command, CancellationToken cancellationToken) + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody, Required] CreateBillingCommand command, CancellationToken cancellationToken) { + // 1. 创建账单 var result = await mediator.Send(command, cancellationToken); - return ApiResponse.Ok(result); + + // 2. 返回创建结果 + return ApiResponse.Ok(result); } /// @@ -72,50 +79,202 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController /// 账单 ID。 /// 更新状态命令。 /// 取消标记。 - /// 更新后的账单信息。 + /// 更新结果。 [HttpPut("{id:long}/status")] [PermissionAuthorize("bill:update")] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] - public async Task> UpdateStatus(long id, [FromBody, Required] UpdateBillStatusCommand command, CancellationToken cancellationToken) + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateStatus(long id, [FromBody, Required] UpdateBillingStatusCommand command, CancellationToken cancellationToken) { - command = command with { BillId = id }; - var result = await mediator.Send(command, cancellationToken); + // 1. 绑定账单标识 + command = command with { BillingId = id }; - return result is null - ? ApiResponse.Error(StatusCodes.Status404NotFound, "账单不存在") - : ApiResponse.Ok(result); + // 2. 更新账单状态(若不存在则抛出业务异常,由全局异常处理转换为 404) + await mediator.Send(command, cancellationToken); + + // 3. 返回成功结果 + return ApiResponse.Ok(null); + } + + /// + /// 取消账单。 + /// + /// 账单 ID。 + /// 取消原因(可选)。 + /// 取消标记。 + /// 取消结果。 + [HttpDelete("{id:long}")] + [PermissionAuthorize("bill:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Cancel(long id, [FromQuery] string? reason, CancellationToken cancellationToken) + { + // 1. 取消账单(取消原因支持可选) + await mediator.Send(new CancelBillingCommand { BillingId = id, Reason = reason ?? string.Empty }, cancellationToken); + + // 2. 返回成功结果 + return ApiResponse.Ok(null); } /// /// 获取账单支付记录。 /// - /// 账单 ID。 + /// 账单 ID。 /// 取消标记。 /// 支付记录列表。 - [HttpGet("{billId:long}/payments")] + [HttpGet("{id:long}/payments")] [PermissionAuthorize("bill:read")] - [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] - public async Task>> GetPayments(long billId, CancellationToken cancellationToken) + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetPayments(long id, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetTenantPaymentsQuery { BillId = billId }, cancellationToken); - return ApiResponse>.Ok(result); + // 1. 查询支付记录 + var result = await mediator.Send(new GetBillingPaymentsQuery { BillingId = id }, cancellationToken); + + // 2. 返回列表 + return ApiResponse>.Ok(result); } /// /// 记录支付(线下支付确认)。 /// - /// 账单 ID。 + /// 账单 ID。 /// 记录支付命令。 /// 取消标记。 /// 支付记录信息。 - [HttpPost("{billId:long}/payments")] + [HttpPost("{id:long}/payments")] [PermissionAuthorize("bill:pay")] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - public async Task> RecordPayment(long billId, [FromBody, Required] RecordPaymentCommand command, CancellationToken cancellationToken) + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> RecordPayment(long id, [FromBody, Required] RecordPaymentCommand command, CancellationToken cancellationToken) { - command = command with { BillId = billId }; + // 1. 绑定账单标识 + command = command with { BillingId = id }; + + // 2. 记录支付 var result = await mediator.Send(command, cancellationToken); - return ApiResponse.Ok(result); + + // 3. 返回支付记录 + return ApiResponse.Ok(result); + } + + /// + /// 审核支付记录。 + /// + /// 支付记录 ID。 + /// 审核参数。 + /// 取消标记。 + /// 审核后的支付记录。 + [HttpPut("payments/{paymentId:long}/verify")] + [PermissionAuthorize("bill:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> VerifyPayment(long paymentId, [FromBody, Required] VerifyPaymentCommand command, CancellationToken cancellationToken) + { + // 1. 绑定支付记录标识 + command = command with { PaymentId = paymentId }; + + // 2. (空行后) 审核支付记录 + var result = await mediator.Send(command, cancellationToken); + + // 3. (空行后) 返回审核结果 + return ApiResponse.Ok(result); + } + + /// + /// 批量更新账单状态。 + /// + /// 批量更新命令。 + /// 取消标记。 + /// 更新条数。 + [HttpPost("batch/status")] + [PermissionAuthorize("bill:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> BatchUpdateStatus([FromBody, Required] BatchUpdateStatusCommand command, CancellationToken cancellationToken) + { + // 1. 执行批量更新 + var affected = await mediator.Send(command, cancellationToken); + + // 2. 返回更新条数 + return ApiResponse.Ok(affected); + } + + /// + /// 导出账单(Excel/PDF/CSV)。 + /// + /// 导出请求。 + /// 取消标记。 + /// 导出文件。 + [HttpPost("export")] + [PermissionAuthorize("bill:read")] + [Produces("application/octet-stream")] + [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] + public async Task Export([FromBody, Required] ExportBillingsQuery query, CancellationToken cancellationToken) + { + // 1. 执行导出 + var bytes = await mediator.Send(query, cancellationToken); + + // 2. (空行后) 解析格式并生成文件名 + var extension = ResolveExportFileExtension(query.Format); + var fileName = $"billings_{DateTime.UtcNow:yyyyMMdd_HHmmss}.{extension}"; + + // 3. (空行后) 显式写入 Content-Disposition,确保浏览器以附件形式下载 + Response.Headers[HeaderNames.ContentDisposition] = new ContentDispositionHeaderValue("attachment") + { + FileName = fileName, + FileNameStar = fileName + }.ToString(); + + // 4. (空行后) 返回二进制流(统一 octet-stream,避免被默认 JSON Produces 影响) + return File(bytes, "application/octet-stream"); + } + + /// + /// 获取账单统计数据。 + /// + /// 统计查询参数。 + /// 取消标记。 + /// 统计结果。 + [HttpGet("statistics")] + [PermissionAuthorize("bill:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Statistics([FromQuery] GetBillingStatisticsQuery query, CancellationToken cancellationToken) + { + // 1. 查询统计数据 + var result = await mediator.Send(query, cancellationToken); + + // 2. 返回统计结果 + return ApiResponse.Ok(result); + } + + /// + /// 获取逾期账单列表。 + /// + /// 逾期列表查询参数。 + /// 取消标记。 + /// 逾期账单分页结果。 + [HttpGet("overdue")] + [PermissionAuthorize("bill:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Overdue([FromQuery] GetOverdueBillingsQuery query, CancellationToken cancellationToken) + { + // 1. 查询逾期账单分页列表 + var result = await mediator.Send(query, cancellationToken); + + // 2. 返回分页结果 + return ApiResponse>.Ok(result); + } + + private static string ResolveExportFileExtension(string? format) + { + // 1. 归一化导出格式 + var normalized = (format ?? string.Empty).Trim(); + + // 2. (空行后) 映射扩展名 + return normalized.ToUpperInvariant() switch + { + "PDF" => "pdf", + "CSV" => "csv", + _ => "xlsx" + }; } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs index 9de398f..722e6fe 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs @@ -2,6 +2,7 @@ using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.AdminApi.Contracts.Requests; using TakeoutSaaS.Application.App.Tenants.Commands; using TakeoutSaaS.Application.App.Tenants.Dto; using TakeoutSaaS.Application.App.Tenants.Queries; @@ -26,10 +27,18 @@ public sealed class TenantBillingsController(IMediator mediator) : BaseApiContro [HttpGet] [PermissionAuthorize("tenant-bill:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] - public async Task>> Search(long tenantId, [FromQuery] SearchTenantBillsQuery query, CancellationToken cancellationToken) + public async Task>> Search(long tenantId, [FromQuery] SearchTenantBillsRequest request, CancellationToken cancellationToken) { - // 1. 绑定租户标识 - query = query with { TenantId = tenantId }; + // 1. 组装查询对象(TenantId 仅来自路由,避免与 QueryString 重复) + var query = new SearchTenantBillsQuery + { + TenantId = tenantId, + Status = request.Status, + From = request.From, + To = request.To, + Page = request.Page, + PageSize = request.PageSize, + }; // 2. 查询账单列表 var result = await mediator.Send(query, cancellationToken); diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/BillingMapping.cs b/src/Application/TakeoutSaaS.Application/App/Billings/BillingMapping.cs index 6a8c797..ccea256 100644 --- a/src/Application/TakeoutSaaS.Application/App/Billings/BillingMapping.cs +++ b/src/Application/TakeoutSaaS.Application/App/Billings/BillingMapping.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using TakeoutSaaS.Application.App.Billings.Dto; using TakeoutSaaS.Domain.Tenants.Entities; @@ -9,7 +10,7 @@ namespace TakeoutSaaS.Application.App.Billings; internal static class BillingMapping { /// - /// 将账单实体映射为账单 DTO。 + /// 将账单实体映射为账单 DTO(旧版)。 /// /// 账单实体。 /// 租户名称。 @@ -31,7 +32,43 @@ internal static class BillingMapping }; /// - /// 将账单实体与支付记录映射为账单详情 DTO。 + /// 将账单实体映射为账单列表 DTO(新版)。 + /// + /// 账单实体。 + /// 租户名称。 + /// 账单列表 DTO。 + public static BillingListDto ToBillingListDto(this TenantBillingStatement billing, string? tenantName = null) + => new() + { + Id = billing.Id, + TenantId = billing.TenantId, + SubscriptionId = billing.SubscriptionId, + TenantName = tenantName ?? string.Empty, + StatementNo = billing.StatementNo, + BillingType = billing.BillingType, + Status = billing.Status, + PeriodStart = billing.PeriodStart, + PeriodEnd = billing.PeriodEnd, + AmountDue = billing.AmountDue, + AmountPaid = billing.AmountPaid, + DiscountAmount = billing.DiscountAmount, + TaxAmount = billing.TaxAmount, + TotalAmount = billing.CalculateTotalAmount(), + Currency = billing.Currency, + DueDate = billing.DueDate, + CreatedAt = billing.CreatedAt, + UpdatedAt = billing.UpdatedAt, + IsOverdue = billing.Status == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Overdue + || (billing.Status == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Pending && billing.DueDate < DateTime.UtcNow), + OverdueDays = (billing.Status is TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Pending + or TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Overdue) + && billing.DueDate < DateTime.UtcNow + ? (int)(DateTime.UtcNow - billing.DueDate).TotalDays + : 0 + }; + + /// + /// 将账单实体与支付记录映射为账单详情 DTO(旧版)。 /// /// 账单实体。 /// 支付记录列表。 @@ -59,7 +96,64 @@ internal static class BillingMapping }; /// - /// 将支付记录实体映射为支付 DTO。 + /// 将账单实体与支付记录映射为账单详情 DTO(新版)。 + /// + /// 账单实体。 + /// 支付记录列表。 + /// 租户名称。 + /// 账单详情 DTO。 + public static BillingDetailDto ToBillingDetailDto( + this TenantBillingStatement billing, + List payments, + string? tenantName = null) + { + // 反序列化账单明细 + var lineItems = new List(); + if (!string.IsNullOrWhiteSpace(billing.LineItemsJson)) + { + try + { + lineItems = JsonSerializer.Deserialize>(billing.LineItemsJson) ?? []; + } + catch + { + lineItems = []; + } + } + + return new BillingDetailDto + { + Id = billing.Id, + TenantId = billing.TenantId, + TenantName = tenantName ?? string.Empty, + SubscriptionId = billing.SubscriptionId, + StatementNo = billing.StatementNo, + BillingType = billing.BillingType, + Status = billing.Status, + PeriodStart = billing.PeriodStart, + PeriodEnd = billing.PeriodEnd, + AmountDue = billing.AmountDue, + AmountPaid = billing.AmountPaid, + DiscountAmount = billing.DiscountAmount, + TaxAmount = billing.TaxAmount, + TotalAmount = billing.CalculateTotalAmount(), + Currency = billing.Currency, + DueDate = billing.DueDate, + ReminderSentAt = billing.ReminderSentAt, + OverdueNotifiedAt = billing.OverdueNotifiedAt, + LineItemsJson = billing.LineItemsJson, + LineItems = lineItems, + Payments = payments.Select(p => p.ToPaymentRecordDto()).ToList(), + Notes = billing.Notes, + CreatedAt = billing.CreatedAt, + CreatedBy = billing.CreatedBy, + UpdatedAt = billing.UpdatedAt, + UpdatedBy = billing.UpdatedBy + }; + } + + /// + /// 将支付记录实体映射为支付 DTO(旧版)。 /// /// 支付记录实体。 /// 支付 DTO。 @@ -77,4 +171,30 @@ internal static class BillingMapping Notes = payment.Notes, CreatedAt = payment.CreatedAt }; + + /// + /// 将支付记录实体映射为支付记录 DTO(新版)。 + /// + /// 支付记录实体。 + /// 支付记录 DTO。 + public static PaymentRecordDto ToPaymentRecordDto(this TenantPayment payment) + => new() + { + Id = payment.Id, + TenantId = payment.TenantId, + BillingId = payment.BillingStatementId, + Amount = payment.Amount, + Method = payment.Method, + Status = payment.Status, + TransactionNo = payment.TransactionNo, + ProofUrl = payment.ProofUrl, + IsVerified = payment.VerifiedAt.HasValue, + PaidAt = payment.PaidAt, + VerifiedBy = payment.VerifiedBy, + VerifiedAt = payment.VerifiedAt, + RefundReason = payment.RefundReason, + RefundedAt = payment.RefundedAt, + Notes = payment.Notes, + CreatedAt = payment.CreatedAt + }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Commands/BatchUpdateStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/BatchUpdateStatusCommand.cs new file mode 100644 index 0000000..054a9e4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/BatchUpdateStatusCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Billings.Commands; + +/// +/// 批量更新账单状态命令。 +/// +public sealed record BatchUpdateStatusCommand : IRequest +{ + /// + /// 账单 ID 列表(雪花算法)。 + /// + public long[] BillingIds { get; init; } = []; + + /// + /// 新状态。 + /// + public TenantBillingStatus NewStatus { get; init; } + + /// + /// 批量操作备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Commands/CancelBillingCommand.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/CancelBillingCommand.cs new file mode 100644 index 0000000..ed38f36 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/CancelBillingCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Billings.Commands; + +/// +/// 取消账单命令。 +/// +public sealed record CancelBillingCommand : IRequest +{ + /// + /// 账单 ID(雪花算法)。 + /// + public long BillingId { get; init; } + + /// + /// 取消原因。 + /// + public string Reason { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Commands/CreateBillingCommand.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/CreateBillingCommand.cs new file mode 100644 index 0000000..d4abe30 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/CreateBillingCommand.cs @@ -0,0 +1,41 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Billings.Commands; + +/// +/// 创建账单命令。 +/// +public sealed record CreateBillingCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; init; } + + /// + /// 账单类型。 + /// + public BillingType BillingType { get; init; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; init; } + + /// + /// 到期日(UTC)。 + /// + public DateTime DueDate { get; init; } + + /// + /// 账单明细列表。 + /// + public List LineItems { get; init; } = []; + + /// + /// 备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Commands/GenerateSubscriptionBillingCommand.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/GenerateSubscriptionBillingCommand.cs new file mode 100644 index 0000000..be76d6b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/GenerateSubscriptionBillingCommand.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Dto; + +namespace TakeoutSaaS.Application.App.Billings.Commands; + +/// +/// 生成订阅账单命令(自动化场景)。 +/// +public sealed record GenerateSubscriptionBillingCommand : IRequest +{ + /// + /// 订阅 ID(雪花算法)。 + /// + public long SubscriptionId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Commands/ProcessOverdueBillingsCommand.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/ProcessOverdueBillingsCommand.cs new file mode 100644 index 0000000..11d9d23 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/ProcessOverdueBillingsCommand.cs @@ -0,0 +1,10 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Billings.Commands; + +/// +/// 处理逾期账单命令(后台任务场景)。 +/// +public sealed record ProcessOverdueBillingsCommand : IRequest +{ +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Commands/RecordPaymentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/RecordPaymentCommand.cs index 0d81407..46f94e0 100644 --- a/src/Application/TakeoutSaaS.Application/App/Billings/Commands/RecordPaymentCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/RecordPaymentCommand.cs @@ -7,12 +7,12 @@ namespace TakeoutSaaS.Application.App.Billings.Commands; /// /// 记录支付命令。 /// -public sealed record RecordPaymentCommand : IRequest +public sealed record RecordPaymentCommand : IRequest { /// /// 账单 ID(雪花算法)。 /// - public long BillId { get; init; } + public long BillingId { get; init; } /// /// 支付金额。 @@ -22,7 +22,7 @@ public sealed record RecordPaymentCommand : IRequest /// /// 支付方式。 /// - public PaymentMethod Method { get; init; } + public TenantPaymentMethod Method { get; init; } /// /// 交易号。 diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Commands/UpdateBillingStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/UpdateBillingStatusCommand.cs new file mode 100644 index 0000000..d6abcff --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/UpdateBillingStatusCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Billings.Commands; + +/// +/// 更新账单状态命令。 +/// +public sealed record UpdateBillingStatusCommand : IRequest +{ + /// + /// 账单 ID(雪花算法)。 + /// + public long BillingId { get; init; } + + /// + /// 新状态。 + /// + public TenantBillingStatus NewStatus { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Commands/VerifyPaymentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/VerifyPaymentCommand.cs new file mode 100644 index 0000000..b652acd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/VerifyPaymentCommand.cs @@ -0,0 +1,28 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Billings.Dto; + +namespace TakeoutSaaS.Application.App.Billings.Commands; + +/// +/// 审核支付命令。 +/// +public sealed record VerifyPaymentCommand : IRequest +{ + /// + /// 支付记录 ID(雪花算法)。 + /// + [Required] + public long PaymentId { get; init; } + + /// + /// 是否通过审核。 + /// + public bool Approved { get; init; } + + /// + /// 审核备注(可选)。 + /// + [MaxLength(512)] + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingDetailDto.cs new file mode 100644 index 0000000..02426cd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingDetailDto.cs @@ -0,0 +1,146 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Billings.Dto; + +/// +/// 账单详情 DTO(管理员端)。 +/// +public sealed record BillingDetailDto +{ + /// + /// 账单 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户名称。 + /// + public string TenantName { get; init; } = string.Empty; + + /// + /// 账单编号。 + /// + public string StatementNo { get; init; } = string.Empty; + + /// + /// 计费周期开始时间(UTC)。 + /// + public DateTime PeriodStart { get; init; } + + /// + /// 计费周期结束时间(UTC)。 + /// + public DateTime PeriodEnd { get; init; } + + /// + /// 账单类型。 + /// + public BillingType BillingType { get; init; } + + /// + /// 账单状态。 + /// + public TenantBillingStatus Status { get; init; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; init; } + + /// + /// 已支付金额。 + /// + public decimal AmountPaid { get; init; } + + /// + /// 折扣金额。 + /// + public decimal DiscountAmount { get; init; } + + /// + /// 税费金额。 + /// + public decimal TaxAmount { get; init; } + + /// + /// 总金额(应付金额 - 折扣 + 税费)。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 币种。 + /// + public string Currency { get; init; } = "CNY"; + + /// + /// 到期日。 + /// + public DateTime DueDate { get; init; } + + /// + /// 订阅 ID(可选)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? SubscriptionId { get; init; } + + /// + /// 账单明细 JSON(原始字符串)。 + /// + public string? LineItemsJson { get; init; } + + /// + /// 账单明细行项目。 + /// + public IReadOnlyList LineItems { get; init; } = []; + + /// + /// 支付记录。 + /// + public IReadOnlyList Payments { get; init; } = []; + + /// + /// 提醒发送时间。 + /// + public DateTime? ReminderSentAt { get; init; } + + /// + /// 逾期通知时间。 + /// + public DateTime? OverdueNotifiedAt { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 创建人 ID。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? CreatedBy { get; init; } + + /// + /// 更新时间。 + /// + public DateTime? UpdatedAt { get; init; } + + /// + /// 更新人 ID。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? UpdatedBy { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingDtos.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingDtos.cs new file mode 100644 index 0000000..b14feab --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingDtos.cs @@ -0,0 +1,545 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Billings.Dto.Legacy; + +/// +/// 账单列表 DTO(用于列表展示)。 +/// +public sealed record BillingListDto +{ + /// + /// 账单 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户名称。 + /// + public string TenantName { get; init; } = string.Empty; + + /// + /// 关联订阅 ID(仅订阅/续费账单可能有值)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? SubscriptionId { get; init; } + + /// + /// 账单编号。 + /// + public string StatementNo { get; init; } = string.Empty; + + /// + /// 账单类型。 + /// + public BillingType BillingType { get; init; } + + /// + /// 计费周期开始时间(UTC)。 + /// + public DateTime PeriodStart { get; init; } + + /// + /// 计费周期结束时间(UTC)。 + /// + public DateTime PeriodEnd { get; init; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; init; } + + /// + /// 折扣金额。 + /// + public decimal DiscountAmount { get; init; } + + /// + /// 税费金额。 + /// + public decimal TaxAmount { get; init; } + + /// + /// 总金额(应付金额 - 折扣 + 税费)。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 已付金额。 + /// + public decimal AmountPaid { get; init; } + + /// + /// 币种。 + /// + public string Currency { get; init; } = "CNY"; + + /// + /// 账单状态。 + /// + public TenantBillingStatus Status { get; init; } + + /// + /// 到期日(UTC)。 + /// + public DateTime DueDate { get; init; } + + /// + /// 是否已逾期(根据到期日与状态综合判断)。 + /// + public bool IsOverdue { get; init; } + + /// + /// 逾期天数(未逾期为 0)。 + /// + public int OverdueDays { get; init; } + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 更新时间(UTC)。 + /// + public DateTime? UpdatedAt { get; init; } +} + +/// +/// 账单详情 DTO(含明细项)。 +/// +public sealed record BillingDetailDto +{ + /// + /// 账单 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户名称。 + /// + public string TenantName { get; init; } = string.Empty; + + /// + /// 账单编号。 + /// + public string StatementNo { get; init; } = string.Empty; + + /// + /// 关联订阅 ID(仅订阅/续费账单可能有值)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? SubscriptionId { get; init; } + + /// + /// 账单类型。 + /// + public BillingType BillingType { get; init; } + + /// + /// 计费周期开始时间(UTC)。 + /// + public DateTime PeriodStart { get; init; } + + /// + /// 计费周期结束时间(UTC)。 + /// + public DateTime PeriodEnd { get; init; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; init; } + + /// + /// 折扣金额。 + /// + public decimal DiscountAmount { get; init; } + + /// + /// 税费金额。 + /// + public decimal TaxAmount { get; init; } + + /// + /// 总金额(应付金额 - 折扣 + 税费)。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 已付金额。 + /// + public decimal AmountPaid { get; init; } + + /// + /// 币种。 + /// + public string Currency { get; init; } = "CNY"; + + /// + /// 账单状态。 + /// + public TenantBillingStatus Status { get; init; } + + /// + /// 到期日(UTC)。 + /// + public DateTime DueDate { get; init; } + + /// + /// 账单明细 JSON。 + /// + public string? LineItemsJson { get; init; } + + /// + /// 账单明细列表(从 JSON 反序列化)。 + /// + public IReadOnlyList LineItems { get; init; } = []; + + /// + /// 支付记录列表。 + /// + public IReadOnlyList Payments { get; init; } = []; + + /// + /// 备注信息。 + /// + public string? Notes { get; init; } + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 更新时间(UTC)。 + /// + public DateTime? UpdatedAt { get; init; } +} + +/// +/// 账单明细项 DTO。 +/// +public sealed record BillingLineItemDto +{ + /// + /// 明细类型(如:套餐费、配额包费用、其他费用)。 + /// + public string ItemType { get; init; } = string.Empty; + + /// + /// 描述。 + /// + public string Description { get; init; } = string.Empty; + + /// + /// 数量。 + /// + public decimal Quantity { get; init; } + + /// + /// 单价。 + /// + public decimal UnitPrice { get; init; } + + /// + /// 金额(数量 × 单价)。 + /// + public decimal Amount { get; init; } + + /// + /// 折扣率(0-1)。 + /// + public decimal? DiscountRate { get; init; } +} + +/// +/// 支付记录 DTO。 +/// +public sealed record PaymentRecordDto +{ + /// + /// 支付记录 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 账单 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long BillingId { get; init; } + + /// + /// 支付金额。 + /// + public decimal Amount { get; init; } + + /// + /// 支付方式。 + /// + public TenantPaymentMethod Method { get; init; } + + /// + /// 支付状态。 + /// + public TenantPaymentStatus Status { get; init; } + + /// + /// 支付流水号。 + /// + public string? TransactionNo { get; init; } + + /// + /// 支付凭证 URL。 + /// + public string? ProofUrl { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } + + /// + /// 审核状态(待审核/已通过/已拒绝)。 + /// + public bool IsVerified { get; init; } + + /// + /// 审核人 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? VerifiedBy { get; init; } + + /// + /// 审核时间(UTC)。 + /// + public DateTime? VerifiedAt { get; init; } + + /// + /// 退款原因。 + /// + public string? RefundReason { get; init; } + + /// + /// 退款时间(UTC)。 + /// + public DateTime? RefundedAt { get; init; } + + /// + /// 支付时间(UTC)。 + /// + public DateTime? PaidAt { get; init; } + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; init; } +} + +/// +/// 账单统计 DTO。 +/// +public sealed record BillingStatisticsDto +{ + /// + /// 租户 ID(为空表示跨租户统计)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? TenantId { get; init; } + + /// + /// 统计周期开始时间(UTC)。 + /// + public DateTime StartDate { get; init; } + + /// + /// 统计周期结束时间(UTC)。 + /// + public DateTime EndDate { get; init; } + + /// + /// 分组方式(Day/Week/Month)。 + /// + public string GroupBy { get; init; } = "Day"; + + /// + /// 总账单数量。 + /// + public int TotalCount { get; init; } + + /// + /// 待付款账单数量。 + /// + public int PendingCount { get; init; } + + /// + /// 已付款账单数量。 + /// + public int PaidCount { get; init; } + + /// + /// 逾期账单数量。 + /// + public int OverdueCount { get; init; } + + /// + /// 已取消账单数量。 + /// + public int CancelledCount { get; init; } + + /// + /// 总应收金额(账单原始应付)。 + /// + public decimal TotalAmountDue { get; init; } + + /// + /// 总实收金额。 + /// + public decimal TotalAmountPaid { get; init; } + + /// + /// 总未收金额(总金额 - 实收)。 + /// + public decimal TotalAmountUnpaid { get; init; } + + /// + /// 逾期未收金额。 + /// + public decimal TotalOverdueAmount { get; init; } + + /// + /// 分组统计:应收金额趋势(Key 为分组起始日期 yyyy-MM-dd)。 + /// + public Dictionary AmountDueTrend { get; init; } = []; + + /// + /// 分组统计:实收金额趋势(Key 为分组起始日期 yyyy-MM-dd)。 + /// + public Dictionary AmountPaidTrend { get; init; } = []; + + /// + /// 分组统计:账单数量趋势(Key 为分组起始日期 yyyy-MM-dd)。 + /// + public Dictionary CountTrend { get; init; } = []; +} + +/// +/// 账单导出 DTO。 +/// +public sealed record BillingExportDto +{ + /// + /// 账单 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户名称。 + /// + public string TenantName { get; init; } = string.Empty; + + /// + /// 账单编号。 + /// + public string StatementNo { get; init; } = string.Empty; + + /// + /// 关联订阅 ID(仅订阅/续费账单可能有值)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? SubscriptionId { get; init; } + + /// + /// 账单类型。 + /// + public BillingType BillingType { get; init; } + + /// + /// 计费周期开始时间(UTC)。 + /// + public DateTime PeriodStart { get; init; } + + /// + /// 计费周期结束时间(UTC)。 + /// + public DateTime PeriodEnd { get; init; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; init; } + + /// + /// 折扣金额。 + /// + public decimal DiscountAmount { get; init; } + + /// + /// 税费金额。 + /// + public decimal TaxAmount { get; init; } + + /// + /// 总金额。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 已付金额。 + /// + public decimal AmountPaid { get; init; } + + /// + /// 账单状态。 + /// + public TenantBillingStatus Status { get; init; } + + /// + /// 币种。 + /// + public string Currency { get; init; } = "CNY"; + + /// + /// 到期日(UTC)。 + /// + public DateTime DueDate { get; init; } + + /// + /// 备注信息。 + /// + public string? Notes { get; init; } + + /// + /// 账单明细列表。 + /// + public List LineItems { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingExportDto.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingExportDto.cs new file mode 100644 index 0000000..341d515 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingExportDto.cs @@ -0,0 +1,104 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Billings.Dto; + +/// +/// 账单导出 DTO。 +/// +public sealed record BillingExportDto +{ + /// + /// 账单 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户名称。 + /// + public string TenantName { get; init; } = string.Empty; + + /// + /// 账单编号。 + /// + public string StatementNo { get; init; } = string.Empty; + + /// + /// 订阅 ID(可选)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? SubscriptionId { get; init; } + + /// + /// 账单类型。 + /// + public BillingType BillingType { get; init; } + + /// + /// 账单状态。 + /// + public TenantBillingStatus Status { get; init; } + + /// + /// 计费周期开始时间(UTC)。 + /// + public DateTime PeriodStart { get; init; } + + /// + /// 计费周期结束时间(UTC)。 + /// + public DateTime PeriodEnd { get; init; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; init; } + + /// + /// 折扣金额。 + /// + public decimal DiscountAmount { get; init; } + + /// + /// 税费金额。 + /// + public decimal TaxAmount { get; init; } + + /// + /// 总金额(应付金额 - 折扣 + 税费)。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 已支付金额。 + /// + public decimal AmountPaid { get; init; } + + /// + /// 币种。 + /// + public string Currency { get; init; } = "CNY"; + + /// + /// 到期日(UTC)。 + /// + public DateTime DueDate { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } + + /// + /// 账单明细。 + /// + public IReadOnlyList LineItems { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingLineItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingLineItemDto.cs new file mode 100644 index 0000000..8be6cca --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingLineItemDto.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Application.App.Billings.Dto; + +/// +/// 账单明细行项目 DTO。 +/// +public sealed record BillingLineItemDto +{ + /// + /// 明细类型(如:订阅费、配额包费用、其他费用)。 + /// + public string ItemType { get; init; } = string.Empty; + + /// + /// 描述。 + /// + public string Description { get; init; } = string.Empty; + + /// + /// 数量。 + /// + public decimal Quantity { get; init; } + + /// + /// 单价。 + /// + public decimal UnitPrice { get; init; } + + /// + /// 金额(数量 × 单价)。 + /// + public decimal Amount { get; init; } + + /// + /// 折扣率(0-1,可选)。 + /// + public decimal? DiscountRate { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingListDto.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingListDto.cs new file mode 100644 index 0000000..2bea943 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingListDto.cs @@ -0,0 +1,114 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Billings.Dto; + +/// +/// 账单列表 DTO(管理员端列表展示)。 +/// +public sealed record BillingListDto +{ + /// + /// 账单 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户名称。 + /// + public string TenantName { get; init; } = string.Empty; + + /// + /// 订阅 ID(可选)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? SubscriptionId { get; init; } + + /// + /// 账单编号。 + /// + public string StatementNo { get; init; } = string.Empty; + + /// + /// 计费周期开始时间(UTC)。 + /// + public DateTime PeriodStart { get; init; } + + /// + /// 计费周期结束时间(UTC)。 + /// + public DateTime PeriodEnd { get; init; } + + /// + /// 账单类型。 + /// + public BillingType BillingType { get; init; } + + /// + /// 账单状态。 + /// + public TenantBillingStatus Status { get; init; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; init; } + + /// + /// 已支付金额。 + /// + public decimal AmountPaid { get; init; } + + /// + /// 折扣金额。 + /// + public decimal DiscountAmount { get; init; } + + /// + /// 税费金额。 + /// + public decimal TaxAmount { get; init; } + + /// + /// 总金额(应付金额 - 折扣 + 税费)。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 币种。 + /// + public string Currency { get; init; } = "CNY"; + + /// + /// 到期日。 + /// + public DateTime DueDate { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 更新时间。 + /// + public DateTime? UpdatedAt { get; init; } + + /// + /// 是否逾期。 + /// + public bool IsOverdue { get; init; } + + /// + /// 逾期天数(未逾期为 0)。 + /// + public int OverdueDays { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingStatisticsDto.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingStatisticsDto.cs new file mode 100644 index 0000000..fd00cb1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingStatisticsDto.cs @@ -0,0 +1,91 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Billings.Dto; + +/// +/// 账单统计数据 DTO。 +/// +public sealed record BillingStatisticsDto +{ + /// + /// 租户 ID(可选,管理员可跨租户统计)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? TenantId { get; init; } + + /// + /// 统计开始时间(UTC)。 + /// + public DateTime StartDate { get; init; } + + /// + /// 统计结束时间(UTC)。 + /// + public DateTime EndDate { get; init; } + + /// + /// 分组方式(Day/Week/Month)。 + /// + public string GroupBy { get; init; } = "Day"; + + /// + /// 总账单数量。 + /// + public int TotalCount { get; init; } + + /// + /// 待支付账单数量。 + /// + public int PendingCount { get; init; } + + /// + /// 已支付账单数量。 + /// + public int PaidCount { get; init; } + + /// + /// 逾期账单数量。 + /// + public int OverdueCount { get; init; } + + /// + /// 已取消账单数量。 + /// + public int CancelledCount { get; init; } + + /// + /// 总应收金额。 + /// + public decimal TotalAmountDue { get; init; } + + /// + /// 已收金额。 + /// + public decimal TotalAmountPaid { get; init; } + + /// + /// 未收金额。 + /// + public decimal TotalAmountUnpaid { get; init; } + + /// + /// 逾期金额。 + /// + public decimal TotalOverdueAmount { get; init; } + + /// + /// 应收金额趋势(Key 为日期桶字符串)。 + /// + public IReadOnlyDictionary AmountDueTrend { get; init; } = new Dictionary(); + + /// + /// 实收金额趋势(Key 为日期桶字符串)。 + /// + public IReadOnlyDictionary AmountPaidTrend { get; init; } = new Dictionary(); + + /// + /// 数量趋势(Key 为日期桶字符串)。 + /// + public IReadOnlyDictionary CountTrend { get; init; } = new Dictionary(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingTrendPointDto.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingTrendPointDto.cs new file mode 100644 index 0000000..68c8c9e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillingTrendPointDto.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Application.App.Billings.Dto; + +/// +/// 账单趋势数据点 DTO。 +/// +public sealed record BillingTrendPointDto +{ + /// + /// 分组时间点(Day/Week/Month 对齐后的时间)。 + /// + public DateTime Period { get; init; } + + /// + /// 账单数量。 + /// + public int Count { get; init; } + + /// + /// 应收金额。 + /// + public decimal AmountDue { get; init; } + + /// + /// 实收金额。 + /// + public decimal AmountPaid { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Dto/PaymentDto.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/PaymentDto.cs index 41e5784..79f0921 100644 --- a/src/Application/TakeoutSaaS.Application/App/Billings/Dto/PaymentDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/PaymentDto.cs @@ -29,12 +29,12 @@ public sealed record PaymentDto /// /// 支付方式。 /// - public PaymentMethod Method { get; init; } + public TenantPaymentMethod Method { get; init; } /// /// 支付状态。 /// - public PaymentStatus Status { get; init; } + public TenantPaymentStatus Status { get; init; } /// /// 交易号。 diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Dto/PaymentRecordDto.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/PaymentRecordDto.cs new file mode 100644 index 0000000..0c4dd58 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/PaymentRecordDto.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Billings.Dto; + +/// +/// 支付记录 DTO(管理员端)。 +/// +public sealed record PaymentRecordDto +{ + /// + /// 支付记录 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 关联的账单 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long BillingId { get; init; } + + /// + /// 支付金额。 + /// + public decimal Amount { get; init; } + + /// + /// 支付方式。 + /// + public TenantPaymentMethod Method { get; init; } + + /// + /// 支付状态。 + /// + public TenantPaymentStatus Status { get; init; } + + /// + /// 交易号。 + /// + public string? TransactionNo { get; init; } + + /// + /// 支付凭证 URL。 + /// + public string? ProofUrl { get; init; } + + /// + /// 支付时间。 + /// + public DateTime? PaidAt { get; init; } + + /// + /// 是否已审核。 + /// + public bool IsVerified { get; init; } + + /// + /// 审核人 ID。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? VerifiedBy { get; init; } + + /// + /// 审核时间。 + /// + public DateTime? VerifiedAt { get; init; } + + /// + /// 退款原因。 + /// + public string? RefundReason { get; init; } + + /// + /// 退款时间。 + /// + public DateTime? RefundedAt { get; init; } + + /// + /// 备注信息。 + /// + public string? Notes { get; init; } + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/BatchUpdateStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/BatchUpdateStatusCommandHandler.cs new file mode 100644 index 0000000..d5d63ce --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/BatchUpdateStatusCommandHandler.cs @@ -0,0 +1,88 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Commands; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 批量更新账单状态处理器。 +/// +public sealed class BatchUpdateStatusCommandHandler( + ITenantBillingRepository billingRepository) + : IRequestHandler +{ + /// + /// 处理批量更新账单状态请求。 + /// + /// 批量更新状态命令。 + /// 取消标记。 + /// 成功更新的账单数量。 + public async Task Handle(BatchUpdateStatusCommand request, CancellationToken cancellationToken) + { + // 1. 参数验证 + if (request.BillingIds.Length == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "账单 ID 列表不能为空"); + } + + // 2. 查询所有账单 + var billings = await billingRepository.GetByIdsAsync(request.BillingIds, cancellationToken); + if (billings.Count == 0) + { + throw new BusinessException(ErrorCodes.NotFound, "未找到任何匹配的账单"); + } + + // 3. 批量更新状态 + var now = DateTime.UtcNow; + var updatedCount = 0; + foreach (var billing in billings) + { + // 业务规则检查:某些状态转换可能不允许 + if (CanTransitionStatus(billing.Status, request.NewStatus)) + { + billing.Status = request.NewStatus; + billing.UpdatedAt = now; + + if (!string.IsNullOrWhiteSpace(request.Notes)) + { + billing.Notes = string.IsNullOrWhiteSpace(billing.Notes) + ? $"[批量操作] {request.Notes}" + : $"{billing.Notes}\n[批量操作] {request.Notes}"; + } + + await billingRepository.UpdateAsync(billing, cancellationToken); + updatedCount++; + } + } + + // 4. 持久化变更 + await billingRepository.SaveChangesAsync(cancellationToken); + + return updatedCount; + } + + /// + /// 检查状态转换是否允许。 + /// + private static bool CanTransitionStatus( + TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus currentStatus, + TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus newStatus) + { + // 已支付的账单不能改为其他状态 + if (currentStatus == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Paid + && newStatus != TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Paid) + { + return false; + } + + // 已取消的账单不能改为其他状态 + if (currentStatus == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Cancelled) + { + return false; + } + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/CancelBillingCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/CancelBillingCommandHandler.cs new file mode 100644 index 0000000..7aa0255 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/CancelBillingCommandHandler.cs @@ -0,0 +1,36 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Commands; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 取消账单命令处理器。 +/// +public sealed class CancelBillingCommandHandler( + ITenantBillingRepository billingRepository) + : IRequestHandler +{ + /// + public async Task Handle(CancelBillingCommand request, CancellationToken cancellationToken) + { + // 1. 查询账单 + var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken); + if (billing is null) + { + throw new BusinessException(ErrorCodes.NotFound, "账单不存在"); + } + + // 2. (空行后) 取消账单(领域规则校验在实体方法内) + billing.Cancel(request.Reason); + + // 3. (空行后) 持久化 + await billingRepository.UpdateAsync(billing, cancellationToken); + await billingRepository.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/CreateBillingCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/CreateBillingCommandHandler.cs new file mode 100644 index 0000000..3243dcd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/CreateBillingCommandHandler.cs @@ -0,0 +1,65 @@ +using MediatR; +using System.Text.Json; +using TakeoutSaaS.Application.App.Billings.Commands; +using TakeoutSaaS.Application.App.Billings.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 创建账单命令处理器。 +/// +public sealed class CreateBillingCommandHandler( + ITenantRepository tenantRepository, + ITenantBillingRepository billingRepository, + IIdGenerator idGenerator) + : IRequestHandler +{ + /// + public async Task Handle(CreateBillingCommand request, CancellationToken cancellationToken) + { + // 1. 校验租户存在 + var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken); + if (tenant is null) + { + throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + } + + // 2. (空行后) 构建账单实体 + var now = DateTime.UtcNow; + var statementNo = $"BIL-{now:yyyyMMdd}-{idGenerator.NextId()}"; + var lineItemsJson = JsonSerializer.Serialize(request.LineItems); + + var billing = new TenantBillingStatement + { + TenantId = request.TenantId, + StatementNo = statementNo, + BillingType = request.BillingType, + SubscriptionId = null, + PeriodStart = now, + PeriodEnd = now, + AmountDue = request.AmountDue, + DiscountAmount = 0m, + TaxAmount = 0m, + AmountPaid = 0m, + Currency = "CNY", + Status = TenantBillingStatus.Pending, + DueDate = request.DueDate, + LineItemsJson = lineItemsJson, + Notes = request.Notes + }; + + // 3. (空行后) 持久化账单 + await billingRepository.AddAsync(billing, cancellationToken); + await billingRepository.SaveChangesAsync(cancellationToken); + + // 4. (空行后) 返回详情 DTO + return billing.ToBillingDetailDto([], tenant.Name); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ExportBillingsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ExportBillingsQueryHandler.cs new file mode 100644 index 0000000..1309c2a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ExportBillingsQueryHandler.cs @@ -0,0 +1,44 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Domain.Tenants.Services; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 导出账单处理器。 +/// +public sealed class ExportBillingsQueryHandler( + ITenantBillingRepository billingRepository, + IBillingExportService exportService) + : IRequestHandler +{ + /// + public async Task Handle(ExportBillingsQuery request, CancellationToken cancellationToken) + { + // 1. 参数验证 + if (request.BillingIds.Length == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "账单 ID 列表不能为空"); + } + + // 2. (空行后) 查询账单数据 + var billings = await billingRepository.GetByIdsAsync(request.BillingIds, cancellationToken); + if (billings.Count == 0) + { + throw new BusinessException(ErrorCodes.NotFound, "未找到任何匹配的账单"); + } + + // 3. (空行后) 根据格式导出 + var format = (request.Format ?? string.Empty).Trim().ToLowerInvariant(); + return format switch + { + "excel" or "xlsx" => await exportService.ExportToExcelAsync(billings, cancellationToken), + "pdf" => await exportService.ExportToPdfAsync(billings, cancellationToken), + "csv" => await exportService.ExportToCsvAsync(billings, cancellationToken), + _ => throw new BusinessException(ErrorCodes.BadRequest, $"不支持的导出格式: {request.Format}") + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GenerateSubscriptionBillingCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GenerateSubscriptionBillingCommandHandler.cs new file mode 100644 index 0000000..2dc97e7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GenerateSubscriptionBillingCommandHandler.cs @@ -0,0 +1,102 @@ +using MediatR; +using System.Text.Json; +using TakeoutSaaS.Application.App.Billings.Commands; +using TakeoutSaaS.Application.App.Billings.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 生成订阅账单命令处理器。 +/// +public sealed class GenerateSubscriptionBillingCommandHandler( + ISubscriptionRepository subscriptionRepository, + ITenantBillingRepository billingRepository, + IIdGenerator idGenerator) + : IRequestHandler +{ + /// + public async Task Handle(GenerateSubscriptionBillingCommand request, CancellationToken cancellationToken) + { + // 1. 查询订阅详情(含租户/套餐信息) + var detail = await subscriptionRepository.GetDetailAsync(request.SubscriptionId, cancellationToken); + if (detail is null) + { + throw new BusinessException(ErrorCodes.NotFound, "订阅不存在"); + } + + // 2. (空行后) 校验套餐价格信息 + var subscription = detail.Subscription; + var package = detail.Package; + if (package is null) + { + throw new BusinessException(ErrorCodes.BusinessError, "订阅未关联有效套餐,无法生成账单"); + } + + // 3. (空行后) 按订阅周期选择价格(简化规则:优先按年/按月) + var billingPeriodDays = (subscription.EffectiveTo - subscription.EffectiveFrom).TotalDays; + var amountDue = billingPeriodDays >= 300 + ? package.YearlyPrice + : package.MonthlyPrice; + + if (!amountDue.HasValue) + { + throw new BusinessException(ErrorCodes.BusinessError, "套餐价格未配置,无法生成账单"); + } + + // 4. (空行后) 幂等校验:同一周期开始时间仅允许存在一张未取消账单 + var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync(subscription.TenantId, subscription.EffectiveFrom, cancellationToken); + if (exists) + { + throw new BusinessException(ErrorCodes.Conflict, "该订阅周期的账单已存在"); + } + + // 5. (空行后) 构建账单实体 + var now = DateTime.UtcNow; + var statementNo = $"BIL-{now:yyyyMMdd}-{idGenerator.NextId()}"; + var lineItems = new List + { + new() + { + ItemType = "Subscription", + Description = $"套餐 {package.Name} 订阅费用", + Quantity = 1, + UnitPrice = amountDue.Value, + Amount = amountDue.Value, + DiscountRate = null + } + }; + + var billing = new TenantBillingStatement + { + TenantId = subscription.TenantId, + StatementNo = statementNo, + BillingType = BillingType.Subscription, + SubscriptionId = subscription.Id, + PeriodStart = subscription.EffectiveFrom, + PeriodEnd = subscription.EffectiveTo, + AmountDue = amountDue.Value, + DiscountAmount = 0m, + TaxAmount = 0m, + AmountPaid = 0m, + Currency = "CNY", + Status = TenantBillingStatus.Pending, + DueDate = now.AddDays(7), + LineItemsJson = JsonSerializer.Serialize(lineItems), + Notes = subscription.Notes + }; + + // 6. (空行后) 持久化账单 + await billingRepository.AddAsync(billing, cancellationToken); + await billingRepository.SaveChangesAsync(cancellationToken); + + // 7. (空行后) 返回详情 DTO + return billing.ToBillingDetailDto([], detail.TenantName); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillListQueryHandler.cs index f63d55a..51782d5 100644 --- a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillListQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillListQueryHandler.cs @@ -28,6 +28,8 @@ public sealed class GetBillListQueryHandler( request.Status, request.StartDate, request.EndDate, + null, + null, request.Keyword, request.PageNumber, request.PageSize, diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingDetailQueryHandler.cs new file mode 100644 index 0000000..50a90d4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingDetailQueryHandler.cs @@ -0,0 +1,218 @@ +using MediatR; +using System.Data; +using System.Data.Common; +using System.Text.Json; +using TakeoutSaaS.Application.App.Billings.Dto; +using TakeoutSaaS.Application.App.Billings.Queries; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 查询账单详情处理器。 +/// +public sealed class GetBillingDetailQueryHandler( + IDapperExecutor dapperExecutor) + : IRequestHandler +{ + /// + /// 处理查询账单详情请求。 + /// + /// 查询命令。 + /// 取消标记。 + /// 账单详情 DTO。 + public async Task Handle(GetBillingDetailQuery request, CancellationToken cancellationToken) + { + // 1. 查询账单 + 支付记录(同一连接,避免多次往返) + return await dapperExecutor.QueryAsync( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + // 1.1 查询账单 + await using var billCommand = CreateCommand( + connection, + BuildBillingSql(), + [ + ("billingId", request.BillingId) + ]); + + await using var billReader = await billCommand.ExecuteReaderAsync(token); + if (!await billReader.ReadAsync(token)) + { + throw new BusinessException(ErrorCodes.NotFound, "账单不存在"); + } + + DateTime? reminderSentAt = billReader.IsDBNull(15) ? null : billReader.GetDateTime(15); + DateTime? overdueNotifiedAt = billReader.IsDBNull(16) ? null : billReader.GetDateTime(16); + var notes = billReader.IsDBNull(17) ? null : billReader.GetString(17); + var lineItemsJson = billReader.IsDBNull(18) ? null : billReader.GetString(18); + long? createdBy = billReader.IsDBNull(20) ? null : billReader.GetInt64(20); + long? updatedBy = billReader.IsDBNull(22) ? null : billReader.GetInt64(22); + + // 1.2 (空行后) 反序列化账单明细 + var lineItems = new List(); + if (!string.IsNullOrWhiteSpace(lineItemsJson)) + { + try + { + lineItems = JsonSerializer.Deserialize>(lineItemsJson) ?? []; + } + catch + { + lineItems = []; + } + } + + // 1.3 (空行后) 查询支付记录 + var payments = new List(); + await using var paymentCommand = CreateCommand( + connection, + BuildPaymentsSql(), + [ + ("billingId", request.BillingId) + ]); + + await using var paymentReader = await paymentCommand.ExecuteReaderAsync(token); + while (await paymentReader.ReadAsync(token)) + { + payments.Add(new PaymentRecordDto + { + Id = paymentReader.GetInt64(0), + TenantId = paymentReader.GetInt64(1), + BillingId = paymentReader.GetInt64(2), + Amount = paymentReader.GetDecimal(3), + Method = (TenantPaymentMethod)paymentReader.GetInt32(4), + Status = (TenantPaymentStatus)paymentReader.GetInt32(5), + TransactionNo = paymentReader.IsDBNull(6) ? null : paymentReader.GetString(6), + ProofUrl = paymentReader.IsDBNull(7) ? null : paymentReader.GetString(7), + Notes = paymentReader.IsDBNull(8) ? null : paymentReader.GetString(8), + VerifiedBy = paymentReader.IsDBNull(9) ? null : paymentReader.GetInt64(9), + VerifiedAt = paymentReader.IsDBNull(10) ? null : paymentReader.GetDateTime(10), + RefundReason = paymentReader.IsDBNull(11) ? null : paymentReader.GetString(11), + RefundedAt = paymentReader.IsDBNull(12) ? null : paymentReader.GetDateTime(12), + PaidAt = paymentReader.IsDBNull(13) ? null : paymentReader.GetDateTime(13), + IsVerified = !paymentReader.IsDBNull(10), + CreatedAt = paymentReader.GetDateTime(14) + }); + } + + // 1.4 (空行后) 组装详情 DTO + var amountDue = billReader.GetDecimal(9); + var discountAmount = billReader.GetDecimal(10); + var taxAmount = billReader.GetDecimal(11); + var totalAmount = amountDue - discountAmount + taxAmount; + + return new BillingDetailDto + { + Id = billReader.GetInt64(0), + TenantId = billReader.GetInt64(1), + TenantName = billReader.IsDBNull(2) ? string.Empty : billReader.GetString(2), + SubscriptionId = billReader.IsDBNull(3) ? null : billReader.GetInt64(3), + StatementNo = billReader.GetString(4), + BillingType = (BillingType)billReader.GetInt32(5), + Status = (TenantBillingStatus)billReader.GetInt32(6), + PeriodStart = billReader.GetDateTime(7), + PeriodEnd = billReader.GetDateTime(8), + AmountDue = billReader.GetDecimal(9), + DiscountAmount = billReader.GetDecimal(10), + TaxAmount = billReader.GetDecimal(11), + TotalAmount = totalAmount, + AmountPaid = billReader.GetDecimal(12), + Currency = billReader.IsDBNull(13) ? "CNY" : billReader.GetString(13), + DueDate = billReader.GetDateTime(14), + ReminderSentAt = reminderSentAt, + OverdueNotifiedAt = overdueNotifiedAt, + LineItemsJson = lineItemsJson, + LineItems = lineItems, + Payments = payments, + Notes = notes, + CreatedAt = billReader.GetDateTime(19), + CreatedBy = createdBy, + UpdatedAt = billReader.IsDBNull(21) ? null : billReader.GetDateTime(21), + UpdatedBy = updatedBy + }; + }, + cancellationToken); + } + + private static string BuildBillingSql() + { + return """ + select + b."Id", + b."TenantId", + t."Name" as "TenantName", + b."SubscriptionId", + b."StatementNo", + b."BillingType", + b."Status", + b."PeriodStart", + b."PeriodEnd", + b."AmountDue", + b."DiscountAmount", + b."TaxAmount", + b."AmountPaid", + b."Currency", + b."DueDate", + b."ReminderSentAt", + b."OverdueNotifiedAt", + b."Notes", + b."LineItemsJson", + b."CreatedAt", + b."CreatedBy", + b."UpdatedAt", + b."UpdatedBy" + from public.tenant_billing_statements b + join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null + where b."DeletedAt" is null + and b."Id" = @billingId + limit 1; + """; + } + + private static string BuildPaymentsSql() + { + return """ + select + p."Id", + p."TenantId", + p."BillingStatementId", + p."Amount", + p."Method", + p."Status", + p."TransactionNo", + p."ProofUrl", + p."Notes", + p."VerifiedBy", + p."VerifiedAt", + p."RefundReason", + p."RefundedAt", + p."PaidAt", + p."CreatedAt" + from public.tenant_payments p + where p."DeletedAt" is null + and p."BillingStatementId" = @billingId + order by p."CreatedAt" desc; + """; + } + + private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + + foreach (var (name, value) in parameters) + { + var p = command.CreateParameter(); + p.ParameterName = name; + p.Value = value ?? DBNull.Value; + command.Parameters.Add(p); + } + + return (DbCommand)command; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingListQueryHandler.cs new file mode 100644 index 0000000..7d5fca8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingListQueryHandler.cs @@ -0,0 +1,233 @@ +using MediatR; +using System.Data; +using System.Data.Common; +using TakeoutSaaS.Application.App.Billings.Dto; +using TakeoutSaaS.Application.App.Billings.Queries; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 分页查询账单列表处理器。 +/// +public sealed class GetBillingListQueryHandler( + IDapperExecutor dapperExecutor) + : IRequestHandler> +{ + /// + /// 处理分页查询账单列表请求。 + /// + /// 查询命令。 + /// 取消标记。 + /// 分页账单列表 DTO。 + public async Task> Handle(GetBillingListQuery request, CancellationToken cancellationToken) + { + // 1. 参数规范化 + var page = request.PageNumber <= 0 ? 1 : request.PageNumber; + var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize; + var keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim(); + var minAmount = request.MinAmount; + var maxAmount = request.MaxAmount; + var offset = (page - 1) * pageSize; + + // 1.1 (空行后) 金额区间规范化(避免 min > max 导致结果为空) + if (minAmount.HasValue && maxAmount.HasValue && minAmount.Value > maxAmount.Value) + { + (minAmount, maxAmount) = (maxAmount, minAmount); + } + + // 2. (空行后) 排序白名单(防 SQL 注入) + var orderBy = request.SortBy?.Trim() switch + { + "DueDate" => "b.\"DueDate\"", + "AmountDue" => "b.\"AmountDue\"", + "PeriodStart" => "b.\"PeriodStart\"", + "PeriodEnd" => "b.\"PeriodEnd\"", + "CreatedAt" => "b.\"CreatedAt\"", + _ => "b.\"CreatedAt\"" + }; + + // 3. (空行后) 查询总数 + 列表 + return await dapperExecutor.QueryAsync( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + // 3.1 统计总数 + var total = await ExecuteScalarIntAsync( + connection, + BuildCountSql(), + [ + ("tenantId", request.TenantId), + ("status", request.Status.HasValue ? (int)request.Status.Value : null), + ("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null), + ("startDate", request.StartDate), + ("endDate", request.EndDate), + ("minAmount", minAmount), + ("maxAmount", maxAmount), + ("keyword", keyword) + ], + token); + + // 3.2 (空行后) 查询列表 + var listSql = BuildListSql(orderBy, request.SortDesc); + await using var listCommand = CreateCommand( + connection, + listSql, + [ + ("tenantId", request.TenantId), + ("status", request.Status.HasValue ? (int)request.Status.Value : null), + ("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null), + ("startDate", request.StartDate), + ("endDate", request.EndDate), + ("minAmount", minAmount), + ("maxAmount", maxAmount), + ("keyword", keyword), + ("offset", offset), + ("limit", pageSize) + ]); + + await using var reader = await listCommand.ExecuteReaderAsync(token); + var now = DateTime.UtcNow; + var items = new List(); + while (await reader.ReadAsync(token)) + { + var dueDate = reader.GetDateTime(13); + var status = (TenantBillingStatus)reader.GetInt32(12); + var amountDue = reader.GetDecimal(8); + var discountAmount = reader.GetDecimal(9); + var taxAmount = reader.GetDecimal(10); + var totalAmount = amountDue - discountAmount + taxAmount; + + // 3.2.1 (空行后) 逾期辅助字段 + var isOverdue = status is TenantBillingStatus.Overdue + || (status is TenantBillingStatus.Pending && dueDate < now); + var overdueDays = dueDate < now ? (int)(now - dueDate).TotalDays : 0; + + items.Add(new BillingListDto + { + Id = reader.GetInt64(0), + TenantId = reader.GetInt64(1), + TenantName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2), + SubscriptionId = reader.IsDBNull(3) ? null : reader.GetInt64(3), + StatementNo = reader.GetString(4), + BillingType = (BillingType)reader.GetInt32(5), + PeriodStart = reader.GetDateTime(6), + PeriodEnd = reader.GetDateTime(7), + AmountDue = amountDue, + DiscountAmount = discountAmount, + TaxAmount = taxAmount, + TotalAmount = totalAmount, + AmountPaid = reader.GetDecimal(11), + Currency = reader.IsDBNull(14) ? "CNY" : reader.GetString(14), + Status = status, + DueDate = dueDate, + IsOverdue = isOverdue, + OverdueDays = overdueDays, + CreatedAt = reader.GetDateTime(15), + UpdatedAt = reader.IsDBNull(16) ? null : reader.GetDateTime(16) + }); + } + + // 3.3 (空行后) 返回分页 + return new PagedResult(items, page, pageSize, total); + }, + cancellationToken); + } + + private static string BuildCountSql() + { + return """ + select count(*) + from public.tenant_billing_statements b + join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null + where b."DeletedAt" is null + and (@tenantId::bigint is null or b."TenantId" = @tenantId) + and (@status::int is null or b."Status" = @status) + and (@billingType::int is null or b."BillingType" = @billingType) + and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate) + and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate) + and (@minAmount::numeric is null or b."AmountDue" >= @minAmount) + and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount) + and ( + @keyword::text is null + or b."StatementNo" ilike ('%' || @keyword::text || '%') + or t."Name" ilike ('%' || @keyword::text || '%') + ); + """; + } + + private static string BuildListSql(string orderBy, bool sortDesc) + { + var direction = sortDesc ? "desc" : "asc"; + + return $""" + select + b."Id", + b."TenantId", + t."Name" as "TenantName", + b."SubscriptionId", + b."StatementNo", + b."BillingType", + b."PeriodStart", + b."PeriodEnd", + b."AmountDue", + b."DiscountAmount", + b."TaxAmount", + b."AmountPaid", + b."Status", + b."DueDate", + b."Currency", + b."CreatedAt", + b."UpdatedAt" + from public.tenant_billing_statements b + join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null + where b."DeletedAt" is null + and (@tenantId::bigint is null or b."TenantId" = @tenantId) + and (@status::int is null or b."Status" = @status) + and (@billingType::int is null or b."BillingType" = @billingType) + and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate) + and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate) + and (@minAmount::numeric is null or b."AmountDue" >= @minAmount) + and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount) + and ( + @keyword::text is null + or b."StatementNo" ilike ('%' || @keyword::text || '%') + or t."Name" ilike ('%' || @keyword::text || '%') + ) + order by {orderBy} {direction} + offset @offset + limit @limit; + """; + } + + private static async Task ExecuteScalarIntAsync( + IDbConnection connection, + string sql, + (string Name, object? Value)[] parameters, + CancellationToken cancellationToken) + { + await using var command = CreateCommand(connection, sql, parameters); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is null or DBNull ? 0 : Convert.ToInt32(result); + } + + private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + + foreach (var (name, value) in parameters) + { + var p = command.CreateParameter(); + p.ParameterName = name; + p.Value = value ?? DBNull.Value; + command.Parameters.Add(p); + } + + return (DbCommand)command; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingPaymentsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingPaymentsQueryHandler.cs new file mode 100644 index 0000000..dcca623 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingPaymentsQueryHandler.cs @@ -0,0 +1,139 @@ +using MediatR; +using System.Data; +using System.Data.Common; +using TakeoutSaaS.Application.App.Billings.Dto; +using TakeoutSaaS.Application.App.Billings.Queries; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 查询账单支付记录处理器。 +/// +public sealed class GetBillingPaymentsQueryHandler( + IDapperExecutor dapperExecutor) + : IRequestHandler> +{ + /// + /// 处理查询账单支付记录请求。 + /// + /// 查询命令。 + /// 取消标记。 + /// 支付记录列表 DTO。 + public async Task> Handle(GetBillingPaymentsQuery request, CancellationToken cancellationToken) + { + // 1. 校验账单是否存在 + return await dapperExecutor.QueryAsync( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + // 1.1 校验账单存在 + var exists = await ExecuteScalarIntAsync( + connection, + """ + select 1 + from public.tenant_billing_statements b + where b."DeletedAt" is null + and b."Id" = @billingId + limit 1; + """, + [ + ("billingId", request.BillingId) + ], + token); + + if (exists == 0) + { + throw new BusinessException(ErrorCodes.NotFound, "账单不存在"); + } + + // 1.2 (空行后) 查询支付记录 + await using var command = CreateCommand( + connection, + """ + select + p."Id", + p."TenantId", + p."BillingStatementId", + p."Amount", + p."Method", + p."Status", + p."TransactionNo", + p."ProofUrl", + p."Notes", + p."VerifiedBy", + p."VerifiedAt", + p."RefundReason", + p."RefundedAt", + p."PaidAt", + p."CreatedAt" + from public.tenant_payments p + where p."DeletedAt" is null + and p."BillingStatementId" = @billingId + order by p."CreatedAt" desc; + """, + [ + ("billingId", request.BillingId) + ]); + + await using var reader = await command.ExecuteReaderAsync(token); + var results = new List(); + while (await reader.ReadAsync(token)) + { + results.Add(new PaymentRecordDto + { + Id = reader.GetInt64(0), + TenantId = reader.GetInt64(1), + BillingId = reader.GetInt64(2), + Amount = reader.GetDecimal(3), + Method = (TenantPaymentMethod)reader.GetInt32(4), + Status = (TenantPaymentStatus)reader.GetInt32(5), + TransactionNo = reader.IsDBNull(6) ? null : reader.GetString(6), + ProofUrl = reader.IsDBNull(7) ? null : reader.GetString(7), + Notes = reader.IsDBNull(8) ? null : reader.GetString(8), + VerifiedBy = reader.IsDBNull(9) ? null : reader.GetInt64(9), + VerifiedAt = reader.IsDBNull(10) ? null : reader.GetDateTime(10), + RefundReason = reader.IsDBNull(11) ? null : reader.GetString(11), + RefundedAt = reader.IsDBNull(12) ? null : reader.GetDateTime(12), + PaidAt = reader.IsDBNull(13) ? null : reader.GetDateTime(13), + IsVerified = !reader.IsDBNull(10), + CreatedAt = reader.GetDateTime(14) + }); + } + + return results; + }, + cancellationToken); + } + + private static async Task ExecuteScalarIntAsync( + IDbConnection connection, + string sql, + (string Name, object? Value)[] parameters, + CancellationToken cancellationToken) + { + await using var command = CreateCommand(connection, sql, parameters); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is null or DBNull ? 0 : Convert.ToInt32(result); + } + + private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + + foreach (var (name, value) in parameters) + { + var p = command.CreateParameter(); + p.ParameterName = name; + p.Value = value ?? DBNull.Value; + command.Parameters.Add(p); + } + + return (DbCommand)command; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingStatisticsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingStatisticsQueryHandler.cs new file mode 100644 index 0000000..4054573 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingStatisticsQueryHandler.cs @@ -0,0 +1,187 @@ +using MediatR; +using System.Data; +using System.Data.Common; +using TakeoutSaaS.Application.App.Billings.Dto; +using TakeoutSaaS.Application.App.Billings.Queries; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 查询账单统计数据处理器。 +/// +public sealed class GetBillingStatisticsQueryHandler( + IDapperExecutor dapperExecutor) + : IRequestHandler +{ + /// + /// 处理查询账单统计数据请求。 + /// + /// 查询命令。 + /// 取消标记。 + /// 账单统计数据 DTO。 + public async Task Handle(GetBillingStatisticsQuery request, CancellationToken cancellationToken) + { + // 1. 参数规范化 + var startDate = request.StartDate ?? DateTime.UtcNow.AddMonths(-1); + var endDate = request.EndDate ?? DateTime.UtcNow; + var groupBy = NormalizeGroupBy(request.GroupBy); + + // 2. (空行后) 查询统计数据(总览 + 趋势) + return await dapperExecutor.QueryAsync( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + // 2.1 总览统计 + await using var summaryCommand = CreateCommand( + connection, + BuildSummarySql(), + [ + ("tenantId", request.TenantId), + ("startDate", startDate), + ("endDate", endDate), + ("now", DateTime.UtcNow) + ]); + + await using var summaryReader = await summaryCommand.ExecuteReaderAsync(token); + await summaryReader.ReadAsync(token); + + var totalCount = summaryReader.IsDBNull(0) ? 0 : summaryReader.GetInt32(0); + var pendingCount = summaryReader.IsDBNull(1) ? 0 : summaryReader.GetInt32(1); + var paidCount = summaryReader.IsDBNull(2) ? 0 : summaryReader.GetInt32(2); + var overdueCount = summaryReader.IsDBNull(3) ? 0 : summaryReader.GetInt32(3); + var cancelledCount = summaryReader.IsDBNull(4) ? 0 : summaryReader.GetInt32(4); + var totalAmountDue = summaryReader.IsDBNull(5) ? 0m : summaryReader.GetDecimal(5); + var totalAmountPaid = summaryReader.IsDBNull(6) ? 0m : summaryReader.GetDecimal(6); + var totalAmountUnpaid = summaryReader.IsDBNull(7) ? 0m : summaryReader.GetDecimal(7); + var totalOverdueAmount = summaryReader.IsDBNull(8) ? 0m : summaryReader.GetDecimal(8); + + // 2.2 (空行后) 趋势数据 + await using var trendCommand = CreateCommand( + connection, + BuildTrendSql(groupBy), + [ + ("tenantId", request.TenantId), + ("startDate", startDate), + ("endDate", endDate) + ]); + + await using var trendReader = await trendCommand.ExecuteReaderAsync(token); + var amountDueTrend = new Dictionary(); + var amountPaidTrend = new Dictionary(); + var countTrend = new Dictionary(); + while (await trendReader.ReadAsync(token)) + { + var bucket = trendReader.GetDateTime(0); + var key = bucket.ToString("yyyy-MM-dd"); + + amountDueTrend[key] = trendReader.IsDBNull(1) ? 0m : trendReader.GetDecimal(1); + amountPaidTrend[key] = trendReader.IsDBNull(2) ? 0m : trendReader.GetDecimal(2); + countTrend[key] = trendReader.IsDBNull(3) ? 0 : trendReader.GetInt32(3); + } + + // 2.3 (空行后) 组装 DTO + return new BillingStatisticsDto + { + TenantId = request.TenantId, + StartDate = startDate, + EndDate = endDate, + GroupBy = groupBy, + TotalCount = totalCount, + PendingCount = pendingCount, + PaidCount = paidCount, + OverdueCount = overdueCount, + CancelledCount = cancelledCount, + TotalAmountDue = totalAmountDue, + TotalAmountPaid = totalAmountPaid, + TotalAmountUnpaid = totalAmountUnpaid, + TotalOverdueAmount = totalOverdueAmount, + AmountDueTrend = amountDueTrend, + AmountPaidTrend = amountPaidTrend, + CountTrend = countTrend + }; + }, + cancellationToken); + } + + private static string NormalizeGroupBy(string? groupBy) + { + return groupBy?.Trim() switch + { + "Week" => "Week", + "Month" => "Month", + _ => "Day" + }; + } + + private static string BuildSummarySql() + { + return """ + select + count(*)::int as "TotalCount", + coalesce(sum(case when b."Status" = 0 then 1 else 0 end), 0)::int as "PendingCount", + coalesce(sum(case when b."Status" = 1 then 1 else 0 end), 0)::int as "PaidCount", + coalesce(sum(case when b."Status" = 2 then 1 else 0 end), 0)::int as "OverdueCount", + coalesce(sum(case when b."Status" = 3 then 1 else 0 end), 0)::int as "CancelledCount", + coalesce(sum(b."AmountDue"), 0)::numeric as "TotalAmountDue", + coalesce(sum(b."AmountPaid"), 0)::numeric as "TotalAmountPaid", + coalesce(sum((b."AmountDue" - b."DiscountAmount" + b."TaxAmount") - b."AmountPaid"), 0)::numeric as "TotalAmountUnpaid", + coalesce(sum( + case + when b."Status" in (0, 2) and b."DueDate" < @now + then (b."AmountDue" - b."DiscountAmount" + b."TaxAmount") - b."AmountPaid" + else 0 + end + ), 0)::numeric as "TotalOverdueAmount" + from public.tenant_billing_statements b + where b."DeletedAt" is null + and (@tenantId::bigint is null or b."TenantId" = @tenantId) + and b."PeriodStart" >= @startDate + and b."PeriodEnd" <= @endDate; + """; + } + + private static string BuildTrendSql(string groupBy) + { + var dateTrunc = groupBy switch + { + "Week" => "week", + "Month" => "month", + _ => "day" + }; + + return $""" + select + date_trunc('{dateTrunc}', b."PeriodStart") as "Bucket", + coalesce(sum(b."AmountDue"), 0)::numeric as "AmountDue", + coalesce(sum(b."AmountPaid"), 0)::numeric as "AmountPaid", + count(*)::int as "Count" + from public.tenant_billing_statements b + where b."DeletedAt" is null + and (@tenantId::bigint is null or b."TenantId" = @tenantId) + and b."PeriodStart" >= @startDate + and b."PeriodEnd" <= @endDate + group by 1 + order by 1 asc; + """; + } + + private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + + foreach (var (name, value) in parameters) + { + var p = command.CreateParameter(); + p.ParameterName = name; + p.Value = value ?? DBNull.Value; + command.Parameters.Add(p); + } + + return (DbCommand)command; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetOverdueBillingsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetOverdueBillingsQueryHandler.cs new file mode 100644 index 0000000..7acfe55 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetOverdueBillingsQueryHandler.cs @@ -0,0 +1,172 @@ +using MediatR; +using System.Data; +using System.Data.Common; +using TakeoutSaaS.Application.App.Billings.Dto; +using TakeoutSaaS.Application.App.Billings.Queries; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 查询逾期账单列表处理器。 +/// +public sealed class GetOverdueBillingsQueryHandler( + IDapperExecutor dapperExecutor) + : IRequestHandler> +{ + /// + /// 处理查询逾期账单列表请求。 + /// + /// 查询命令。 + /// 取消标记。 + /// 分页逾期账单列表 DTO。 + public async Task> Handle(GetOverdueBillingsQuery request, CancellationToken cancellationToken) + { + // 1. 参数规范化 + var page = request.PageNumber <= 0 ? 1 : request.PageNumber; + var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize; + var offset = (page - 1) * pageSize; + var now = DateTime.UtcNow; + + // 2. (空行后) 查询总数 + 列表 + return await dapperExecutor.QueryAsync( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + // 2.1 统计总数 + var total = await ExecuteScalarIntAsync( + connection, + BuildCountSql(), + [ + ("now", now) + ], + token); + + // 2.2 (空行后) 查询列表 + await using var listCommand = CreateCommand( + connection, + BuildListSql(), + [ + ("now", now), + ("offset", offset), + ("limit", pageSize) + ]); + + await using var reader = await listCommand.ExecuteReaderAsync(token); + var items = new List(); + while (await reader.ReadAsync(token)) + { + var dueDate = reader.GetDateTime(13); + var status = (TenantBillingStatus)reader.GetInt32(12); + var amountDue = reader.GetDecimal(8); + var discountAmount = reader.GetDecimal(9); + var taxAmount = reader.GetDecimal(10); + var totalAmount = amountDue - discountAmount + taxAmount; + var overdueDays = dueDate < now ? (int)(now - dueDate).TotalDays : 0; + + items.Add(new BillingListDto + { + Id = reader.GetInt64(0), + TenantId = reader.GetInt64(1), + TenantName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2), + SubscriptionId = reader.IsDBNull(3) ? null : reader.GetInt64(3), + StatementNo = reader.GetString(4), + BillingType = (BillingType)reader.GetInt32(5), + PeriodStart = reader.GetDateTime(6), + PeriodEnd = reader.GetDateTime(7), + AmountDue = amountDue, + DiscountAmount = discountAmount, + TaxAmount = taxAmount, + TotalAmount = totalAmount, + AmountPaid = reader.GetDecimal(11), + Status = status, + DueDate = dueDate, + Currency = reader.IsDBNull(14) ? "CNY" : reader.GetString(14), + IsOverdue = true, + OverdueDays = overdueDays, + CreatedAt = reader.GetDateTime(15), + UpdatedAt = reader.IsDBNull(16) ? null : reader.GetDateTime(16) + }); + } + + // 2.3 (空行后) 返回分页 + return new PagedResult(items, page, pageSize, total); + }, + cancellationToken); + } + + private static string BuildCountSql() + { + return """ + select count(*) + from public.tenant_billing_statements b + join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null + where b."DeletedAt" is null + and b."DueDate" < @now + and b."Status" in (0, 2); + """; + } + + private static string BuildListSql() + { + return """ + select + b."Id", + b."TenantId", + t."Name" as "TenantName", + b."SubscriptionId", + b."StatementNo", + b."BillingType", + b."PeriodStart", + b."PeriodEnd", + b."AmountDue", + b."DiscountAmount", + b."TaxAmount", + b."AmountPaid", + b."Status", + b."DueDate", + b."Currency", + b."CreatedAt", + b."UpdatedAt" + from public.tenant_billing_statements b + join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null + where b."DeletedAt" is null + and b."DueDate" < @now + and b."Status" in (0, 2) + order by b."DueDate" asc + offset @offset + limit @limit; + """; + } + + private static async Task ExecuteScalarIntAsync( + IDbConnection connection, + string sql, + (string Name, object? Value)[] parameters, + CancellationToken cancellationToken) + { + await using var command = CreateCommand(connection, sql, parameters); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is null or DBNull ? 0 : Convert.ToInt32(result); + } + + private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + + foreach (var (name, value) in parameters) + { + var p = command.CreateParameter(); + p.ParameterName = name; + p.Value = value ?? DBNull.Value; + command.Parameters.Add(p); + } + + return (DbCommand)command; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ProcessOverdueBillingsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ProcessOverdueBillingsCommandHandler.cs new file mode 100644 index 0000000..2d6ab08 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ProcessOverdueBillingsCommandHandler.cs @@ -0,0 +1,49 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Commands; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 处理逾期账单命令处理器(后台任务)。 +/// +public sealed class ProcessOverdueBillingsCommandHandler( + ITenantBillingRepository billingRepository) + : IRequestHandler +{ + /// + public async Task Handle(ProcessOverdueBillingsCommand request, CancellationToken cancellationToken) + { + // 1. 查询逾期账单(到期日已过且未支付) + var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken); + if (overdueBillings.Count == 0) + { + return 0; + } + + // 2. (空行后) 标记为逾期并更新通知时间 + var now = DateTime.UtcNow; + var updatedCount = 0; + foreach (var billing in overdueBillings) + { + var before = billing.Status; + billing.MarkAsOverdue(); + + if (before != billing.Status) + { + billing.OverdueNotifiedAt ??= now; + await billingRepository.UpdateAsync(billing, cancellationToken); + updatedCount++; + } + } + + // 3. (空行后) 持久化 + if (updatedCount > 0) + { + await billingRepository.SaveChangesAsync(cancellationToken); + } + + return updatedCount; + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/RecordPaymentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/RecordPaymentCommandHandler.cs index c993631..ed51e97 100644 --- a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/RecordPaymentCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/RecordPaymentCommandHandler.cs @@ -6,6 +6,7 @@ using TakeoutSaaS.Domain.Tenants.Enums; using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; namespace TakeoutSaaS.Application.App.Billings.Handlers; @@ -14,8 +15,9 @@ namespace TakeoutSaaS.Application.App.Billings.Handlers; /// public sealed class RecordPaymentCommandHandler( ITenantBillingRepository billingRepository, - ITenantPaymentRepository paymentRepository) - : IRequestHandler + ITenantPaymentRepository paymentRepository, + IIdGenerator idGenerator) + : IRequestHandler { /// /// 处理记录支付请求。 @@ -23,44 +25,57 @@ public sealed class RecordPaymentCommandHandler( /// 记录支付命令。 /// 取消标记。 /// 支付 DTO。 - public async Task Handle(RecordPaymentCommand request, CancellationToken cancellationToken) + public async Task Handle(RecordPaymentCommand request, CancellationToken cancellationToken) { // 1. 查询账单 - var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken); - if (bill is null) + var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken); + if (billing is null) { throw new BusinessException(ErrorCodes.NotFound, "账单不存在"); } - // 2. 构建支付记录 + // 2. (空行后) 业务规则检查 + if (billing.Status == TenantBillingStatus.Paid) + { + throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许重复收款"); + } + + if (billing.Status == TenantBillingStatus.Cancelled) + { + throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许收款"); + } + + // 3. (空行后) 幂等校验:交易号唯一 + if (!string.IsNullOrWhiteSpace(request.TransactionNo)) + { + var exists = await paymentRepository.GetByTransactionNoAsync(request.TransactionNo.Trim(), cancellationToken); + if (exists is not null) + { + throw new BusinessException(ErrorCodes.Conflict, "交易号已存在,疑似重复提交"); + } + } + + // 4. (空行后) 构建支付记录(默认待审核) + var now = DateTime.UtcNow; var payment = new TenantPayment { - TenantId = bill.TenantId, - BillingStatementId = request.BillId, + Id = idGenerator.NextId(), + TenantId = billing.TenantId, + BillingStatementId = request.BillingId, Amount = request.Amount, Method = request.Method, - Status = PaymentStatus.Success, - TransactionNo = request.TransactionNo, + Status = TenantPaymentStatus.Pending, + TransactionNo = string.IsNullOrWhiteSpace(request.TransactionNo) ? null : request.TransactionNo.Trim(), ProofUrl = request.ProofUrl, - PaidAt = DateTime.UtcNow, + PaidAt = now, Notes = request.Notes }; - // 3. 更新账单已付金额 - bill.AmountPaid += request.Amount; - - // 4. 如果已付金额 >= 应付金额,标记为已支付 - if (bill.AmountPaid >= bill.AmountDue) - { - bill.Status = TenantBillingStatus.Paid; - } - - // 5. 持久化变更 + // 5. (空行后) 持久化变更 await paymentRepository.AddAsync(payment, cancellationToken); - await billingRepository.UpdateAsync(bill, cancellationToken); await paymentRepository.SaveChangesAsync(cancellationToken); - // 6. 返回 DTO - return payment.ToDto(); + // 6. (空行后) 返回 DTO + return payment.ToPaymentRecordDto(); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/UpdateBillingStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/UpdateBillingStatusCommandHandler.cs new file mode 100644 index 0000000..9ec5615 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/UpdateBillingStatusCommandHandler.cs @@ -0,0 +1,54 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Commands; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 更新账单状态命令处理器。 +/// +public sealed class UpdateBillingStatusCommandHandler( + ITenantBillingRepository billingRepository) + : IRequestHandler +{ + /// + public async Task Handle(UpdateBillingStatusCommand request, CancellationToken cancellationToken) + { + // 1. 查询账单 + var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken); + if (billing is null) + { + throw new BusinessException(ErrorCodes.NotFound, "账单不存在"); + } + + // 2. (空行后) 状态转换规则校验 + if (billing.Status == TenantBillingStatus.Paid && request.NewStatus != TenantBillingStatus.Paid) + { + throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许改为其他状态"); + } + + if (billing.Status == TenantBillingStatus.Cancelled) + { + throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许变更状态"); + } + + // 3. (空行后) 更新状态与备注 + billing.Status = request.NewStatus; + if (!string.IsNullOrWhiteSpace(request.Notes)) + { + billing.Notes = string.IsNullOrWhiteSpace(billing.Notes) + ? $"[状态变更] {request.Notes}" + : $"{billing.Notes}\n[状态变更] {request.Notes}"; + } + + // 4. (空行后) 持久化 + await billingRepository.UpdateAsync(billing, cancellationToken); + await billingRepository.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/VerifyPaymentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/VerifyPaymentCommandHandler.cs new file mode 100644 index 0000000..e975a94 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/VerifyPaymentCommandHandler.cs @@ -0,0 +1,73 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Commands; +using TakeoutSaaS.Application.App.Billings.Dto; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 审核支付命令处理器。 +/// +public sealed class VerifyPaymentCommandHandler( + ITenantPaymentRepository paymentRepository, + ITenantBillingRepository billingRepository, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + /// + public async Task Handle(VerifyPaymentCommand request, CancellationToken cancellationToken) + { + // 1. 校验操作者身份 + if (!currentUserAccessor.IsAuthenticated || currentUserAccessor.UserId <= 0) + { + throw new BusinessException(ErrorCodes.Unauthorized, "未登录或无效的操作者身份"); + } + + // 2. (空行后) 查询支付记录 + var payment = await paymentRepository.FindByIdAsync(request.PaymentId, cancellationToken); + if (payment is null) + { + throw new BusinessException(ErrorCodes.NotFound, "支付记录不存在"); + } + + // 3. (空行后) 查询关联账单 + var billing = await billingRepository.FindByIdAsync(payment.BillingStatementId, cancellationToken); + if (billing is null) + { + throw new BusinessException(ErrorCodes.NotFound, "关联账单不存在"); + } + + // 4. (空行后) 归一化审核备注 + var normalizedNotes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(); + + // 5. (空行后) 根据审核结果更新支付与账单状态 + if (request.Approved) + { + payment.Verify(currentUserAccessor.UserId); + payment.Notes = normalizedNotes; + + billing.MarkAsPaid(payment.Amount, payment.TransactionNo ?? string.Empty); + } + else + { + payment.Reject(currentUserAccessor.UserId, normalizedNotes ?? string.Empty); + payment.Notes = normalizedNotes; + } + + // 6. (空行后) 持久化更新状态 + await paymentRepository.UpdateAsync(payment, cancellationToken); + if (request.Approved) + { + await billingRepository.UpdateAsync(billing, cancellationToken); + } + + // 7. (空行后) 保存数据库更改 + await paymentRepository.SaveChangesAsync(cancellationToken); + + // 8. (空行后) 返回 DTO + return payment.ToPaymentRecordDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Mappings/BillingProfile.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Mappings/BillingProfile.cs new file mode 100644 index 0000000..3b3a25b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Mappings/BillingProfile.cs @@ -0,0 +1,66 @@ +using AutoMapper; +using System.Text.Json; +using TakeoutSaaS.Application.App.Billings.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Billings.Mappings; + +/// +/// 账单模块 AutoMapper Profile。 +/// +public sealed class BillingProfile : Profile +{ + /// + /// 初始化映射配置。 + /// + public BillingProfile() + { + // 1. 账单实体 -> 列表 DTO + CreateMap() + .ForMember(x => x.TenantName, opt => opt.Ignore()) + .ForMember(x => x.TotalAmount, opt => opt.MapFrom(src => src.CalculateTotalAmount())) + .ForMember(x => x.IsOverdue, opt => opt.MapFrom(src => + src.Status == TenantBillingStatus.Overdue + || (src.Status == TenantBillingStatus.Pending && src.DueDate < DateTime.UtcNow))) + .ForMember(x => x.OverdueDays, opt => opt.MapFrom(src => + src.DueDate < DateTime.UtcNow ? (int)(DateTime.UtcNow - src.DueDate).TotalDays : 0)); + + // 2. (空行后) 账单实体 -> 详情 DTO + CreateMap() + .ForMember(x => x.TenantName, opt => opt.Ignore()) + .ForMember(x => x.TotalAmount, opt => opt.MapFrom(src => src.CalculateTotalAmount())) + .ForMember(x => x.LineItemsJson, opt => opt.MapFrom(src => src.LineItemsJson)) + .ForMember(x => x.LineItems, opt => opt.MapFrom(src => DeserializeLineItems(src.LineItemsJson))) + .ForMember(x => x.Payments, opt => opt.Ignore()); + + // 3. (空行后) 账单实体 -> 导出 DTO + CreateMap() + .ForMember(x => x.TenantName, opt => opt.Ignore()) + .ForMember(x => x.TotalAmount, opt => opt.MapFrom(src => src.CalculateTotalAmount())) + .ForMember(x => x.LineItems, opt => opt.MapFrom(src => DeserializeLineItems(src.LineItemsJson))); + + // 4. (空行后) 支付实体 -> 支付记录 DTO + CreateMap() + .ForMember(x => x.BillingId, opt => opt.MapFrom(src => src.BillingStatementId)) + .ForMember(x => x.IsVerified, opt => opt.MapFrom(src => src.VerifiedAt.HasValue)); + } + + private static IReadOnlyList DeserializeLineItems(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(json) ?? []; + } + catch + { + return []; + } + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Queries/ExportBillingsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/ExportBillingsQuery.cs new file mode 100644 index 0000000..c800a21 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/ExportBillingsQuery.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Billings.Queries; + +/// +/// 导出账单(Excel/PDF/CSV)。 +/// +public sealed record ExportBillingsQuery : IRequest +{ + /// + /// 要导出的账单 ID 列表。 + /// + public long[] BillingIds { get; init; } = []; + + /// + /// 导出格式(Excel/Pdf/Csv)。 + /// + public string Format { get; init; } = "Excel"; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingDetailQuery.cs new file mode 100644 index 0000000..c4fc6fd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingDetailQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Dto; + +namespace TakeoutSaaS.Application.App.Billings.Queries; + +/// +/// 查询账单详情(含明细项)。 +/// +public sealed record GetBillingDetailQuery : IRequest +{ + /// + /// 账单 ID(雪花算法)。 + /// + public long BillingId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingListQuery.cs new file mode 100644 index 0000000..da964e7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingListQuery.cs @@ -0,0 +1,72 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Billings.Queries; + +/// +/// 分页查询账单列表。 +/// +public sealed record GetBillingListQuery : IRequest> +{ + /// + /// 租户 ID(可选,管理员可查询所有租户)。 + /// + public long? TenantId { get; init; } + + /// + /// 账单状态筛选。 + /// + public TenantBillingStatus? Status { get; init; } + + /// + /// 账单类型筛选。 + /// + public BillingType? BillingType { get; init; } + + /// + /// 账单起始时间(UTC)筛选。 + /// + public DateTime? StartDate { get; init; } + + /// + /// 账单结束时间(UTC)筛选。 + /// + public DateTime? EndDate { get; init; } + + /// + /// 关键词搜索(账单编号)。 + /// + public string? Keyword { get; init; } + + /// + /// 最小应付金额筛选(包含)。 + /// + public decimal? MinAmount { get; init; } + + /// + /// 最大应付金额筛选(包含)。 + /// + public decimal? MaxAmount { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int PageNumber { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 排序字段(DueDate/CreatedAt/AmountDue)。 + /// + public string? SortBy { get; init; } + + /// + /// 是否降序排序。 + /// + public bool SortDesc { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingPaymentsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingPaymentsQuery.cs new file mode 100644 index 0000000..04e01c4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingPaymentsQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Dto; + +namespace TakeoutSaaS.Application.App.Billings.Queries; + +/// +/// 查询账单的支付记录。 +/// +public sealed record GetBillingPaymentsQuery : IRequest> +{ + /// + /// 账单 ID(雪花算法)。 + /// + public long BillingId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingStatisticsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingStatisticsQuery.cs new file mode 100644 index 0000000..5927f57 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingStatisticsQuery.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Dto; + +namespace TakeoutSaaS.Application.App.Billings.Queries; + +/// +/// 查询账单统计数据。 +/// +public sealed record GetBillingStatisticsQuery : IRequest +{ + /// + /// 租户 ID(可选,管理员可查询所有租户)。 + /// + public long? TenantId { get; init; } + + /// + /// 统计开始时间(UTC)。 + /// + public DateTime? StartDate { get; init; } + + /// + /// 统计结束时间(UTC)。 + /// + public DateTime? EndDate { get; init; } + + /// + /// 分组方式(Day/Week/Month)。 + /// + public string? GroupBy { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetOverdueBillingsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetOverdueBillingsQuery.cs new file mode 100644 index 0000000..171019e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetOverdueBillingsQuery.cs @@ -0,0 +1,21 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Billings.Queries; + +/// +/// 查询逾期账单列表。 +/// +public sealed record GetOverdueBillingsQuery : IRequest> +{ + /// + /// 页码(从 1 开始)。 + /// + public int PageNumber { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Validators/CreateBillingCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Validators/CreateBillingCommandValidator.cs new file mode 100644 index 0000000..a992c27 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Validators/CreateBillingCommandValidator.cs @@ -0,0 +1,73 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Billings.Commands; + +namespace TakeoutSaaS.Application.App.Billings.Validators; + +/// +/// 创建账单命令验证器。 +/// +public sealed class CreateBillingCommandValidator : AbstractValidator +{ + public CreateBillingCommandValidator() + { + // 1. 租户 ID 必填 + RuleFor(x => x.TenantId) + .GreaterThan(0) + .WithMessage("租户 ID 必须大于 0"); + + // 2. (空行后) 账单类型必填 + RuleFor(x => x.BillingType) + .IsInEnum() + .WithMessage("账单类型无效"); + + // 3. (空行后) 应付金额必须大于 0 + RuleFor(x => x.AmountDue) + .GreaterThan(0) + .WithMessage("应付金额必须大于 0"); + + // 4. (空行后) 到期日必须是未来时间 + RuleFor(x => x.DueDate) + .GreaterThan(DateTime.UtcNow) + .WithMessage("到期日必须是未来时间"); + + // 5. (空行后) 账单明细至少包含一项 + RuleFor(x => x.LineItems) + .NotEmpty() + .WithMessage("账单明细不能为空"); + + // 6. (空行后) 账单明细项验证 + RuleForEach(x => x.LineItems) + .ChildRules(lineItem => + { + lineItem.RuleFor(x => x.ItemType) + .NotEmpty() + .WithMessage("账单明细类型不能为空") + .MaximumLength(50) + .WithMessage("账单明细类型不能超过 50 个字符"); + + lineItem.RuleFor(x => x.Description) + .NotEmpty() + .WithMessage("账单明细描述不能为空") + .MaximumLength(200) + .WithMessage("账单明细描述不能超过 200 个字符"); + + lineItem.RuleFor(x => x.Quantity) + .GreaterThan(0) + .WithMessage("账单明细数量必须大于 0"); + + lineItem.RuleFor(x => x.UnitPrice) + .GreaterThanOrEqualTo(0) + .WithMessage("账单明细单价不能为负数"); + + lineItem.RuleFor(x => x.Amount) + .GreaterThanOrEqualTo(0) + .WithMessage("账单明细金额不能为负数"); + }); + + // 7. (空行后) 备注长度限制(可选) + RuleFor(x => x.Notes) + .MaximumLength(500) + .WithMessage("备注不能超过 500 个字符") + .When(x => !string.IsNullOrWhiteSpace(x.Notes)); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Validators/RecordPaymentCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Validators/RecordPaymentCommandValidator.cs new file mode 100644 index 0000000..8af9a36 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Validators/RecordPaymentCommandValidator.cs @@ -0,0 +1,49 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Billings.Commands; + +namespace TakeoutSaaS.Application.App.Billings.Validators; + +/// +/// 记录支付命令验证器。 +/// +public sealed class RecordPaymentCommandValidator : AbstractValidator +{ + public RecordPaymentCommandValidator() + { + // 1. 账单 ID 必填 + RuleFor(x => x.BillingId) + .GreaterThan(0) + .WithMessage("账单 ID 必须大于 0"); + + // 2. (空行后) 支付金额必须大于 0 + RuleFor(x => x.Amount) + .GreaterThan(0) + .WithMessage("支付金额必须大于 0") + .LessThanOrEqualTo(1_000_000_000) + .WithMessage("支付金额不能超过 10 亿"); + + // 3. (空行后) 支付方式必填 + RuleFor(x => x.Method) + .IsInEnum() + .WithMessage("支付方式无效"); + + // 4. (空行后) 交易号必填 + RuleFor(x => x.TransactionNo) + .NotEmpty() + .WithMessage("交易号不能为空") + .MaximumLength(64) + .WithMessage("交易号不能超过 64 个字符"); + + // 5. (空行后) 支付凭证 URL(可选) + RuleFor(x => x.ProofUrl) + .MaximumLength(500) + .WithMessage("支付凭证 URL 不能超过 500 个字符") + .When(x => !string.IsNullOrWhiteSpace(x.ProofUrl)); + + // 6. (空行后) 备注(可选) + RuleFor(x => x.Notes) + .MaximumLength(500) + .WithMessage("备注不能超过 500 个字符") + .When(x => !string.IsNullOrWhiteSpace(x.Notes)); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Validators/UpdateBillingStatusCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Validators/UpdateBillingStatusCommandValidator.cs new file mode 100644 index 0000000..cfa5401 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Validators/UpdateBillingStatusCommandValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Billings.Commands; + +namespace TakeoutSaaS.Application.App.Billings.Validators; + +/// +/// 更新账单状态命令验证器。 +/// +public sealed class UpdateBillingStatusCommandValidator : AbstractValidator +{ + public UpdateBillingStatusCommandValidator() + { + // 1. 账单 ID 必填 + RuleFor(x => x.BillingId) + .GreaterThan(0) + .WithMessage("账单 ID 必须大于 0"); + + // 2. (空行后) 状态枚举校验 + RuleFor(x => x.NewStatus) + .IsInEnum() + .WithMessage("新状态无效"); + + // 3. (空行后) 备注长度限制(可选) + RuleFor(x => x.Notes) + .MaximumLength(500) + .WithMessage("备注不能超过 500 个字符") + .When(x => !string.IsNullOrWhiteSpace(x.Notes)); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs index 8f2944e..1fd8435 100644 --- a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs +++ b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs @@ -20,6 +20,9 @@ public static class AppApplicationServiceCollectionExtensions { services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + + // (空行后) 注册 AutoMapper Profile + services.AddAutoMapper(Assembly.GetExecutingAssembly()); services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); return services; diff --git a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj index 73fff0e..8d09840 100644 --- a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj +++ b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj @@ -7,11 +7,13 @@ - - - - - + + + + + + + diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/BillingLineItem.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/BillingLineItem.cs new file mode 100644 index 0000000..a1811d0 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/BillingLineItem.cs @@ -0,0 +1,84 @@ +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 账单明细项(值对象)。 +/// 用于记录账单中的单项费用明细,如套餐费用、配额包费用等。 +/// +public sealed class BillingLineItem +{ + /// + /// 明细项类型(如:套餐费、配额包、其他费用)。 + /// + public string ItemType { get; set; } = string.Empty; + + /// + /// 明细项描述。 + /// + public string Description { get; set; } = string.Empty; + + /// + /// 数量。 + /// + public decimal Quantity { get; set; } + + /// + /// 单价。 + /// + public decimal UnitPrice { get; set; } + + /// + /// 金额(数量 × 单价)。 + /// + public decimal Amount { get; set; } + + /// + /// 折扣率(0-1 之间,如 0.1 表示 10% 折扣)。 + /// + public decimal DiscountRate { get; set; } + + /// + /// 创建账单明细项。 + /// + /// 明细项类型。 + /// 描述。 + /// 数量。 + /// 单价。 + /// 折扣率。 + /// 账单明细项实例。 + public static BillingLineItem Create( + string itemType, + string description, + decimal quantity, + decimal unitPrice, + decimal discountRate = 0) + { + var amount = quantity * unitPrice * (1 - discountRate); + return new BillingLineItem + { + ItemType = itemType, + Description = description, + Quantity = quantity, + UnitPrice = unitPrice, + Amount = amount, + DiscountRate = discountRate + }; + } + + /// + /// 计算折扣后的金额。 + /// + /// 折扣后金额。 + public decimal CalculateDiscountedAmount() + { + return Quantity * UnitPrice * (1 - DiscountRate); + } + + /// + /// 获取折扣金额。 + /// + /// 折扣金额。 + public decimal GetDiscountAmount() + { + return Quantity * UnitPrice * DiscountRate; + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantBillingStatement.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantBillingStatement.cs index 4fb50e2..e7d12de 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantBillingStatement.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantBillingStatement.cs @@ -13,6 +13,16 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase /// public string StatementNo { get; set; } = string.Empty; + /// + /// 账单类型(订阅账单/配额包账单/手动账单/续费账单)。 + /// + public BillingType BillingType { get; set; } = BillingType.Subscription; + + /// + /// 关联的订阅 ID(仅当 BillingType 为 Subscription 或 Renewal 时有值)。 + /// + public long? SubscriptionId { get; set; } + /// /// 账单周期开始时间。 /// @@ -24,15 +34,30 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase public DateTime PeriodEnd { get; set; } /// - /// 应付金额。 + /// 应付金额(原始金额)。 /// public decimal AmountDue { get; set; } + /// + /// 折扣金额。 + /// + public decimal DiscountAmount { get; set; } + + /// + /// 税费金额。 + /// + public decimal TaxAmount { get; set; } + /// /// 实付金额。 /// public decimal AmountPaid { get; set; } + /// + /// 货币类型(默认 CNY)。 + /// + public string Currency { get; set; } = "CNY"; + /// /// 当前付款状态。 /// @@ -43,8 +68,133 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase /// public DateTime DueDate { get; set; } + /// + /// 提醒发送时间(续费提醒、逾期提醒等)。 + /// + public DateTime? ReminderSentAt { get; set; } + + /// + /// 逾期通知时间。 + /// + public DateTime? OverdueNotifiedAt { get; set; } + /// /// 账单明细 JSON,记录各项费用。 /// public string? LineItemsJson { get; set; } + + /// + /// 备注信息(如:人工备注、取消原因等)。 + /// + public string? Notes { get; set; } + + /// + /// 计算总金额(应付金额 - 折扣 + 税费)。 + /// + /// 总金额。 + public decimal CalculateTotalAmount() + { + return AmountDue - DiscountAmount + TaxAmount; + } + + /// + /// 标记为已支付(直接结清)。 + /// + public void MarkAsPaid() + { + // 1. 计算剩余应付金额 + var remainingAmount = CalculateTotalAmount() - AmountPaid; + + // 2. 若已结清则直接返回 + if (remainingAmount <= 0) + { + Status = TenantBillingStatus.Paid; + return; + } + + // 3. 补足剩余金额并标记为已支付 + MarkAsPaid(remainingAmount, string.Empty); + } + + /// + /// 标记为已支付。 + /// + /// 支付金额。 + /// 交易号。 + public void MarkAsPaid(decimal amount, string transactionNo) + { + if (Status == TenantBillingStatus.Paid) + { + throw new InvalidOperationException("账单已经处于已支付状态,不能重复标记。"); + } + + if (Status == TenantBillingStatus.Cancelled) + { + throw new InvalidOperationException("已取消的账单不能标记为已支付。"); + } + + // 1. 累加支付金额 + AmountPaid += amount; + + // 2. 如果实付金额大于等于应付总额,则标记为已支付 + if (AmountPaid >= CalculateTotalAmount()) + { + Status = TenantBillingStatus.Paid; + } + } + + /// + /// 标记为逾期。 + /// + public void MarkAsOverdue() + { + // 1. 仅待支付账单允许标记逾期 + if (Status != TenantBillingStatus.Pending) + { + return; + } + + // 2. 未超过到期日则不处理 + if (DateTime.UtcNow <= DueDate) + { + return; + } + + // 3. 标记为逾期(通知时间由外部流程在发送通知时写入) + Status = TenantBillingStatus.Overdue; + } + + /// + /// 取消账单。 + /// + public void Cancel() + { + Cancel(null); + } + + /// + /// 取消账单。 + /// + /// 取消原因。 + public void Cancel(string? reason) + { + if (Status == TenantBillingStatus.Paid) + { + throw new InvalidOperationException("已支付的账单不能取消。"); + } + + if (Status == TenantBillingStatus.Cancelled) + { + throw new InvalidOperationException("账单已经处于取消状态。"); + } + + // 1. 变更状态 + Status = TenantBillingStatus.Cancelled; + + // 2. 记录取消原因(可选) + if (!string.IsNullOrWhiteSpace(reason)) + { + Notes = string.IsNullOrWhiteSpace(Notes) ? $"[取消原因] {reason}" : $"{Notes}\n[取消原因] {reason}"; + } + } } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPayment.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPayment.cs index 7db207f..e81dc4e 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPayment.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPayment.cs @@ -21,12 +21,12 @@ public sealed class TenantPayment : MultiTenantEntityBase /// /// 支付方式。 /// - public PaymentMethod Method { get; set; } + public TenantPaymentMethod Method { get; set; } /// /// 支付状态。 /// - public PaymentStatus Status { get; set; } + public TenantPaymentStatus Status { get; set; } /// /// 交易号。 @@ -43,8 +43,116 @@ public sealed class TenantPayment : MultiTenantEntityBase /// public DateTime? PaidAt { get; set; } + /// + /// 退款原因。 + /// + public string? RefundReason { get; set; } + + /// + /// 退款时间。 + /// + public DateTime? RefundedAt { get; set; } + + /// + /// 审核人 ID(管理员)。 + /// + public long? VerifiedBy { get; set; } + + /// + /// 审核时间。 + /// + public DateTime? VerifiedAt { get; set; } + /// /// 备注信息。 /// public string? Notes { get; set; } + + /// + /// 审核支付记录(确认支付有效性)。 + /// + public void Verify() + { + if (Status != TenantPaymentStatus.Pending) + { + throw new InvalidOperationException("只有待审核的支付记录才能被审核。"); + } + + if (VerifiedAt.HasValue) + { + throw new InvalidOperationException("该支付记录已经被审核过。"); + } + + // 1. 标记为支付成功 + Status = TenantPaymentStatus.Success; + + // 2. 写入审核时间与支付时间 + VerifiedAt = DateTime.UtcNow; + PaidAt ??= DateTime.UtcNow; + } + + /// + /// 审核支付记录(确认支付有效性)。 + /// + /// 审核人 ID。 + public void Verify(long verifierId) + { + Verify(); + VerifiedBy = verifierId; + } + + /// + /// 退款。 + /// + public void Refund() + { + if (string.IsNullOrWhiteSpace(RefundReason)) + { + throw new InvalidOperationException("退款原因不能为空。"); + } + + Refund(RefundReason); + } + + /// + /// 退款。 + /// + /// 退款原因。 + public void Refund(string reason) + { + if (Status == TenantPaymentStatus.Refunded) + { + throw new InvalidOperationException("该支付记录已经处于退款状态。"); + } + + if (Status != TenantPaymentStatus.Success) + { + throw new InvalidOperationException("只有支付成功的记录才能退款。"); + } + + // 1. 标记退款状态 + Status = TenantPaymentStatus.Refunded; + + // 2. 写入退款原因与退款时间 + RefundReason = reason; + RefundedAt = DateTime.UtcNow; + } + + /// + /// 拒绝支付(审核不通过)。 + /// + /// 审核人 ID。 + /// 拒绝原因。 + public void Reject(long verifierId, string reason) + { + if (Status != TenantPaymentStatus.Pending) + { + throw new InvalidOperationException("只有待审核的支付记录才能被拒绝。"); + } + + Status = TenantPaymentStatus.Failed; + VerifiedBy = verifierId; + VerifiedAt = DateTime.UtcNow; + Notes = $"拒绝原因: {reason}"; + } } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/BillingExportFormat.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/BillingExportFormat.cs new file mode 100644 index 0000000..19765f2 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/BillingExportFormat.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 账单导出格式。 +/// +public enum BillingExportFormat +{ + /// + /// Excel 格式(.xlsx)。 + /// + Excel = 0, + + /// + /// PDF 格式(.pdf)。 + /// + Pdf = 1, + + /// + /// CSV 格式(.csv)。 + /// + Csv = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/BillingType.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/BillingType.cs new file mode 100644 index 0000000..980da39 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/BillingType.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 账单类型。 +/// +public enum BillingType +{ + /// + /// 订阅账单(周期性订阅费用)。 + /// + Subscription = 0, + + /// + /// 配额包购买(一次性配额包购买)。 + /// + QuotaPurchase = 1, + + /// + /// 手动创建(管理员手动生成的账单)。 + /// + Manual = 2, + + /// + /// 续费账单(自动续费生成的账单)。 + /// + Renewal = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentMethod.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPaymentMethod.cs similarity index 84% rename from src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentMethod.cs rename to src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPaymentMethod.cs index e73b78d..558fa00 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentMethod.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPaymentMethod.cs @@ -1,9 +1,9 @@ namespace TakeoutSaaS.Domain.Tenants.Enums; /// -/// 支付方式。 +/// 租户支付方式。 /// -public enum PaymentMethod +public enum TenantPaymentMethod { /// /// 线上支付。 diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentStatus.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPaymentStatus.cs similarity index 86% rename from src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentStatus.cs rename to src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPaymentStatus.cs index 97b539a..a1f758b 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentStatus.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPaymentStatus.cs @@ -1,9 +1,9 @@ namespace TakeoutSaaS.Domain.Tenants.Enums; /// -/// 支付状态。 +/// 租户支付状态。 /// -public enum PaymentStatus +public enum TenantPaymentStatus { /// /// 待支付。 diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs index 8baae69..7c88d2b 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs @@ -42,6 +42,14 @@ public interface ITenantBillingRepository /// 账单实体或 null。 Task FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default); + /// + /// 按账单编号获取账单(不限租户,管理员端使用)。 + /// + /// 账单编号。 + /// 取消标记。 + /// 账单实体或 null。 + Task GetByStatementNoAsync(string statementNo, CancellationToken cancellationToken = default); + /// /// 判断是否已存在指定周期开始时间的未取消账单(用于自动续费幂等)。 /// @@ -54,6 +62,39 @@ public interface ITenantBillingRepository DateTime periodStart, CancellationToken cancellationToken = default); + /// + /// 获取逾期账单列表(已过到期日且未支付)。 + /// + /// 取消标记。 + /// 逾期账单集合。 + Task> GetOverdueBillingsAsync(CancellationToken cancellationToken = default); + + /// + /// 获取即将到期的账单列表(未来 N 天内到期且未支付)。 + /// + /// 提前天数。 + /// 取消标记。 + /// 即将到期的账单集合。 + Task> GetBillingsDueSoonAsync(int daysAhead, CancellationToken cancellationToken = default); + + /// + /// 按租户 ID 获取账单列表。 + /// + /// 租户 ID。 + /// 取消标记。 + /// 账单集合。 + Task> GetByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 按 ID 列表批量获取账单(管理员端/批量操作场景)。 + /// + /// 账单 ID 列表。 + /// 取消标记。 + /// 账单实体列表。 + Task> GetByIdsAsync( + IReadOnlyCollection billingIds, + CancellationToken cancellationToken = default); + /// /// 新增账单。 /// @@ -84,6 +125,8 @@ public interface ITenantBillingRepository /// 账单状态筛选(可选)。 /// 开始时间(UTC,可选)。 /// 结束时间(UTC,可选)。 + /// 最小应付金额筛选(包含,可选)。 + /// 最大应付金额筛选(包含,可选)。 /// 关键词搜索(账单号或租户名)。 /// 页码(从 1 开始)。 /// 页大小。 @@ -94,11 +137,29 @@ public interface ITenantBillingRepository TenantBillingStatus? status, DateTime? from, DateTime? to, + decimal? minAmount, + decimal? maxAmount, string? keyword, int pageNumber, int pageSize, CancellationToken cancellationToken = default); + /// + /// 获取账单统计数据(用于报表与仪表盘)。 + /// + /// 租户 ID(可选,管理员可查询所有租户)。 + /// 统计开始时间(UTC)。 + /// 统计结束时间(UTC)。 + /// 分组方式(Day/Week/Month)。 + /// 取消标记。 + /// 统计结果。 + Task GetStatisticsAsync( + long? tenantId, + DateTime startDate, + DateTime endDate, + string groupBy, + CancellationToken cancellationToken = default); + /// /// 按 ID 获取账单(不限租户,管理员端使用)。 /// @@ -107,3 +168,80 @@ public interface ITenantBillingRepository /// 账单实体或 null。 Task FindByIdAsync(long billingId, CancellationToken cancellationToken = default); } + +/// +/// 账单统计结果。 +/// +public sealed record TenantBillingStatistics +{ + /// + /// 总账单金额(统计区间内)。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 已支付金额(统计区间内)。 + /// + public decimal PaidAmount { get; init; } + + /// + /// 未支付金额(统计区间内)。 + /// + public decimal UnpaidAmount { get; init; } + + /// + /// 逾期金额(统计区间内)。 + /// + public decimal OverdueAmount { get; init; } + + /// + /// 总账单数量(统计区间内)。 + /// + public int TotalCount { get; init; } + + /// + /// 已支付账单数量(统计区间内)。 + /// + public int PaidCount { get; init; } + + /// + /// 未支付账单数量(统计区间内)。 + /// + public int UnpaidCount { get; init; } + + /// + /// 逾期账单数量(统计区间内)。 + /// + public int OverdueCount { get; init; } + + /// + /// 趋势数据(按 groupBy 聚合)。 + /// + public IReadOnlyList TrendData { get; init; } = []; +} + +/// +/// 账单趋势统计点。 +/// +public sealed record TenantBillingTrendDataPoint +{ + /// + /// 分组时间点(Day/Week/Month 的代表日期,UTC)。 + /// + public DateTime Period { get; init; } + + /// + /// 账单数量。 + /// + public int Count { get; init; } + + /// + /// 总金额。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 已支付金额。 + /// + public decimal PaidAmount { get; init; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPaymentRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPaymentRepository.cs index bb2214c..2deaa91 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPaymentRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPaymentRepository.cs @@ -15,6 +15,14 @@ public interface ITenantPaymentRepository /// 支付记录集合。 Task> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default); + /// + /// 计算指定账单的累计已支付金额。 + /// + /// 账单 ID。 + /// 取消标记。 + /// 累计已支付金额。 + Task GetTotalPaidAmountAsync(long billingStatementId, CancellationToken cancellationToken = default); + /// /// 按 ID 获取支付记录。 /// @@ -23,6 +31,14 @@ public interface ITenantPaymentRepository /// 支付记录实体或 null。 Task FindByIdAsync(long paymentId, CancellationToken cancellationToken = default); + /// + /// 按交易号获取支付记录。 + /// + /// 交易号。 + /// 取消标记。 + /// 支付记录实体或 null。 + Task GetByTransactionNoAsync(string transactionNo, CancellationToken cancellationToken = default); + /// /// 新增支付记录。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Services/IBillingDomainService.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Services/IBillingDomainService.cs new file mode 100644 index 0000000..a1a0680 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Services/IBillingDomainService.cs @@ -0,0 +1,64 @@ +using TakeoutSaaS.Domain.Tenants.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Services; + +/// +/// 账单领域服务接口。 +/// 负责处理账单生成、账单编号生成、逾期处理等跨实体的业务逻辑。 +/// +public interface IBillingDomainService +{ + /// + /// 根据订阅信息生成账单。 + /// + /// 租户订阅信息。 + /// 取消标记。 + /// 生成的账单实体。 + Task GenerateSubscriptionBillingAsync( + TenantSubscription subscription, + CancellationToken cancellationToken = default); + + /// + /// 根据配额包购买信息生成账单。 + /// + /// 租户 ID。 + /// 配额包信息。 + /// 购买数量。 + /// 取消标记。 + /// 生成的账单实体。 + Task GenerateQuotaPurchaseBillingAsync( + long tenantId, + QuotaPackage quotaPackage, + int quantity, + CancellationToken cancellationToken = default); + + /// + /// 生成唯一的账单编号。 + /// 格式示例:BIL-20251217-000001 + /// + /// 账单编号。 + string GenerateStatementNo(); + + /// + /// 处理逾期账单(批量标记逾期状态)。 + /// + /// 取消标记。 + /// 处理的账单数量。 + Task ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default); + + /// + /// 计算账单总金额(含折扣和税费)。 + /// + /// 基础金额。 + /// 折扣金额。 + /// 税费金额。 + /// 总金额。 + decimal CalculateTotalAmount(decimal baseAmount, decimal discountAmount, decimal taxAmount); + + /// + /// 验证账单状态是否可以进行支付操作。 + /// + /// 账单实体。 + /// 是否可以支付。 + bool CanProcessPayment(TenantBillingStatement billing); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Services/IBillingExportService.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Services/IBillingExportService.cs new file mode 100644 index 0000000..a9882b1 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Services/IBillingExportService.cs @@ -0,0 +1,33 @@ +using TakeoutSaaS.Domain.Tenants.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Services; + +/// +/// 账单导出服务接口。 +/// +public interface IBillingExportService +{ + /// + /// 导出为 Excel(XLSX)。 + /// + /// 账单数据。 + /// 取消标记。 + /// 文件字节数组。 + Task ExportToExcelAsync(IReadOnlyList billings, CancellationToken cancellationToken = default); + + /// + /// 导出为 PDF。 + /// + /// 账单数据。 + /// 取消标记。 + /// 文件字节数组。 + Task ExportToPdfAsync(IReadOnlyList billings, CancellationToken cancellationToken = default); + + /// + /// 导出为 CSV。 + /// + /// 账单数据。 + /// 取消标记。 + /// 文件字节数组。 + Task ExportToCsvAsync(IReadOnlyList billings, CancellationToken cancellationToken = default); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs index ab6cb73..309d030 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -8,9 +8,12 @@ using TakeoutSaaS.Domain.Payments.Repositories; using TakeoutSaaS.Domain.Products.Repositories; using TakeoutSaaS.Domain.Stores.Repositories; using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Domain.Tenants.Services; using TakeoutSaaS.Infrastructure.App.Options; using TakeoutSaaS.Infrastructure.App.Persistence; +using TakeoutSaaS.Infrastructure.App.Persistence.Repositories; using TakeoutSaaS.Infrastructure.App.Repositories; +using TakeoutSaaS.Infrastructure.App.Services; using TakeoutSaaS.Infrastructure.Common.Extensions; using TakeoutSaaS.Shared.Abstractions.Constants; @@ -40,8 +43,8 @@ public static class AppServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -52,6 +55,10 @@ public static class AppServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + // 1. 账单领域/导出服务 + services.AddScoped(); + services.AddScoped(); + services.AddOptions() .Bind(configuration.GetSection(AppSeedOptions.SectionName)) .ValidateDataAnnotations(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Configurations/TenantBillingStatementConfiguration.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Configurations/TenantBillingStatementConfiguration.cs new file mode 100644 index 0000000..2d17cd5 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Configurations/TenantBillingStatementConfiguration.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Infrastructure.App.Persistence.Configurations; + +/// +/// EF Core 映射配置。 +/// +public sealed class TenantBillingStatementConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tenant_billing_statements"); + builder.HasKey(x => x.Id); + + // 1. 字段约束 + builder.Property(x => x.StatementNo).HasMaxLength(64).IsRequired(); + builder.Property(x => x.BillingType).HasConversion(); + builder.Property(x => x.AmountDue).HasPrecision(18, 2); + builder.Property(x => x.DiscountAmount).HasPrecision(18, 2); + builder.Property(x => x.TaxAmount).HasPrecision(18, 2); + builder.Property(x => x.AmountPaid).HasPrecision(18, 2); + builder.Property(x => x.Currency).HasMaxLength(8).HasDefaultValue("CNY"); + builder.Property(x => x.Status).HasConversion(); + + // 2. (空行后) JSON 字段(当前以 text 存储 JSON 字符串,便于兼容历史迁移) + builder.Property(x => x.LineItemsJson).HasColumnType("text"); + + // 3. (空行后) 备注字段 + builder.Property(x => x.Notes).HasMaxLength(512); + + // 4. (空行后) 唯一约束与索引 + builder.HasIndex(x => new { x.TenantId, x.StatementNo }).IsUnique(); + + // 5. (空行后) 性能索引(高频查询:租户+状态+到期日) + builder.HasIndex(x => new { x.TenantId, x.Status, x.DueDate }) + .HasDatabaseName("idx_billing_tenant_status_duedate"); + + // 6. (空行后) 逾期扫描索引(仅索引 Pending/Overdue) + builder.HasIndex(x => new { x.Status, x.DueDate }) + .HasDatabaseName("idx_billing_status_duedate") + .HasFilter($"\"Status\" IN ({(int)TenantBillingStatus.Pending}, {(int)TenantBillingStatus.Overdue})"); + + // 7. (空行后) 创建时间索引(支持列表倒序) + builder.HasIndex(x => x.CreatedAt) + .HasDatabaseName("idx_billing_created_at"); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Configurations/TenantPaymentConfiguration.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Configurations/TenantPaymentConfiguration.cs new file mode 100644 index 0000000..97aac3a --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Configurations/TenantPaymentConfiguration.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TakeoutSaaS.Domain.Tenants.Entities; + +namespace TakeoutSaaS.Infrastructure.App.Persistence.Configurations; + +/// +/// EF Core 映射配置。 +/// +public sealed class TenantPaymentConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tenant_payments"); + builder.HasKey(x => x.Id); + + // 1. 字段约束 + builder.Property(x => x.BillingStatementId).IsRequired(); + builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired(); + builder.Property(x => x.Method).HasConversion(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.TransactionNo).HasMaxLength(64); + builder.Property(x => x.ProofUrl).HasMaxLength(512); + builder.Property(x => x.RefundReason).HasMaxLength(512); + builder.Property(x => x.Notes).HasMaxLength(512); + + // 2. (空行后) 复合索引:租户+账单 + builder.HasIndex(x => new { x.TenantId, x.BillingStatementId }); + + // 3. (空行后) 支付记录时间排序索引 + builder.HasIndex(x => new { x.BillingStatementId, x.PaidAt }) + .HasDatabaseName("idx_payment_billing_paidat"); + + // 4. (空行后) 交易号索引(部分索引:仅非空) + builder.HasIndex(x => x.TransactionNo) + .HasDatabaseName("idx_payment_transaction_no") + .HasFilter("\"TransactionNo\" IS NOT NULL"); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantBillingRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantBillingRepository.cs new file mode 100644 index 0000000..ac3db16 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantBillingRepository.cs @@ -0,0 +1,379 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories; + +/// +/// 租户账单仓储实现(EF Core)。 +/// +public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITenantBillingRepository +{ + /// + public async Task> SearchAsync( + long tenantId, + TenantBillingStatus? status, + DateTime? from, + DateTime? to, + CancellationToken cancellationToken = default) + { + // 1. 构建基础查询:忽略全局过滤器,显式过滤租户与软删除 + var query = context.TenantBillingStatements + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.DeletedAt == null && x.TenantId == tenantId); + + // 2. (空行后) 按状态过滤 + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + // 3. (空行后) 按日期范围过滤(账单周期) + if (from.HasValue) + { + query = query.Where(x => x.PeriodStart >= from.Value); + } + + if (to.HasValue) + { + query = query.Where(x => x.PeriodEnd <= to.Value); + } + + // 4. (空行后) 排序返回 + return await query + .OrderByDescending(x => x.PeriodEnd) + .ToListAsync(cancellationToken); + } + + /// + public Task FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default) + { + return context.TenantBillingStatements + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == billingId, cancellationToken); + } + + /// + public Task FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default) + { + var normalized = statementNo.Trim(); + + return context.TenantBillingStatements + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.StatementNo == normalized, cancellationToken); + } + + /// + public Task GetByStatementNoAsync(string statementNo, CancellationToken cancellationToken = default) + { + var normalized = statementNo.Trim(); + + return context.TenantBillingStatements + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == normalized, cancellationToken); + } + + /// + public Task ExistsNotCancelledByPeriodStartAsync( + long tenantId, + DateTime periodStart, + CancellationToken cancellationToken = default) + { + return context.TenantBillingStatements + .IgnoreQueryFilters() + .AsNoTracking() + .AnyAsync( + x => x.DeletedAt == null + && x.TenantId == tenantId + && x.PeriodStart == periodStart + && x.Status != TenantBillingStatus.Cancelled, + cancellationToken); + } + + /// + public async Task> GetOverdueBillingsAsync(CancellationToken cancellationToken = default) + { + // 1. 以当前 UTC 时间作为逾期判断基准 + var now = DateTime.UtcNow; + + // 2. (空行后) 查询逾期且未结清/未取消的账单 + return await context.TenantBillingStatements + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.DeletedAt == null + && x.DueDate < now + && x.Status != TenantBillingStatus.Paid + && x.Status != TenantBillingStatus.Cancelled) + .OrderBy(x => x.DueDate) + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetBillingsDueSoonAsync(int daysAhead, CancellationToken cancellationToken = default) + { + // 1. 计算到期窗口 + var now = DateTime.UtcNow; + var dueTo = now.AddDays(daysAhead); + + // 2. (空行后) 仅查询待支付账单 + return await context.TenantBillingStatements + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.DeletedAt == null + && x.Status == TenantBillingStatus.Pending + && x.DueDate >= now + && x.DueDate <= dueTo) + .OrderBy(x => x.DueDate) + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default) + { + return await context.TenantBillingStatements + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.DeletedAt == null && x.TenantId == tenantId) + .OrderByDescending(x => x.PeriodEnd) + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetByIdsAsync(IReadOnlyCollection billingIds, CancellationToken cancellationToken = default) + { + if (billingIds.Count == 0) + { + return Array.Empty(); + } + + // 1. 忽略全局过滤器以支持管理员端跨租户导出/批量操作 + return await context.TenantBillingStatements + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.DeletedAt == null && billingIds.Contains(x.Id)) + .OrderByDescending(x => x.PeriodStart) + .ToListAsync(cancellationToken); + } + + /// + public Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default) + { + return context.TenantBillingStatements.AddAsync(bill, cancellationToken).AsTask(); + } + + /// + public Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default) + { + context.TenantBillingStatements.Update(bill); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } + + /// + public async Task<(IReadOnlyList Items, int Total)> SearchPagedAsync( + long? tenantId, + TenantBillingStatus? status, + DateTime? from, + DateTime? to, + decimal? minAmount, + decimal? maxAmount, + string? keyword, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default) + { + // 1. 构建基础查询(管理员端跨租户查询,忽略过滤器) + var query = context.TenantBillingStatements + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.DeletedAt == null); + + // 2. (空行后) 按租户过滤(可选) + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + // 3. (空行后) 按状态过滤(可选) + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + // 4. (空行后) 按日期范围过滤(账单周期) + if (from.HasValue) + { + query = query.Where(x => x.PeriodStart >= from.Value); + } + + if (to.HasValue) + { + query = query.Where(x => x.PeriodEnd <= to.Value); + } + + // 5. (空行后) 按金额范围过滤(应付金额,包含边界) + if (minAmount.HasValue) + { + query = query.Where(x => x.AmountDue >= minAmount.Value); + } + + if (maxAmount.HasValue) + { + query = query.Where(x => x.AmountDue <= maxAmount.Value); + } + + // 6. (空行后) 关键字过滤(账单号或租户名) + if (!string.IsNullOrWhiteSpace(keyword)) + { + var normalized = keyword.Trim(); + + query = + from b in query + join t in context.Tenants + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.DeletedAt == null) + on b.TenantId equals t.Id + where EF.Functions.ILike(b.StatementNo, $"%{normalized}%") + || EF.Functions.ILike(t.Name, $"%{normalized}%") + select b; + } + + // 7. (空行后) 统计总数 + var total = await query.CountAsync(cancellationToken); + + // 8. (空行后) 分页查询 + var items = await query + .OrderByDescending(x => x.PeriodEnd) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, total); + } + + /// + public async Task GetStatisticsAsync( + long? tenantId, + DateTime startDate, + DateTime endDate, + string groupBy, + CancellationToken cancellationToken = default) + { + // 1. 构建基础查询(忽略过滤器,显式过滤软删除/租户/时间范围) + var query = context.TenantBillingStatements + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.DeletedAt == null + && (!tenantId.HasValue || x.TenantId == tenantId.Value) + && x.PeriodStart >= startDate + && x.PeriodEnd <= endDate); + + // 2. (空行后) 聚合统计(金额统一使用:应付 - 折扣 + 税费) + var now = DateTime.UtcNow; + var totalAmount = await query.SumAsync(x => x.AmountDue - x.DiscountAmount + x.TaxAmount, cancellationToken); + var paidAmount = await query.Where(x => x.Status == TenantBillingStatus.Paid).SumAsync(x => x.AmountPaid, cancellationToken); + var unpaidAmount = await query.SumAsync(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid, cancellationToken); + var overdueAmount = await query + .Where(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now) + .SumAsync(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid, cancellationToken); + + // 3. (空行后) 数量统计 + var totalCount = await query.CountAsync(cancellationToken); + var paidCount = await query.CountAsync(x => x.Status == TenantBillingStatus.Paid, cancellationToken); + var unpaidCount = await query.CountAsync(x => x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue, cancellationToken); + var overdueCount = await query.CountAsync(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now, cancellationToken); + + // 4. (空行后) 趋势统计 + var normalizedGroupBy = NormalizeGroupBy(groupBy); + var trendRaw = await query + .Select(x => new + { + x.PeriodStart, + x.AmountDue, + x.DiscountAmount, + x.TaxAmount, + x.AmountPaid + }) + .ToListAsync(cancellationToken); + + // 4.1 (空行后) 在内存中按 Day/Week/Month 聚合(避免依赖特定数据库函数扩展) + var trend = trendRaw + .GroupBy(x => GetTrendBucket(x.PeriodStart, normalizedGroupBy)) + .Select(g => new TenantBillingTrendDataPoint + { + Period = g.Key, + Count = g.Count(), + TotalAmount = g.Sum(x => x.AmountDue - x.DiscountAmount + x.TaxAmount), + PaidAmount = g.Sum(x => x.AmountPaid) + }) + .OrderBy(x => x.Period) + .ToList(); + + return new TenantBillingStatistics + { + TotalAmount = totalAmount, + PaidAmount = paidAmount, + UnpaidAmount = unpaidAmount, + OverdueAmount = overdueAmount, + TotalCount = totalCount, + PaidCount = paidCount, + UnpaidCount = unpaidCount, + OverdueCount = overdueCount, + TrendData = trend + }; + } + + /// + public Task FindByIdAsync(long billingId, CancellationToken cancellationToken = default) + { + return context.TenantBillingStatements + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == billingId, cancellationToken); + } + + private static string NormalizeGroupBy(string groupBy) + { + return groupBy.Trim() switch + { + "Week" => "Week", + "Month" => "Month", + _ => "Day" + }; + } + + private static DateTime GetTrendBucket(DateTime periodStart, string groupBy) + { + var date = periodStart.Date; + + return groupBy switch + { + "Month" => new DateTime(date.Year, date.Month, 1, 0, 0, 0, DateTimeKind.Utc), + "Week" => GetWeekStart(date), + _ => new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, DateTimeKind.Utc) + }; + } + + private static DateTime GetWeekStart(DateTime date) + { + // 1. 将周一作为一周起始(与 PostgreSQL date_trunc('week', ...) 对齐) + var dayOfWeek = (int)date.DayOfWeek; // Sunday=0, Monday=1, ... + var daysSinceMonday = (dayOfWeek + 6) % 7; + + // 2. (空行后) 回退到周一 00:00:00(UTC) + var monday = date.AddDays(-daysSinceMonday); + return new DateTime(monday.Year, monday.Month, monday.Day, 0, 0, 0, DateTimeKind.Utc); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantPaymentRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantPaymentRepository.cs new file mode 100644 index 0000000..d9ca04c --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantPaymentRepository.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories; + +/// +/// 租户支付记录仓储实现(EF Core)。 +/// +public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITenantPaymentRepository +{ + /// + public async Task> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default) + { + return await context.TenantPayments + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.DeletedAt == null && x.BillingStatementId == billingStatementId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + /// + public async Task GetTotalPaidAmountAsync(long billingStatementId, CancellationToken cancellationToken = default) + { + // 1. 仅统计支付成功的记录 + return await context.TenantPayments + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.DeletedAt == null + && x.BillingStatementId == billingStatementId + && x.Status == TenantPaymentStatus.Success) + .SumAsync(x => x.Amount, cancellationToken); + } + + /// + public Task FindByIdAsync(long paymentId, CancellationToken cancellationToken = default) + { + return context.TenantPayments + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == paymentId, cancellationToken); + } + + /// + public Task GetByTransactionNoAsync(string transactionNo, CancellationToken cancellationToken = default) + { + var normalized = transactionNo.Trim(); + + return context.TenantPayments + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.DeletedAt == null && x.TransactionNo == normalized, cancellationToken); + } + + /// + public Task AddAsync(TenantPayment payment, CancellationToken cancellationToken = default) + { + return context.TenantPayments.AddAsync(payment, cancellationToken).AsTask(); + } + + /// + public Task UpdateAsync(TenantPayment payment, CancellationToken cancellationToken = default) + { + context.TenantPayments.Update(payment); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index 73cf234..3b1b9c3 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -24,6 +24,7 @@ using TakeoutSaaS.Infrastructure.Common.Persistence; using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Tenancy; +using TakeoutSaaS.Infrastructure.App.Persistence.Configurations; namespace TakeoutSaaS.Infrastructure.App.Persistence; @@ -762,28 +763,12 @@ public sealed class TakeoutAppDbContext( private static void ConfigureTenantBilling(EntityTypeBuilder builder) { - builder.ToTable("tenant_billing_statements"); - builder.HasKey(x => x.Id); - builder.Property(x => x.StatementNo).HasMaxLength(64).IsRequired(); - builder.Property(x => x.AmountDue).HasPrecision(18, 2); - builder.Property(x => x.AmountPaid).HasPrecision(18, 2); - builder.Property(x => x.Status).HasConversion(); - builder.Property(x => x.LineItemsJson).HasColumnType("text"); - builder.HasIndex(x => new { x.TenantId, x.StatementNo }).IsUnique(); + new TenantBillingStatementConfiguration().Configure(builder); } private static void ConfigureTenantPayment(EntityTypeBuilder builder) { - builder.ToTable("tenant_payments"); - builder.HasKey(x => x.Id); - builder.Property(x => x.BillingStatementId).IsRequired(); - builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired(); - builder.Property(x => x.Method).HasConversion(); - builder.Property(x => x.Status).HasConversion(); - builder.Property(x => x.TransactionNo).HasMaxLength(64); - builder.Property(x => x.ProofUrl).HasMaxLength(512); - builder.Property(x => x.Notes).HasMaxLength(512); - builder.HasIndex(x => new { x.TenantId, x.BillingStatementId }); + new TenantPaymentConfiguration().Configure(builder); } private static void ConfigureTenantNotification(EntityTypeBuilder builder) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs deleted file mode 100644 index 3d9f51a..0000000 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs +++ /dev/null @@ -1,155 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using TakeoutSaaS.Domain.Tenants.Entities; -using TakeoutSaaS.Domain.Tenants.Enums; -using TakeoutSaaS.Domain.Tenants.Repositories; -using TakeoutSaaS.Infrastructure.App.Persistence; - -namespace TakeoutSaaS.Infrastructure.App.Repositories; - -/// -/// EF 租户账单仓储。 -/// -public sealed class EfTenantBillingRepository(TakeoutAppDbContext context) : ITenantBillingRepository -{ - /// - public Task> SearchAsync( - long tenantId, - TenantBillingStatus? status, - DateTime? from, - DateTime? to, - CancellationToken cancellationToken = default) - { - var query = context.TenantBillingStatements.AsNoTracking() - .Where(x => x.TenantId == tenantId); - - if (status.HasValue) - { - query = query.Where(x => x.Status == status.Value); - } - - if (from.HasValue) - { - query = query.Where(x => x.PeriodStart >= from.Value); - } - - if (to.HasValue) - { - query = query.Where(x => x.PeriodEnd <= to.Value); - } - - return query - .OrderByDescending(x => x.PeriodEnd) - .ToListAsync(cancellationToken) - .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); - } - - /// - public async Task<(IReadOnlyList Items, int Total)> SearchPagedAsync( - long? tenantId, - TenantBillingStatus? status, - DateTime? from, - DateTime? to, - string? keyword, - int pageNumber, - int pageSize, - CancellationToken cancellationToken = default) - { - var query = context.TenantBillingStatements.AsNoTracking(); - - // 1. 按租户过滤(可选) - if (tenantId.HasValue) - { - query = query.Where(x => x.TenantId == tenantId.Value); - } - - // 2. 按状态过滤 - if (status.HasValue) - { - query = query.Where(x => x.Status == status.Value); - } - - // 3. 按日期范围过滤 - if (from.HasValue) - { - query = query.Where(x => x.PeriodStart >= from.Value); - } - - if (to.HasValue) - { - query = query.Where(x => x.PeriodEnd <= to.Value); - } - - // 4. 按关键字过滤(账单编号) - if (!string.IsNullOrWhiteSpace(keyword)) - { - var normalizedKeyword = keyword.Trim(); - query = query.Where(x => EF.Functions.ILike(x.StatementNo, $"%{normalizedKeyword}%")); - } - - // 5. 统计总数 - var total = await query.CountAsync(cancellationToken); - - // 6. 分页查询 - var items = await query - .OrderByDescending(x => x.PeriodEnd) - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize) - .ToListAsync(cancellationToken); - - return (items, total); - } - - /// - public Task FindByIdAsync(long billingId, CancellationToken cancellationToken = default) - { - return context.TenantBillingStatements.AsNoTracking() - .FirstOrDefaultAsync(x => x.Id == billingId, cancellationToken); - } - - /// - public Task FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default) - { - return context.TenantBillingStatements.AsNoTracking() - .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == billingId, cancellationToken); - } - - /// - public Task FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default) - { - return context.TenantBillingStatements.AsNoTracking() - .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StatementNo == statementNo, cancellationToken); - } - - /// - public Task ExistsNotCancelledByPeriodStartAsync( - long tenantId, - DateTime periodStart, - CancellationToken cancellationToken = default) - { - return context.TenantBillingStatements.AsNoTracking() - .AnyAsync( - x => x.TenantId == tenantId - && x.PeriodStart == periodStart - && x.Status != TenantBillingStatus.Cancelled, - cancellationToken); - } - - /// - public Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default) - { - return context.TenantBillingStatements.AddAsync(bill, cancellationToken).AsTask(); - } - - /// - public Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default) - { - context.TenantBillingStatements.Update(bill); - return Task.CompletedTask; - } - - /// - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - return context.SaveChangesAsync(cancellationToken); - } -} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPaymentRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPaymentRepository.cs deleted file mode 100644 index 935580a..0000000 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPaymentRepository.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using TakeoutSaaS.Domain.Tenants.Entities; -using TakeoutSaaS.Domain.Tenants.Repositories; -using TakeoutSaaS.Infrastructure.App.Persistence; - -namespace TakeoutSaaS.Infrastructure.App.Repositories; - -/// -/// EF 租户支付记录仓储。 -/// -public sealed class EfTenantPaymentRepository(TakeoutAppDbContext context) : ITenantPaymentRepository -{ - /// - public async Task> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default) - { - return await context.TenantPayments.AsNoTracking() - .Where(x => x.BillingStatementId == billingStatementId) - .OrderByDescending(x => x.PaidAt) - .ToListAsync(cancellationToken); - } - - /// - public Task FindByIdAsync(long paymentId, CancellationToken cancellationToken = default) - { - return context.TenantPayments.AsNoTracking() - .FirstOrDefaultAsync(x => x.Id == paymentId, cancellationToken); - } - - /// - public Task AddAsync(TenantPayment payment, CancellationToken cancellationToken = default) - { - return context.TenantPayments.AddAsync(payment, cancellationToken).AsTask(); - } - - /// - public Task UpdateAsync(TenantPayment payment, CancellationToken cancellationToken = default) - { - context.TenantPayments.Update(payment); - return Task.CompletedTask; - } - - /// - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - return context.SaveChangesAsync(cancellationToken); - } -} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingDomainService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingDomainService.cs new file mode 100644 index 0000000..0cf1759 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingDomainService.cs @@ -0,0 +1,202 @@ +using System.Globalization; +using System.Text.Json; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Domain.Tenants.Services; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Infrastructure.App.Services; + +/// +/// 账单领域服务实现。 +/// +public sealed class BillingDomainService( + ITenantBillingRepository billingRepository, + ITenantPackageRepository tenantPackageRepository, + IIdGenerator idGenerator) : IBillingDomainService +{ + /// + public async Task GenerateSubscriptionBillingAsync( + TenantSubscription subscription, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(subscription); + + // 1. 校验幂等:同一周期开始时间只能存在一张未取消账单 + var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync( + subscription.TenantId, + subscription.EffectiveFrom, + cancellationToken); + if (exists) + { + throw new InvalidOperationException("该订阅周期的账单已存在。"); + } + + // 2. (空行后) 查询套餐价格信息 + var package = await tenantPackageRepository.FindByIdAsync(subscription.TenantPackageId, cancellationToken); + if (package is null) + { + throw new InvalidOperationException("订阅未关联有效套餐,无法生成账单。"); + } + + // 3. (空行后) 选择价格(简化规则:优先按年/按月) + var days = (subscription.EffectiveTo - subscription.EffectiveFrom).TotalDays; + var amountDue = days >= 300 ? package.YearlyPrice : package.MonthlyPrice; + if (!amountDue.HasValue) + { + throw new InvalidOperationException("套餐价格未配置,无法生成账单。"); + } + + // 4. (空行后) 生成账单明细 + var lineItems = new List + { + BillingLineItem.Create( + itemType: "Subscription", + description: $"套餐 {package.Name} 订阅费用", + quantity: 1, + unitPrice: amountDue.Value) + }; + + // 5. (空行后) 构建账单实体 + var now = DateTime.UtcNow; + return new TenantBillingStatement + { + Id = idGenerator.NextId(), + TenantId = subscription.TenantId, + StatementNo = GenerateStatementNo(), + BillingType = BillingType.Subscription, + SubscriptionId = subscription.Id, + PeriodStart = subscription.EffectiveFrom, + PeriodEnd = subscription.EffectiveTo, + AmountDue = amountDue.Value, + DiscountAmount = 0m, + TaxAmount = 0m, + AmountPaid = 0m, + Currency = "CNY", + Status = TenantBillingStatus.Pending, + DueDate = now.AddDays(7), + LineItemsJson = JsonSerializer.Serialize(lineItems), + Notes = subscription.Notes + }; + } + + /// + public Task GenerateQuotaPurchaseBillingAsync( + long tenantId, + QuotaPackage quotaPackage, + int quantity, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(quotaPackage); + + if (quantity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(quantity), "购买数量必须大于 0。"); + } + + // 1. 计算金额 + var amountDue = quotaPackage.Price * quantity; + + // 2. (空行后) 生成账单明细 + var lineItems = new List + { + BillingLineItem.Create( + itemType: "QuotaPurchase", + description: $"配额包 {quotaPackage.Name} × {quantity}", + quantity: quantity, + unitPrice: quotaPackage.Price) + }; + + // 3. (空行后) 构建账单实体 + var now = DateTime.UtcNow; + var billing = new TenantBillingStatement + { + Id = idGenerator.NextId(), + TenantId = tenantId, + StatementNo = GenerateStatementNo(), + BillingType = BillingType.QuotaPurchase, + SubscriptionId = null, + PeriodStart = now, + PeriodEnd = now, + AmountDue = amountDue, + DiscountAmount = 0m, + TaxAmount = 0m, + AmountPaid = 0m, + Currency = "CNY", + Status = TenantBillingStatus.Pending, + DueDate = now.AddDays(7), + LineItemsJson = JsonSerializer.Serialize(lineItems), + Notes = quotaPackage.Description + }; + + return Task.FromResult(billing); + } + + /// + public string GenerateStatementNo() + { + // 1. 账单号格式:BILL-{yyyyMMdd}-{序号} + var date = DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture); + + // 2. (空行后) 使用雪花 ID 作为全局递增序号,确保分布式唯一 + var sequence = idGenerator.NextId(); + return $"BILL-{date}-{sequence}"; + } + + /// + public async Task ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default) + { + // 1. 查询当前已逾期且未支付/未取消的账单(由仓储按 DueDate 筛选) + var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken); + if (overdueBillings.Count == 0) + { + return 0; + } + + // 2. (空行后) 批量标记逾期(仅处理 Pending) + var processedAt = DateTime.UtcNow; + var updated = 0; + foreach (var billing in overdueBillings) + { + if (billing.Status != TenantBillingStatus.Pending) + { + continue; + } + + billing.MarkAsOverdue(); + billing.OverdueNotifiedAt ??= processedAt; + billing.UpdatedAt = processedAt; + + await billingRepository.UpdateAsync(billing, cancellationToken); + updated++; + } + + // 3. (空行后) 持久化 + if (updated > 0) + { + await billingRepository.SaveChangesAsync(cancellationToken); + } + + return updated; + } + + /// + public decimal CalculateTotalAmount(decimal baseAmount, decimal discountAmount, decimal taxAmount) + { + return baseAmount - discountAmount + taxAmount; + } + + /// + public bool CanProcessPayment(TenantBillingStatement billing) + { + ArgumentNullException.ThrowIfNull(billing); + + return billing.Status switch + { + TenantBillingStatus.Pending => true, + TenantBillingStatus.Overdue => true, + _ => false + }; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingExportService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingExportService.cs new file mode 100644 index 0000000..9578c97 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingExportService.cs @@ -0,0 +1,203 @@ +using ClosedXML.Excel; +using CsvHelper; +using CsvHelper.Configuration; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using System.Globalization; +using System.Text; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Services; + +namespace TakeoutSaaS.Infrastructure.App.Services; + +/// +/// 账单导出服务实现(Excel/PDF/CSV)。 +/// +public sealed class BillingExportService : IBillingExportService +{ + /// + /// 初始化导出服务并配置 QuestPDF 许可证。 + /// + public BillingExportService() + { + QuestPDF.Settings.License = LicenseType.Community; + } + + /// + public Task ExportToExcelAsync(IReadOnlyList billings, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(billings); + + // 1. 创建工作簿与工作表 + using var workbook = new XLWorkbook(); + var worksheet = workbook.Worksheets.Add("Billings"); + + // 2. (空行后) 写入表头 + var headers = new[] + { + "Id", "TenantId", "StatementNo", "BillingType", "Status", + "PeriodStart", "PeriodEnd", "AmountDue", "DiscountAmount", "TaxAmount", "TotalAmount", + "AmountPaid", "Currency", "DueDate", "Notes", "LineItemsJson" + }; + + for (var i = 0; i < headers.Length; i++) + { + worksheet.Cell(1, i + 1).Value = headers[i]; + } + + // 3. (空行后) 写入数据行 + for (var rowIndex = 0; rowIndex < billings.Count; rowIndex++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var billing = billings[rowIndex]; + var totalAmount = billing.CalculateTotalAmount(); + var r = rowIndex + 2; + + worksheet.Cell(r, 1).Value = billing.Id; + worksheet.Cell(r, 2).Value = billing.TenantId; + worksheet.Cell(r, 3).Value = billing.StatementNo; + worksheet.Cell(r, 4).Value = billing.BillingType.ToString(); + worksheet.Cell(r, 5).Value = billing.Status.ToString(); + worksheet.Cell(r, 6).Value = billing.PeriodStart.ToString("O", CultureInfo.InvariantCulture); + worksheet.Cell(r, 7).Value = billing.PeriodEnd.ToString("O", CultureInfo.InvariantCulture); + worksheet.Cell(r, 8).Value = billing.AmountDue; + worksheet.Cell(r, 9).Value = billing.DiscountAmount; + worksheet.Cell(r, 10).Value = billing.TaxAmount; + worksheet.Cell(r, 11).Value = totalAmount; + worksheet.Cell(r, 12).Value = billing.AmountPaid; + worksheet.Cell(r, 13).Value = billing.Currency; + worksheet.Cell(r, 14).Value = billing.DueDate.ToString("O", CultureInfo.InvariantCulture); + worksheet.Cell(r, 15).Value = billing.Notes ?? string.Empty; + worksheet.Cell(r, 16).Value = billing.LineItemsJson ?? string.Empty; + } + + // 4. (空行后) 自动调整列宽并输出 + worksheet.Columns().AdjustToContents(); + + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return Task.FromResult(stream.ToArray()); + } + + /// + public Task ExportToPdfAsync(IReadOnlyList billings, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(billings); + + // 1. 生成 PDF 文档(避免复杂表格,按条目输出) + var document = Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(20); + page.DefaultTextStyle(x => x.FontSize(10)); + + page.Content().Column(column => + { + column.Spacing(6); + + // 2. 标题 + column.Item().Text("Billings Export").FontSize(16).SemiBold(); + + // 3. (空行后) 逐条输出 + for (var i = 0; i < billings.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var b = billings[i]; + var total = b.CalculateTotalAmount(); + + column.Item().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(8).Column(item => + { + item.Spacing(2); + item.Item().Text($"StatementNo: {b.StatementNo}"); + item.Item().Text($"TenantId: {b.TenantId} BillingType: {b.BillingType} Status: {b.Status}"); + item.Item().Text($"Period: {b.PeriodStart:yyyy-MM-dd} ~ {b.PeriodEnd:yyyy-MM-dd} DueDate: {b.DueDate:yyyy-MM-dd}"); + item.Item().Text($"AmountDue: {b.AmountDue:0.##} Discount: {b.DiscountAmount:0.##} Tax: {b.TaxAmount:0.##}"); + item.Item().Text($"Total: {total:0.##} Paid: {b.AmountPaid:0.##} Currency: {b.Currency}"); + + if (!string.IsNullOrWhiteSpace(b.Notes)) + { + item.Item().Text($"Notes: {b.Notes}"); + } + }); + } + }); + }); + }); + + // 4. (空行后) 输出字节 + var bytes = document.GeneratePdf(); + return Task.FromResult(bytes); + } + + /// + public async Task ExportToCsvAsync(IReadOnlyList billings, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(billings); + + // 1. 使用 UTF-8 BOM,便于 Excel 直接打开 + await using var stream = new MemoryStream(); + await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), leaveOpen: true); + + var config = new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true + }; + + await using var csv = new CsvWriter(writer, config); + + // 2. 写入表头 + csv.WriteField("Id"); + csv.WriteField("TenantId"); + csv.WriteField("StatementNo"); + csv.WriteField("BillingType"); + csv.WriteField("Status"); + csv.WriteField("PeriodStart"); + csv.WriteField("PeriodEnd"); + csv.WriteField("AmountDue"); + csv.WriteField("DiscountAmount"); + csv.WriteField("TaxAmount"); + csv.WriteField("TotalAmount"); + csv.WriteField("AmountPaid"); + csv.WriteField("Currency"); + csv.WriteField("DueDate"); + csv.WriteField("Notes"); + csv.WriteField("LineItemsJson"); + await csv.NextRecordAsync(); + + // 3. (空行后) 写入数据行 + foreach (var b in billings) + { + cancellationToken.ThrowIfCancellationRequested(); + + var total = b.CalculateTotalAmount(); + + csv.WriteField(b.Id); + csv.WriteField(b.TenantId); + csv.WriteField(b.StatementNo); + csv.WriteField(b.BillingType.ToString()); + csv.WriteField(b.Status.ToString()); + csv.WriteField(b.PeriodStart.ToString("O", CultureInfo.InvariantCulture)); + csv.WriteField(b.PeriodEnd.ToString("O", CultureInfo.InvariantCulture)); + csv.WriteField(b.AmountDue); + csv.WriteField(b.DiscountAmount); + csv.WriteField(b.TaxAmount); + csv.WriteField(total); + csv.WriteField(b.AmountPaid); + csv.WriteField(b.Currency); + csv.WriteField(b.DueDate.ToString("O", CultureInfo.InvariantCulture)); + csv.WriteField(b.Notes ?? string.Empty); + csv.WriteField(b.LineItemsJson ?? string.Empty); + + await csv.NextRecordAsync(); + } + + // 4. (空行后) Flush 并返回字节 + await writer.FlushAsync(cancellationToken); + return stream.ToArray(); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251217160046_UpdateTenantBillingSchema.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251217160046_UpdateTenantBillingSchema.Designer.cs new file mode 100644 index 0000000..45e6d39 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251217160046_UpdateTenantBillingSchema.Designer.cs @@ -0,0 +1,7174 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.App.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations +{ + [DbContext(typeof(TakeoutAppDbContext))] + [Migration("20251217160046_UpdateTenantBillingSchema")] + partial class UpdateTenantBillingSchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricAlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConditionJson") + .IsRequired() + .HasColumnType("text") + .HasComment("触发条件 JSON。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasComment("是否启用。"); + + b.Property("MetricDefinitionId") + .HasColumnType("bigint") + .HasComment("关联指标。"); + + b.Property("NotificationChannels") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("通知渠道。"); + + b.Property("Severity") + .HasColumnType("integer") + .HasComment("告警级别。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MetricDefinitionId", "Severity"); + + b.ToTable("metric_alert_rules", null, t => + { + t.HasComment("指标告警规则。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("指标编码。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DefaultAggregation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("默认聚合方式。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("说明。"); + + b.Property("DimensionsJson") + .HasColumnType("text") + .HasComment("维度描述 JSON。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("指标名称。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("metric_definitions", null, t => + { + t.HasComment("指标定义,描述可观测的数据点。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DimensionKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("维度键(JSON)。"); + + b.Property("MetricDefinitionId") + .HasColumnType("bigint") + .HasComment("指标定义 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasComment("数值。"); + + b.Property("WindowEnd") + .HasColumnType("timestamp with time zone") + .HasComment("统计时间窗口结束。"); + + b.Property("WindowStart") + .HasColumnType("timestamp with time zone") + .HasComment("统计时间窗口开始。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MetricDefinitionId", "DimensionKey", "WindowStart", "WindowEnd") + .IsUnique(); + + b.ToTable("metric_snapshots", null, t => + { + t.HasComment("指标快照,用于大盘展示。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.Coupon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("券码或序列号。"); + + b.Property("CouponTemplateId") + .HasColumnType("bigint") + .HasComment("模板标识。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone") + .HasComment("到期时间。"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasComment("发放时间。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("订单 ID(已使用时记录)。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasComment("使用时间。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("归属用户。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("coupons", null, t => + { + t.HasComment("用户领取的券。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.CouponTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowStack") + .HasColumnType("boolean") + .HasComment("是否允许叠加其他优惠。"); + + b.Property("ChannelsJson") + .HasColumnType("text") + .HasComment("发放渠道(JSON)。"); + + b.Property("ClaimedQuantity") + .HasColumnType("integer") + .HasComment("已领取数量。"); + + b.Property("CouponType") + .HasColumnType("integer") + .HasComment("券类型。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注。"); + + b.Property("DiscountCap") + .HasColumnType("numeric") + .HasComment("折扣上限(针对折扣券)。"); + + b.Property("MinimumSpend") + .HasColumnType("numeric") + .HasComment("最低消费门槛。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("模板名称。"); + + b.Property("ProductScopeJson") + .HasColumnType("text") + .HasComment("适用品类或商品范围(JSON)。"); + + b.Property("RelativeValidDays") + .HasColumnType("integer") + .HasComment("有效天数(相对发放时间)。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("StoreScopeJson") + .HasColumnType("text") + .HasComment("适用门店 ID 集合(JSON)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TotalQuantity") + .HasColumnType("integer") + .HasComment("总发放数量上限。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone") + .HasComment("可用开始时间。"); + + b.Property("ValidTo") + .HasColumnType("timestamp with time zone") + .HasComment("可用结束时间。"); + + b.Property("Value") + .HasColumnType("numeric") + .HasComment("面值或折扣额度。"); + + b.HasKey("Id"); + + b.ToTable("coupon_templates", null, t => + { + t.HasComment("优惠券模板。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PromotionCampaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AudienceDescription") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("目标人群描述。"); + + b.Property("BannerUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("营销素材(如 banner)。"); + + b.Property("Budget") + .HasColumnType("numeric") + .HasComment("预算金额。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndAt") + .HasColumnType("timestamp with time zone") + .HasComment("结束时间。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("活动名称。"); + + b.Property("PromotionType") + .HasColumnType("integer") + .HasComment("活动类型。"); + + b.Property("RulesJson") + .IsRequired() + .HasColumnType("text") + .HasComment("活动规则 JSON。"); + + b.Property("StartAt") + .HasColumnType("timestamp with time zone") + .HasComment("开始时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("活动状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.ToTable("promotion_campaigns", null, t => + { + t.HasComment("营销活动配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChatSessionId") + .HasColumnType("bigint") + .HasComment("会话标识。"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("消息内容。"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("消息类型(文字/图片/语音等)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsRead") + .HasColumnType("boolean") + .HasComment("是否已读。"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone") + .HasComment("读取时间。"); + + b.Property("SenderType") + .HasColumnType("integer") + .HasComment("发送方类型。"); + + b.Property("SenderUserId") + .HasColumnType("bigint") + .HasComment("发送方用户 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ChatSessionId", "CreatedAt"); + + b.ToTable("chat_messages", null, t => + { + t.HasComment("会话消息。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentUserId") + .HasColumnType("bigint") + .HasComment("当前客服员工 ID。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CustomerUserId") + .HasColumnType("bigint") + .HasComment("顾客用户 ID。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasComment("结束时间。"); + + b.Property("IsBotActive") + .HasColumnType("boolean") + .HasComment("是否机器人接待中。"); + + b.Property("SessionCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("会话编号。"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasComment("开始时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("会话状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("所属门店(可空为平台)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SessionCode") + .IsUnique(); + + b.ToTable("chat_sessions", null, t => + { + t.HasComment("客服会话。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AssignedAgentId") + .HasColumnType("bigint") + .HasComment("指派的客服。"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone") + .HasComment("关闭时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CustomerUserId") + .HasColumnType("bigint") + .HasComment("客户用户 ID。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasComment("工单详情。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单(如有)。"); + + b.Property("Priority") + .HasColumnType("integer") + .HasComment("优先级。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("工单主题。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TicketNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("工单编号。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "TicketNo") + .IsUnique(); + + b.ToTable("support_tickets", null, t => + { + t.HasComment("客服工单。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttachmentsJson") + .HasColumnType("text") + .HasComment("附件 JSON。"); + + b.Property("AuthorUserId") + .HasColumnType("bigint") + .HasComment("评论人 ID。"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("评论内容。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsInternal") + .HasColumnType("boolean") + .HasComment("是否内部备注。"); + + b.Property("SupportTicketId") + .HasColumnType("bigint") + .HasComment("工单标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SupportTicketId"); + + b.ToTable("ticket_comments", null, t => + { + t.HasComment("工单评论/流转记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Deliveries.Entities.DeliveryEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryOrderId") + .HasColumnType("bigint") + .HasComment("配送单标识。"); + + b.Property("EventType") + .HasColumnType("integer") + .HasComment("事件类型。"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("事件描述。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("Payload") + .HasColumnType("text") + .HasComment("原始数据 JSON。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "DeliveryOrderId", "EventType"); + + b.ToTable("delivery_events", null, t => + { + t.HasComment("配送状态事件流水。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Deliveries.Entities.DeliveryOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CourierName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("骑手姓名。"); + + b.Property("CourierPhone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("骑手电话。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveredAt") + .HasColumnType("timestamp with time zone") + .HasComment("完成时间。"); + + b.Property("DeliveryFee") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("配送费。"); + + b.Property("DispatchedAt") + .HasColumnType("timestamp with time zone") + .HasComment("下发时间。"); + + b.Property("FailureReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("异常原因。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("获取或设置关联订单 ID。"); + + b.Property("PickedUpAt") + .HasColumnType("timestamp with time zone") + .HasComment("取餐时间。"); + + b.Property("Provider") + .HasColumnType("integer") + .HasComment("配送服务商。"); + + b.Property("ProviderOrderId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("第三方配送单号。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OrderId") + .IsUnique(); + + b.ToTable("delivery_orders", null, t => + { + t.HasComment("配送单。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Distribution.Entities.AffiliateOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AffiliatePartnerId") + .HasColumnType("bigint") + .HasComment("推广人标识。"); + + b.Property("BuyerUserId") + .HasColumnType("bigint") + .HasComment("用户 ID。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EstimatedCommission") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("预计佣金。"); + + b.Property("OrderAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("订单金额。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单。"); + + b.Property("SettledAt") + .HasColumnType("timestamp with time zone") + .HasComment("结算完成时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AffiliatePartnerId", "OrderId") + .IsUnique(); + + b.ToTable("affiliate_orders", null, t => + { + t.HasComment("分销订单记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Distribution.Entities.AffiliatePartner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelType") + .HasColumnType("integer") + .HasComment("渠道类型。"); + + b.Property("CommissionRate") + .HasColumnType("numeric") + .HasComment("分成比例(0-1)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("昵称或渠道名称。"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("联系电话。"); + + b.Property("Remarks") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("审核备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户 ID(如绑定平台账号)。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "DisplayName"); + + b.ToTable("affiliate_partners", null, t => + { + t.HasComment("分销/推广合作伙伴。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Distribution.Entities.AffiliatePayout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AffiliatePartnerId") + .HasColumnType("bigint") + .HasComment("合作伙伴标识。"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("结算金额。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasComment("打款时间。"); + + b.Property("Period") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("结算周期描述。"); + + b.Property("Remarks") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AffiliatePartnerId", "Period") + .IsUnique(); + + b.ToTable("affiliate_payouts", null, t => + { + t.HasComment("佣金结算记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CheckInCampaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowMakeupCount") + .HasColumnType("integer") + .HasComment("支持补签次数。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("活动描述。"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasComment("结束日期。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("活动名称。"); + + b.Property("RewardsJson") + .IsRequired() + .HasColumnType("text") + .HasComment("连签奖励 JSON。"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasComment("开始日期。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name"); + + b.ToTable("checkin_campaigns", null, t => + { + t.HasComment("签到活动配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CheckInRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckInCampaignId") + .HasColumnType("bigint") + .HasComment("活动标识。"); + + b.Property("CheckInDate") + .HasColumnType("timestamp with time zone") + .HasComment("签到日期(本地)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsMakeup") + .HasColumnType("boolean") + .HasComment("是否补签。"); + + b.Property("RewardJson") + .IsRequired() + .HasColumnType("text") + .HasComment("获得奖励 JSON。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "CheckInCampaignId", "UserId", "CheckInDate") + .IsUnique(); + + b.ToTable("checkin_records", null, t => + { + t.HasComment("用户签到记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CommunityComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorUserId") + .HasColumnType("bigint") + .HasComment("评论人。"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("评论内容。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasComment("状态。"); + + b.Property("ParentId") + .HasColumnType("bigint") + .HasComment("父级评论 ID。"); + + b.Property("PostId") + .HasColumnType("bigint") + .HasComment("动态标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "PostId", "CreatedAt"); + + b.ToTable("community_comments", null, t => + { + t.HasComment("社区评论。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CommunityPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorUserId") + .HasColumnType("bigint") + .HasComment("作者用户 ID。"); + + b.Property("CommentCount") + .HasColumnType("integer") + .HasComment("评论数。"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasComment("内容。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("LikeCount") + .HasColumnType("integer") + .HasComment("点赞数。"); + + b.Property("MediaJson") + .HasColumnType("text") + .HasComment("媒体资源 JSON。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Title") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AuthorUserId", "CreatedAt"); + + b.ToTable("community_posts", null, t => + { + t.HasComment("社区动态。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CommunityReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PostId") + .HasColumnType("bigint") + .HasComment("动态 ID。"); + + b.Property("ReactedAt") + .HasColumnType("timestamp with time zone") + .HasComment("时间戳。"); + + b.Property("ReactionType") + .HasColumnType("integer") + .HasComment("反应类型。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户 ID。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "PostId", "UserId") + .IsUnique(); + + b.ToTable("community_reactions", null, t => + { + t.HasComment("社区互动反馈。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.GroupBuying.Entities.GroupOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone") + .HasComment("取消时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CurrentCount") + .HasColumnType("integer") + .HasComment("当前已参与人数。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndAt") + .HasColumnType("timestamp with time zone") + .HasComment("结束时间。"); + + b.Property("GroupOrderNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("拼单编号。"); + + b.Property("GroupPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("拼团价格。"); + + b.Property("LeaderUserId") + .HasColumnType("bigint") + .HasComment("团长用户 ID。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("关联商品或套餐。"); + + b.Property("StartAt") + .HasColumnType("timestamp with time zone") + .HasComment("开始时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("拼团状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("SucceededAt") + .HasColumnType("timestamp with time zone") + .HasComment("成团时间。"); + + b.Property("TargetCount") + .HasColumnType("integer") + .HasComment("成团需要的人数。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "GroupOrderNo") + .IsUnique(); + + b.ToTable("group_orders", null, t => + { + t.HasComment("拼单活动。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.GroupBuying.Entities.GroupParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("GroupOrderId") + .HasColumnType("bigint") + .HasComment("拼单活动标识。"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasComment("参与时间。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("对应订单标识。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("参与状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "GroupOrderId", "UserId") + .IsUnique(); + + b.ToTable("group_participants", null, t => + { + t.HasComment("拼单参与者。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Inventory.Entities.InventoryAdjustment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdjustmentType") + .HasColumnType("integer") + .HasComment("调整类型。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("InventoryItemId") + .HasColumnType("bigint") + .HasComment("对应的库存记录标识。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人标识。"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasComment("调整数量,正数增加,负数减少。"); + + b.Property("Reason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("原因说明。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "InventoryItemId", "OccurredAt"); + + b.ToTable("inventory_adjustments", null, t => + { + t.HasComment("库存调整记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Inventory.Entities.InventoryBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BatchNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("批次编号。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpireDate") + .HasColumnType("timestamp with time zone") + .HasComment("过期日期。"); + + b.Property("ProductSkuId") + .HasColumnType("bigint") + .HasComment("SKU 标识。"); + + b.Property("ProductionDate") + .HasColumnType("timestamp with time zone") + .HasComment("生产日期。"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasComment("入库数量。"); + + b.Property("RemainingQuantity") + .HasColumnType("integer") + .HasComment("剩余数量。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "ProductSkuId", "BatchNumber") + .IsUnique(); + + b.ToTable("inventory_batches", null, t => + { + t.HasComment("SKU 批次信息。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Inventory.Entities.InventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BatchConsumeStrategy") + .HasColumnType("integer") + .HasComment("批次扣减策略。"); + + b.Property("BatchNumber") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("批次编号,可为空表示混批。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpireDate") + .HasColumnType("timestamp with time zone") + .HasComment("过期日期。"); + + b.Property("IsPresale") + .HasColumnType("boolean") + .HasComment("是否预售商品。"); + + b.Property("IsSoldOut") + .HasColumnType("boolean") + .HasComment("是否标记售罄。"); + + b.Property("Location") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("储位或仓位信息。"); + + b.Property("MaxQuantityPerOrder") + .HasColumnType("integer") + .HasComment("单品限购(覆盖商品级 MaxQuantityPerOrder)。"); + + b.Property("PresaleCapacity") + .HasColumnType("integer") + .HasComment("预售名额(上限)。"); + + b.Property("PresaleEndTime") + .HasColumnType("timestamp with time zone") + .HasComment("预售结束时间(UTC)。"); + + b.Property("PresaleLocked") + .HasColumnType("integer") + .HasComment("当前预售已锁定数量。"); + + b.Property("PresaleStartTime") + .HasColumnType("timestamp with time zone") + .HasComment("预售开始时间(UTC)。"); + + b.Property("ProductSkuId") + .HasColumnType("bigint") + .HasComment("SKU 标识。"); + + b.Property("QuantityOnHand") + .HasColumnType("integer") + .HasComment("可用库存。"); + + b.Property("QuantityReserved") + .HasColumnType("integer") + .HasComment("已锁定库存(订单占用)。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("SafetyStock") + .HasColumnType("integer") + .HasComment("安全库存阈值。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "ProductSkuId", "BatchNumber"); + + b.ToTable("inventory_items", null, t => + { + t.HasComment("SKU 在门店的库存信息。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Inventory.Entities.InventoryLockRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasComment("过期时间(UTC)。"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("幂等键。"); + + b.Property("IsPresale") + .HasColumnType("boolean") + .HasComment("是否预售锁定。"); + + b.Property("ProductSkuId") + .HasColumnType("bigint") + .HasComment("SKU ID。"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasComment("锁定数量。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("锁定状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("TenantId", "StoreId", "ProductSkuId", "Status"); + + b.ToTable("inventory_lock_records", null, t => + { + t.HasComment("库存锁定记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberGrowthLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChangeValue") + .HasColumnType("integer") + .HasComment("变动数量。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CurrentValue") + .HasColumnType("integer") + .HasComment("当前成长值。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasComment("会员标识。"); + + b.Property("Notes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MemberId", "OccurredAt"); + + b.ToTable("member_growth_logs", null, t => + { + t.HasComment("成长值变动日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberPointLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BalanceAfterChange") + .HasColumnType("integer") + .HasComment("变动后余额。"); + + b.Property("ChangeAmount") + .HasColumnType("integer") + .HasComment("变动数量,可为负值。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone") + .HasComment("过期时间(如适用)。"); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasComment("会员标识。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("Reason") + .HasColumnType("integer") + .HasComment("变动原因。"); + + b.Property("SourceId") + .HasColumnType("bigint") + .HasComment("来源 ID(订单、活动等)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MemberId", "OccurredAt"); + + b.ToTable("member_point_ledgers", null, t => + { + t.HasComment("积分变动流水。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("头像。"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone") + .HasComment("生日。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("GrowthValue") + .HasColumnType("integer") + .HasComment("成长值/经验值。"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasComment("注册时间。"); + + b.Property("MemberTierId") + .HasColumnType("bigint") + .HasComment("当前会员等级 ID。"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("手机号。"); + + b.Property("Nickname") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("昵称。"); + + b.Property("PointsBalance") + .HasColumnType("integer") + .HasComment("会员积分余额。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("会员状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Mobile") + .IsUnique(); + + b.ToTable("member_profiles", null, t => + { + t.HasComment("会员档案。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberTier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BenefitsJson") + .IsRequired() + .HasColumnType("text") + .HasComment("等级权益(JSON)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("等级名称。"); + + b.Property("RequiredGrowth") + .HasColumnType("integer") + .HasComment("所需成长值。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("member_tiers", null, t => + { + t.HasComment("会员等级定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("详细地址。"); + + b.Property("BrandAlias") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("品牌简称或别名。"); + + b.Property("BrandName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("品牌名称(对外展示)。"); + + b.Property("BusinessLicenseImageUrl") + .HasColumnType("text") + .HasComment("营业执照扫描件地址。"); + + b.Property("BusinessLicenseNumber") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("营业执照号。"); + + b.Property("Category") + .HasColumnType("text") + .HasComment("品牌所属品类,如火锅、咖啡等。"); + + b.Property("City") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在城市。"); + + b.Property("ContactEmail") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("联系邮箱。"); + + b.Property("ContactPhone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("联系电话。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("District") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在区县。"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasComment("入驻时间。"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次审核时间。"); + + b.Property("Latitude") + .HasColumnType("double precision") + .HasComment("纬度信息。"); + + b.Property("LegalPerson") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("法人或负责人姓名。"); + + b.Property("LogoUrl") + .HasColumnType("text") + .HasComment("品牌 Logo。"); + + b.Property("Longitude") + .HasColumnType("double precision") + .HasComment("经度信息。"); + + b.Property("Province") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在省份。"); + + b.Property("ReviewRemarks") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("审核备注或驳回原因。"); + + b.Property("ServicePhone") + .HasColumnType("text") + .HasComment("客服电话。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("入驻状态。"); + + b.Property("SupportEmail") + .HasColumnType("text") + .HasComment("客服邮箱。"); + + b.Property("TaxNumber") + .HasColumnType("text") + .HasComment("税号/统一社会信用代码。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("merchants", null, t => + { + t.HasComment("商户主体信息,承载入驻和资质审核结果。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("integer") + .HasComment("动作类型。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("详情描述。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("商户标识。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人 ID。"); + + b.Property("OperatorName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("操作人名称。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MerchantId"); + + b.ToTable("merchant_audit_logs", null, t => + { + t.HasComment("商户入驻审核日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasComment("显示顺序,越小越靠前。"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasComment("是否可用。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("类目名称。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("merchant_categories", null, t => + { + t.HasComment("商户可选类目。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantContract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContractNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("合同编号。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasComment("合同结束时间。"); + + b.Property("FileUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("合同文件存储地址。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户标识。"); + + b.Property("SignedAt") + .HasColumnType("timestamp with time zone") + .HasComment("签署时间。"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasComment("合同开始时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("合同状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TerminatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("终止时间。"); + + b.Property("TerminationReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("终止原因。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MerchantId", "ContractNumber") + .IsUnique(); + + b.ToTable("merchant_contracts", null, t => + { + t.HasComment("商户合同记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DocumentNumber") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("证照编号。"); + + b.Property("DocumentType") + .HasColumnType("integer") + .HasComment("证照类型。"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasComment("到期日期。"); + + b.Property("FileUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("证照文件链接。"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasComment("签发日期。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户标识。"); + + b.Property("Remarks") + .HasColumnType("text") + .HasComment("审核备注或驳回原因。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("审核状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MerchantId", "DocumentType"); + + b.ToTable("merchant_documents", null, t => + { + t.HasComment("商户提交的资质或证照材料。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantStaff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Email") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("邮箱地址。"); + + b.Property("IdentityUserId") + .HasColumnType("bigint") + .HasComment("登录账号 ID(指向统一身份体系)。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户标识。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("员工姓名。"); + + b.Property("PermissionsJson") + .HasColumnType("text") + .HasComment("自定义权限(JSON)。"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("手机号。"); + + b.Property("RoleType") + .HasColumnType("integer") + .HasComment("员工角色类型。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("员工状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("可选的关联门店 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MerchantId", "Phone"); + + b.ToTable("merchant_staff", null, t => + { + t.HasComment("商户员工账号,支持门店维度分配。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Navigation.Entities.MapLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Landmark") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("打车/导航落点描述。"); + + b.Property("Latitude") + .HasColumnType("double precision") + .HasComment("纬度。"); + + b.Property("Longitude") + .HasColumnType("double precision") + .HasComment("经度。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("名称。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("关联门店 ID,可空表示独立 POI。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("map_locations", null, t => + { + t.HasComment("地图 POI 信息,用于门店定位和推荐。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Navigation.Entities.NavigationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Channel") + .HasColumnType("integer") + .HasComment("来源通道(小程序、H5 等)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone") + .HasComment("请求时间。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店 ID。"); + + b.Property("TargetApp") + .HasColumnType("integer") + .HasComment("跳转的地图应用。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户 ID。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "UserId", "StoreId", "RequestedAt"); + + b.ToTable("navigation_requests", null, t => + { + t.HasComment("用户发起的导航请求日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Ordering.Entities.CartItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttributesJson") + .HasColumnType("text") + .HasComment("扩展 JSON(规格、加料选项等)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("商品或 SKU 标识。"); + + b.Property("ProductName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("商品名称快照。"); + + b.Property("ProductSkuId") + .HasColumnType("bigint") + .HasComment("SKU 标识。"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasComment("数量。"); + + b.Property("Remark") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("自定义备注(口味要求)。"); + + b.Property("ShoppingCartId") + .HasColumnType("bigint") + .HasComment("所属购物车标识。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("单价快照。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ShoppingCartId"); + + b.ToTable("cart_items", null, t => + { + t.HasComment("购物车条目。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Ordering.Entities.CartItemAddon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CartItemId") + .HasColumnType("bigint") + .HasComment("所属购物车条目。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExtraPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("附加价格。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("选项名称。"); + + b.Property("OptionId") + .HasColumnType("bigint") + .HasComment("选项 ID(可对应 ProductAddonOption)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.ToTable("cart_item_addons", null, t => + { + t.HasComment("购物车条目的加料/附加项。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Ordering.Entities.CheckoutSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasComment("过期时间(UTC)。"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("会话 Token。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("会话状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.Property("ValidationResultJson") + .IsRequired() + .HasColumnType("text") + .HasComment("校验结果明细 JSON。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SessionToken") + .IsUnique(); + + b.ToTable("checkout_sessions", null, t => + { + t.HasComment("结账会话,记录校验上下文。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Ordering.Entities.ShoppingCart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryPreference") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("履约方式(堂食/自提/配送)缓存。"); + + b.Property("LastModifiedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次修改时间(UTC)。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("购物车状态,包含正常/锁定。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TableContext") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("桌码或场景标识(扫码点餐)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "UserId", "StoreId") + .IsUnique(); + + b.ToTable("shopping_carts", null, t => + { + t.HasComment("用户购物车,按租户/门店隔离。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CancelReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("取消原因。"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone") + .HasComment("取消时间。"); + + b.Property("Channel") + .HasColumnType("integer") + .HasComment("下单渠道。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CustomerName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("顾客姓名。"); + + b.Property("CustomerPhone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("顾客手机号。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryType") + .HasColumnType("integer") + .HasComment("履约类型。"); + + b.Property("DiscountAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("优惠金额。"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasComment("完成时间。"); + + b.Property("ItemsAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("商品总额。"); + + b.Property("OrderNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("订单号。"); + + b.Property("PaidAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("实付金额。"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasComment("支付时间。"); + + b.Property("PayableAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("应付金额。"); + + b.Property("PaymentStatus") + .HasColumnType("integer") + .HasComment("支付状态。"); + + b.Property("QueueNumber") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("排队号(如有)。"); + + b.Property("Remark") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注。"); + + b.Property("ReservationId") + .HasColumnType("bigint") + .HasComment("预约 ID。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店。"); + + b.Property("TableNo") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("就餐桌号。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OrderNo") + .IsUnique(); + + b.HasIndex("TenantId", "StoreId", "Status"); + + b.ToTable("orders", null, t => + { + t.HasComment("交易订单。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttributesJson") + .HasColumnType("text") + .HasComment("自定义属性 JSON。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DiscountAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("折扣金额。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("订单 ID。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("商品 ID。"); + + b.Property("ProductName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("商品名称。"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasComment("数量。"); + + b.Property("SkuName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("SKU/规格描述。"); + + b.Property("SubTotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("小计。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasComment("单位。"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("单价。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("TenantId", "OrderId"); + + b.ToTable("order_items", null, t => + { + t.HasComment("订单明细。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.OrderStatusHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Notes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注信息。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人标识(可为空表示系统)。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("订单标识。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("变更后的状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OrderId", "OccurredAt"); + + b.ToTable("order_status_histories", null, t => + { + t.HasComment("订单状态流转记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.RefundRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("申请金额。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单标识。"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasComment("审核完成时间。"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("申请原因。"); + + b.Property("RefundNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("退款单号。"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone") + .HasComment("用户提交时间。"); + + b.Property("ReviewNotes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("审核备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("退款状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "RefundNo") + .IsUnique(); + + b.ToTable("refund_requests", null, t => + { + t.HasComment("售后/退款申请。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Payments.Entities.PaymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("支付金额。"); + + b.Property("ChannelTransactionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("第三方渠道单号。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Method") + .HasColumnType("integer") + .HasComment("支付方式。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单。"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasComment("支付完成时间。"); + + b.Property("Payload") + .HasColumnType("text") + .HasComment("原始回调内容。"); + + b.Property("Remark") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("错误/备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("支付状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TradeNo") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("平台交易号。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OrderId"); + + b.ToTable("payment_records", null, t => + { + t.HasComment("支付流水。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Payments.Entities.PaymentRefundRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("退款金额。"); + + b.Property("ChannelRefundId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("渠道退款流水号。"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("完成时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单标识。"); + + b.Property("Payload") + .HasColumnType("text") + .HasComment("渠道返回的原始数据 JSON。"); + + b.Property("PaymentRecordId") + .HasColumnType("bigint") + .HasComment("原支付记录标识。"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone") + .HasComment("退款请求时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("退款状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "PaymentRecordId"); + + b.ToTable("payment_refund_records", null, t => + { + t.HasComment("支付渠道退款流水。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("bigint") + .HasComment("所属分类。"); + + b.Property("CoverImage") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("主图。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasColumnType("text") + .HasComment("商品描述。"); + + b.Property("EnableDelivery") + .HasColumnType("boolean") + .HasComment("支持配送。"); + + b.Property("EnableDineIn") + .HasColumnType("boolean") + .HasComment("支持堂食。"); + + b.Property("EnablePickup") + .HasColumnType("boolean") + .HasComment("支持自提。"); + + b.Property("GalleryImages") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("Gallery 图片逗号分隔。"); + + b.Property("IsFeatured") + .HasColumnType("boolean") + .HasComment("是否热门推荐。"); + + b.Property("MaxQuantityPerOrder") + .HasColumnType("integer") + .HasComment("最大每单限购。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("商品名称。"); + + b.Property("OriginalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("原价。"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("现价。"); + + b.Property("SpuCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("商品编码。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("商品状态。"); + + b.Property("StockQuantity") + .HasColumnType("integer") + .HasComment("库存数量(可选)。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("所属门店。"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("副标题/卖点。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasComment("售卖单位(份/杯等)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SpuCode") + .IsUnique(); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("products", null, t => + { + t.HasComment("商品(SPU)信息。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductAddonGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsRequired") + .HasColumnType("boolean") + .HasComment("是否必选。"); + + b.Property("MaxSelect") + .HasColumnType("integer") + .HasComment("最大选择数量。"); + + b.Property("MinSelect") + .HasColumnType("integer") + .HasComment("最小选择数量。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("分组名称。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("所属商品。"); + + b.Property("SelectionType") + .HasColumnType("integer") + .HasComment("选择类型。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ProductId", "Name"); + + b.ToTable("product_addon_groups", null, t => + { + t.HasComment("加料/做法分组。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductAddonOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddonGroupId") + .HasColumnType("bigint") + .HasComment("所属加料分组。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExtraPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("附加价格。"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasComment("是否默认选项。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("选项名称。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.ToTable("product_addon_options", null, t => + { + t.HasComment("加料选项。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductAttributeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsRequired") + .HasColumnType("boolean") + .HasComment("是否必选。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("分组名称,例如“辣度”“份量”。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("所属商品标识。"); + + b.Property("SelectionType") + .HasColumnType("integer") + .HasComment("选择类型(单选/多选)。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("显示排序。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("关联门店,可为空表示所有门店共享。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "Name"); + + b.ToTable("product_attribute_groups", null, t => + { + t.HasComment("商品规格/属性分组。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductAttributeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttributeGroupId") + .HasColumnType("bigint") + .HasComment("所属规格组。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExtraPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("附加价格。"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasComment("是否默认选中。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("选项名称。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AttributeGroupId", "Name") + .IsUnique(); + + b.ToTable("product_attribute_options", null, t => + { + t.HasComment("商品规格选项。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("分类描述。"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasComment("是否启用。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("分类名称。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("所属门店。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("product_categories", null, t => + { + t.HasComment("商品分类。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductMediaAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Caption") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("描述或标题。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("MediaType") + .HasColumnType("integer") + .HasComment("媒体类型。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("商品标识。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("媒资链接。"); + + b.HasKey("Id"); + + b.ToTable("product_media_assets", null, t => + { + t.HasComment("商品媒资素材。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductPricingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConditionsJson") + .IsRequired() + .HasColumnType("text") + .HasComment("条件描述(JSON),如会员等级、渠道等。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone") + .HasComment("生效结束时间。"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("特殊价格。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("所属商品。"); + + b.Property("RuleType") + .HasColumnType("integer") + .HasComment("策略类型。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasComment("生效开始时间。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("WeekdaysJson") + .HasColumnType("text") + .HasComment("生效星期(JSON 数组)。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ProductId", "RuleType"); + + b.ToTable("product_pricing_rules", null, t => + { + t.HasComment("商品价格策略,支持会员价/时段价等。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductSku", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttributesJson") + .IsRequired() + .HasColumnType("text") + .HasComment("规格属性 JSON(记录选项 ID)。"); + + b.Property("Barcode") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("条形码。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("OriginalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("原价。"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("售价。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("所属商品标识。"); + + b.Property("SkuCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("SKU 编码。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StockQuantity") + .HasColumnType("integer") + .HasComment("可售库存。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Weight") + .HasPrecision(10, 3) + .HasColumnType("numeric(10,3)") + .HasComment("重量(千克)。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SkuCode") + .IsUnique(); + + b.ToTable("product_skus", null, t => + { + t.HasComment("商品 SKU,记录具体规格组合价格。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Queues.Entities.QueueTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalledAt") + .HasColumnType("timestamp with time zone") + .HasComment("叫号时间。"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone") + .HasComment("取消时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EstimatedWaitMinutes") + .HasColumnType("integer") + .HasComment("预计等待分钟。"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasComment("过号时间。"); + + b.Property("PartySize") + .HasColumnType("integer") + .HasComment("就餐人数。"); + + b.Property("Remark") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("获取或设置所属门店 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TicketNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("排队编号。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId"); + + b.HasIndex("TenantId", "StoreId", "TicketNumber") + .IsUnique(); + + b.ToTable("queue_tickets", null, t => + { + t.HasComment("排队叫号。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Reservations.Entities.Reservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone") + .HasComment("取消时间。"); + + b.Property("CheckInCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("核销码/到店码。"); + + b.Property("CheckedInAt") + .HasColumnType("timestamp with time zone") + .HasComment("实际签到时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CustomerName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("客户姓名。"); + + b.Property("CustomerPhone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("联系电话。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PeopleCount") + .HasColumnType("integer") + .HasComment("用餐人数。"); + + b.Property("Remark") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注。"); + + b.Property("ReservationNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("预约号。"); + + b.Property("ReservationTime") + .HasColumnType("timestamp with time zone") + .HasComment("预约时间(UTC)。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店。"); + + b.Property("TablePreference") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("桌型/标签。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ReservationNo") + .IsUnique(); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("reservations", null, t => + { + t.HasComment("预约/预订记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("详细地址。"); + + b.Property("Announcement") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("门店公告。"); + + b.Property("BusinessHours") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("门店营业时段描述(备用字符串)。"); + + b.Property("City") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在城市。"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("门店编码,便于扫码及外部对接。"); + + b.Property("Country") + .HasColumnType("text") + .HasComment("所在国家或地区。"); + + b.Property("CoverImageUrl") + .HasColumnType("text") + .HasComment("门店海报。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryRadiusKm") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)") + .HasComment("默认配送半径(公里)。"); + + b.Property("Description") + .HasColumnType("text") + .HasComment("门店描述或公告。"); + + b.Property("District") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("区县信息。"); + + b.Property("Latitude") + .HasColumnType("double precision") + .HasComment("纬度。"); + + b.Property("Longitude") + .HasColumnType("double precision") + .HasComment("高德/腾讯地图经度。"); + + b.Property("ManagerName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("门店负责人姓名。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户标识。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("门店名称。"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("联系电话。"); + + b.Property("Province") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在省份。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("门店当前运营状态。"); + + b.Property("SupportsDelivery") + .HasColumnType("boolean") + .HasComment("是否支持配送。"); + + b.Property("SupportsDineIn") + .HasColumnType("boolean") + .HasComment("是否支持堂食。"); + + b.Property("SupportsPickup") + .HasColumnType("boolean") + .HasComment("是否支持自提。"); + + b.Property("SupportsQueueing") + .HasColumnType("boolean") + .HasComment("支持排队叫号。"); + + b.Property("SupportsReservation") + .HasColumnType("boolean") + .HasComment("支持预约。"); + + b.Property("Tags") + .HasColumnType("text") + .HasComment("门店标签(逗号分隔)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.HasIndex("TenantId", "MerchantId"); + + b.ToTable("stores", null, t => + { + t.HasComment("门店信息,承载营业配置与能力。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreBusinessHour", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CapacityLimit") + .HasColumnType("integer") + .HasComment("最大接待容量或单量限制。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DayOfWeek") + .HasColumnType("integer") + .HasComment("星期几,0 表示周日。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndTime") + .HasColumnType("interval") + .HasComment("结束时间(本地时间)。"); + + b.Property("HourType") + .HasColumnType("integer") + .HasComment("时段类型(正常营业、休息、预约等)。"); + + b.Property("Notes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("StartTime") + .HasColumnType("interval") + .HasComment("开始时间(本地时间)。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "DayOfWeek"); + + b.ToTable("store_business_hours", null, t => + { + t.HasComment("门店营业时段配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreDeliveryZone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryFee") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("配送费。"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasComment("预计送达分钟。"); + + b.Property("MinimumOrderAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("起送价。"); + + b.Property("PolygonGeoJson") + .IsRequired() + .HasColumnType("text") + .HasComment("GeoJSON 表示的多边形范围。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("ZoneName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("区域名称。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "ZoneName"); + + b.ToTable("store_delivery_zones", null, t => + { + t.HasComment("门店配送范围配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreEmployeeShift", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndTime") + .HasColumnType("interval") + .HasComment("结束时间。"); + + b.Property("Notes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("RoleType") + .HasColumnType("integer") + .HasComment("排班角色。"); + + b.Property("ShiftDate") + .HasColumnType("timestamp with time zone") + .HasComment("班次日期。"); + + b.Property("StaffId") + .HasColumnType("bigint") + .HasComment("员工标识。"); + + b.Property("StartTime") + .HasColumnType("interval") + .HasComment("开始时间。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "ShiftDate", "StaffId") + .IsUnique(); + + b.ToTable("store_employee_shifts", null, t => + { + t.HasComment("门店员工排班记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("Date") + .HasColumnType("timestamp with time zone") + .HasComment("日期。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsClosed") + .HasColumnType("boolean") + .HasComment("是否全天闭店。"); + + b.Property("Reason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("说明内容。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "Date") + .IsUnique(); + + b.ToTable("store_holidays", null, t => + { + t.HasComment("门店休息日或特殊营业日。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StorePickupSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowDaysAhead") + .HasColumnType("integer") + .HasComment("可预约天数(含当天)。"); + + b.Property("AllowToday") + .HasColumnType("boolean") + .HasComment("是否允许当天自提。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DefaultCutoffMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(30) + .HasComment("默认截单分钟(开始前多少分钟截止)。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("MaxQuantityPerOrder") + .HasColumnType("integer") + .HasComment("单笔自提最大份数。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId") + .IsUnique(); + + b.ToTable("store_pickup_settings", null, t => + { + t.HasComment("门店自提配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StorePickupSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Capacity") + .HasColumnType("integer") + .HasComment("容量(份数)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CutoffMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(30) + .HasComment("截单分钟(开始前多少分钟截止)。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndTime") + .HasColumnType("interval") + .HasComment("当天结束时间(UTC)。"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasComment("是否启用。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("档期名称。"); + + b.Property("ReservedCount") + .HasColumnType("integer") + .HasComment("已占用数量。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("StartTime") + .HasColumnType("interval") + .HasComment("当天开始时间(UTC)。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Weekdays") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("适用星期(逗号分隔 1-7)。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "Name"); + + b.ToTable("store_pickup_slots", null, t => + { + t.HasComment("门店自提档期。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreTable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("bigint") + .HasComment("所在区域 ID。"); + + b.Property("Capacity") + .HasColumnType("integer") + .HasComment("可容纳人数。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("QrCodeUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("桌码二维码地址。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前桌台状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TableCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("桌码。"); + + b.Property("Tags") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("桌台标签(堂食、快餐等)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "TableCode") + .IsUnique(); + + b.ToTable("store_tables", null, t => + { + t.HasComment("桌台信息与二维码绑定。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreTableArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("区域描述。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("区域名称。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "Name") + .IsUnique(); + + b.ToTable("store_table_areas", null, t => + { + t.HasComment("门店桌台区域配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.OperationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("OperationType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("操作类型:BatchExtend, BatchRemind, StatusChange 等。"); + + b.Property("OperatorId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("操作人ID。"); + + b.Property("OperatorName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("操作人名称。"); + + b.Property("Parameters") + .HasColumnType("text") + .HasComment("操作参数(JSON)。"); + + b.Property("Result") + .HasColumnType("text") + .HasComment("操作结果(JSON)。"); + + b.Property("Success") + .HasColumnType("boolean") + .HasComment("是否成功。"); + + b.Property("TargetIds") + .HasColumnType("text") + .HasComment("目标ID列表(JSON)。"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("目标类型:Subscription, Bill 等。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OperationType", "CreatedAt"); + + b.ToTable("operation_logs", null, t => + { + t.HasComment("运营操作日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.QuotaPackage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述。"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasComment("是否上架。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("配额包名称。"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("价格。"); + + b.Property("QuotaType") + .HasColumnType("integer") + .HasComment("配额类型。"); + + b.Property("QuotaValue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("配额数值。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasComment("排序。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("QuotaType", "IsActive", "SortOrder"); + + b.ToTable("quota_packages", null, t => + { + t.HasComment("配额包定义(平台提供的可购买配额包)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("text") + .HasComment("详细地址信息。"); + + b.Property("City") + .HasColumnType("text") + .HasComment("所在城市。"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("租户短编码,作为跨系统引用的唯一标识。"); + + b.Property("ContactEmail") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("主联系人邮箱。"); + + b.Property("ContactName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("主联系人姓名。"); + + b.Property("ContactPhone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("主联系人电话。"); + + b.Property("Country") + .HasColumnType("text") + .HasComment("所在国家/地区。"); + + b.Property("CoverImageUrl") + .HasColumnType("text") + .HasComment("品牌海报或封面图。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone") + .HasComment("服务生效时间(UTC)。"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone") + .HasComment("服务到期时间(UTC)。"); + + b.Property("Industry") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所属行业,如餐饮、零售等。"); + + b.Property("LegalEntityName") + .HasColumnType("text") + .HasComment("法人或公司主体名称。"); + + b.Property("LogoUrl") + .HasColumnType("text") + .HasComment("LOGO 图片地址。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("租户全称或品牌名称。"); + + b.Property("PrimaryOwnerUserId") + .HasColumnType("bigint") + .HasComment("系统内对应的租户所有者账号 ID。"); + + b.Property("Province") + .HasColumnType("text") + .HasComment("所在省份或州。"); + + b.Property("Remarks") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注信息,用于运营记录特殊说明。"); + + b.Property("ShortName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("对外展示的简称。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("租户当前状态,涵盖审核、启用、停用等场景。"); + + b.Property("SuspendedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次暂停服务时间。"); + + b.Property("SuspensionReason") + .HasColumnType("text") + .HasComment("暂停或终止的原因说明。"); + + b.Property("Tags") + .HasColumnType("text") + .HasComment("业务标签集合(逗号分隔)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Website") + .HasColumnType("text") + .HasComment("官网或主要宣传链接。"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ContactPhone") + .IsUnique(); + + b.ToTable("tenants", null, t => + { + t.HasComment("平台租户信息,描述租户的生命周期与基础资料。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantAnnouncement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnnouncementType") + .HasColumnType("integer") + .HasComment("公告类型。"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasComment("公告正文(可为 Markdown/HTML,前端自行渲染)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone") + .HasComment("生效时间(UTC)。"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone") + .HasComment("失效时间(UTC),为空表示长期有效。"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasComment("是否启用。"); + + b.Property("Priority") + .HasColumnType("integer") + .HasComment("展示优先级,数值越大越靠前。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("公告标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AnnouncementType", "IsActive"); + + b.HasIndex("TenantId", "EffectiveFrom", "EffectiveTo"); + + b.ToTable("tenant_announcements", null, t => + { + t.HasComment("租户公告。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantAnnouncementRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnnouncementId") + .HasColumnType("bigint") + .HasComment("公告 ID。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone") + .HasComment("已读时间。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("已读用户 ID(后台账号),为空表示租户级已读。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AnnouncementId", "UserId") + .IsUnique(); + + b.ToTable("tenant_announcement_reads", null, t => + { + t.HasComment("租户公告已读记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("integer") + .HasComment("操作类型。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CurrentStatus") + .HasColumnType("integer") + .HasComment("新状态。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("详细描述。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人 ID。"); + + b.Property("OperatorName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("操作人名称。"); + + b.Property("PreviousStatus") + .HasColumnType("integer") + .HasComment("原状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("关联的租户标识。"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("日志标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("tenant_audit_logs", null, t => + { + t.HasComment("租户运营审核日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantBillingStatement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountDue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("应付金额(原始金额)。"); + + b.Property("AmountPaid") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("实付金额。"); + + b.Property("BillingType") + .HasColumnType("integer") + .HasComment("账单类型(订阅账单/配额包账单/手动账单/续费账单)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasDefaultValue("CNY") + .HasComment("货币类型(默认 CNY)。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DiscountAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("折扣金额。"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone") + .HasComment("到期日。"); + + b.Property("LineItemsJson") + .HasColumnType("text") + .HasComment("账单明细 JSON,记录各项费用。"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注信息(如:人工备注、取消原因等)。"); + + b.Property("OverdueNotifiedAt") + .HasColumnType("timestamp with time zone") + .HasComment("逾期通知时间。"); + + b.Property("PeriodEnd") + .HasColumnType("timestamp with time zone") + .HasComment("账单周期结束时间。"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone") + .HasComment("账单周期开始时间。"); + + b.Property("ReminderSentAt") + .HasColumnType("timestamp with time zone") + .HasComment("提醒发送时间(续费提醒、逾期提醒等)。"); + + b.Property("StatementNo") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("账单编号,供对账查询。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前付款状态。"); + + b.Property("SubscriptionId") + .HasColumnType("bigint") + .HasComment("关联的订阅 ID(仅当 BillingType 为 Subscription 或 Renewal 时有值)。"); + + b.Property("TaxAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("税费金额。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("idx_billing_created_at"); + + b.HasIndex("Status", "DueDate") + .HasDatabaseName("idx_billing_status_duedate") + .HasFilter("\"Status\" IN (0, 2)"); + + b.HasIndex("TenantId", "StatementNo") + .IsUnique(); + + b.HasIndex("TenantId", "Status", "DueDate") + .HasDatabaseName("idx_billing_tenant_status_duedate"); + + b.ToTable("tenant_billing_statements", null, t => + { + t.HasComment("租户账单,用于呈现周期性收费。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Channel") + .HasColumnType("integer") + .HasComment("发布通道(站内、邮件、短信等)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("通知正文。"); + + b.Property("MetadataJson") + .HasColumnType("text") + .HasComment("附加元数据 JSON。"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone") + .HasComment("租户是否已阅读。"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasComment("推送时间。"); + + b.Property("Severity") + .HasColumnType("integer") + .HasComment("通知重要级别。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("通知标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Channel", "SentAt"); + + b.ToTable("tenant_notifications", null, t => + { + t.HasComment("面向租户的站内通知或消息推送。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantPackage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("套餐描述,包含适用场景、权益等。"); + + b.Property("FeaturePoliciesJson") + .HasColumnType("text") + .HasComment("权益明细 JSON,记录自定义特性开关。"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasComment("是否仍启用(平台控制)。"); + + b.Property("IsAllowNewTenantPurchase") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否允许新租户购买/选择(仅影响新购)。"); + + b.Property("IsPublicVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否对外可见(展示页/套餐列表可见性)。"); + + b.Property("IsRecommended") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否推荐展示(运营推荐标识)。"); + + b.Property("MaxAccountCount") + .HasColumnType("integer") + .HasComment("允许创建的最大账号数。"); + + b.Property("MaxDeliveryOrders") + .HasColumnType("integer") + .HasComment("每月可调用的配送单数量上限。"); + + b.Property("MaxSmsCredits") + .HasColumnType("integer") + .HasComment("每月短信额度上限。"); + + b.Property("MaxStorageGb") + .HasColumnType("integer") + .HasComment("存储容量上限(GB)。"); + + b.Property("MaxStoreCount") + .HasColumnType("integer") + .HasComment("允许的最大门店数。"); + + b.Property("MonthlyPrice") + .HasColumnType("numeric") + .HasComment("月付价格,单位:人民币元。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("套餐名称,展示给租户的简称。"); + + b.Property("PackageType") + .HasColumnType("integer") + .HasComment("套餐分类(试用、标准、旗舰等)。"); + + b.Property("PublishStatus") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasComment("发布状态:0=草稿,1=已发布。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasComment("展示排序,数值越小越靠前。"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasComment("套餐标签(用于展示与对比页)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("YearlyPrice") + .HasColumnType("numeric") + .HasComment("年付价格,单位:人民币元。"); + + b.HasKey("Id"); + + b.HasIndex("IsActive", "SortOrder"); + + b.HasIndex("PublishStatus", "IsActive", "IsPublicVisible", "IsAllowNewTenantPurchase", "SortOrder"); + + b.ToTable("tenant_packages", null, t => + { + t.HasComment("平台提供的租户套餐定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantPayment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("支付金额。"); + + b.Property("BillingStatementId") + .HasColumnType("bigint") + .HasComment("关联的账单 ID。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Method") + .HasColumnType("integer") + .HasComment("支付方式。"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注信息。"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasComment("支付时间。"); + + b.Property("ProofUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("支付凭证 URL。"); + + b.Property("RefundReason") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("退款原因。"); + + b.Property("RefundedAt") + .HasColumnType("timestamp with time zone") + .HasComment("退款时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("支付状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TransactionNo") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("交易号。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasComment("审核时间。"); + + b.Property("VerifiedBy") + .HasColumnType("bigint") + .HasComment("审核人 ID(管理员)。"); + + b.HasKey("Id"); + + b.HasIndex("TransactionNo") + .HasDatabaseName("idx_payment_transaction_no") + .HasFilter("\"TransactionNo\" IS NOT NULL"); + + b.HasIndex("BillingStatementId", "PaidAt") + .HasDatabaseName("idx_payment_billing_paidat"); + + b.HasIndex("TenantId", "BillingStatementId"); + + b.ToTable("tenant_payments", null, t => + { + t.HasComment("租户支付记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantQuotaPackagePurchase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasComment("过期时间(可选)。"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注。"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("购买价格。"); + + b.Property("PurchasedAt") + .HasColumnType("timestamp with time zone") + .HasComment("购买时间。"); + + b.Property("QuotaPackageId") + .HasColumnType("bigint") + .HasComment("配额包 ID。"); + + b.Property("QuotaValue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("购买时的配额值。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "QuotaPackageId", "PurchasedAt"); + + b.ToTable("tenant_quota_package_purchases", null, t => + { + t.HasComment("租户配额包购买记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantQuotaUsage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("LastResetAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次重置时间。"); + + b.Property("LimitValue") + .HasColumnType("numeric") + .HasComment("当前配额上限。"); + + b.Property("QuotaType") + .HasColumnType("integer") + .HasComment("配额类型,例如门店数、短信条数等。"); + + b.Property("ResetCycle") + .HasColumnType("text") + .HasComment("配额刷新周期描述(如月、年)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UsedValue") + .HasColumnType("numeric") + .HasComment("已消耗的数量。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "QuotaType") + .IsUnique(); + + b.ToTable("tenant_quota_usages", null, t => + { + t.HasComment("租户配额使用情况快照。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantReviewClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimedAt") + .HasColumnType("timestamp with time zone") + .HasComment("领取时间(UTC)。"); + + b.Property("ClaimedBy") + .HasColumnType("bigint") + .HasComment("领取人用户 ID。"); + + b.Property("ClaimedByName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("领取人名称(展示用快照)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ReleasedAt") + .HasColumnType("timestamp with time zone") + .HasComment("释放时间(UTC),未释放时为 null。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("被领取的租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("ClaimedBy"); + + b.HasIndex("TenantId") + .IsUnique() + .HasFilter("\"ReleasedAt\" IS NULL AND \"DeletedAt\" IS NULL"); + + b.ToTable("tenant_review_claims", null, t => + { + t.HasComment("租户入驻审核领取记录(防止多管理员并发审核)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoRenew") + .HasColumnType("boolean") + .HasComment("是否开启自动续费。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone") + .HasComment("订阅生效时间(UTC)。"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone") + .HasComment("订阅到期时间(UTC)。"); + + b.Property("NextBillingDate") + .HasColumnType("timestamp with time zone") + .HasComment("下一个计费时间,配合自动续费使用。"); + + b.Property("Notes") + .HasColumnType("text") + .HasComment("运营备注信息。"); + + b.Property("ScheduledPackageId") + .HasColumnType("bigint") + .HasComment("若已排期升降配,对应的新套餐 ID。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("订阅当前状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TenantPackageId") + .HasColumnType("bigint") + .HasComment("当前订阅关联的套餐标识。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "TenantPackageId"); + + b.ToTable("tenant_subscriptions", null, t => + { + t.HasComment("租户套餐订阅记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantSubscriptionHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("numeric") + .HasComment("相关费用。"); + + b.Property("ChangeType") + .HasColumnType("integer") + .HasComment("变更类型。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasComment("币种。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone") + .HasComment("生效时间。"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone") + .HasComment("到期时间。"); + + b.Property("FromPackageId") + .HasColumnType("bigint") + .HasComment("原套餐 ID。"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("租户标识。"); + + b.Property("TenantSubscriptionId") + .HasColumnType("bigint") + .HasComment("对应的订阅 ID。"); + + b.Property("ToPackageId") + .HasColumnType("bigint") + .HasComment("新套餐 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "TenantSubscriptionId"); + + b.ToTable("tenant_subscription_histories", null, t => + { + t.HasComment("租户套餐订阅变更记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantVerificationProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdditionalDataJson") + .HasColumnType("text") + .HasComment("附加资料(JSON)。"); + + b.Property("BankAccountName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("开户名。"); + + b.Property("BankAccountNumber") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("银行账号。"); + + b.Property("BankName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("银行名称。"); + + b.Property("BusinessLicenseNumber") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("营业执照编号。"); + + b.Property("BusinessLicenseUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("营业执照文件地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("LegalPersonIdBackUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("法人身份证反面。"); + + b.Property("LegalPersonIdFrontUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("法人身份证正面。"); + + b.Property("LegalPersonIdNumber") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("法人身份证号。"); + + b.Property("LegalPersonName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("法人姓名。"); + + b.Property("ReviewRemarks") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("审核备注。"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone") + .HasComment("审核时间。"); + + b.Property("ReviewedBy") + .HasColumnType("bigint") + .HasComment("审核人 ID。"); + + b.Property("ReviewedByName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("审核人姓名。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("实名状态。"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone") + .HasComment("提交时间。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("对应的租户标识。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("tenant_verification_profiles", null, t => + { + t.HasComment("租户实名认证资料。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.OrderItem", b => + { + b.HasOne("TakeoutSaaS.Domain.Orders.Entities.Order", null) + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251217160046_UpdateTenantBillingSchema.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251217160046_UpdateTenantBillingSchema.cs new file mode 100644 index 0000000..6a882f2 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251217160046_UpdateTenantBillingSchema.cs @@ -0,0 +1,237 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations +{ + /// + public partial class UpdateTenantBillingSchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RefundReason", + table: "tenant_payments", + type: "character varying(512)", + maxLength: 512, + nullable: true, + comment: "退款原因。"); + + migrationBuilder.AddColumn( + name: "RefundedAt", + table: "tenant_payments", + type: "timestamp with time zone", + nullable: true, + comment: "退款时间。"); + + migrationBuilder.AddColumn( + name: "VerifiedAt", + table: "tenant_payments", + type: "timestamp with time zone", + nullable: true, + comment: "审核时间。"); + + migrationBuilder.AddColumn( + name: "VerifiedBy", + table: "tenant_payments", + type: "bigint", + nullable: true, + comment: "审核人 ID(管理员)。"); + + migrationBuilder.AlterColumn( + name: "AmountDue", + table: "tenant_billing_statements", + type: "numeric(18,2)", + precision: 18, + scale: 2, + nullable: false, + comment: "应付金额(原始金额)。", + oldClrType: typeof(decimal), + oldType: "numeric(18,2)", + oldPrecision: 18, + oldScale: 2, + oldComment: "应付金额。"); + + migrationBuilder.AddColumn( + name: "BillingType", + table: "tenant_billing_statements", + type: "integer", + nullable: false, + defaultValue: 0, + comment: "账单类型(订阅账单/配额包账单/手动账单/续费账单)。"); + + migrationBuilder.AddColumn( + name: "Currency", + table: "tenant_billing_statements", + type: "character varying(8)", + maxLength: 8, + nullable: false, + defaultValue: "CNY", + comment: "货币类型(默认 CNY)。"); + + migrationBuilder.AddColumn( + name: "DiscountAmount", + table: "tenant_billing_statements", + type: "numeric(18,2)", + precision: 18, + scale: 2, + nullable: false, + defaultValue: 0m, + comment: "折扣金额。"); + + migrationBuilder.AddColumn( + name: "Notes", + table: "tenant_billing_statements", + type: "character varying(512)", + maxLength: 512, + nullable: true, + comment: "备注信息(如:人工备注、取消原因等)。"); + + migrationBuilder.AddColumn( + name: "OverdueNotifiedAt", + table: "tenant_billing_statements", + type: "timestamp with time zone", + nullable: true, + comment: "逾期通知时间。"); + + migrationBuilder.AddColumn( + name: "ReminderSentAt", + table: "tenant_billing_statements", + type: "timestamp with time zone", + nullable: true, + comment: "提醒发送时间(续费提醒、逾期提醒等)。"); + + migrationBuilder.AddColumn( + name: "SubscriptionId", + table: "tenant_billing_statements", + type: "bigint", + nullable: true, + comment: "关联的订阅 ID(仅当 BillingType 为 Subscription 或 Renewal 时有值)。"); + + migrationBuilder.AddColumn( + name: "TaxAmount", + table: "tenant_billing_statements", + type: "numeric(18,2)", + precision: 18, + scale: 2, + nullable: false, + defaultValue: 0m, + comment: "税费金额。"); + + migrationBuilder.CreateIndex( + name: "idx_payment_billing_paidat", + table: "tenant_payments", + columns: new[] { "BillingStatementId", "PaidAt" }); + + migrationBuilder.CreateIndex( + name: "idx_payment_transaction_no", + table: "tenant_payments", + column: "TransactionNo", + filter: "\"TransactionNo\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "idx_billing_created_at", + table: "tenant_billing_statements", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "idx_billing_status_duedate", + table: "tenant_billing_statements", + columns: new[] { "Status", "DueDate" }, + filter: "\"Status\" IN (0, 2)"); + + migrationBuilder.CreateIndex( + name: "idx_billing_tenant_status_duedate", + table: "tenant_billing_statements", + columns: new[] { "TenantId", "Status", "DueDate" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "idx_payment_billing_paidat", + table: "tenant_payments"); + + migrationBuilder.DropIndex( + name: "idx_payment_transaction_no", + table: "tenant_payments"); + + migrationBuilder.DropIndex( + name: "idx_billing_created_at", + table: "tenant_billing_statements"); + + migrationBuilder.DropIndex( + name: "idx_billing_status_duedate", + table: "tenant_billing_statements"); + + migrationBuilder.DropIndex( + name: "idx_billing_tenant_status_duedate", + table: "tenant_billing_statements"); + + migrationBuilder.DropColumn( + name: "RefundReason", + table: "tenant_payments"); + + migrationBuilder.DropColumn( + name: "RefundedAt", + table: "tenant_payments"); + + migrationBuilder.DropColumn( + name: "VerifiedAt", + table: "tenant_payments"); + + migrationBuilder.DropColumn( + name: "VerifiedBy", + table: "tenant_payments"); + + migrationBuilder.DropColumn( + name: "BillingType", + table: "tenant_billing_statements"); + + migrationBuilder.DropColumn( + name: "Currency", + table: "tenant_billing_statements"); + + migrationBuilder.DropColumn( + name: "DiscountAmount", + table: "tenant_billing_statements"); + + migrationBuilder.DropColumn( + name: "Notes", + table: "tenant_billing_statements"); + + migrationBuilder.DropColumn( + name: "OverdueNotifiedAt", + table: "tenant_billing_statements"); + + migrationBuilder.DropColumn( + name: "ReminderSentAt", + table: "tenant_billing_statements"); + + migrationBuilder.DropColumn( + name: "SubscriptionId", + table: "tenant_billing_statements"); + + migrationBuilder.DropColumn( + name: "TaxAmount", + table: "tenant_billing_statements"); + + migrationBuilder.AlterColumn( + name: "AmountDue", + table: "tenant_billing_statements", + type: "numeric(18,2)", + precision: 18, + scale: 2, + nullable: false, + comment: "应付金额。", + oldClrType: typeof(decimal), + oldType: "numeric(18,2)", + oldPrecision: 18, + oldScale: 2, + oldComment: "应付金额(原始金额)。"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs index 441723a..6ea1c05 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs @@ -6221,13 +6221,17 @@ namespace TakeoutSaaS.Infrastructure.Migrations b.Property("AmountDue") .HasPrecision(18, 2) .HasColumnType("numeric(18,2)") - .HasComment("应付金额。"); + .HasComment("应付金额(原始金额)。"); b.Property("AmountPaid") .HasPrecision(18, 2) .HasColumnType("numeric(18,2)") .HasComment("实付金额。"); + b.Property("BillingType") + .HasColumnType("integer") + .HasComment("账单类型(订阅账单/配额包账单/手动账单/续费账单)。"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone") .HasComment("创建时间(UTC)。"); @@ -6236,6 +6240,14 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("bigint") .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasDefaultValue("CNY") + .HasComment("货币类型(默认 CNY)。"); + b.Property("DeletedAt") .HasColumnType("timestamp with time zone") .HasComment("软删除时间(UTC),未删除时为 null。"); @@ -6244,6 +6256,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("bigint") .HasComment("删除人用户标识(软删除),未删除时为 null。"); + b.Property("DiscountAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("折扣金额。"); + b.Property("DueDate") .HasColumnType("timestamp with time zone") .HasComment("到期日。"); @@ -6252,6 +6269,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("text") .HasComment("账单明细 JSON,记录各项费用。"); + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注信息(如:人工备注、取消原因等)。"); + + b.Property("OverdueNotifiedAt") + .HasColumnType("timestamp with time zone") + .HasComment("逾期通知时间。"); + b.Property("PeriodEnd") .HasColumnType("timestamp with time zone") .HasComment("账单周期结束时间。"); @@ -6260,6 +6286,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("timestamp with time zone") .HasComment("账单周期开始时间。"); + b.Property("ReminderSentAt") + .HasColumnType("timestamp with time zone") + .HasComment("提醒发送时间(续费提醒、逾期提醒等)。"); + b.Property("StatementNo") .IsRequired() .HasMaxLength(64) @@ -6270,6 +6300,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("integer") .HasComment("当前付款状态。"); + b.Property("SubscriptionId") + .HasColumnType("bigint") + .HasComment("关联的订阅 ID(仅当 BillingType 为 Subscription 或 Renewal 时有值)。"); + + b.Property("TaxAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("税费金额。"); + b.Property("TenantId") .HasColumnType("bigint") .HasComment("所属租户 ID。"); @@ -6284,9 +6323,19 @@ namespace TakeoutSaaS.Infrastructure.Migrations b.HasKey("Id"); + b.HasIndex("CreatedAt") + .HasDatabaseName("idx_billing_created_at"); + + b.HasIndex("Status", "DueDate") + .HasDatabaseName("idx_billing_status_duedate") + .HasFilter("\"Status\" IN (0, 2)"); + b.HasIndex("TenantId", "StatementNo") .IsUnique(); + b.HasIndex("TenantId", "Status", "DueDate") + .HasDatabaseName("idx_billing_tenant_status_duedate"); + b.ToTable("tenant_billing_statements", null, t => { t.HasComment("租户账单,用于呈现周期性收费。"); @@ -6555,6 +6604,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("character varying(512)") .HasComment("支付凭证 URL。"); + b.Property("RefundReason") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("退款原因。"); + + b.Property("RefundedAt") + .HasColumnType("timestamp with time zone") + .HasComment("退款时间。"); + b.Property("Status") .HasColumnType("integer") .HasComment("支付状态。"); @@ -6576,8 +6634,23 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("bigint") .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasComment("审核时间。"); + + b.Property("VerifiedBy") + .HasColumnType("bigint") + .HasComment("审核人 ID(管理员)。"); + b.HasKey("Id"); + b.HasIndex("TransactionNo") + .HasDatabaseName("idx_payment_transaction_no") + .HasFilter("\"TransactionNo\" IS NOT NULL"); + + b.HasIndex("BillingStatementId", "PaidAt") + .HasDatabaseName("idx_payment_billing_paidat"); + b.HasIndex("TenantId", "BillingStatementId"); b.ToTable("tenant_payments", null, t => diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj index ba314f274b97f023164f7b0013a7d88685d70d0e..06ca75d906ea812ad41ade7ca5aed0e6d11ff50b 100644 GIT binary patch delta 143 zcmZpZX_Mbz!zAd;ki(GAP|T3Zkirnb;LG4M*^pVB*MLEf!H~g#!3ac8X5^8ayn)Gx z+Zm{?jKKq_HfM4yn=H36P=y{C!_?$V?%-&c9KfWuS%$feg+Gv?6sWkEp@boT!G*yM XY_t*BSYxmSMw9)S{Ws5I&tU@qBS{@E delta 29 lcmZpZZ