diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreAuditsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreAuditsController.cs new file mode 100644 index 0000000..ab376b0 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreAuditsController.cs @@ -0,0 +1,222 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.StoreAudits.Commands; +using TakeoutSaaS.Application.App.StoreAudits.Dto; +using TakeoutSaaS.Application.App.StoreAudits.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 门店审核与风控管理(平台)。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/platform/store-audits")] +[Route("api/admin/v{version:apiVersion}/platform/store-audits")] +public sealed class StoreAuditsController(IMediator mediator, ITenantContextAccessor tenantContextAccessor) : BaseApiController +{ + /// + /// 查询待审核门店列表。 + /// + /// 待审核门店分页列表。 + [HttpGet("pending")] + [PermissionAuthorize("store-audit:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListPending( + [FromQuery] ListPendingStoreAuditsQuery query, + CancellationToken cancellationToken) + { + // 1. 查询待审核门店列表 + var result = await ExecuteAsPlatformAsync(() => mediator.Send(query, cancellationToken)); + + // 2. 返回分页结果 + return ApiResponse>.Ok(result); + } + + /// + /// 获取门店审核详情。 + /// + /// 门店 ID。 + /// 取消标记。 + /// 审核详情。 + [HttpGet("{storeId:long}")] + [PermissionAuthorize("store-audit:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> GetDetail(long storeId, CancellationToken cancellationToken) + { + // 1. 获取审核详情 + var result = await ExecuteAsPlatformAsync(() => + mediator.Send(new GetStoreAuditDetailQuery { StoreId = storeId }, cancellationToken)); + + // 2. 返回详情或未找到 + return result is null + ? ApiResponse.Error(ErrorCodes.NotFound, "门店不存在") + : ApiResponse.Ok(result); + } + + /// + /// 审核通过。 + /// + /// 门店 ID。 + /// 审核命令。 + /// 取消标记。 + /// 操作结果。 + [HttpPost("{storeId:long}/approve")] + [PermissionAuthorize("store-audit:approve")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Approve( + long storeId, + [FromBody, Required] ApproveStoreCommand command, + CancellationToken cancellationToken) + { + // 1. 执行审核通过 + var request = command with { StoreId = storeId }; + var result = await ExecuteAsPlatformAsync(() => mediator.Send(request, cancellationToken)); + + // 2. 返回结果 + return ApiResponse.Ok(result); + } + + /// + /// 审核驳回。 + /// + /// 门店 ID。 + /// 驳回命令。 + /// 取消标记。 + /// 操作结果。 + [HttpPost("{storeId:long}/reject")] + [PermissionAuthorize("store-audit:reject")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Reject( + long storeId, + [FromBody, Required] RejectStoreCommand command, + CancellationToken cancellationToken) + { + // 1. 执行审核驳回 + var request = command with { StoreId = storeId }; + var result = await ExecuteAsPlatformAsync(() => mediator.Send(request, cancellationToken)); + + // 2. 返回结果 + return ApiResponse.Ok(result); + } + + /// + /// 查询审核记录。 + /// + /// 门店 ID。 + /// 页码。 + /// 每页数量。 + /// 取消标记。 + /// 审核记录分页列表。 + [HttpGet("{storeId:long}/records")] + [PermissionAuthorize("store-audit:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListRecords( + long storeId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + // 1. 执行记录查询 + var query = new ListStoreAuditRecordsQuery + { + StoreId = storeId, + Page = page, + PageSize = pageSize + }; + var result = await ExecuteAsPlatformAsync(() => mediator.Send(query, cancellationToken)); + + // 2. 返回分页结果 + return ApiResponse>.Ok(result); + } + + /// + /// 获取审核统计数据。 + /// + /// 查询参数。 + /// 取消标记。 + /// 统计数据。 + [HttpGet("statistics")] + [PermissionAuthorize("store-audit:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> GetStatistics( + [FromQuery] GetStoreAuditStatisticsQuery query, + CancellationToken cancellationToken) + { + // 1. 执行统计查询 + var result = await ExecuteAsPlatformAsync(() => mediator.Send(query, cancellationToken)); + + // 2. 返回统计结果 + return ApiResponse.Ok(result); + } + + /// + /// 强制关闭门店。 + /// + /// 门店 ID。 + /// 关闭命令。 + /// 取消标记。 + /// 操作结果。 + [HttpPost("{storeId:long}/force-close")] + [PermissionAuthorize("store-audit:force-close")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ForceClose( + long storeId, + [FromBody, Required] ForceCloseStoreCommand command, + CancellationToken cancellationToken) + { + // 1. 执行强制关闭 + var request = command with { StoreId = storeId }; + var result = await ExecuteAsPlatformAsync(() => mediator.Send(request, cancellationToken)); + + // 2. 返回结果 + return ApiResponse.Ok(result); + } + + /// + /// 解除强制关闭。 + /// + /// 门店 ID。 + /// 解除命令。 + /// 取消标记。 + /// 操作结果。 + [HttpPost("{storeId:long}/reopen")] + [PermissionAuthorize("store-audit:force-close")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Reopen( + long storeId, + [FromBody, Required] ReopenStoreCommand command, + CancellationToken cancellationToken) + { + // 1. 执行解除强制关闭 + var request = command with { StoreId = storeId }; + var result = await ExecuteAsPlatformAsync(() => mediator.Send(request, cancellationToken)); + + // 2. 返回结果 + return ApiResponse.Ok(result); + } + + private async Task ExecuteAsPlatformAsync(Func> action) + { + var original = tenantContextAccessor.Current; + tenantContextAccessor.Current = new TenantContext(0, null, "platform"); + + // 1. (空行后) 切换到平台上下文执行 + try + { + return await action(); + } + finally + { + tenantContextAccessor.Current = original; + } + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreQualificationsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreQualificationsController.cs new file mode 100644 index 0000000..ce56b25 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreQualificationsController.cs @@ -0,0 +1,60 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 门店资质预警(平台)。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/platform/store-qualifications")] +[Route("api/admin/v{version:apiVersion}/platform/store-qualifications")] +public sealed class StoreQualificationsController( + IMediator mediator, + ITenantContextAccessor tenantContextAccessor) + : BaseApiController +{ + /// + /// 查询资质即将过期/已过期列表。 + /// + /// 查询参数。 + /// 取消标记。 + /// 资质预警分页结果。 + [HttpGet("expiring")] + [PermissionAuthorize("store-qualification:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ListExpiring( + [FromQuery] ListExpiringStoreQualificationsQuery query, + CancellationToken cancellationToken) + { + // 1. 查询资质预警 + var result = await ExecuteAsPlatformAsync(() => mediator.Send(query, cancellationToken)); + + // 2. (空行后) 返回结果 + return ApiResponse.Ok(result); + } + + private async Task ExecuteAsPlatformAsync(Func> action) + { + var original = tenantContextAccessor.Current; + tenantContextAccessor.Current = new TenantContext(0, null, "platform"); + + // 1. (空行后) 切换到平台上下文执行 + try + { + return await action(); + } + finally + { + tenantContextAccessor.Current = original; + } + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs index 7d51d20..5bb3b1d 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs @@ -47,6 +47,10 @@ public sealed class StoresController(IMediator mediator) : BaseApiController public async Task>> List( [FromQuery] long? merchantId, [FromQuery] StoreStatus? status, + [FromQuery] StoreAuditStatus? auditStatus, + [FromQuery] StoreBusinessStatus? businessStatus, + [FromQuery] StoreOwnershipType? ownershipType, + [FromQuery] string? keyword, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? sortBy = null, @@ -58,6 +62,10 @@ public sealed class StoresController(IMediator mediator) : BaseApiController { MerchantId = merchantId, Status = status, + AuditStatus = auditStatus, + BusinessStatus = businessStatus, + OwnershipType = ownershipType, + Keyword = keyword, Page = page, PageSize = pageSize, SortBy = sortBy, @@ -131,6 +139,170 @@ public sealed class StoresController(IMediator mediator) : BaseApiController : ApiResponse.Error(ErrorCodes.NotFound, "门店不存在"); } + /// + /// 提交门店审核。 + /// + [HttpPost("{storeId:long}/submit")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> SubmitAudit(long storeId, [FromBody] SubmitStoreAuditCommand command, CancellationToken cancellationToken) + { + // 1. 绑定门店 ID + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + // 2. (空行后) 执行提交 + var result = await mediator.Send(command, cancellationToken); + + // 3. (空行后) 返回结果 + return ApiResponse.Ok(result); + } + + /// + /// 切换门店经营状态。 + /// + [HttpPost("{storeId:long}/business-status")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ToggleBusinessStatus(long storeId, [FromBody] ToggleBusinessStatusCommand command, CancellationToken cancellationToken) + { + // 1. 绑定门店 ID + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + // 2. (空行后) 执行切换 + var result = await mediator.Send(command, cancellationToken); + + // 3. (空行后) 返回结果 + return ApiResponse.Ok(result); + } + + /// + /// 查询门店资质列表。 + /// + [HttpGet("{storeId:long}/qualifications")] + [PermissionAuthorize("store:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListQualifications(long storeId, CancellationToken cancellationToken) + { + // 1. 查询资质列表 + var result = await mediator.Send(new ListStoreQualificationsQuery { StoreId = storeId }, cancellationToken); + + // 2. 返回结果 + return ApiResponse>.Ok(result); + } + + /// + /// 检查门店资质完整性。 + /// + [HttpGet("{storeId:long}/qualifications/check")] + [PermissionAuthorize("store:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CheckQualifications(long storeId, CancellationToken cancellationToken) + { + // 1. 执行检查 + var result = await mediator.Send(new CheckStoreQualificationsQuery { StoreId = storeId }, cancellationToken); + + // 2. 返回检查结果 + return ApiResponse.Ok(result); + } + + /// + /// 新增门店资质。 + /// + [HttpPost("{storeId:long}/qualifications")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateQualification(long storeId, [FromBody] CreateStoreQualificationCommand command, CancellationToken cancellationToken) + { + // 1. 绑定门店 ID + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + // 2. (空行后) 执行创建 + var result = await mediator.Send(command, cancellationToken); + + // 3. (空行后) 返回结果 + return ApiResponse.Ok(result); + } + + /// + /// 更新门店资质。 + /// + [HttpPut("{storeId:long}/qualifications/{qualificationId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateQualification( + long storeId, + long qualificationId, + [FromBody] UpdateStoreQualificationCommand command, + CancellationToken cancellationToken) + { + // 1. 绑定资质 ID + if (command.StoreId == 0 || command.QualificationId == 0) + { + command = command with { StoreId = storeId, QualificationId = qualificationId }; + } + + // 2. (空行后) 执行更新 + var result = await mediator.Send(command, cancellationToken); + + // 3. (空行后) 返回结果或 404 + return result is null + ? ApiResponse.Error(ErrorCodes.NotFound, "资质不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除门店资质。 + /// + [HttpDelete("{storeId:long}/qualifications/{qualificationId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> DeleteQualification(long storeId, long qualificationId, CancellationToken cancellationToken) + { + // 1. 执行删除 + var result = await mediator.Send(new DeleteStoreQualificationCommand + { + StoreId = storeId, + QualificationId = qualificationId + }, cancellationToken); + + // 2. 返回结果 + return ApiResponse.Ok(result); + } + + /// + /// 批量更新营业时段。 + /// + [HttpPut("{storeId:long}/business-hours/batch")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> BatchUpdateBusinessHours( + long storeId, + [FromBody] BatchUpdateBusinessHoursCommand command, + CancellationToken cancellationToken) + { + // 1. 绑定门店 ID + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + // 2. (空行后) 执行批量更新 + var result = await mediator.Send(command, cancellationToken); + + // 3. (空行后) 返回结果 + return ApiResponse>.Ok(result); + } + /// /// 查询门店营业时段。 /// @@ -259,6 +431,90 @@ public sealed class StoresController(IMediator mediator) : BaseApiController : ApiResponse.Error(ErrorCodes.NotFound, "配送区域不存在"); } + /// + /// 配送范围检测。 + /// + [HttpPost("{storeId:long}/delivery-check")] + [PermissionAuthorize("store:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CheckDeliveryZone( + long storeId, + [FromBody] CheckStoreDeliveryZoneQuery query, + CancellationToken cancellationToken) + { + // 1. 绑定门店 ID + if (query.StoreId == 0) + { + query = query with { StoreId = storeId }; + } + + // 2. (空行后) 执行检测 + var result = await mediator.Send(query, cancellationToken); + + // 3. (空行后) 返回结果 + return ApiResponse.Ok(result); + } + + /// + /// 获取门店费用配置。 + /// + [HttpGet("{storeId:long}/fee")] + [PermissionAuthorize("store:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> GetFee(long storeId, CancellationToken cancellationToken) + { + // 1. 查询费用配置 + var result = await mediator.Send(new GetStoreFeeQuery { StoreId = storeId }, cancellationToken); + + // 2. 返回结果 + return ApiResponse.Ok(result ?? new StoreFeeDto()); + } + + /// + /// 更新门店费用配置。 + /// + [HttpPut("{storeId:long}/fee")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> UpdateFee(long storeId, [FromBody] UpdateStoreFeeCommand command, CancellationToken cancellationToken) + { + // 1. 绑定门店 ID + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + // 2. (空行后) 执行更新 + var result = await mediator.Send(command, cancellationToken); + + // 3. (空行后) 返回结果 + return ApiResponse.Ok(result); + } + + /// + /// 门店费用预览。 + /// + [HttpPost("{storeId:long}/fee/calculate")] + [PermissionAuthorize("store:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CalculateFee( + long storeId, + [FromBody] CalculateStoreFeeQuery query, + CancellationToken cancellationToken) + { + // 1. 绑定门店 ID + if (query.StoreId == 0) + { + query = query with { StoreId = storeId }; + } + + // 2. (空行后) 执行计算 + var result = await mediator.Send(query, cancellationToken); + + // 3. (空行后) 返回结果 + return ApiResponse.Ok(result); + } + /// /// 查询门店节假日。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Commands/ApproveStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Commands/ApproveStoreCommand.cs new file mode 100644 index 0000000..882d9e0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Commands/ApproveStoreCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.StoreAudits.Dto; + +namespace TakeoutSaaS.Application.App.StoreAudits.Commands; + +/// +/// 审核通过命令。 +/// +public sealed record ApproveStoreCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 审核备注。 + /// + public string? Remark { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Commands/ForceCloseStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Commands/ForceCloseStoreCommand.cs new file mode 100644 index 0000000..14cf24a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Commands/ForceCloseStoreCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.StoreAudits.Dto; + +namespace TakeoutSaaS.Application.App.StoreAudits.Commands; + +/// +/// 强制关闭门店命令。 +/// +public sealed record ForceCloseStoreCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 关闭原因。 + /// + public string Reason { get; init; } = string.Empty; + + /// + /// 备注。 + /// + public string? Remark { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Commands/RejectStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Commands/RejectStoreCommand.cs new file mode 100644 index 0000000..1f0f238 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Commands/RejectStoreCommand.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.StoreAudits.Dto; + +namespace TakeoutSaaS.Application.App.StoreAudits.Commands; + +/// +/// 审核驳回命令。 +/// +public sealed record RejectStoreCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 驳回原因 ID。 + /// + public long RejectionReasonId { get; init; } + + /// + /// 驳回原因补充说明。 + /// + public string? RejectionReasonText { get; init; } + + /// + /// 审核备注。 + /// + public string? Remark { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Commands/ReopenStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Commands/ReopenStoreCommand.cs new file mode 100644 index 0000000..74755ff --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Commands/ReopenStoreCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.StoreAudits.Dto; + +namespace TakeoutSaaS.Application.App.StoreAudits.Commands; + +/// +/// 解除强制关闭命令。 +/// +public sealed record ReopenStoreCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 备注。 + /// + public string? Remark { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/PendingStoreAuditDto.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/PendingStoreAuditDto.cs new file mode 100644 index 0000000..3087fd5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/PendingStoreAuditDto.cs @@ -0,0 +1,84 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.StoreAudits.Dto; + +/// +/// 待审核门店 DTO。 +/// +public sealed record PendingStoreAuditDto +{ + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 门店名称。 + /// + public string StoreName { get; init; } = string.Empty; + + /// + /// 门店编码。 + /// + public string StoreCode { get; init; } = string.Empty; + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户名称。 + /// + public string TenantName { get; init; } = string.Empty; + + /// + /// 商户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long MerchantId { get; init; } + + /// + /// 商户名称。 + /// + public string MerchantName { get; init; } = string.Empty; + + /// + /// 门头招牌图。 + /// + public string? SignboardImageUrl { get; init; } + + /// + /// 完整地址。 + /// + public string FullAddress { get; init; } = string.Empty; + + /// + /// 主体类型。 + /// + public StoreOwnershipType OwnershipType { get; init; } + + /// + /// 提交时间。 + /// + public DateTime? SubmittedAt { get; init; } + + /// + /// 等待天数。 + /// + public int WaitingDays { get; init; } + + /// + /// 是否超时。 + /// + public bool IsOverdue { get; init; } + + /// + /// 资质数量。 + /// + public int QualificationCount { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditActionResultDto.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditActionResultDto.cs new file mode 100644 index 0000000..d99b10b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditActionResultDto.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.StoreAudits.Dto; + +/// +/// 审核/风控操作结果 DTO。 +/// +public sealed record StoreAuditActionResultDto +{ + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 审核状态。 + /// + public StoreAuditStatus AuditStatus { get; init; } + + /// + /// 经营状态。 + /// + public StoreBusinessStatus BusinessStatus { get; init; } + + /// + /// 驳回原因。 + /// + public string? RejectionReason { get; init; } + + /// + /// 提示信息。 + /// + public string? Message { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditDailyTrendDto.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditDailyTrendDto.cs new file mode 100644 index 0000000..a4d84f3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditDailyTrendDto.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Application.App.StoreAudits.Dto; + +/// +/// 审核统计趋势项。 +/// +public sealed record StoreAuditDailyTrendDto +{ + /// + /// 日期。 + /// + public DateOnly Date { get; init; } + + /// + /// 提交数量。 + /// + public int Submitted { get; init; } + + /// + /// 通过数量。 + /// + public int Approved { get; init; } + + /// + /// 驳回数量。 + /// + public int Rejected { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditDetailDto.cs new file mode 100644 index 0000000..3530167 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditDetailDto.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.StoreAudits.Dto; + +/// +/// 门店审核详情 DTO。 +/// +public sealed record StoreAuditDetailDto +{ + /// + /// 门店信息。 + /// + public StoreAuditStoreDto Store { get; init; } = new(); + + /// + /// 租户信息。 + /// + public StoreAuditTenantDto Tenant { get; init; } = new(); + + /// + /// 商户信息。 + /// + public StoreAuditMerchantDto Merchant { get; init; } = new(); + + /// + /// 资质列表。 + /// + public IReadOnlyList Qualifications { get; init; } = []; + + /// + /// 审核记录。 + /// + public IReadOnlyList AuditHistory { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditMerchantDto.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditMerchantDto.cs new file mode 100644 index 0000000..5dcbc6b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditMerchantDto.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.StoreAudits.Dto; + +/// +/// 门店审核详情 - 商户信息。 +/// +public sealed record StoreAuditMerchantDto +{ + /// + /// 商户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商户名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 法人或主体名称。 + /// + public string? LegalName { get; init; } + + /// + /// 统一社会信用代码。 + /// + public string? CreditCode { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditRecordDto.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditRecordDto.cs new file mode 100644 index 0000000..15b8993 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditRecordDto.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.StoreAudits.Dto; + +/// +/// 门店审核记录 DTO。 +/// +public sealed record StoreAuditRecordDto +{ + /// + /// 记录 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 审核动作。 + /// + public StoreAuditAction Action { get; init; } + + /// + /// 动作名称。 + /// + public string ActionName { get; init; } = string.Empty; + + /// + /// 操作人 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long? OperatorId { get; init; } + + /// + /// 操作人名称。 + /// + public string OperatorName { get; init; } = string.Empty; + + /// + /// 操作前状态。 + /// + public StoreAuditStatus? PreviousStatus { get; init; } + + /// + /// 操作后状态。 + /// + public StoreAuditStatus NewStatus { get; init; } + + /// + /// 驳回理由 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long? RejectionReasonId { get; init; } + + /// + /// 驳回理由。 + /// + public string? RejectionReasonText { get; init; } + + /// + /// 备注。 + /// + public string? Remark { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditStatisticsDto.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditStatisticsDto.cs new file mode 100644 index 0000000..495b10f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditStatisticsDto.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Application.App.StoreAudits.Dto; + +/// +/// 审核统计 DTO。 +/// +public sealed record StoreAuditStatisticsDto +{ + /// + /// 待审核数量。 + /// + public int PendingCount { get; init; } + + /// + /// 超时数量。 + /// + public int OverdueCount { get; init; } + + /// + /// 审核通过数量。 + /// + public int ApprovedCount { get; init; } + + /// + /// 审核驳回数量。 + /// + public int RejectedCount { get; init; } + + /// + /// 平均处理时长(小时)。 + /// + public double AvgProcessingHours { get; init; } + + /// + /// 每日趋势。 + /// + public IReadOnlyList DailyTrend { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditStoreDto.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditStoreDto.cs new file mode 100644 index 0000000..110638e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditStoreDto.cs @@ -0,0 +1,82 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.StoreAudits.Dto; + +/// +/// 门店审核详情 - 门店信息。 +/// +public sealed record StoreAuditStoreDto +{ + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 门店名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 门店编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 联系电话。 + /// + public string? Phone { get; init; } + + /// + /// 门头招牌图。 + /// + public string? SignboardImageUrl { get; init; } + + /// + /// 省份。 + /// + public string? Province { get; init; } + + /// + /// 城市。 + /// + public string? City { get; init; } + + /// + /// 区县。 + /// + public string? District { get; init; } + + /// + /// 详细地址。 + /// + public string? Address { get; init; } + + /// + /// 经度。 + /// + public double? Longitude { get; init; } + + /// + /// 纬度。 + /// + public double? Latitude { get; init; } + + /// + /// 主体类型。 + /// + public StoreOwnershipType OwnershipType { get; init; } + + /// + /// 审核状态。 + /// + public StoreAuditStatus AuditStatus { get; init; } + + /// + /// 提交时间。 + /// + public DateTime? SubmittedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditTenantDto.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditTenantDto.cs new file mode 100644 index 0000000..50d3cfd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Dto/StoreAuditTenantDto.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.StoreAudits.Dto; + +/// +/// 门店审核详情 - 租户信息。 +/// +public sealed record StoreAuditTenantDto +{ + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 联系人。 + /// + public string? ContactName { get; init; } + + /// + /// 联系电话。 + /// + public string? ContactPhone { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/ApproveStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/ApproveStoreCommandHandler.cs new file mode 100644 index 0000000..a08d565 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/ApproveStoreCommandHandler.cs @@ -0,0 +1,158 @@ +using System.Data; +using System.Data.Common; +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.StoreAudits.Commands; +using TakeoutSaaS.Application.App.StoreAudits.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.StoreAudits.Handlers; + +/// +/// 审核通过处理器。 +/// +public sealed class ApproveStoreCommandHandler( + IStoreRepository storeRepository, + IDapperExecutor dapperExecutor, + ICurrentUserAccessor currentUserAccessor, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(ApproveStoreCommand request, CancellationToken cancellationToken) + { + // 1. 获取门店快照 + var snapshot = await QueryStoreSnapshotAsync(request.StoreId, cancellationToken); + if (!snapshot.HasValue) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 1.1 (空行后) 校验审核状态 + if (snapshot.Value.AuditStatus != StoreAuditStatus.Pending) + { + throw new BusinessException(ErrorCodes.Conflict, "门店不处于待审核状态"); + } + + // 2. (空行后) 获取门店实体 + var store = await storeRepository.FindByIdAsync(request.StoreId, snapshot.Value.TenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 3. (空行后) 更新状态并记录审核 + var previousStatus = store.AuditStatus; + var now = DateTime.UtcNow; + store.AuditStatus = StoreAuditStatus.Activated; + store.BusinessStatus = StoreBusinessStatus.Resting; + store.ActivatedAt ??= now; + store.RejectionReason = null; + store.ClosureReason = null; + store.ClosureReasonText = null; + + await storeRepository.UpdateStoreAsync(store, cancellationToken); + await storeRepository.AddAuditRecordAsync(new StoreAuditRecord + { + StoreId = store.Id, + Action = StoreAuditAction.Approve, + PreviousStatus = previousStatus, + NewStatus = store.AuditStatus, + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName(), + Remarks = request.Remark + }, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("门店 {StoreId} 审核通过", store.Id); + + // 4. (空行后) 返回结果 + return new StoreAuditActionResultDto + { + StoreId = store.Id, + AuditStatus = store.AuditStatus, + BusinessStatus = store.BusinessStatus, + Message = "门店已激活" + }; + } + + private async Task<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?> QueryStoreSnapshotAsync( + long storeId, + CancellationToken cancellationToken) + { + // 1. 查询门店基础字段 + return await dapperExecutor.QueryAsync<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?>( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + await using var command = CreateCommand( + connection, + BuildStoreSnapshotSql(), + [ + ("storeId", storeId) + ]); + + // 1.1 (空行后) 执行查询 + await using var reader = await command.ExecuteReaderAsync(token); + if (!await reader.ReadAsync(token)) + { + return null; + } + + // 1.2 (空行后) 返回快照 + return ( + reader.GetInt64(reader.GetOrdinal("TenantId")), + (StoreAuditStatus)reader.GetInt32(reader.GetOrdinal("AuditStatus")), + (StoreBusinessStatus)reader.GetInt32(reader.GetOrdinal("BusinessStatus"))); + }, + cancellationToken); + } + + private long? ResolveOperatorId() + { + var id = currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } + + private static string BuildStoreSnapshotSql() + { + return """ + select + s."TenantId", + s."AuditStatus", + s."BusinessStatus" + from public.stores s + where s."DeletedAt" is null + and s."Id" = @storeId; + """; + } + + private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + + // 1. (空行后) 绑定参数 + 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/StoreAudits/Handlers/ForceCloseStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/ForceCloseStoreCommandHandler.cs new file mode 100644 index 0000000..8adbdac --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/ForceCloseStoreCommandHandler.cs @@ -0,0 +1,161 @@ +using System.Data; +using System.Data.Common; +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.StoreAudits.Commands; +using TakeoutSaaS.Application.App.StoreAudits.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.StoreAudits.Handlers; + +/// +/// 强制关闭门店处理器。 +/// +public sealed class ForceCloseStoreCommandHandler( + IStoreRepository storeRepository, + IDapperExecutor dapperExecutor, + ICurrentUserAccessor currentUserAccessor, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(ForceCloseStoreCommand request, CancellationToken cancellationToken) + { + // 1. 获取门店快照 + var snapshot = await QueryStoreSnapshotAsync(request.StoreId, cancellationToken); + if (!snapshot.HasValue) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 1.1 (空行后) 校验审核与经营状态 + if (snapshot.Value.AuditStatus != StoreAuditStatus.Activated) + { + throw new BusinessException(ErrorCodes.Conflict, "门店未激活,无法强制关闭"); + } + + if (snapshot.Value.BusinessStatus == StoreBusinessStatus.ForceClosed) + { + throw new BusinessException(ErrorCodes.Conflict, "门店已处于强制关闭状态"); + } + + // 2. (空行后) 获取门店实体 + var store = await storeRepository.FindByIdAsync(request.StoreId, snapshot.Value.TenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 3. (空行后) 更新状态并记录风控 + var now = DateTime.UtcNow; + store.BusinessStatus = StoreBusinessStatus.ForceClosed; + store.ClosureReason = StoreClosureReason.PlatformSuspended; + store.ClosureReasonText = request.Reason; + store.ForceCloseReason = request.Reason; + store.ForceClosedAt = now; + + await storeRepository.UpdateStoreAsync(store, cancellationToken); + await storeRepository.AddAuditRecordAsync(new StoreAuditRecord + { + StoreId = store.Id, + Action = StoreAuditAction.ForceClose, + PreviousStatus = store.AuditStatus, + NewStatus = store.AuditStatus, + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName(), + Remarks = request.Remark + }, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("门店 {StoreId} 强制关闭", store.Id); + + // 4. (空行后) 返回结果 + return new StoreAuditActionResultDto + { + StoreId = store.Id, + AuditStatus = store.AuditStatus, + BusinessStatus = store.BusinessStatus, + Message = "门店已强制关闭" + }; + } + + private async Task<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?> QueryStoreSnapshotAsync( + long storeId, + CancellationToken cancellationToken) + { + // 1. 查询门店基础字段 + return await dapperExecutor.QueryAsync<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?>( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + await using var command = CreateCommand( + connection, + BuildStoreSnapshotSql(), + [ + ("storeId", storeId) + ]); + + // 1.1 (空行后) 执行查询 + await using var reader = await command.ExecuteReaderAsync(token); + if (!await reader.ReadAsync(token)) + { + return null; + } + + // 1.2 (空行后) 返回快照 + return ( + reader.GetInt64(reader.GetOrdinal("TenantId")), + (StoreAuditStatus)reader.GetInt32(reader.GetOrdinal("AuditStatus")), + (StoreBusinessStatus)reader.GetInt32(reader.GetOrdinal("BusinessStatus"))); + }, + cancellationToken); + } + + private long? ResolveOperatorId() + { + var id = currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } + + private static string BuildStoreSnapshotSql() + { + return """ + select + s."TenantId", + s."AuditStatus", + s."BusinessStatus" + from public.stores s + where s."DeletedAt" is null + and s."Id" = @storeId; + """; + } + + private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + + // 1. (空行后) 绑定参数 + 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/StoreAudits/Handlers/GetStoreAuditDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/GetStoreAuditDetailQueryHandler.cs new file mode 100644 index 0000000..c0ca46d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/GetStoreAuditDetailQueryHandler.cs @@ -0,0 +1,322 @@ +using System.Data; +using System.Data.Common; +using MediatR; +using TakeoutSaaS.Application.App.StoreAudits; +using TakeoutSaaS.Application.App.StoreAudits.Dto; +using TakeoutSaaS.Application.App.StoreAudits.Queries; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; + +namespace TakeoutSaaS.Application.App.StoreAudits.Handlers; + +/// +/// 门店审核详情查询处理器。 +/// +public sealed class GetStoreAuditDetailQueryHandler( + IDapperExecutor dapperExecutor) + : IRequestHandler +{ + /// + public async Task Handle(GetStoreAuditDetailQuery request, CancellationToken cancellationToken) + { + // 1. 查询门店与主体信息 + return await dapperExecutor.QueryAsync( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + // 1.1 查询门店基础信息 + await using var storeCommand = CreateCommand( + connection, + BuildStoreSql(), + [ + ("storeId", request.StoreId) + ]); + + await using var reader = await storeCommand.ExecuteReaderAsync(token); + if (!await reader.ReadAsync(token)) + { + return null; + } + + // 1.2 (空行后) 映射门店信息 + var store = new StoreAuditStoreDto + { + Id = reader.GetInt64(reader.GetOrdinal("StoreId")), + Name = reader.GetString(reader.GetOrdinal("StoreName")), + Code = reader.GetString(reader.GetOrdinal("StoreCode")), + Phone = reader.IsDBNull(reader.GetOrdinal("Phone")) ? null : reader.GetString(reader.GetOrdinal("Phone")), + SignboardImageUrl = reader.IsDBNull(reader.GetOrdinal("SignboardImageUrl")) + ? null + : reader.GetString(reader.GetOrdinal("SignboardImageUrl")), + Province = reader.IsDBNull(reader.GetOrdinal("Province")) ? null : reader.GetString(reader.GetOrdinal("Province")), + City = reader.IsDBNull(reader.GetOrdinal("City")) ? null : reader.GetString(reader.GetOrdinal("City")), + District = reader.IsDBNull(reader.GetOrdinal("District")) ? null : reader.GetString(reader.GetOrdinal("District")), + Address = reader.IsDBNull(reader.GetOrdinal("Address")) ? null : reader.GetString(reader.GetOrdinal("Address")), + Longitude = reader.IsDBNull(reader.GetOrdinal("Longitude")) ? null : reader.GetDouble(reader.GetOrdinal("Longitude")), + Latitude = reader.IsDBNull(reader.GetOrdinal("Latitude")) ? null : reader.GetDouble(reader.GetOrdinal("Latitude")), + OwnershipType = (StoreOwnershipType)reader.GetInt32(reader.GetOrdinal("OwnershipType")), + AuditStatus = (StoreAuditStatus)reader.GetInt32(reader.GetOrdinal("AuditStatus")), + SubmittedAt = reader.IsDBNull(reader.GetOrdinal("SubmittedAt")) + ? null + : reader.GetDateTime(reader.GetOrdinal("SubmittedAt")) + }; + + // 1.3 (空行后) 映射租户信息 + var tenant = new StoreAuditTenantDto + { + Id = reader.GetInt64(reader.GetOrdinal("TenantId")), + Name = reader.GetString(reader.GetOrdinal("TenantName")), + ContactName = reader.IsDBNull(reader.GetOrdinal("TenantContactName")) + ? null + : reader.GetString(reader.GetOrdinal("TenantContactName")), + ContactPhone = reader.IsDBNull(reader.GetOrdinal("TenantContactPhone")) + ? null + : reader.GetString(reader.GetOrdinal("TenantContactPhone")) + }; + + // 1.4 (空行后) 映射商户信息 + var merchant = new StoreAuditMerchantDto + { + Id = reader.GetInt64(reader.GetOrdinal("MerchantId")), + Name = reader.GetString(reader.GetOrdinal("MerchantName")), + LegalName = reader.IsDBNull(reader.GetOrdinal("MerchantLegalName")) + ? null + : reader.GetString(reader.GetOrdinal("MerchantLegalName")), + CreditCode = reader.IsDBNull(reader.GetOrdinal("MerchantCreditCode")) + ? null + : reader.GetString(reader.GetOrdinal("MerchantCreditCode")) + }; + + // 2. (空行后) 查询资质列表 + var qualifications = await QueryQualificationsAsync(connection, request.StoreId, token); + + // 3. (空行后) 查询审核记录 + var auditHistory = await QueryAuditRecordsAsync(connection, request.StoreId, token); + + // 4. (空行后) 组装结果 + return new StoreAuditDetailDto + { + Store = store, + Tenant = tenant, + Merchant = merchant, + Qualifications = qualifications, + AuditHistory = auditHistory + }; + }, + cancellationToken); + } + + private static async Task> QueryQualificationsAsync( + IDbConnection connection, + long storeId, + CancellationToken cancellationToken) + { + // 1. 查询门店资质 + await using var command = CreateCommand( + connection, + BuildQualificationSql(), + [ + ("storeId", storeId) + ]); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + var items = new List(); + if (!reader.HasRows) + { + return items; + } + + // 2. (空行后) 映射资质 DTO + var now = DateTime.UtcNow.Date; + while (await reader.ReadAsync(cancellationToken)) + { + DateTime? expiresAt = reader.IsDBNull(reader.GetOrdinal("ExpiresAt")) + ? null + : reader.GetDateTime(reader.GetOrdinal("ExpiresAt")); + int? daysUntilExpiry = expiresAt.HasValue + ? (int)Math.Ceiling((expiresAt.Value.Date - now).TotalDays) + : null; + var isExpired = expiresAt.HasValue && expiresAt.Value < DateTime.UtcNow; + var isExpiringSoon = expiresAt.HasValue + && expiresAt.Value >= DateTime.UtcNow + && expiresAt.Value <= DateTime.UtcNow.AddDays(30); + + // 2.1 (空行后) 写入列表 + items.Add(new StoreQualificationDto + { + Id = reader.GetInt64(reader.GetOrdinal("Id")), + StoreId = reader.GetInt64(reader.GetOrdinal("StoreId")), + QualificationType = (StoreQualificationType)reader.GetInt32(reader.GetOrdinal("QualificationType")), + FileUrl = reader.GetString(reader.GetOrdinal("FileUrl")), + DocumentNumber = reader.IsDBNull(reader.GetOrdinal("DocumentNumber")) + ? null + : reader.GetString(reader.GetOrdinal("DocumentNumber")), + IssuedAt = reader.IsDBNull(reader.GetOrdinal("IssuedAt")) + ? null + : reader.GetDateTime(reader.GetOrdinal("IssuedAt")), + ExpiresAt = expiresAt, + IsExpired = isExpired, + IsExpiringSoon = isExpiringSoon, + DaysUntilExpiry = daysUntilExpiry, + SortOrder = reader.GetInt32(reader.GetOrdinal("SortOrder")), + CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")), + UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt")) + ? null + : reader.GetDateTime(reader.GetOrdinal("UpdatedAt")) + }); + } + + // 3. (空行后) 返回结果 + return items; + } + + private static async Task> QueryAuditRecordsAsync( + IDbConnection connection, + long storeId, + CancellationToken cancellationToken) + { + // 1. 查询审核记录 + await using var command = CreateCommand( + connection, + BuildAuditRecordSql(), + [ + ("storeId", storeId) + ]); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + var items = new List(); + if (!reader.HasRows) + { + return items; + } + + // 2. (空行后) 映射审核记录 DTO + while (await reader.ReadAsync(cancellationToken)) + { + var action = (StoreAuditAction)reader.GetInt32(reader.GetOrdinal("Action")); + items.Add(new StoreAuditRecordDto + { + Id = reader.GetInt64(reader.GetOrdinal("Id")), + Action = action, + ActionName = StoreAuditActionNameResolver.Resolve(action), + OperatorId = reader.IsDBNull(reader.GetOrdinal("OperatorId")) + ? null + : reader.GetInt64(reader.GetOrdinal("OperatorId")), + OperatorName = reader.GetString(reader.GetOrdinal("OperatorName")), + PreviousStatus = reader.IsDBNull(reader.GetOrdinal("PreviousStatus")) + ? null + : (StoreAuditStatus)reader.GetInt32(reader.GetOrdinal("PreviousStatus")), + NewStatus = (StoreAuditStatus)reader.GetInt32(reader.GetOrdinal("NewStatus")), + RejectionReasonId = reader.IsDBNull(reader.GetOrdinal("RejectionReasonId")) + ? null + : reader.GetInt64(reader.GetOrdinal("RejectionReasonId")), + RejectionReasonText = reader.IsDBNull(reader.GetOrdinal("RejectionReason")) + ? null + : reader.GetString(reader.GetOrdinal("RejectionReason")), + Remark = reader.IsDBNull(reader.GetOrdinal("Remarks")) + ? null + : reader.GetString(reader.GetOrdinal("Remarks")), + CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")) + }); + } + + // 3. (空行后) 返回结果 + return items; + } + + private static string BuildStoreSql() + { + return """ + select + s."Id" as "StoreId", + s."Name" as "StoreName", + s."Code" as "StoreCode", + s."Phone", + s."SignboardImageUrl", + s."Province", + s."City", + s."District", + s."Address", + s."Longitude", + s."Latitude", + s."OwnershipType", + s."AuditStatus", + s."SubmittedAt", + s."TenantId", + t."Name" as "TenantName", + t."ContactName" as "TenantContactName", + t."ContactPhone" as "TenantContactPhone", + s."MerchantId", + m."BrandName" as "MerchantName", + m."LegalPerson" as "MerchantLegalName", + m."TaxNumber" as "MerchantCreditCode" + from public.stores s + join public.tenants t on t."Id" = s."TenantId" and t."DeletedAt" is null + join public.merchants m on m."Id" = s."MerchantId" and m."DeletedAt" is null + where s."DeletedAt" is null + and s."Id" = @storeId; + """; + } + + private static string BuildQualificationSql() + { + return """ + select + q."Id", + q."StoreId", + q."QualificationType", + q."FileUrl", + q."DocumentNumber", + q."IssuedAt", + q."ExpiresAt", + q."SortOrder", + q."CreatedAt", + q."UpdatedAt" + from public.store_qualifications q + where q."DeletedAt" is null + and q."StoreId" = @storeId + order by q."SortOrder", q."QualificationType"; + """; + } + + private static string BuildAuditRecordSql() + { + return """ + select + r."Id", + r."Action", + r."OperatorId", + r."OperatorName", + r."PreviousStatus", + r."NewStatus", + r."RejectionReasonId", + r."RejectionReason", + r."Remarks", + r."CreatedAt" + from public.store_audit_records r + where r."DeletedAt" is null + and r."StoreId" = @storeId + order by r."CreatedAt" desc; + """; + } + + private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + + // 1. (空行后) 绑定参数 + 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/StoreAudits/Handlers/GetStoreAuditStatisticsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/GetStoreAuditStatisticsQueryHandler.cs new file mode 100644 index 0000000..f710a79 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/GetStoreAuditStatisticsQueryHandler.cs @@ -0,0 +1,279 @@ +using System.Data; +using System.Data.Common; +using MediatR; +using TakeoutSaaS.Application.App.StoreAudits.Dto; +using TakeoutSaaS.Application.App.StoreAudits.Queries; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; + +namespace TakeoutSaaS.Application.App.StoreAudits.Handlers; + +/// +/// 审核统计查询处理器。 +/// +public sealed class GetStoreAuditStatisticsQueryHandler( + IDapperExecutor dapperExecutor) + : IRequestHandler +{ + /// + public async Task Handle(GetStoreAuditStatisticsQuery request, CancellationToken cancellationToken) + { + // 1. 规范化日期范围 + var today = DateTime.UtcNow.Date; + var dateFrom = request.DateFrom?.Date ?? today.AddDays(-30); + var dateTo = request.DateTo?.Date ?? today; + if (dateFrom > dateTo) + { + (dateFrom, dateTo) = (dateTo, dateFrom); + } + + // 1.1 (空行后) 计算统计边界 + var dateToExclusive = dateTo.AddDays(1); + var overdueDeadline = DateTime.UtcNow.AddDays(-7); + + // 2. (空行后) 查询统计 + return await dapperExecutor.QueryAsync( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + // 2.1 读取待审核与超时数量 + var pendingCount = await ExecuteScalarIntAsync( + connection, + BuildPendingCountSql(), + [ + ("pendingStatus", (int)StoreAuditStatus.Pending) + ], + token); + var overdueCount = await ExecuteScalarIntAsync( + connection, + BuildOverdueCountSql(), + [ + ("pendingStatus", (int)StoreAuditStatus.Pending), + ("overdueDeadline", overdueDeadline) + ], + token); + + // 2.2 (空行后) 读取通过/驳回数量 + var (approvedCount, rejectedCount) = await QueryApproveRejectCountsAsync( + connection, + dateFrom, + dateToExclusive, + token); + + // 2.3 (空行后) 读取平均处理时长 + var avgProcessingHours = await ExecuteScalarDoubleAsync( + connection, + BuildAvgProcessingSql(), + [ + ("dateFrom", dateFrom), + ("dateTo", dateToExclusive), + ("approveAction", (int)StoreAuditAction.Approve), + ("rejectAction", (int)StoreAuditAction.Reject) + ], + token); + + // 2.4 (空行后) 读取每日趋势 + var dailyTrend = await QueryDailyTrendAsync( + connection, + dateFrom, + dateToExclusive, + token); + + // 2.5 (空行后) 组装结果 + return new StoreAuditStatisticsDto + { + PendingCount = pendingCount, + OverdueCount = overdueCount, + ApprovedCount = approvedCount, + RejectedCount = rejectedCount, + AvgProcessingHours = avgProcessingHours, + DailyTrend = dailyTrend + }; + }, + cancellationToken); + } + + private static async Task<(int ApprovedCount, int RejectedCount)> QueryApproveRejectCountsAsync( + IDbConnection connection, + DateTime dateFrom, + DateTime dateTo, + CancellationToken cancellationToken) + { + // 1. 查询通过/驳回统计 + await using var command = CreateCommand( + connection, + BuildApproveRejectCountSql(), + [ + ("dateFrom", dateFrom), + ("dateTo", dateTo), + ("approveAction", (int)StoreAuditAction.Approve), + ("rejectAction", (int)StoreAuditAction.Reject) + ]); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return (0, 0); + } + + // 2. (空行后) 返回统计 + var approved = reader.IsDBNull(reader.GetOrdinal("ApprovedCount")) ? 0 : reader.GetInt32(reader.GetOrdinal("ApprovedCount")); + var rejected = reader.IsDBNull(reader.GetOrdinal("RejectedCount")) ? 0 : reader.GetInt32(reader.GetOrdinal("RejectedCount")); + return (approved, rejected); + } + + private static async Task> QueryDailyTrendAsync( + IDbConnection connection, + DateTime dateFrom, + DateTime dateTo, + CancellationToken cancellationToken) + { + // 1. 查询每日趋势 + await using var command = CreateCommand( + connection, + BuildDailyTrendSql(), + [ + ("dateFrom", dateFrom), + ("dateTo", dateTo), + ("submitAction", (int)StoreAuditAction.Submit), + ("resubmitAction", (int)StoreAuditAction.Resubmit), + ("approveAction", (int)StoreAuditAction.Approve), + ("rejectAction", (int)StoreAuditAction.Reject) + ]); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + var items = new List(); + if (!reader.HasRows) + { + return items; + } + + // 2. (空行后) 映射趋势项 + var dateOrdinal = reader.GetOrdinal("Date"); + var submittedOrdinal = reader.GetOrdinal("SubmittedCount"); + var approvedOrdinal = reader.GetOrdinal("ApprovedCount"); + var rejectedOrdinal = reader.GetOrdinal("RejectedCount"); + while (await reader.ReadAsync(cancellationToken)) + { + var date = reader.GetDateTime(dateOrdinal); + items.Add(new StoreAuditDailyTrendDto + { + Date = DateOnly.FromDateTime(date), + Submitted = reader.GetInt32(submittedOrdinal), + Approved = reader.GetInt32(approvedOrdinal), + Rejected = reader.GetInt32(rejectedOrdinal) + }); + } + + // 3. (空行后) 返回趋势列表 + return items; + } + + private static string BuildPendingCountSql() + { + return """ + select count(*) + from public.stores s + where s."DeletedAt" is null + and s."AuditStatus" = @pendingStatus; + """; + } + + private static string BuildOverdueCountSql() + { + return """ + select count(*) + from public.stores s + where s."DeletedAt" is null + and s."AuditStatus" = @pendingStatus + and s."SubmittedAt" <= @overdueDeadline; + """; + } + + private static string BuildApproveRejectCountSql() + { + return """ + select + sum(case when r."Action" = @approveAction then 1 else 0 end) as "ApprovedCount", + sum(case when r."Action" = @rejectAction then 1 else 0 end) as "RejectedCount" + from public.store_audit_records r + where r."DeletedAt" is null + and r."CreatedAt" >= @dateFrom + and r."CreatedAt" < @dateTo + and r."Action" in (@approveAction, @rejectAction); + """; + } + + private static string BuildAvgProcessingSql() + { + return """ + select + avg(extract(epoch from (r."CreatedAt" - s."SubmittedAt")) / 3600.0) as "AvgHours" + from public.store_audit_records r + join public.stores s on s."Id" = r."StoreId" and s."DeletedAt" is null + where r."DeletedAt" is null + and r."Action" in (@approveAction, @rejectAction) + and r."CreatedAt" >= @dateFrom + and r."CreatedAt" < @dateTo + and s."SubmittedAt" is not null; + """; + } + + private static string BuildDailyTrendSql() + { + return """ + select + date_trunc('day', r."CreatedAt") as "Date", + sum(case when r."Action" in (@submitAction, @resubmitAction) then 1 else 0 end) as "SubmittedCount", + sum(case when r."Action" = @approveAction then 1 else 0 end) as "ApprovedCount", + sum(case when r."Action" = @rejectAction then 1 else 0 end) as "RejectedCount" + from public.store_audit_records r + where r."DeletedAt" is null + and r."CreatedAt" >= @dateFrom + and r."CreatedAt" < @dateTo + group by date_trunc('day', r."CreatedAt") + order by date_trunc('day', r."CreatedAt"); + """; + } + + 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 async Task ExecuteScalarDoubleAsync( + 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 ? 0d : Convert.ToDouble(result); + } + + private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + + // 1. (空行后) 绑定参数 + 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/StoreAudits/Handlers/ListPendingStoreAuditsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/ListPendingStoreAuditsQueryHandler.cs new file mode 100644 index 0000000..1973112 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/ListPendingStoreAuditsQueryHandler.cs @@ -0,0 +1,250 @@ +using System.Data; +using System.Data.Common; +using MediatR; +using TakeoutSaaS.Application.App.StoreAudits.Dto; +using TakeoutSaaS.Application.App.StoreAudits.Queries; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.StoreAudits.Handlers; + +/// +/// 待审核门店列表查询处理器。 +/// +public sealed class ListPendingStoreAuditsQueryHandler( + IDapperExecutor dapperExecutor) + : IRequestHandler> +{ + /// + public async Task> Handle(ListPendingStoreAuditsQuery request, CancellationToken cancellationToken) + { + // 1. 参数规范化 + var page = request.Page <= 0 ? 1 : request.Page; + var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize; + var keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim(); + var offset = (page - 1) * pageSize; + var now = DateTime.UtcNow; + var overdueDeadline = now.AddDays(-7); + + // 2. (空行后) 排序白名单 + var orderBy = request.SortBy?.Trim() switch + { + "StoreName" => "s.\"Name\"", + "MerchantName" => "m.\"BrandName\"", + "TenantName" => "t.\"Name\"", + "SubmittedAt" => "s.\"SubmittedAt\"", + _ => "s.\"SubmittedAt\"" + }; + + // 3. (空行后) 执行查询 + return await dapperExecutor.QueryAsync( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + // 3.1 统计总数 + var total = await ExecuteScalarIntAsync( + connection, + BuildCountSql(), + [ + ("tenantId", request.TenantId), + ("keyword", keyword), + ("submittedFrom", request.SubmittedFrom), + ("submittedTo", request.SubmittedTo), + ("overdueOnly", request.OverdueOnly), + ("overdueDeadline", overdueDeadline), + ("pendingStatus", (int)StoreAuditStatus.Pending) + ], + token); + + // 3.2 (空行后) 查询列表 + var listSql = BuildListSql(orderBy, request.SortDesc); + await using var listCommand = CreateCommand( + connection, + listSql, + [ + ("tenantId", request.TenantId), + ("keyword", keyword), + ("submittedFrom", request.SubmittedFrom), + ("submittedTo", request.SubmittedTo), + ("overdueOnly", request.OverdueOnly), + ("overdueDeadline", overdueDeadline), + ("pendingStatus", (int)StoreAuditStatus.Pending), + ("offset", offset), + ("limit", pageSize) + ]); + + await using var reader = await listCommand.ExecuteReaderAsync(token); + + // 3.3 (空行后) 读取并映射 + var items = new List(); + if (!reader.HasRows) + { + return new PagedResult(items, page, pageSize, total); + } + + // 3.3.1 (空行后) 初始化字段序号 + var storeIdOrdinal = reader.GetOrdinal("StoreId"); + var storeNameOrdinal = reader.GetOrdinal("StoreName"); + var storeCodeOrdinal = reader.GetOrdinal("StoreCode"); + var tenantIdOrdinal = reader.GetOrdinal("TenantId"); + var tenantNameOrdinal = reader.GetOrdinal("TenantName"); + var merchantIdOrdinal = reader.GetOrdinal("MerchantId"); + var merchantNameOrdinal = reader.GetOrdinal("MerchantName"); + var signboardOrdinal = reader.GetOrdinal("SignboardImageUrl"); + var provinceOrdinal = reader.GetOrdinal("Province"); + var cityOrdinal = reader.GetOrdinal("City"); + var districtOrdinal = reader.GetOrdinal("District"); + var addressOrdinal = reader.GetOrdinal("Address"); + var ownershipOrdinal = reader.GetOrdinal("OwnershipType"); + var submittedAtOrdinal = reader.GetOrdinal("SubmittedAt"); + var qualificationCountOrdinal = reader.GetOrdinal("QualificationCount"); + + while (await reader.ReadAsync(token)) + { + DateTime? submittedAt = reader.IsDBNull(submittedAtOrdinal) + ? null + : reader.GetDateTime(submittedAtOrdinal); + var waitingDays = submittedAt.HasValue + ? (int)Math.Floor((now - submittedAt.Value).TotalDays) + : 0; + if (waitingDays < 0) + { + waitingDays = 0; + } + + // 3.3.2 (空行后) 组装地址信息 + var province = reader.IsDBNull(provinceOrdinal) ? null : reader.GetString(provinceOrdinal); + var city = reader.IsDBNull(cityOrdinal) ? null : reader.GetString(cityOrdinal); + var district = reader.IsDBNull(districtOrdinal) ? null : reader.GetString(districtOrdinal); + var address = reader.IsDBNull(addressOrdinal) ? null : reader.GetString(addressOrdinal); + var fullAddress = string.Concat( + province ?? string.Empty, + city ?? string.Empty, + district ?? string.Empty, + address ?? string.Empty); + + items.Add(new PendingStoreAuditDto + { + StoreId = reader.GetInt64(storeIdOrdinal), + StoreName = reader.GetString(storeNameOrdinal), + StoreCode = reader.GetString(storeCodeOrdinal), + TenantId = reader.GetInt64(tenantIdOrdinal), + TenantName = reader.GetString(tenantNameOrdinal), + MerchantId = reader.GetInt64(merchantIdOrdinal), + MerchantName = reader.GetString(merchantNameOrdinal), + SignboardImageUrl = reader.IsDBNull(signboardOrdinal) ? null : reader.GetString(signboardOrdinal), + FullAddress = fullAddress, + OwnershipType = (StoreOwnershipType)reader.GetInt32(ownershipOrdinal), + SubmittedAt = submittedAt, + WaitingDays = waitingDays, + IsOverdue = submittedAt.HasValue && submittedAt.Value <= overdueDeadline, + QualificationCount = reader.GetInt32(qualificationCountOrdinal) + }); + } + + // 3.4 (空行后) 返回分页结果 + return new PagedResult(items, page, pageSize, total); + }, + cancellationToken); + } + + private static string BuildCountSql() + { + return """ + select count(*) + from public.stores s + join public.merchants m on m."Id" = s."MerchantId" and m."DeletedAt" is null + join public.tenants t on t."Id" = s."TenantId" and t."DeletedAt" is null + where s."DeletedAt" is null + and s."AuditStatus" = @pendingStatus + and (@tenantId::bigint is null or s."TenantId" = @tenantId) + and ( + @keyword::text is null + or s."Name" ilike ('%' || @keyword::text || '%') + or s."Code" ilike ('%' || @keyword::text || '%') + or m."BrandName" ilike ('%' || @keyword::text || '%') + ) + and (@submittedFrom::timestamp with time zone is null or s."SubmittedAt" >= @submittedFrom) + and (@submittedTo::timestamp with time zone is null or s."SubmittedAt" <= @submittedTo) + and (@overdueOnly::boolean = false or s."SubmittedAt" <= @overdueDeadline); + """; + } + + private static string BuildListSql(string orderBy, bool sortDesc) + { + var direction = sortDesc ? "desc" : "asc"; + + // 1. (空行后) 构造列表 SQL + return $""" + select + s."Id" as "StoreId", + s."Name" as "StoreName", + s."Code" as "StoreCode", + s."TenantId", + t."Name" as "TenantName", + s."MerchantId", + m."BrandName" as "MerchantName", + s."SignboardImageUrl", + s."Province", + s."City", + s."District", + s."Address", + s."OwnershipType", + s."SubmittedAt", + ( + select count(1) + from public.store_qualifications q + where q."StoreId" = s."Id" and q."DeletedAt" is null + ) as "QualificationCount" + from public.stores s + join public.merchants m on m."Id" = s."MerchantId" and m."DeletedAt" is null + join public.tenants t on t."Id" = s."TenantId" and t."DeletedAt" is null + where s."DeletedAt" is null + and s."AuditStatus" = @pendingStatus + and (@tenantId::bigint is null or s."TenantId" = @tenantId) + and ( + @keyword::text is null + or s."Name" ilike ('%' || @keyword::text || '%') + or s."Code" ilike ('%' || @keyword::text || '%') + or m."BrandName" ilike ('%' || @keyword::text || '%') + ) + and (@submittedFrom::timestamp with time zone is null or s."SubmittedAt" >= @submittedFrom) + and (@submittedTo::timestamp with time zone is null or s."SubmittedAt" <= @submittedTo) + and (@overdueOnly::boolean = false or s."SubmittedAt" <= @overdueDeadline) + 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; + + // 1. (空行后) 绑定参数 + 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/StoreAudits/Handlers/ListStoreAuditRecordsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/ListStoreAuditRecordsQueryHandler.cs new file mode 100644 index 0000000..8731d0b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/ListStoreAuditRecordsQueryHandler.cs @@ -0,0 +1,166 @@ +using System.Data; +using System.Data.Common; +using MediatR; +using TakeoutSaaS.Application.App.StoreAudits; +using TakeoutSaaS.Application.App.StoreAudits.Dto; +using TakeoutSaaS.Application.App.StoreAudits.Queries; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.StoreAudits.Handlers; + +/// +/// 门店审核记录查询处理器。 +/// +public sealed class ListStoreAuditRecordsQueryHandler( + IDapperExecutor dapperExecutor) + : IRequestHandler> +{ + /// + public async Task> Handle(ListStoreAuditRecordsQuery request, CancellationToken cancellationToken) + { + // 1. 参数规范化 + var page = request.Page <= 0 ? 1 : request.Page; + var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize; + var offset = (page - 1) * pageSize; + + // 2. (空行后) 查询审核记录 + return await dapperExecutor.QueryAsync( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + // 2.1 统计总数 + var total = await ExecuteScalarIntAsync( + connection, + BuildCountSql(), + [ + ("storeId", request.StoreId) + ], + token); + + // 2.2 (空行后) 查询列表 + await using var listCommand = CreateCommand( + connection, + BuildListSql(), + [ + ("storeId", request.StoreId), + ("offset", offset), + ("limit", pageSize) + ]); + + await using var reader = await listCommand.ExecuteReaderAsync(token); + + // 2.3 (空行后) 映射列表 + var items = new List(); + if (!reader.HasRows) + { + return new PagedResult(items, page, pageSize, total); + } + + // 2.3.1 (空行后) 初始化字段序号 + var idOrdinal = reader.GetOrdinal("Id"); + var actionOrdinal = reader.GetOrdinal("Action"); + var operatorIdOrdinal = reader.GetOrdinal("OperatorId"); + var operatorNameOrdinal = reader.GetOrdinal("OperatorName"); + var previousStatusOrdinal = reader.GetOrdinal("PreviousStatus"); + var newStatusOrdinal = reader.GetOrdinal("NewStatus"); + var rejectionReasonIdOrdinal = reader.GetOrdinal("RejectionReasonId"); + var rejectionReasonOrdinal = reader.GetOrdinal("RejectionReason"); + var remarksOrdinal = reader.GetOrdinal("Remarks"); + var createdAtOrdinal = reader.GetOrdinal("CreatedAt"); + + while (await reader.ReadAsync(token)) + { + var action = (StoreAuditAction)reader.GetInt32(actionOrdinal); + items.Add(new StoreAuditRecordDto + { + Id = reader.GetInt64(idOrdinal), + Action = action, + ActionName = StoreAuditActionNameResolver.Resolve(action), + OperatorId = reader.IsDBNull(operatorIdOrdinal) ? null : reader.GetInt64(operatorIdOrdinal), + OperatorName = reader.GetString(operatorNameOrdinal), + PreviousStatus = reader.IsDBNull(previousStatusOrdinal) + ? null + : (StoreAuditStatus)reader.GetInt32(previousStatusOrdinal), + NewStatus = (StoreAuditStatus)reader.GetInt32(newStatusOrdinal), + RejectionReasonId = reader.IsDBNull(rejectionReasonIdOrdinal) + ? null + : reader.GetInt64(rejectionReasonIdOrdinal), + RejectionReasonText = reader.IsDBNull(rejectionReasonOrdinal) + ? null + : reader.GetString(rejectionReasonOrdinal), + Remark = reader.IsDBNull(remarksOrdinal) ? null : reader.GetString(remarksOrdinal), + CreatedAt = reader.GetDateTime(createdAtOrdinal) + }); + } + + // 2.4 (空行后) 返回分页结果 + return new PagedResult(items, page, pageSize, total); + }, + cancellationToken); + } + + private static string BuildCountSql() + { + return """ + select count(*) + from public.store_audit_records r + where r."DeletedAt" is null + and r."StoreId" = @storeId; + """; + } + + private static string BuildListSql() + { + return """ + select + r."Id", + r."Action", + r."OperatorId", + r."OperatorName", + r."PreviousStatus", + r."NewStatus", + r."RejectionReasonId", + r."RejectionReason", + r."Remarks", + r."CreatedAt" + from public.store_audit_records r + where r."DeletedAt" is null + and r."StoreId" = @storeId + order by r."CreatedAt" desc + 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; + + // 1. (空行后) 绑定参数 + 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/StoreAudits/Handlers/RejectStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/RejectStoreCommandHandler.cs new file mode 100644 index 0000000..fdc6ec5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/RejectStoreCommandHandler.cs @@ -0,0 +1,157 @@ +using System.Data; +using System.Data.Common; +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.StoreAudits.Commands; +using TakeoutSaaS.Application.App.StoreAudits.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.StoreAudits.Handlers; + +/// +/// 审核驳回处理器。 +/// +public sealed class RejectStoreCommandHandler( + IStoreRepository storeRepository, + IDapperExecutor dapperExecutor, + ICurrentUserAccessor currentUserAccessor, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(RejectStoreCommand request, CancellationToken cancellationToken) + { + // 1. 获取门店快照 + var snapshot = await QueryStoreSnapshotAsync(request.StoreId, cancellationToken); + if (!snapshot.HasValue) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 1.1 (空行后) 校验审核状态 + if (snapshot.Value.AuditStatus != StoreAuditStatus.Pending) + { + throw new BusinessException(ErrorCodes.Conflict, "门店不处于待审核状态"); + } + + // 2. (空行后) 获取门店实体 + var store = await storeRepository.FindByIdAsync(request.StoreId, snapshot.Value.TenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 3. (空行后) 更新状态并记录审核 + var previousStatus = store.AuditStatus; + store.AuditStatus = StoreAuditStatus.Rejected; + store.RejectionReason = request.RejectionReasonText; + store.BusinessStatus = StoreBusinessStatus.Resting; + + await storeRepository.UpdateStoreAsync(store, cancellationToken); + await storeRepository.AddAuditRecordAsync(new StoreAuditRecord + { + StoreId = store.Id, + Action = StoreAuditAction.Reject, + PreviousStatus = previousStatus, + NewStatus = store.AuditStatus, + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName(), + RejectionReasonId = request.RejectionReasonId, + RejectionReason = request.RejectionReasonText, + Remarks = request.Remark + }, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("门店 {StoreId} 审核驳回", store.Id); + + // 4. (空行后) 返回结果 + return new StoreAuditActionResultDto + { + StoreId = store.Id, + AuditStatus = store.AuditStatus, + BusinessStatus = store.BusinessStatus, + RejectionReason = store.RejectionReason, + Message = "已驳回,租户可修改后重新提交" + }; + } + + private async Task<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?> QueryStoreSnapshotAsync( + long storeId, + CancellationToken cancellationToken) + { + // 1. 查询门店基础字段 + return await dapperExecutor.QueryAsync<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?>( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + await using var command = CreateCommand( + connection, + BuildStoreSnapshotSql(), + [ + ("storeId", storeId) + ]); + + // 1.1 (空行后) 执行查询 + await using var reader = await command.ExecuteReaderAsync(token); + if (!await reader.ReadAsync(token)) + { + return null; + } + + // 1.2 (空行后) 返回快照 + return ( + reader.GetInt64(reader.GetOrdinal("TenantId")), + (StoreAuditStatus)reader.GetInt32(reader.GetOrdinal("AuditStatus")), + (StoreBusinessStatus)reader.GetInt32(reader.GetOrdinal("BusinessStatus"))); + }, + cancellationToken); + } + + private long? ResolveOperatorId() + { + var id = currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } + + private static string BuildStoreSnapshotSql() + { + return """ + select + s."TenantId", + s."AuditStatus", + s."BusinessStatus" + from public.stores s + where s."DeletedAt" is null + and s."Id" = @storeId; + """; + } + + private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + + // 1. (空行后) 绑定参数 + 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/StoreAudits/Handlers/ReopenStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/ReopenStoreCommandHandler.cs new file mode 100644 index 0000000..a02d535 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Handlers/ReopenStoreCommandHandler.cs @@ -0,0 +1,160 @@ +using System.Data; +using System.Data.Common; +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.StoreAudits.Commands; +using TakeoutSaaS.Application.App.StoreAudits.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.StoreAudits.Handlers; + +/// +/// 解除强制关闭处理器。 +/// +public sealed class ReopenStoreCommandHandler( + IStoreRepository storeRepository, + IDapperExecutor dapperExecutor, + ICurrentUserAccessor currentUserAccessor, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(ReopenStoreCommand request, CancellationToken cancellationToken) + { + // 1. 获取门店快照 + var snapshot = await QueryStoreSnapshotAsync(request.StoreId, cancellationToken); + if (!snapshot.HasValue) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 1.1 (空行后) 校验审核与经营状态 + if (snapshot.Value.AuditStatus != StoreAuditStatus.Activated) + { + throw new BusinessException(ErrorCodes.Conflict, "门店未激活,无法解除关闭"); + } + + if (snapshot.Value.BusinessStatus != StoreBusinessStatus.ForceClosed) + { + throw new BusinessException(ErrorCodes.Conflict, "门店未处于强制关闭状态"); + } + + // 2. (空行后) 获取门店实体 + var store = await storeRepository.FindByIdAsync(request.StoreId, snapshot.Value.TenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 3. (空行后) 更新状态并记录风控 + store.BusinessStatus = StoreBusinessStatus.Resting; + store.ClosureReason = null; + store.ClosureReasonText = null; + store.ForceCloseReason = null; + store.ForceClosedAt = null; + + await storeRepository.UpdateStoreAsync(store, cancellationToken); + await storeRepository.AddAuditRecordAsync(new StoreAuditRecord + { + StoreId = store.Id, + Action = StoreAuditAction.Reopen, + PreviousStatus = store.AuditStatus, + NewStatus = store.AuditStatus, + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName(), + Remarks = request.Remark + }, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("门店 {StoreId} 解除强制关闭", store.Id); + + // 4. (空行后) 返回结果 + return new StoreAuditActionResultDto + { + StoreId = store.Id, + AuditStatus = store.AuditStatus, + BusinessStatus = store.BusinessStatus, + Message = "强制关闭已解除,门店恢复为休息中状态" + }; + } + + private async Task<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?> QueryStoreSnapshotAsync( + long storeId, + CancellationToken cancellationToken) + { + // 1. 查询门店基础字段 + return await dapperExecutor.QueryAsync<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?>( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + await using var command = CreateCommand( + connection, + BuildStoreSnapshotSql(), + [ + ("storeId", storeId) + ]); + + // 1.1 (空行后) 执行查询 + await using var reader = await command.ExecuteReaderAsync(token); + if (!await reader.ReadAsync(token)) + { + return null; + } + + // 1.2 (空行后) 返回快照 + return ( + reader.GetInt64(reader.GetOrdinal("TenantId")), + (StoreAuditStatus)reader.GetInt32(reader.GetOrdinal("AuditStatus")), + (StoreBusinessStatus)reader.GetInt32(reader.GetOrdinal("BusinessStatus"))); + }, + cancellationToken); + } + + private long? ResolveOperatorId() + { + var id = currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } + + private static string BuildStoreSnapshotSql() + { + return """ + select + s."TenantId", + s."AuditStatus", + s."BusinessStatus" + from public.stores s + where s."DeletedAt" is null + and s."Id" = @storeId; + """; + } + + private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + + // 1. (空行后) 绑定参数 + 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/StoreAudits/Queries/GetStoreAuditDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Queries/GetStoreAuditDetailQuery.cs new file mode 100644 index 0000000..17e528c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Queries/GetStoreAuditDetailQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.StoreAudits.Dto; + +namespace TakeoutSaaS.Application.App.StoreAudits.Queries; + +/// +/// 获取门店审核详情查询。 +/// +public sealed record GetStoreAuditDetailQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Queries/GetStoreAuditStatisticsQuery.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Queries/GetStoreAuditStatisticsQuery.cs new file mode 100644 index 0000000..c756851 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Queries/GetStoreAuditStatisticsQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.StoreAudits.Dto; + +namespace TakeoutSaaS.Application.App.StoreAudits.Queries; + +/// +/// 获取审核统计查询。 +/// +public sealed record GetStoreAuditStatisticsQuery : IRequest +{ + /// + /// 起始日期。 + /// + public DateTime? DateFrom { get; init; } + + /// + /// 截止日期。 + /// + public DateTime? DateTo { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Queries/ListPendingStoreAuditsQuery.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Queries/ListPendingStoreAuditsQuery.cs new file mode 100644 index 0000000..a7f2748 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Queries/ListPendingStoreAuditsQuery.cs @@ -0,0 +1,56 @@ +using MediatR; +using TakeoutSaaS.Application.App.StoreAudits.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.StoreAudits.Queries; + +/// +/// 查询待审核门店列表。 +/// +public sealed record ListPendingStoreAuditsQuery : IRequest> +{ + /// + /// 租户 ID。 + /// + public long? TenantId { get; init; } + + /// + /// 关键词。 + /// + public string? Keyword { get; init; } + + /// + /// 提交起始时间。 + /// + public DateTime? SubmittedFrom { get; init; } + + /// + /// 提交截止时间。 + /// + public DateTime? SubmittedTo { get; init; } + + /// + /// 是否只显示超时。 + /// + public bool OverdueOnly { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页数量。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 排序字段。 + /// + public string? SortBy { get; init; } + + /// + /// 是否降序。 + /// + public bool SortDesc { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Queries/ListStoreAuditRecordsQuery.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Queries/ListStoreAuditRecordsQuery.cs new file mode 100644 index 0000000..ac05c3a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Queries/ListStoreAuditRecordsQuery.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.StoreAudits.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.StoreAudits.Queries; + +/// +/// 查询门店审核记录。 +/// +public sealed record ListStoreAuditRecordsQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页数量。 + /// + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/StoreAuditActionNameResolver.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/StoreAuditActionNameResolver.cs new file mode 100644 index 0000000..c4f35f4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/StoreAuditActionNameResolver.cs @@ -0,0 +1,26 @@ +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.StoreAudits; + +/// +/// 门店审核动作名称解析器。 +/// +public static class StoreAuditActionNameResolver +{ + /// + /// 获取动作名称。 + /// + /// 审核动作。 + /// 动作名称。 + public static string Resolve(StoreAuditAction action) => action switch + { + StoreAuditAction.Submit => "提交审核", + StoreAuditAction.Resubmit => "重新提交", + StoreAuditAction.Approve => "审核通过", + StoreAuditAction.Reject => "审核驳回", + StoreAuditAction.ForceClose => "强制关闭", + StoreAuditAction.Reopen => "解除关闭", + StoreAuditAction.AutoActivate => "自动激活", + _ => "未知操作" + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Validators/ApproveStoreCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Validators/ApproveStoreCommandValidator.cs new file mode 100644 index 0000000..fddba8b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Validators/ApproveStoreCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.StoreAudits.Commands; + +namespace TakeoutSaaS.Application.App.StoreAudits.Validators; + +/// +/// 审核通过命令验证器。 +/// +public sealed class ApproveStoreCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ApproveStoreCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Remark).MaximumLength(500); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Validators/ForceCloseStoreCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Validators/ForceCloseStoreCommandValidator.cs new file mode 100644 index 0000000..31e3db9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Validators/ForceCloseStoreCommandValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.StoreAudits.Commands; + +namespace TakeoutSaaS.Application.App.StoreAudits.Validators; + +/// +/// 强制关闭命令验证器。 +/// +public sealed class ForceCloseStoreCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ForceCloseStoreCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Reason).NotEmpty().MaximumLength(500); + RuleFor(x => x.Remark).MaximumLength(500); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Validators/RejectStoreCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Validators/RejectStoreCommandValidator.cs new file mode 100644 index 0000000..8cf278e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Validators/RejectStoreCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.StoreAudits.Commands; + +namespace TakeoutSaaS.Application.App.StoreAudits.Validators; + +/// +/// 审核驳回命令验证器。 +/// +public sealed class RejectStoreCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public RejectStoreCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.RejectionReasonId).GreaterThan(0); + RuleFor(x => x.RejectionReasonText).MaximumLength(500); + RuleFor(x => x.Remark).MaximumLength(500); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/StoreAudits/Validators/ReopenStoreCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Validators/ReopenStoreCommandValidator.cs new file mode 100644 index 0000000..e44fc46 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/StoreAudits/Validators/ReopenStoreCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.StoreAudits.Commands; + +namespace TakeoutSaaS.Application.App.StoreAudits.Validators; + +/// +/// 解除强制关闭命令验证器。 +/// +public sealed class ReopenStoreCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ReopenStoreCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Remark).MaximumLength(500); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/BatchUpdateBusinessHoursCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/BatchUpdateBusinessHoursCommand.cs new file mode 100644 index 0000000..2f4b50b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/BatchUpdateBusinessHoursCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 批量更新营业时段命令。 +/// +public sealed record BatchUpdateBusinessHoursCommand : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 营业时段集合。 + /// + public IReadOnlyList Items { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs index 3315f92..4a0f093 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs @@ -7,105 +7,120 @@ namespace TakeoutSaaS.Application.App.Stores.Commands; /// /// 创建门店命令。 /// -public sealed class CreateStoreCommand : IRequest +public sealed record CreateStoreCommand : IRequest { /// /// 商户 ID。 /// - public long MerchantId { get; set; } + public long MerchantId { get; init; } /// /// 门店编码。 /// - public string Code { get; set; } = string.Empty; + public string Code { get; init; } = string.Empty; /// /// 门店名称。 /// - public string Name { get; set; } = string.Empty; + public string Name { get; init; } = string.Empty; /// /// 电话。 /// - public string? Phone { get; set; } + public string? Phone { get; init; } /// /// 负责人。 /// - public string? ManagerName { get; set; } + public string? ManagerName { get; init; } /// /// 状态。 /// - public StoreStatus Status { get; set; } = StoreStatus.Closed; + public StoreStatus Status { get; init; } = StoreStatus.Closed; + + /// + /// 门头招牌图。 + /// + public string? SignboardImageUrl { get; init; } + + /// + /// 主体类型。 + /// + public StoreOwnershipType OwnershipType { get; init; } = StoreOwnershipType.SameEntity; + + /// + /// 行业类目 ID。 + /// + public long? CategoryId { get; init; } /// /// 省份。 /// - public string? Province { get; set; } + public string? Province { get; init; } /// /// 城市。 /// - public string? City { get; set; } + public string? City { get; init; } /// /// 区县。 /// - public string? District { get; set; } + public string? District { get; init; } /// /// 详细地址。 /// - public string? Address { get; set; } + public string? Address { get; init; } /// /// 经度。 /// - public double? Longitude { get; set; } + public double? Longitude { get; init; } /// /// 纬度。 /// - public double? Latitude { get; set; } + public double? Latitude { get; init; } /// /// 公告。 /// - public string? Announcement { get; set; } + public string? Announcement { get; init; } /// /// 标签。 /// - public string? Tags { get; set; } + public string? Tags { get; init; } /// /// 配送半径。 /// - public decimal DeliveryRadiusKm { get; set; } + public decimal DeliveryRadiusKm { get; init; } /// /// 支持堂食。 /// - public bool SupportsDineIn { get; set; } = true; + public bool SupportsDineIn { get; init; } = true; /// /// 支持自提。 /// - public bool SupportsPickup { get; set; } = true; + public bool SupportsPickup { get; init; } = true; /// /// 支持配送。 /// - public bool SupportsDelivery { get; set; } = true; + public bool SupportsDelivery { get; init; } = true; /// /// 支持预约。 /// - public bool SupportsReservation { get; set; } + public bool SupportsReservation { get; init; } /// /// 支持排队叫号。 /// - public bool SupportsQueueing { get; set; } + public bool SupportsQueueing { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreQualificationCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreQualificationCommand.cs new file mode 100644 index 0000000..49d88a7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreQualificationCommand.cs @@ -0,0 +1,46 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建门店资质命令。 +/// +public sealed record CreateStoreQualificationCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 资质类型。 + /// + public StoreQualificationType QualificationType { get; init; } + + /// + /// 证照文件 URL。 + /// + public string FileUrl { get; init; } = string.Empty; + + /// + /// 证照编号。 + /// + public string? DocumentNumber { get; init; } + + /// + /// 签发日期。 + /// + public DateTime? IssuedAt { get; init; } + + /// + /// 到期日期。 + /// + public DateTime? ExpiresAt { get; init; } + + /// + /// 排序值。 + /// + public int SortOrder { get; init; } = 100; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreQualificationCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreQualificationCommand.cs new file mode 100644 index 0000000..59e954c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreQualificationCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除门店资质命令。 +/// +public sealed record DeleteStoreQualificationCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 资质 ID。 + /// + public long QualificationId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/SubmitStoreAuditCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/SubmitStoreAuditCommand.cs new file mode 100644 index 0000000..3e9802b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/SubmitStoreAuditCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 提交门店审核命令。 +/// +public sealed record SubmitStoreAuditCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/ToggleBusinessStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/ToggleBusinessStatusCommand.cs new file mode 100644 index 0000000..000f677 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/ToggleBusinessStatusCommand.cs @@ -0,0 +1,31 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 切换门店经营状态命令。 +/// +public sealed record ToggleBusinessStatusCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 目标经营状态。 + /// + public StoreBusinessStatus BusinessStatus { get; init; } + + /// + /// 歇业原因。 + /// + public StoreClosureReason? ClosureReason { get; init; } + + /// + /// 歇业原因补充说明。 + /// + public string? ClosureReasonText { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs index c7ef3b5..3d91b4a 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs @@ -44,6 +44,16 @@ public sealed record UpdateStoreCommand : IRequest /// public StoreStatus Status { get; init; } = StoreStatus.Closed; + /// + /// 门头招牌图。 + /// + public string? SignboardImageUrl { get; init; } + + /// + /// 行业类目 ID。 + /// + public long? CategoryId { get; init; } + /// /// 省份。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreFeeCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreFeeCommand.cs new file mode 100644 index 0000000..d569dcd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreFeeCommand.cs @@ -0,0 +1,41 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新门店费用配置命令。 +/// +public sealed record UpdateStoreFeeCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 起送费。 + /// + public decimal MinimumOrderAmount { get; init; } + + /// + /// 配送费。 + /// + public decimal DeliveryFee { get; init; } + + /// + /// 打包费模式。 + /// + public PackagingFeeMode PackagingFeeMode { get; init; } + + /// + /// 固定打包费。 + /// + public decimal? FixedPackagingFee { get; init; } + + /// + /// 免配送费门槛。 + /// + public decimal? FreeDeliveryThreshold { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreQualificationCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreQualificationCommand.cs new file mode 100644 index 0000000..018843f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreQualificationCommand.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新门店资质命令。 +/// +public sealed record UpdateStoreQualificationCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 资质 ID。 + /// + public long QualificationId { get; init; } + + /// + /// 证照文件 URL。 + /// + public string? FileUrl { get; init; } + + /// + /// 证照编号。 + /// + public string? DocumentNumber { get; init; } + + /// + /// 签发日期。 + /// + public DateTime? IssuedAt { get; init; } + + /// + /// 到期日期。 + /// + public DateTime? ExpiresAt { get; init; } + + /// + /// 排序值。 + /// + public int? SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreBusinessHourInputDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreBusinessHourInputDto.cs new file mode 100644 index 0000000..c08b894 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreBusinessHourInputDto.cs @@ -0,0 +1,39 @@ +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 批量更新营业时段输入项。 +/// +public sealed record StoreBusinessHourInputDto +{ + /// + /// 星期几。 + /// + public DayOfWeek DayOfWeek { get; init; } + + /// + /// 时段类型。 + /// + public BusinessHourType HourType { get; init; } + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 容量限制。 + /// + public int? CapacityLimit { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDeliveryCheckResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDeliveryCheckResultDto.cs new file mode 100644 index 0000000..700b474 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDeliveryCheckResultDto.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 配送范围检测结果。 +/// +public sealed record StoreDeliveryCheckResultDto +{ + /// + /// 是否在范围内。 + /// + public bool InRange { get; init; } + + /// + /// 距离(公里)。 + /// + public decimal? Distance { get; init; } + + /// + /// 命中的配送区域 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long? DeliveryZoneId { get; init; } + + /// + /// 命中的配送区域名称。 + /// + public string? DeliveryZoneName { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs index 8cf3cc0..fb3e1ae 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs @@ -52,6 +52,67 @@ public sealed class StoreDto /// public StoreStatus Status { get; init; } + /// + /// 门头招牌图。 + /// + public string? SignboardImageUrl { get; init; } + + /// + /// 主体类型。 + /// + public StoreOwnershipType OwnershipType { get; init; } + + /// + /// 审核状态。 + /// + public StoreAuditStatus AuditStatus { get; init; } + + /// + /// 经营状态。 + /// + public StoreBusinessStatus BusinessStatus { get; init; } + + /// + /// 歇业原因。 + /// + public StoreClosureReason? ClosureReason { get; init; } + + /// + /// 歇业原因补充说明。 + /// + public string? ClosureReasonText { get; init; } + + /// + /// 行业类目 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long? CategoryId { get; init; } + + /// + /// 审核驳回原因。 + /// + public string? RejectionReason { get; init; } + + /// + /// 提交审核时间。 + /// + public DateTime? SubmittedAt { get; init; } + + /// + /// 审核通过时间。 + /// + public DateTime? ActivatedAt { get; init; } + + /// + /// 强制关闭时间。 + /// + public DateTime? ForceClosedAt { get; init; } + + /// + /// 强制关闭原因。 + /// + public string? ForceCloseReason { get; init; } + /// /// 省份。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationBreakdownDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationBreakdownDto.cs new file mode 100644 index 0000000..f618ae1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationBreakdownDto.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 打包费拆分明细。 +/// +public sealed record StoreFeeCalculationBreakdownDto +{ + /// + /// SKU ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long SkuId { get; init; } + + /// + /// 数量。 + /// + public int Quantity { get; init; } + + /// + /// 单件打包费。 + /// + public decimal UnitFee { get; init; } + + /// + /// 小计。 + /// + public decimal Subtotal { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationItemDto.cs new file mode 100644 index 0000000..29b2864 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationItemDto.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 费用计算商品项。 +/// +public sealed record StoreFeeCalculationItemDto +{ + /// + /// SKU ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long SkuId { get; init; } + + /// + /// 数量。 + /// + public int Quantity { get; init; } + + /// + /// 单件打包费。 + /// + public decimal PackagingFee { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationRequestDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationRequestDto.cs new file mode 100644 index 0000000..e129bfa --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationRequestDto.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 费用计算请求 DTO。 +/// +public sealed record StoreFeeCalculationRequestDto +{ + /// + /// 商品金额。 + /// + public decimal OrderAmount { get; init; } + + /// + /// 商品种类数量。 + /// + public int? ItemCount { get; init; } + + /// + /// 商品列表。 + /// + public IReadOnlyList Items { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationResultDto.cs new file mode 100644 index 0000000..4b8195b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationResultDto.cs @@ -0,0 +1,64 @@ +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 费用计算结果。 +/// +public sealed record StoreFeeCalculationResultDto +{ + /// + /// 商品金额。 + /// + public decimal OrderAmount { get; init; } + + /// + /// 起送费。 + /// + public decimal MinimumOrderAmount { get; init; } + + /// + /// 是否达到起送费。 + /// + public bool MeetsMinimum { get; init; } + + /// + /// 距离起送差额。 + /// + public decimal? Shortfall { get; init; } + + /// + /// 配送费。 + /// + public decimal DeliveryFee { get; init; } + + /// + /// 打包费。 + /// + public decimal PackagingFee { get; init; } + + /// + /// 打包费模式。 + /// + public PackagingFeeMode PackagingFeeMode { get; init; } + + /// + /// 打包费拆分明细。 + /// + public IReadOnlyList? PackagingFeeBreakdown { get; init; } + + /// + /// 费用合计。 + /// + public decimal TotalFee { get; init; } + + /// + /// 总金额。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 文案提示。 + /// + public string? Message { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeDto.cs new file mode 100644 index 0000000..ec68948 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeDto.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店费用配置 DTO。 +/// +public sealed record StoreFeeDto +{ + /// + /// 费用配置 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 起送费。 + /// + public decimal MinimumOrderAmount { get; init; } + + /// + /// 配送费。 + /// + public decimal DeliveryFee { get; init; } + + /// + /// 打包费模式。 + /// + public PackagingFeeMode PackagingFeeMode { get; init; } + + /// + /// 固定打包费。 + /// + public decimal FixedPackagingFee { get; init; } + + /// + /// 免配送费门槛。 + /// + public decimal? FreeDeliveryThreshold { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 更新时间。 + /// + public DateTime? UpdatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationAlertDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationAlertDto.cs new file mode 100644 index 0000000..960ab6f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationAlertDto.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店资质预警明细 DTO。 +/// +public sealed record StoreQualificationAlertDto +{ + /// + /// 资质 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long QualificationId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 门店名称。 + /// + public string StoreName { get; init; } = string.Empty; + + /// + /// 门店编码。 + /// + public string StoreCode { get; init; } = string.Empty; + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户名称。 + /// + public string TenantName { get; init; } = string.Empty; + + /// + /// 资质类型。 + /// + public StoreQualificationType QualificationType { get; init; } + + /// + /// 过期时间。 + /// + public DateTime? ExpiresAt { get; init; } + + /// + /// 距离过期天数。 + /// + public int? DaysUntilExpiry { get; init; } + + /// + /// 是否已过期。 + /// + public bool IsExpired { get; init; } + + /// + /// 门店经营状态。 + /// + public StoreBusinessStatus StoreBusinessStatus { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationAlertResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationAlertResultDto.cs new file mode 100644 index 0000000..98c335f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationAlertResultDto.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店资质预警分页结果 DTO。 +/// +public sealed record StoreQualificationAlertResultDto +{ + /// + /// 资质预警列表。 + /// + public IReadOnlyList Items { get; init; } = []; + + /// + /// 当前页码。 + /// + public int Page { get; init; } + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } + + /// + /// 总条数。 + /// + public int TotalCount { get; init; } + + /// + /// 总页数。 + /// + public int TotalPages { get; init; } + + /// + /// 统计汇总。 + /// + public StoreQualificationAlertSummaryDto Summary { get; init; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationAlertSummaryDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationAlertSummaryDto.cs new file mode 100644 index 0000000..c3dcc93 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationAlertSummaryDto.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店资质预警统计 DTO。 +/// +public sealed record StoreQualificationAlertSummaryDto +{ + /// + /// 即将过期数量。 + /// + public int ExpiringSoonCount { get; init; } + + /// + /// 已过期数量。 + /// + public int ExpiredCount { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationCheckResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationCheckResultDto.cs new file mode 100644 index 0000000..c7c41fc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationCheckResultDto.cs @@ -0,0 +1,42 @@ +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 资质完整性检查结果。 +/// +public sealed class StoreQualificationCheckResultDto +{ + /// + /// 是否完整。 + /// + public bool IsComplete { get; init; } + + /// + /// 是否允许提交审核。 + /// + public bool CanSubmitAudit { get; init; } + + /// + /// 必要资质清单。 + /// + public IReadOnlyList RequiredTypes { get; init; } = []; + + /// + /// 即将过期数量。 + /// + public int ExpiringSoonCount { get; init; } + + /// + /// 已过期数量。 + /// + public int ExpiredCount { get; init; } + + /// + /// 缺失类型提示。 + /// + public IReadOnlyList MissingTypes { get; init; } = []; + + /// + /// 警告提示。 + /// + public IReadOnlyList Warnings { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationDto.cs new file mode 100644 index 0000000..c0f46b3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationDto.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店资质 DTO。 +/// +public sealed class StoreQualificationDto +{ + /// + /// 资质 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 资质类型。 + /// + public StoreQualificationType QualificationType { get; init; } + + /// + /// 证照文件 URL。 + /// + public string FileUrl { get; init; } = string.Empty; + + /// + /// 证照编号。 + /// + public string? DocumentNumber { get; init; } + + /// + /// 签发日期。 + /// + public DateTime? IssuedAt { get; init; } + + /// + /// 到期日期。 + /// + public DateTime? ExpiresAt { get; init; } + + /// + /// 是否已过期。 + /// + public bool IsExpired { get; init; } + + /// + /// 是否即将过期。 + /// + public bool IsExpiringSoon { get; init; } + + /// + /// 距离过期天数。 + /// + public int? DaysUntilExpiry { get; init; } + + /// + /// 排序值。 + /// + public int SortOrder { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 更新时间。 + /// + public DateTime? UpdatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationRequirementDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationRequirementDto.cs new file mode 100644 index 0000000..c7ad022 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreQualificationRequirementDto.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店资质完整性项。 +/// +public sealed class StoreQualificationRequirementDto +{ + /// + /// 资质类型。 + /// + public StoreQualificationType QualificationType { get; init; } + + /// + /// 是否必需。 + /// + public bool IsRequired { get; init; } + + /// + /// 是否已上传。 + /// + public bool IsUploaded { get; init; } + + /// + /// 是否有效。 + /// + public bool IsValid { get; init; } + + /// + /// 上传数量。 + /// + public int UploadedCount { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/BatchUpdateBusinessHoursCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/BatchUpdateBusinessHoursCommandHandler.cs new file mode 100644 index 0000000..e5ab79d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/BatchUpdateBusinessHoursCommandHandler.cs @@ -0,0 +1,72 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Validators; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 批量更新营业时段处理器。 +/// +public sealed class BatchUpdateBusinessHoursCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> Handle(BatchUpdateBusinessHoursCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. (空行后) 校验时段重叠 + var overlapError = BusinessHourValidators.ValidateOverlap(request.Items); + if (!string.IsNullOrWhiteSpace(overlapError)) + { + throw new BusinessException(ErrorCodes.ValidationFailed, overlapError); + } + + // 3. (空行后) 删除旧时段 + var existingHours = await storeRepository.GetBusinessHoursAsync(request.StoreId, tenantId, cancellationToken); + foreach (var hour in existingHours) + { + await storeRepository.DeleteBusinessHourAsync(hour.Id, tenantId, cancellationToken); + } + + // 4. (空行后) 新增时段配置 + if (request.Items.Count > 0) + { + var hours = request.Items.Select(item => new StoreBusinessHour + { + StoreId = request.StoreId, + DayOfWeek = item.DayOfWeek, + HourType = item.HourType, + StartTime = item.StartTime, + EndTime = item.EndTime, + CapacityLimit = item.CapacityLimit, + Notes = item.Notes?.Trim() + }).ToList(); + + await storeRepository.AddBusinessHoursAsync(hours, cancellationToken); + } + + // 5. (空行后) 保存并返回结果 + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("批量更新门店营业时段 {StoreId}", request.StoreId); + + var refreshed = await storeRepository.GetBusinessHoursAsync(request.StoreId, tenantId, cancellationToken); + return refreshed.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CalculateStoreFeeQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CalculateStoreFeeQueryHandler.cs new file mode 100644 index 0000000..c8f7189 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CalculateStoreFeeQueryHandler.cs @@ -0,0 +1,54 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 门店费用预览查询处理器。 +/// +public sealed class CalculateStoreFeeQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + IStoreFeeCalculationService feeCalculationService) + : IRequestHandler +{ + /// + public async Task Handle(CalculateStoreFeeQuery request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. (空行后) 获取费用配置 + var fee = await storeRepository.GetStoreFeeAsync(request.StoreId, tenantId, cancellationToken) + ?? new StoreFee + { + StoreId = request.StoreId, + MinimumOrderAmount = 0m, + BaseDeliveryFee = 0m, + PackagingFeeMode = PackagingFeeMode.Fixed, + FixedPackagingFee = 0m + }; + + // 3. (空行后) 执行费用计算 + var calculationRequest = new StoreFeeCalculationRequestDto + { + OrderAmount = request.OrderAmount, + ItemCount = request.ItemCount, + Items = request.Items + }; + return feeCalculationService.Calculate(fee, calculationRequest); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CheckStoreDeliveryZoneQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CheckStoreDeliveryZoneQueryHandler.cs new file mode 100644 index 0000000..be01143 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CheckStoreDeliveryZoneQueryHandler.cs @@ -0,0 +1,60 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 配送范围检测查询处理器。 +/// +public sealed class CheckStoreDeliveryZoneQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + IDeliveryZoneService deliveryZoneService) + : IRequestHandler +{ + /// + public async Task Handle(CheckStoreDeliveryZoneQuery request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. (空行后) 执行配送范围判断 + var zones = await storeRepository.GetDeliveryZonesAsync(request.StoreId, tenantId, cancellationToken); + var result = deliveryZoneService.CheckPointInZones(zones, request.Longitude, request.Latitude); + + // 3. (空行后) 计算距离 + if (store.Longitude.HasValue && store.Latitude.HasValue) + { + var distance = CalculateDistanceKm(store.Latitude.Value, store.Longitude.Value, request.Latitude, request.Longitude); + result = result with { Distance = (decimal)Math.Round(distance, 2, MidpointRounding.AwayFromZero) }; + } + return result; + } + + private static double CalculateDistanceKm(double latitude1, double longitude1, double latitude2, double longitude2) + { + const double earthRadius = 6371000d; + var latRad1 = DegreesToRadians(latitude1); + var latRad2 = DegreesToRadians(latitude2); + var deltaLat = DegreesToRadians(latitude2 - latitude1); + var deltaLon = DegreesToRadians(longitude2 - longitude1); + var sinLat = Math.Sin(deltaLat / 2); + var sinLon = Math.Sin(deltaLon / 2); + var a = sinLat * sinLat + Math.Cos(latRad1) * Math.Cos(latRad2) * sinLon * sinLon; + var c = 2 * Math.Asin(Math.Min(1, Math.Sqrt(a))); + return earthRadius * c / 1000d; + } + + private static double DegreesToRadians(double degrees) => degrees * (Math.PI / 180d); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CheckStoreQualificationsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CheckStoreQualificationsQueryHandler.cs new file mode 100644 index 0000000..11dad7b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CheckStoreQualificationsQueryHandler.cs @@ -0,0 +1,133 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 门店资质完整性检查处理器。 +/// +public sealed class CheckStoreQualificationsQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(CheckStoreQualificationsQuery request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. (空行后) 同主体门店默认视为完整 + if (store.OwnershipType == StoreOwnershipType.SameEntity) + { + return new StoreQualificationCheckResultDto + { + IsComplete = true, + CanSubmitAudit = true + }; + } + + // 3. (空行后) 读取资质列表并统计 + var qualifications = await storeRepository.GetQualificationsAsync(request.StoreId, tenantId, cancellationToken); + var grouped = qualifications + .GroupBy(x => x.QualificationType) + .ToDictionary(x => x.Key, x => x.ToList()); + + var expiredCount = qualifications.Count(x => x.IsExpired); + var expiringSoonCount = qualifications.Count(x => x.IsExpiringSoon); + + var foodStats = BuildRequirement(grouped, StoreQualificationType.FoodServiceLicense, true); + var businessStats = BuildRequirement(grouped, StoreQualificationType.BusinessLicense, true); + var storefrontStats = BuildRequirement(grouped, StoreQualificationType.StorefrontPhoto, true); + var interiorStats = BuildInteriorRequirement(grouped); + + var hasLicense = foodStats.IsValid || businessStats.IsValid; + var hasStorefront = storefrontStats.IsValid; + var hasInterior = interiorStats.IsValid; + + var missingTypes = new List(); + if (!hasLicense) + { + missingTypes.Add("营业执照/食品经营许可证"); + } + + if (!hasStorefront) + { + missingTypes.Add("门头实景照"); + } + + if (!hasInterior) + { + missingTypes.Add("店内环境照(至少2张)"); + } + + var warnings = missingTypes.Count == 0 + ? Array.Empty() + : missingTypes.Select(type => $"缺少必要资质:{type}").ToArray(); + + // 4. (空行后) 组装结果 + var requirements = new List + { + foodStats, + businessStats, + storefrontStats, + interiorStats + }; + + var isComplete = hasLicense && hasStorefront && hasInterior; + return new StoreQualificationCheckResultDto + { + IsComplete = isComplete, + CanSubmitAudit = isComplete, + RequiredTypes = requirements, + ExpiringSoonCount = expiringSoonCount, + ExpiredCount = expiredCount, + MissingTypes = missingTypes, + Warnings = warnings + }; + } + + private static StoreQualificationRequirementDto BuildRequirement( + IReadOnlyDictionary> grouped, + StoreQualificationType type, + bool required) + { + var list = grouped.TryGetValue(type, out var items) ? items : []; + var hasUploaded = list.Count > 0; + var hasValid = list.Any(item => !item.IsExpired); + return new StoreQualificationRequirementDto + { + QualificationType = type, + IsRequired = required, + IsUploaded = hasUploaded, + IsValid = hasValid, + UploadedCount = list.Count + }; + } + + private static StoreQualificationRequirementDto BuildInteriorRequirement( + IReadOnlyDictionary> grouped) + { + var list = grouped.TryGetValue(StoreQualificationType.InteriorPhoto, out var items) ? items : []; + var validCount = list.Count(item => !item.IsExpired); + return new StoreQualificationRequirementDto + { + QualificationType = StoreQualificationType.InteriorPhoto, + IsRequired = true, + IsUploaded = list.Count > 0, + IsValid = validCount >= 2, + UploadedCount = list.Count + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs index 104c302..8c55355 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs @@ -1,25 +1,54 @@ using MediatR; using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores; using TakeoutSaaS.Application.App.Stores.Commands; using TakeoutSaaS.Application.App.Stores.Dto; using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; namespace TakeoutSaaS.Application.App.Stores.Handlers; /// /// 创建门店命令处理器。 /// -public sealed class CreateStoreCommandHandler(IStoreRepository storeRepository, ILogger logger) +public sealed class CreateStoreCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) : IRequestHandler { - private readonly IStoreRepository _storeRepository = storeRepository; - private readonly ILogger _logger = logger; - /// public async Task Handle(CreateStoreCommand request, CancellationToken cancellationToken) { - // 1. 构建实体 + // 1. 校验门店坐标唯一性(100 米内禁止重复) + if (request.Longitude.HasValue && request.Latitude.HasValue) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var isDuplicate = await storeRepository.ExistsStoreWithinDistanceAsync( + request.MerchantId, + tenantId, + request.Longitude.Value, + request.Latitude.Value, + 100, + cancellationToken); + if (isDuplicate) + { + throw new BusinessException(ErrorCodes.Conflict, "该位置已存在门店"); + } + } + + // 2. (空行后) 计算审核与经营状态 + var now = DateTime.UtcNow; + var isSameEntity = request.OwnershipType == StoreOwnershipType.SameEntity; + var auditStatus = isSameEntity ? StoreAuditStatus.Activated : StoreAuditStatus.Draft; + var businessStatus = StoreBusinessStatus.Resting; + DateTime? activatedAt = isSameEntity ? now : null; + + // 3. (空行后) 构建实体 var store = new Store { MerchantId = request.MerchantId, @@ -28,6 +57,12 @@ public sealed class CreateStoreCommandHandler(IStoreRepository storeRepository, Phone = request.Phone?.Trim(), ManagerName = request.ManagerName?.Trim(), Status = request.Status, + SignboardImageUrl = request.SignboardImageUrl?.Trim(), + OwnershipType = request.OwnershipType, + AuditStatus = auditStatus, + BusinessStatus = businessStatus, + CategoryId = request.CategoryId, + ActivatedAt = activatedAt, Province = request.Province?.Trim(), City = request.City?.Trim(), District = request.District?.Trim(), @@ -44,39 +79,20 @@ public sealed class CreateStoreCommandHandler(IStoreRepository storeRepository, SupportsQueueing = request.SupportsQueueing }; - // 2. 持久化 - await _storeRepository.AddStoreAsync(store, cancellationToken); - await _storeRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("创建门店 {StoreId} - {StoreName}", store.Id, store.Name); + // 4. (空行后) 持久化并初始化费用配置 + await storeRepository.AddStoreAsync(store, cancellationToken); + await storeRepository.AddStoreFeeAsync(new StoreFee + { + StoreId = store.Id, + MinimumOrderAmount = 0m, + BaseDeliveryFee = 0m, + PackagingFeeMode = PackagingFeeMode.Fixed, + FixedPackagingFee = 0m + }, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("创建门店 {StoreId} - {StoreName}", store.Id, store.Name); - // 3. 返回 DTO - return MapToDto(store); + // 5. (空行后) 返回 DTO + return StoreMapping.ToDto(store); } - - private static StoreDto MapToDto(Store store) => new() - { - Id = store.Id, - TenantId = store.TenantId, - MerchantId = store.MerchantId, - Code = store.Code, - Name = store.Name, - Phone = store.Phone, - ManagerName = store.ManagerName, - Status = store.Status, - Province = store.Province, - City = store.City, - District = store.District, - Address = store.Address, - Longitude = store.Longitude, - Latitude = store.Latitude, - Announcement = store.Announcement, - Tags = store.Tags, - DeliveryRadiusKm = store.DeliveryRadiusKm, - SupportsDineIn = store.SupportsDineIn, - SupportsPickup = store.SupportsPickup, - SupportsDelivery = store.SupportsDelivery, - SupportsReservation = store.SupportsReservation, - SupportsQueueing = store.SupportsQueueing, - CreatedAt = store.CreatedAt - }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreDeliveryZoneCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreDeliveryZoneCommandHandler.cs index c5858b3..185350e 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreDeliveryZoneCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreDeliveryZoneCommandHandler.cs @@ -2,6 +2,7 @@ using MediatR; using Microsoft.Extensions.Logging; using TakeoutSaaS.Application.App.Stores.Commands; using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Services; using TakeoutSaaS.Domain.Stores.Entities; using TakeoutSaaS.Domain.Stores.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; @@ -16,42 +17,46 @@ namespace TakeoutSaaS.Application.App.Stores.Handlers; public sealed class CreateStoreDeliveryZoneCommandHandler( IStoreRepository storeRepository, ITenantProvider tenantProvider, + IGeoJsonValidationService geoJsonValidationService, ILogger logger) : IRequestHandler { - private readonly IStoreRepository _storeRepository = storeRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(CreateStoreDeliveryZoneCommand request, CancellationToken cancellationToken) { // 1. 校验门店存在 - var tenantId = _tenantProvider.GetCurrentTenantId(); - var store = await _storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); if (store is null) { throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); } - // 2. 构建实体 + // 2. (空行后) 校验 GeoJSON + var validation = geoJsonValidationService.ValidatePolygon(request.PolygonGeoJson); + if (!validation.IsValid) + { + throw new BusinessException(ErrorCodes.ValidationFailed, validation.ErrorMessage ?? "配送范围格式错误"); + } + + // 3. (空行后) 构建实体 var zone = new StoreDeliveryZone { StoreId = request.StoreId, ZoneName = request.ZoneName.Trim(), - PolygonGeoJson = request.PolygonGeoJson.Trim(), + PolygonGeoJson = (validation.NormalizedGeoJson ?? request.PolygonGeoJson).Trim(), MinimumOrderAmount = request.MinimumOrderAmount, DeliveryFee = request.DeliveryFee, EstimatedMinutes = request.EstimatedMinutes, SortOrder = request.SortOrder }; - // 3. 持久化 - await _storeRepository.AddDeliveryZonesAsync(new[] { zone }, cancellationToken); - await _storeRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("创建配送区域 {DeliveryZoneId} 对应门店 {StoreId}", zone.Id, request.StoreId); + // 4. (空行后) 持久化 + await storeRepository.AddDeliveryZonesAsync(new[] { zone }, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("创建配送区域 {DeliveryZoneId} 对应门店 {StoreId}", zone.Id, request.StoreId); - // 4. 返回 DTO + // 5. (空行后) 返回 DTO return StoreMapping.ToDto(zone); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreQualificationCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreQualificationCommandHandler.cs new file mode 100644 index 0000000..6f4ebd5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreQualificationCommandHandler.cs @@ -0,0 +1,85 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建门店资质处理器。 +/// +public sealed class CreateStoreQualificationCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(CreateStoreQualificationCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. (空行后) 审核中门店禁止修改资质 + if (store.AuditStatus == StoreAuditStatus.Pending) + { + throw new BusinessException(ErrorCodes.Conflict, "门店审核中,无法修改资质"); + } + + // 3. (空行后) 检查是否需要替换同类型记录 + var qualifications = await storeRepository.GetQualificationsAsync(request.StoreId, tenantId, cancellationToken); + var shouldReplace = ShouldReplace(request.QualificationType); + var existing = shouldReplace + ? qualifications.FirstOrDefault(x => x.QualificationType == request.QualificationType) + : null; + + // 4. (空行后) 构建或更新资质实体 + if (existing is null) + { + existing = new StoreQualification + { + StoreId = request.StoreId, + QualificationType = request.QualificationType, + FileUrl = request.FileUrl.Trim(), + DocumentNumber = request.DocumentNumber?.Trim(), + IssuedAt = request.IssuedAt, + ExpiresAt = request.ExpiresAt, + SortOrder = request.SortOrder + }; + + await storeRepository.AddQualificationAsync(existing, cancellationToken); + } + else + { + existing.FileUrl = request.FileUrl.Trim(); + existing.DocumentNumber = request.DocumentNumber?.Trim(); + existing.IssuedAt = request.IssuedAt; + existing.ExpiresAt = request.ExpiresAt; + existing.SortOrder = request.SortOrder; + + await storeRepository.UpdateQualificationAsync(existing, cancellationToken); + } + + // 5. (空行后) 保存变更并返回结果 + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新门店资质 {QualificationId} 对应门店 {StoreId}", existing.Id, request.StoreId); + + return StoreMapping.ToDto(existing); + } + + private static bool ShouldReplace(StoreQualificationType type) + => type is StoreQualificationType.BusinessLicense + or StoreQualificationType.FoodServiceLicense + or StoreQualificationType.StorefrontPhoto; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreQualificationCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreQualificationCommandHandler.cs new file mode 100644 index 0000000..0d3b662 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreQualificationCommandHandler.cs @@ -0,0 +1,72 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除门店资质处理器。 +/// +public sealed class DeleteStoreQualificationCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(DeleteStoreQualificationCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. (空行后) 审核中门店禁止删除资质 + if (store.AuditStatus == StoreAuditStatus.Pending) + { + throw new BusinessException(ErrorCodes.Conflict, "门店审核中,无法删除资质"); + } + + // 3. (空行后) 获取资质记录 + var qualification = await storeRepository.FindQualificationByIdAsync(request.QualificationId, tenantId, cancellationToken); + if (qualification is null || qualification.StoreId != request.StoreId) + { + return false; + } + + // 4. (空行后) 已激活的外部门店必须保留必要资质 + if (store.OwnershipType == StoreOwnershipType.DifferentEntity + && store.AuditStatus == StoreAuditStatus.Activated + && IsLicenseType(qualification.QualificationType)) + { + var qualifications = await storeRepository.GetQualificationsAsync(request.StoreId, tenantId, cancellationToken); + var remainingValid = qualifications + .Where(item => IsLicenseType(item.QualificationType)) + .Where(item => item.Id != qualification.Id && !item.IsExpired) + .ToList(); + + if (remainingValid.Count == 0) + { + throw new BusinessException(ErrorCodes.Forbidden, "不能删除必要资质"); + } + } + + // 5. (空行后) 执行删除 + await storeRepository.DeleteQualificationAsync(request.QualificationId, tenantId, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("删除门店资质 {QualificationId} 对应门店 {StoreId}", qualification.Id, request.StoreId); + + return true; + } + + private static bool IsLicenseType(StoreQualificationType type) + => type is StoreQualificationType.BusinessLicense or StoreQualificationType.FoodServiceLicense; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs index ecd7415..e8f0841 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs @@ -1,4 +1,5 @@ using MediatR; +using TakeoutSaaS.Application.App.Stores; using TakeoutSaaS.Application.App.Stores.Dto; using TakeoutSaaS.Application.App.Stores.Queries; using TakeoutSaaS.Domain.Stores.Entities; @@ -15,41 +16,11 @@ public sealed class GetStoreByIdQueryHandler( ITenantProvider tenantProvider) : IRequestHandler { - private readonly IStoreRepository _storeRepository = storeRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task Handle(GetStoreByIdQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var store = await _storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); - return store == null ? null : MapToDto(store); + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + return store == null ? null : StoreMapping.ToDto(store); } - - private static StoreDto MapToDto(Store store) => new() - { - Id = store.Id, - TenantId = store.TenantId, - MerchantId = store.MerchantId, - Code = store.Code, - Name = store.Name, - Phone = store.Phone, - ManagerName = store.ManagerName, - Status = store.Status, - Province = store.Province, - City = store.City, - District = store.District, - Address = store.Address, - Longitude = store.Longitude, - Latitude = store.Latitude, - Announcement = store.Announcement, - Tags = store.Tags, - DeliveryRadiusKm = store.DeliveryRadiusKm, - SupportsDineIn = store.SupportsDineIn, - SupportsPickup = store.SupportsPickup, - SupportsDelivery = store.SupportsDelivery, - SupportsReservation = store.SupportsReservation, - SupportsQueueing = store.SupportsQueueing, - CreatedAt = store.CreatedAt - }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreFeeQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreFeeQueryHandler.cs new file mode 100644 index 0000000..0ad2573 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreFeeQueryHandler.cs @@ -0,0 +1,49 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 获取门店费用配置处理器。 +/// +public sealed class GetStoreFeeQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(GetStoreFeeQuery request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. (空行后) 查询费用配置 + var fee = await storeRepository.GetStoreFeeAsync(request.StoreId, tenantId, cancellationToken); + if (fee is null) + { + var fallback = new StoreFee + { + StoreId = request.StoreId, + MinimumOrderAmount = 0m, + BaseDeliveryFee = 0m, + PackagingFeeMode = Domain.Stores.Enums.PackagingFeeMode.Fixed, + FixedPackagingFee = 0m + }; + return StoreMapping.ToDto(fallback); + } + + // 3. (空行后) 返回结果 + return StoreMapping.ToDto(fee); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListExpiringStoreQualificationsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListExpiringStoreQualificationsQueryHandler.cs new file mode 100644 index 0000000..6a7521d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListExpiringStoreQualificationsQueryHandler.cs @@ -0,0 +1,271 @@ +using System.Data; +using System.Data.Common; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 资质预警查询处理器。 +/// +public sealed class ListExpiringStoreQualificationsQueryHandler( + IDapperExecutor dapperExecutor) + : IRequestHandler +{ + /// + public async Task Handle( + ListExpiringStoreQualificationsQuery request, + CancellationToken cancellationToken) + { + // 1. 规范化参数 + var page = request.Page <= 0 ? 1 : request.Page; + var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize; + var daysThreshold = request.DaysThreshold is null or <= 0 ? 30 : request.DaysThreshold.Value; + if (daysThreshold > 365) + { + daysThreshold = 365; + } + var offset = (page - 1) * pageSize; + var now = DateTime.UtcNow; + var expiringBefore = now.AddDays(daysThreshold); + + // 2. (空行后) 执行查询 + return await dapperExecutor.QueryAsync( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + // 2.1 统计汇总 + var summary = await ExecuteSummaryAsync(connection, now, expiringBefore, request.TenantId, token); + + // 2.2 (空行后) 统计总数 + var total = await ExecuteScalarIntAsync( + connection, + BuildCountSql(), + [ + ("tenantId", request.TenantId), + ("expiredOnly", request.Expired), + ("now", now), + ("expiringBefore", expiringBefore) + ], + token); + if (total == 0) + { + return BuildResult([], page, pageSize, total, summary); + } + + // 2.3 (空行后) 查询列表 + await using var listCommand = CreateCommand( + connection, + BuildListSql(), + [ + ("tenantId", request.TenantId), + ("expiredOnly", request.Expired), + ("now", now), + ("expiringBefore", expiringBefore), + ("offset", offset), + ("limit", pageSize) + ]); + + await using var reader = await listCommand.ExecuteReaderAsync(token); + if (!reader.HasRows) + { + return BuildResult([], page, pageSize, total, summary); + } + + // 2.4 (空行后) 初始化字段序号 + var qualificationIdOrdinal = reader.GetOrdinal("QualificationId"); + var storeIdOrdinal = reader.GetOrdinal("StoreId"); + var storeNameOrdinal = reader.GetOrdinal("StoreName"); + var storeCodeOrdinal = reader.GetOrdinal("StoreCode"); + var tenantIdOrdinal = reader.GetOrdinal("TenantId"); + var tenantNameOrdinal = reader.GetOrdinal("TenantName"); + var typeOrdinal = reader.GetOrdinal("QualificationType"); + var expiresAtOrdinal = reader.GetOrdinal("ExpiresAt"); + var businessStatusOrdinal = reader.GetOrdinal("BusinessStatus"); + + // 2.5 (空行后) 读取并映射 + List items = []; + while (await reader.ReadAsync(token)) + { + DateTime? expiresAt = reader.IsDBNull(expiresAtOrdinal) + ? null + : reader.GetDateTime(expiresAtOrdinal); + var isExpired = expiresAt.HasValue && expiresAt.Value < now; + int? daysUntilExpiry = expiresAt.HasValue + ? (int)Math.Ceiling((expiresAt.Value.Date - now.Date).TotalDays) + : null; + + items.Add(new StoreQualificationAlertDto + { + QualificationId = reader.GetInt64(qualificationIdOrdinal), + StoreId = reader.GetInt64(storeIdOrdinal), + StoreName = reader.GetString(storeNameOrdinal), + StoreCode = reader.GetString(storeCodeOrdinal), + TenantId = reader.GetInt64(tenantIdOrdinal), + TenantName = reader.GetString(tenantNameOrdinal), + QualificationType = (StoreQualificationType)reader.GetInt32(typeOrdinal), + ExpiresAt = expiresAt, + DaysUntilExpiry = daysUntilExpiry, + IsExpired = isExpired, + StoreBusinessStatus = (StoreBusinessStatus)reader.GetInt32(businessStatusOrdinal) + }); + } + + // 2.6 (空行后) 组装结果 + return BuildResult(items, page, pageSize, total, summary); + }, + cancellationToken); + } + + private static StoreQualificationAlertResultDto BuildResult( + IReadOnlyList items, + int page, + int pageSize, + int totalCount, + StoreQualificationAlertSummaryDto summary) + { + // 1. 计算总页数 + var totalPages = pageSize == 0 ? 0 : (int)Math.Ceiling(totalCount / (double)pageSize); + + // 2. (空行后) 组装分页结果 + return new StoreQualificationAlertResultDto + { + Items = items, + Page = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = totalPages, + Summary = summary + }; + } + + private static async Task ExecuteSummaryAsync( + IDbConnection connection, + DateTime now, + DateTime expiringBefore, + long? tenantId, + CancellationToken cancellationToken) + { + await using var command = CreateCommand( + connection, + BuildSummarySql(), + [ + ("tenantId", tenantId), + ("now", now), + ("expiringBefore", expiringBefore) + ]); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!reader.HasRows || !await reader.ReadAsync(cancellationToken)) + { + return new StoreQualificationAlertSummaryDto(); + } + + // 1. (空行后) 读取统计结果 + var expiringSoonOrdinal = reader.GetOrdinal("ExpiringSoonCount"); + var expiredOrdinal = reader.GetOrdinal("ExpiredCount"); + + return new StoreQualificationAlertSummaryDto + { + ExpiringSoonCount = reader.IsDBNull(expiringSoonOrdinal) ? 0 : reader.GetInt32(expiringSoonOrdinal), + ExpiredCount = reader.IsDBNull(expiredOrdinal) ? 0 : reader.GetInt32(expiredOrdinal) + }; + } + + private static string BuildCountSql() + { + return """ + select count(*) + from public.store_qualifications q + join public.stores s on s."Id" = q."StoreId" and s."DeletedAt" is null + join public.tenants t on t."Id" = s."TenantId" and t."DeletedAt" is null + where q."DeletedAt" is null + and q."ExpiresAt" is not null + and (@tenantId::bigint is null or s."TenantId" = @tenantId) + and ( + (@expiredOnly::boolean = true and q."ExpiresAt" < @now) + or (@expiredOnly::boolean = false and q."ExpiresAt" <= @expiringBefore) + ); + """; + } + + private static string BuildListSql() + { + return """ + select + q."Id" as "QualificationId", + q."StoreId", + s."Name" as "StoreName", + s."Code" as "StoreCode", + s."TenantId", + t."Name" as "TenantName", + q."QualificationType", + q."ExpiresAt", + s."BusinessStatus" + from public.store_qualifications q + join public.stores s on s."Id" = q."StoreId" and s."DeletedAt" is null + join public.tenants t on t."Id" = s."TenantId" and t."DeletedAt" is null + where q."DeletedAt" is null + and q."ExpiresAt" is not null + and (@tenantId::bigint is null or s."TenantId" = @tenantId) + and ( + (@expiredOnly::boolean = true and q."ExpiresAt" < @now) + or (@expiredOnly::boolean = false and q."ExpiresAt" <= @expiringBefore) + ) + order by q."ExpiresAt" asc, q."Id" asc + offset @offset + limit @limit; + """; + } + + private static string BuildSummarySql() + { + return """ + select + coalesce(sum(case when q."ExpiresAt" < @now then 1 else 0 end), 0) as "ExpiredCount", + coalesce(sum(case when q."ExpiresAt" >= @now and q."ExpiresAt" <= @expiringBefore then 1 else 0 end), 0) as "ExpiringSoonCount" + from public.store_qualifications q + join public.stores s on s."Id" = q."StoreId" and s."DeletedAt" is null + join public.tenants t on t."Id" = s."TenantId" and t."DeletedAt" is null + where q."DeletedAt" is null + and q."ExpiresAt" is not null + and (@tenantId::bigint is null or s."TenantId" = @tenantId); + """; + } + + 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; + + // 1. (空行后) 绑定参数 + foreach (var (name, value) in parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + return (DbCommand)command; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreQualificationsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreQualificationsQueryHandler.cs new file mode 100644 index 0000000..4f4ba93 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreQualificationsQueryHandler.cs @@ -0,0 +1,36 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 门店资质列表查询处理器。 +/// +public sealed class ListStoreQualificationsQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(ListStoreQualificationsQuery request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. (空行后) 读取资质列表 + var qualifications = await storeRepository.GetQualificationsAsync(request.StoreId, tenantId, cancellationToken); + + // 3. (空行后) 映射 DTO + return qualifications.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs index f9b2330..e94570c 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs @@ -1,4 +1,5 @@ using MediatR; +using TakeoutSaaS.Application.App.Stores; using TakeoutSaaS.Application.App.Stores.Dto; using TakeoutSaaS.Application.App.Stores.Queries; using TakeoutSaaS.Domain.Stores.Repositories; @@ -15,19 +16,19 @@ public sealed class SearchStoresQueryHandler( ITenantProvider tenantProvider) : IRequestHandler> { - private readonly IStoreRepository _storeRepository = storeRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task> Handle(SearchStoresQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var stores = await _storeRepository.SearchAsync(tenantId, request.Status, cancellationToken); - - if (request.MerchantId.HasValue) - { - stores = stores.Where(x => x.MerchantId == request.MerchantId.Value).ToList(); - } + var tenantId = tenantProvider.GetCurrentTenantId(); + var stores = await storeRepository.SearchAsync( + tenantId, + request.MerchantId, + request.Status, + request.AuditStatus, + request.BusinessStatus, + request.OwnershipType, + request.Keyword, + cancellationToken); var sorted = ApplySorting(stores, request.SortBy, request.SortDescending); var paged = sorted @@ -35,7 +36,7 @@ public sealed class SearchStoresQueryHandler( .Take(request.PageSize) .ToList(); - var items = paged.Select(MapToDto).ToList(); + var items = paged.Select(StoreMapping.ToDto).ToList(); return new PagedResult(items, request.Page, request.PageSize, stores.Count); } @@ -53,30 +54,4 @@ public sealed class SearchStoresQueryHandler( }; } - private static StoreDto MapToDto(Domain.Stores.Entities.Store store) => new() - { - Id = store.Id, - TenantId = store.TenantId, - MerchantId = store.MerchantId, - Code = store.Code, - Name = store.Name, - Phone = store.Phone, - ManagerName = store.ManagerName, - Status = store.Status, - Province = store.Province, - City = store.City, - District = store.District, - Address = store.Address, - Longitude = store.Longitude, - Latitude = store.Latitude, - Announcement = store.Announcement, - Tags = store.Tags, - DeliveryRadiusKm = store.DeliveryRadiusKm, - SupportsDineIn = store.SupportsDineIn, - SupportsPickup = store.SupportsPickup, - SupportsDelivery = store.SupportsDelivery, - SupportsReservation = store.SupportsReservation, - SupportsQueueing = store.SupportsQueueing, - CreatedAt = store.CreatedAt - }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SubmitStoreAuditCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SubmitStoreAuditCommandHandler.cs new file mode 100644 index 0000000..90b678e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SubmitStoreAuditCommandHandler.cs @@ -0,0 +1,115 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 提交门店审核处理器。 +/// +public sealed class SubmitStoreAuditCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor, + IMediator mediator, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(SubmitStoreAuditCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + if (store.AuditStatus is not StoreAuditStatus.Draft and not StoreAuditStatus.Rejected) + { + throw new BusinessException(ErrorCodes.Conflict, "门店不处于可提交状态"); + } + + // 2. (空行后) 处理同主体门店直接激活 + var now = DateTime.UtcNow; + if (store.OwnershipType == StoreOwnershipType.SameEntity) + { + var previousStatus = store.AuditStatus; + store.AuditStatus = StoreAuditStatus.Activated; + store.BusinessStatus = StoreBusinessStatus.Resting; + store.SubmittedAt ??= now; + store.ActivatedAt ??= now; + store.RejectionReason = null; + + await storeRepository.UpdateStoreAsync(store, cancellationToken); + await storeRepository.AddAuditRecordAsync(new StoreAuditRecord + { + StoreId = store.Id, + Action = StoreAuditAction.AutoActivate, + PreviousStatus = previousStatus, + NewStatus = store.AuditStatus, + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName(), + Remarks = "同主体门店自动激活" + }, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("门店 {StoreId} 同主体自动激活", store.Id); + + return true; + } + + // 3. (空行后) 校验资质完整性 + var checkResult = await mediator.Send(new CheckStoreQualificationsQuery { StoreId = request.StoreId }, cancellationToken); + if (!checkResult.CanSubmitAudit) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "门店资质不完整,无法提交审核"); + } + + // 4. (空行后) 更新审核状态 + var action = store.AuditStatus == StoreAuditStatus.Rejected + ? StoreAuditAction.Resubmit + : StoreAuditAction.Submit; + var previous = store.AuditStatus; + store.AuditStatus = StoreAuditStatus.Pending; + store.BusinessStatus = StoreBusinessStatus.Resting; + store.SubmittedAt = now; + store.RejectionReason = null; + + await storeRepository.UpdateStoreAsync(store, cancellationToken); + await storeRepository.AddAuditRecordAsync(new StoreAuditRecord + { + StoreId = store.Id, + Action = action, + PreviousStatus = previous, + NewStatus = store.AuditStatus, + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName(), + Remarks = action == StoreAuditAction.Resubmit ? "门店重新提交审核" : "门店提交审核" + }, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("门店 {StoreId} 提交审核", store.Id); + + return true; + } + + private long? ResolveOperatorId() + { + var id = currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ToggleBusinessStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ToggleBusinessStatusCommandHandler.cs new file mode 100644 index 0000000..24f8072 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ToggleBusinessStatusCommandHandler.cs @@ -0,0 +1,69 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 切换门店经营状态处理器。 +/// +public sealed class ToggleBusinessStatusCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(ToggleBusinessStatusCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + if (store.AuditStatus != StoreAuditStatus.Activated) + { + throw new BusinessException(ErrorCodes.Conflict, "门店未激活,无法切换状态"); + } + + if (store.BusinessStatus == StoreBusinessStatus.ForceClosed) + { + throw new BusinessException(ErrorCodes.Conflict, "门店已被平台强制关闭,无法切换"); + } + + // 2. (空行后) 应用状态变更 + if (request.BusinessStatus == StoreBusinessStatus.Resting) + { + if (!request.ClosureReason.HasValue) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "切换休息中必须选择歇业原因"); + } + + store.BusinessStatus = StoreBusinessStatus.Resting; + store.ClosureReason = request.ClosureReason; + store.ClosureReasonText = request.ClosureReasonText?.Trim(); + } + else + { + store.BusinessStatus = StoreBusinessStatus.Open; + store.ClosureReason = null; + store.ClosureReasonText = null; + } + + // 3. (空行后) 保存并返回 + await storeRepository.UpdateStoreAsync(store, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("切换门店 {StoreId} 状态至 {BusinessStatus}", store.Id, store.BusinessStatus); + + return StoreMapping.ToDto(store); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs index 025ae4d..69f8133 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs @@ -1,9 +1,13 @@ using MediatR; using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores; using TakeoutSaaS.Application.App.Stores.Commands; using TakeoutSaaS.Application.App.Stores.Dto; using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Tenancy; namespace TakeoutSaaS.Application.App.Stores.Handlers; @@ -17,28 +21,38 @@ public sealed class UpdateStoreCommandHandler( ILogger logger) : IRequestHandler { - private readonly IStoreRepository _storeRepository = storeRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(UpdateStoreCommand request, CancellationToken cancellationToken) { // 1. 读取门店 - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); if (existing == null) { return null; } - // 2. 更新字段 + // 2. 校验状态是否允许更新 + if (existing.AuditStatus == StoreAuditStatus.Pending) + { + throw new BusinessException(ErrorCodes.Conflict, "门店审核中,暂不允许修改"); + } + + // 2.1 (空行后) 强制关闭门店禁止更新 + if (existing.BusinessStatus == StoreBusinessStatus.ForceClosed) + { + throw new BusinessException(ErrorCodes.Conflict, "门店已被强制关闭,暂不允许修改"); + } + + // 3. (空行后) 更新字段 existing.MerchantId = request.MerchantId; existing.Code = request.Code.Trim(); existing.Name = request.Name.Trim(); existing.Phone = request.Phone?.Trim(); existing.ManagerName = request.ManagerName?.Trim(); existing.Status = request.Status; + existing.SignboardImageUrl = request.SignboardImageUrl?.Trim(); + existing.CategoryId = request.CategoryId; existing.Province = request.Province?.Trim(); existing.City = request.City?.Trim(); existing.District = request.District?.Trim(); @@ -54,39 +68,12 @@ public sealed class UpdateStoreCommandHandler( existing.SupportsReservation = request.SupportsReservation; existing.SupportsQueueing = request.SupportsQueueing; - // 3. 持久化 - await _storeRepository.UpdateStoreAsync(existing, cancellationToken); - await _storeRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("更新门店 {StoreId} - {StoreName}", existing.Id, existing.Name); + // 4. (空行后) 持久化 + await storeRepository.UpdateStoreAsync(existing, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新门店 {StoreId} - {StoreName}", existing.Id, existing.Name); - // 4. 返回 DTO - return MapToDto(existing); + // 5. (空行后) 返回 DTO + return StoreMapping.ToDto(existing); } - - private static StoreDto MapToDto(Store store) => new() - { - Id = store.Id, - TenantId = store.TenantId, - MerchantId = store.MerchantId, - Code = store.Code, - Name = store.Name, - Phone = store.Phone, - ManagerName = store.ManagerName, - Status = store.Status, - Province = store.Province, - City = store.City, - District = store.District, - Address = store.Address, - Longitude = store.Longitude, - Latitude = store.Latitude, - Announcement = store.Announcement, - Tags = store.Tags, - DeliveryRadiusKm = store.DeliveryRadiusKm, - SupportsDineIn = store.SupportsDineIn, - SupportsPickup = store.SupportsPickup, - SupportsDelivery = store.SupportsDelivery, - SupportsReservation = store.SupportsReservation, - SupportsQueueing = store.SupportsQueueing, - CreatedAt = store.CreatedAt - }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreDeliveryZoneCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreDeliveryZoneCommandHandler.cs index ecf7f66..66ef04b 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreDeliveryZoneCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreDeliveryZoneCommandHandler.cs @@ -2,6 +2,7 @@ using MediatR; using Microsoft.Extensions.Logging; using TakeoutSaaS.Application.App.Stores.Commands; using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Services; using TakeoutSaaS.Domain.Stores.Entities; using TakeoutSaaS.Domain.Stores.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; @@ -16,19 +17,16 @@ namespace TakeoutSaaS.Application.App.Stores.Handlers; public sealed class UpdateStoreDeliveryZoneCommandHandler( IStoreRepository storeRepository, ITenantProvider tenantProvider, + IGeoJsonValidationService geoJsonValidationService, ILogger logger) : IRequestHandler { - private readonly IStoreRepository _storeRepository = storeRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(UpdateStoreDeliveryZoneCommand request, CancellationToken cancellationToken) { // 1. 读取区域 - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _storeRepository.FindDeliveryZoneByIdAsync(request.DeliveryZoneId, tenantId, cancellationToken); + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await storeRepository.FindDeliveryZoneByIdAsync(request.DeliveryZoneId, tenantId, cancellationToken); if (existing is null) { return null; @@ -40,20 +38,27 @@ public sealed class UpdateStoreDeliveryZoneCommandHandler( throw new BusinessException(ErrorCodes.ValidationFailed, "配送区域不属于该门店"); } - // 3. 更新字段 + // 3. (空行后) 校验 GeoJSON + var validation = geoJsonValidationService.ValidatePolygon(request.PolygonGeoJson); + if (!validation.IsValid) + { + throw new BusinessException(ErrorCodes.ValidationFailed, validation.ErrorMessage ?? "配送范围格式错误"); + } + + // 4. (空行后) 更新字段 existing.ZoneName = request.ZoneName.Trim(); - existing.PolygonGeoJson = request.PolygonGeoJson.Trim(); + existing.PolygonGeoJson = (validation.NormalizedGeoJson ?? request.PolygonGeoJson).Trim(); existing.MinimumOrderAmount = request.MinimumOrderAmount; existing.DeliveryFee = request.DeliveryFee; existing.EstimatedMinutes = request.EstimatedMinutes; existing.SortOrder = request.SortOrder; - // 4. 持久化 - await _storeRepository.UpdateDeliveryZoneAsync(existing, cancellationToken); - await _storeRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("更新配送区域 {DeliveryZoneId} 对应门店 {StoreId}", existing.Id, existing.StoreId); + // 5. (空行后) 持久化 + await storeRepository.UpdateDeliveryZoneAsync(existing, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新配送区域 {DeliveryZoneId} 对应门店 {StoreId}", existing.Id, existing.StoreId); - // 5. 返回 DTO + // 6. (空行后) 返回 DTO return StoreMapping.ToDto(existing); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreFeeCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreFeeCommandHandler.cs new file mode 100644 index 0000000..a437c9c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreFeeCommandHandler.cs @@ -0,0 +1,69 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新门店费用配置处理器。 +/// +public sealed class UpdateStoreFeeCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpdateStoreFeeCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店状态 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + if (store.AuditStatus != StoreAuditStatus.Activated) + { + throw new BusinessException(ErrorCodes.Conflict, "门店未激活,无法配置费用"); + } + if (store.BusinessStatus == StoreBusinessStatus.ForceClosed) + { + throw new BusinessException(ErrorCodes.Conflict, "门店已被强制关闭,无法配置费用"); + } + + // 2. (空行后) 获取或创建费用配置 + var fee = await storeRepository.GetStoreFeeAsync(request.StoreId, tenantId, cancellationToken); + var isNew = fee is null; + fee ??= new StoreFee { StoreId = request.StoreId }; + + // 3. (空行后) 应用更新字段 + fee.MinimumOrderAmount = request.MinimumOrderAmount; + fee.BaseDeliveryFee = request.DeliveryFee; + fee.PackagingFeeMode = request.PackagingFeeMode; + fee.FixedPackagingFee = request.PackagingFeeMode == PackagingFeeMode.Fixed + ? request.FixedPackagingFee ?? 0m + : 0m; + fee.FreeDeliveryThreshold = request.FreeDeliveryThreshold; + + // 4. (空行后) 保存并返回 + if (isNew) + { + await storeRepository.AddStoreFeeAsync(fee, cancellationToken); + } + else + { + await storeRepository.UpdateStoreFeeAsync(fee, cancellationToken); + } + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新门店 {StoreId} 费用配置", request.StoreId); + return StoreMapping.ToDto(fee); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreQualificationCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreQualificationCommandHandler.cs new file mode 100644 index 0000000..26d86e5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreQualificationCommandHandler.cs @@ -0,0 +1,79 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新门店资质处理器。 +/// +public sealed class UpdateStoreQualificationCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpdateStoreQualificationCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. (空行后) 审核中门店禁止修改资质 + if (store.AuditStatus == StoreAuditStatus.Pending) + { + throw new BusinessException(ErrorCodes.Conflict, "门店审核中,无法修改资质"); + } + + // 3. (空行后) 校验资质记录 + var qualification = await storeRepository.FindQualificationByIdAsync(request.QualificationId, tenantId, cancellationToken); + if (qualification is null || qualification.StoreId != request.StoreId) + { + return null; + } + + // 4. (空行后) 更新字段 + if (!string.IsNullOrWhiteSpace(request.FileUrl)) + { + qualification.FileUrl = request.FileUrl.Trim(); + } + + if (request.DocumentNumber is not null) + { + qualification.DocumentNumber = request.DocumentNumber.Trim(); + } + + if (request.IssuedAt.HasValue) + { + qualification.IssuedAt = request.IssuedAt; + } + + if (request.ExpiresAt.HasValue) + { + qualification.ExpiresAt = request.ExpiresAt; + } + + if (request.SortOrder.HasValue) + { + qualification.SortOrder = request.SortOrder.Value; + } + + // 5. (空行后) 保存变更并返回结果 + await storeRepository.UpdateQualificationAsync(qualification, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新门店资质 {QualificationId} 对应门店 {StoreId}", qualification.Id, request.StoreId); + + return StoreMapping.ToDto(qualification); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/CalculateStoreFeeQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/CalculateStoreFeeQuery.cs new file mode 100644 index 0000000..2219baa --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/CalculateStoreFeeQuery.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 费用预览计算查询。 +/// +public sealed record CalculateStoreFeeQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 商品金额。 + /// + public decimal OrderAmount { get; init; } + + /// + /// 商品种类数量。 + /// + public int? ItemCount { get; init; } + + /// + /// 商品列表。 + /// + public IReadOnlyList Items { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/CheckStoreDeliveryZoneQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/CheckStoreDeliveryZoneQuery.cs new file mode 100644 index 0000000..108427b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/CheckStoreDeliveryZoneQuery.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 配送范围检测查询。 +/// +public sealed record CheckStoreDeliveryZoneQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 经度。 + /// + public double Longitude { get; init; } + + /// + /// 纬度。 + /// + public double Latitude { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/CheckStoreQualificationsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/CheckStoreQualificationsQuery.cs new file mode 100644 index 0000000..b7100eb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/CheckStoreQualificationsQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 检查门店资质完整性查询。 +/// +public sealed record CheckStoreQualificationsQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreFeeQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreFeeQuery.cs new file mode 100644 index 0000000..f93897d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreFeeQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 获取门店费用配置查询。 +/// +public sealed record GetStoreFeeQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListExpiringStoreQualificationsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListExpiringStoreQualificationsQuery.cs new file mode 100644 index 0000000..f49ebbf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListExpiringStoreQualificationsQuery.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 资质预警分页查询。 +/// +public sealed record ListExpiringStoreQualificationsQuery : IRequest +{ + /// + /// 过期阈值天数(默认 30 天)。 + /// + public int? DaysThreshold { get; init; } + + /// + /// 租户 ID(可选)。 + /// + public long? TenantId { get; init; } + + /// + /// 是否仅显示已过期。 + /// + public bool Expired { get; init; } + + /// + /// 当前页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreQualificationsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreQualificationsQuery.cs new file mode 100644 index 0000000..c2803b1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreQualificationsQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 查询门店资质列表。 +/// +public sealed record ListStoreQualificationsQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/SearchStoresQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/SearchStoresQuery.cs index f429439..16532d4 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/SearchStoresQuery.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/SearchStoresQuery.cs @@ -20,6 +20,26 @@ public sealed class SearchStoresQuery : IRequest> /// public StoreStatus? Status { get; init; } + /// + /// 审核状态过滤。 + /// + public StoreAuditStatus? AuditStatus { get; init; } + + /// + /// 经营状态过滤。 + /// + public StoreBusinessStatus? BusinessStatus { get; init; } + + /// + /// 主体类型过滤。 + /// + public StoreOwnershipType? OwnershipType { get; init; } + + /// + /// 关键词(名称/编码)。 + /// + public string? Keyword { get; init; } + /// /// 页码。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Services/GeoJsonValidationResult.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Services/GeoJsonValidationResult.cs new file mode 100644 index 0000000..5144d14 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Services/GeoJsonValidationResult.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.App.Stores.Services; + +/// +/// GeoJSON 校验结果。 +/// +public sealed record GeoJsonValidationResult +{ + /// + /// 是否通过校验。 + /// + public bool IsValid { get; init; } + + /// + /// 规范化后的 GeoJSON(自动闭合时输出)。 + /// + public string? NormalizedGeoJson { get; init; } + + /// + /// 错误信息。 + /// + public string? ErrorMessage { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Services/IDeliveryZoneService.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Services/IDeliveryZoneService.cs new file mode 100644 index 0000000..892c39f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Services/IDeliveryZoneService.cs @@ -0,0 +1,22 @@ +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; + +namespace TakeoutSaaS.Application.App.Stores.Services; + +/// +/// 配送范围检测服务。 +/// +public interface IDeliveryZoneService +{ + /// + /// 检测坐标是否落在配送范围内。 + /// + /// 配送区域列表。 + /// 经度。 + /// 纬度。 + /// 检测结果。 + StoreDeliveryCheckResultDto CheckPointInZones( + IReadOnlyList zones, + double longitude, + double latitude); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Services/IGeoJsonValidationService.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Services/IGeoJsonValidationService.cs new file mode 100644 index 0000000..0454921 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Services/IGeoJsonValidationService.cs @@ -0,0 +1,14 @@ +namespace TakeoutSaaS.Application.App.Stores.Services; + +/// +/// GeoJSON 校验服务。 +/// +public interface IGeoJsonValidationService +{ + /// + /// 校验多边形 GeoJSON 并返回规范化结果。 + /// + /// GeoJSON 字符串。 + /// 校验结果。 + GeoJsonValidationResult ValidatePolygon(string geoJson); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Services/IStoreFeeCalculationService.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Services/IStoreFeeCalculationService.cs new file mode 100644 index 0000000..e3c4d91 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Services/IStoreFeeCalculationService.cs @@ -0,0 +1,18 @@ +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; + +namespace TakeoutSaaS.Application.App.Stores.Services; + +/// +/// 门店费用计算服务。 +/// +public interface IStoreFeeCalculationService +{ + /// + /// 计算费用预览。 + /// + /// 门店费用配置。 + /// 计算请求。 + /// 计算结果。 + StoreFeeCalculationResultDto Calculate(StoreFee fee, StoreFeeCalculationRequestDto request); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Services/IStoreSchedulerService.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Services/IStoreSchedulerService.cs new file mode 100644 index 0000000..bb19174 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Services/IStoreSchedulerService.cs @@ -0,0 +1,23 @@ +namespace TakeoutSaaS.Application.App.Stores.Services; + +/// +/// 门店定时任务服务。 +/// +public interface IStoreSchedulerService +{ + /// + /// 自动切换门店营业状态。 + /// + /// 当前时间(UTC)。 + /// 取消标记。 + /// 更新的门店数量。 + Task AutoSwitchBusinessStatusAsync(DateTime now, CancellationToken cancellationToken); + + /// + /// 检查门店资质过期并更新状态。 + /// + /// 当前时间(UTC)。 + /// 取消标记。 + /// 更新的门店数量。 + Task CheckQualificationExpiryAsync(DateTime now, CancellationToken cancellationToken); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs index 8a9a0d2..9a7f5ce 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs @@ -9,6 +9,97 @@ namespace TakeoutSaaS.Application.App.Stores; /// public static class StoreMapping { + /// + /// 映射门店 DTO。 + /// + /// 门店实体。 + /// DTO。 + public static StoreDto ToDto(Store store) => new() + { + Id = store.Id, + TenantId = store.TenantId, + MerchantId = store.MerchantId, + Code = store.Code, + Name = store.Name, + Phone = store.Phone, + ManagerName = store.ManagerName, + Status = store.Status, + SignboardImageUrl = store.SignboardImageUrl, + OwnershipType = store.OwnershipType, + AuditStatus = store.AuditStatus, + BusinessStatus = store.BusinessStatus, + ClosureReason = store.ClosureReason, + ClosureReasonText = store.ClosureReasonText, + CategoryId = store.CategoryId, + RejectionReason = store.RejectionReason, + SubmittedAt = store.SubmittedAt, + ActivatedAt = store.ActivatedAt, + ForceClosedAt = store.ForceClosedAt, + ForceCloseReason = store.ForceCloseReason, + Province = store.Province, + City = store.City, + District = store.District, + Address = store.Address, + Longitude = store.Longitude, + Latitude = store.Latitude, + Announcement = store.Announcement, + Tags = store.Tags, + DeliveryRadiusKm = store.DeliveryRadiusKm, + SupportsDineIn = store.SupportsDineIn, + SupportsPickup = store.SupportsPickup, + SupportsDelivery = store.SupportsDelivery, + SupportsReservation = store.SupportsReservation, + SupportsQueueing = store.SupportsQueueing, + CreatedAt = store.CreatedAt + }; + + /// + /// 映射门店费用 DTO。 + /// + /// 费用配置。 + /// DTO。 + public static StoreFeeDto ToDto(StoreFee fee) => new() + { + Id = fee.Id, + StoreId = fee.StoreId, + MinimumOrderAmount = fee.MinimumOrderAmount, + DeliveryFee = fee.BaseDeliveryFee, + PackagingFeeMode = fee.PackagingFeeMode, + FixedPackagingFee = fee.FixedPackagingFee, + FreeDeliveryThreshold = fee.FreeDeliveryThreshold, + CreatedAt = fee.CreatedAt, + UpdatedAt = fee.UpdatedAt + }; + + /// + /// 映射门店资质 DTO。 + /// + /// 资质实体。 + /// DTO。 + public static StoreQualificationDto ToDto(StoreQualification qualification) + { + int? daysUntilExpiry = qualification.ExpiresAt.HasValue + ? (int)Math.Ceiling((qualification.ExpiresAt.Value.Date - DateTime.UtcNow.Date).TotalDays) + : null; + + return new StoreQualificationDto + { + Id = qualification.Id, + StoreId = qualification.StoreId, + QualificationType = qualification.QualificationType, + FileUrl = qualification.FileUrl, + DocumentNumber = qualification.DocumentNumber, + IssuedAt = qualification.IssuedAt, + ExpiresAt = qualification.ExpiresAt, + IsExpired = qualification.IsExpired, + IsExpiringSoon = qualification.IsExpiringSoon, + DaysUntilExpiry = daysUntilExpiry, + SortOrder = qualification.SortOrder, + CreatedAt = qualification.CreatedAt, + UpdatedAt = qualification.UpdatedAt + }; + } + /// /// 映射营业时段 DTO。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/BatchUpdateBusinessHoursCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/BatchUpdateBusinessHoursCommandValidator.cs new file mode 100644 index 0000000..a113b63 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/BatchUpdateBusinessHoursCommandValidator.cs @@ -0,0 +1,40 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 批量更新营业时段命令验证器。 +/// +public sealed class BatchUpdateBusinessHoursCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public BatchUpdateBusinessHoursCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + + RuleForEach(x => x.Items).ChildRules(item => + { + item.RuleFor(x => x.StartTime).NotNull(); + item.RuleFor(x => x.EndTime).NotNull(); + item.RuleFor(x => x.CapacityLimit).GreaterThanOrEqualTo(0).When(x => x.CapacityLimit.HasValue); + item.RuleFor(x => x.Notes).MaximumLength(256); + }); + + RuleFor(x => x.Items).Custom((items, context) => + { + if (items == null || items.Count == 0) + { + return; + } + + var error = BusinessHourValidators.ValidateOverlap(items); + if (!string.IsNullOrWhiteSpace(error)) + { + context.AddFailure(error); + } + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/BusinessHourValidators.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/BusinessHourValidators.cs new file mode 100644 index 0000000..1a6f2c8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/BusinessHourValidators.cs @@ -0,0 +1,65 @@ +using System.Linq; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 营业时段校验助手。 +/// +public static class BusinessHourValidators +{ + /// + /// 校验营业时段是否存在重叠。 + /// + /// 营业时段列表。 + /// 错误信息,若为空表示通过。 + public static string? ValidateOverlap(IReadOnlyList items) + { + if (items.Count == 0) + { + return null; + } + + var segments = new List<(DayOfWeek Day, TimeSpan Start, TimeSpan End)>(); + foreach (var item in items) + { + if (item.StartTime == item.EndTime) + { + return "营业时段开始时间不能等于结束时间"; + } + + if (item.StartTime < item.EndTime) + { + segments.Add((item.DayOfWeek, item.StartTime, item.EndTime)); + continue; + } + + var nextDay = NextDay(item.DayOfWeek); + segments.Add((item.DayOfWeek, item.StartTime, TimeSpan.FromDays(1))); + segments.Add((nextDay, TimeSpan.Zero, item.EndTime)); + } + + var grouped = segments.GroupBy(x => x.Day).ToList(); + foreach (var group in grouped) + { + var ordered = group.OrderBy(x => x.Start).ToList(); + for (var index = 0; index < ordered.Count - 1; index++) + { + var current = ordered[index]; + var next = ordered[index + 1]; + if (next.Start < current.End) + { + return "营业时段存在重叠,请调整"; + } + } + } + + return null; + } + + private static DayOfWeek NextDay(DayOfWeek day) + { + var next = (int)day + 1; + return next > 6 ? DayOfWeek.Sunday : (DayOfWeek)next; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CalculateStoreFeeQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CalculateStoreFeeQueryValidator.cs new file mode 100644 index 0000000..5b51ad6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CalculateStoreFeeQueryValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Queries; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 费用预览计算查询验证器。 +/// +public sealed class CalculateStoreFeeQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CalculateStoreFeeQueryValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.OrderAmount).GreaterThanOrEqualTo(0); + + RuleForEach(x => x.Items).ChildRules(item => + { + item.RuleFor(x => x.SkuId).GreaterThan(0); + item.RuleFor(x => x.Quantity).GreaterThan(0); + item.RuleFor(x => x.PackagingFee).GreaterThanOrEqualTo(0); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CheckStoreDeliveryZoneQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CheckStoreDeliveryZoneQueryValidator.cs new file mode 100644 index 0000000..0d62c63 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CheckStoreDeliveryZoneQueryValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Queries; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 配送范围检测查询验证器。 +/// +public sealed class CheckStoreDeliveryZoneQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CheckStoreDeliveryZoneQueryValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Longitude).InclusiveBetween(-180, 180); + RuleFor(x => x.Latitude).InclusiveBetween(-90, 90); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreBusinessHourCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreBusinessHourCommandValidator.cs index b0f1b80..2eec370 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreBusinessHourCommandValidator.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreBusinessHourCommandValidator.cs @@ -14,7 +14,7 @@ public sealed class CreateStoreBusinessHourCommandValidator : AbstractValidator< public CreateStoreBusinessHourCommandValidator() { RuleFor(x => x.StoreId).GreaterThan(0); - RuleFor(x => x.StartTime).LessThan(x => x.EndTime).WithMessage("结束时间必须晚于开始时间"); + RuleFor(x => x.StartTime).NotEqual(x => x.EndTime).WithMessage("开始时间不能等于结束时间"); RuleFor(x => x.CapacityLimit).GreaterThanOrEqualTo(0).When(x => x.CapacityLimit.HasValue); RuleFor(x => x.Notes).MaximumLength(256); } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreCommandValidator.cs index a947ae2..e513072 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreCommandValidator.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreCommandValidator.cs @@ -18,10 +18,14 @@ public sealed class CreateStoreCommandValidator : AbstractValidator x.Name).NotEmpty().MaximumLength(128); RuleFor(x => x.Phone).MaximumLength(32); RuleFor(x => x.ManagerName).MaximumLength(64); + RuleFor(x => x.SignboardImageUrl).NotEmpty().MaximumLength(500); + RuleFor(x => x.OwnershipType).IsInEnum(); RuleFor(x => x.Province).MaximumLength(64); RuleFor(x => x.City).MaximumLength(64); RuleFor(x => x.District).MaximumLength(64); RuleFor(x => x.Address).MaximumLength(256); + RuleFor(x => x.Longitude).NotNull(); + RuleFor(x => x.Latitude).NotNull(); RuleFor(x => x.Announcement).MaximumLength(512); RuleFor(x => x.Tags).MaximumLength(256); RuleFor(x => x.DeliveryRadiusKm).GreaterThanOrEqualTo(0); diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreQualificationCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreQualificationCommandValidator.cs new file mode 100644 index 0000000..a7f4179 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreQualificationCommandValidator.cs @@ -0,0 +1,42 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建门店资质命令验证器。 +/// +public sealed class CreateStoreQualificationCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreQualificationCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.QualificationType).IsInEnum(); + RuleFor(x => x.FileUrl).NotEmpty().MaximumLength(500); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + RuleFor(x => x.DocumentNumber).MaximumLength(100); + + RuleFor(x => x.ExpiresAt) + .Must(date => date.HasValue && date.Value.Date > DateTime.UtcNow.Date) + .When(x => IsLicenseType(x.QualificationType)) + .WithMessage("证照有效期必须晚于今天"); + + RuleFor(x => x.DocumentNumber) + .NotEmpty() + .MinimumLength(2) + .When(x => IsLicenseType(x.QualificationType)) + .WithMessage("证照编号不能为空"); + + RuleFor(x => x.ExpiresAt) + .Must(date => !date.HasValue || date.Value.Date > DateTime.UtcNow.Date) + .When(x => !IsLicenseType(x.QualificationType)) + .WithMessage("证照有效期必须晚于今天"); + } + + private static bool IsLicenseType(StoreQualificationType type) + => type is StoreQualificationType.BusinessLicense or StoreQualificationType.FoodServiceLicense; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/DeleteStoreQualificationCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/DeleteStoreQualificationCommandValidator.cs new file mode 100644 index 0000000..7797b76 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/DeleteStoreQualificationCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 删除门店资质命令验证器。 +/// +public sealed class DeleteStoreQualificationCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public DeleteStoreQualificationCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.QualificationId).GreaterThan(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/SubmitStoreAuditCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/SubmitStoreAuditCommandValidator.cs new file mode 100644 index 0000000..c3618d2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/SubmitStoreAuditCommandValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 提交门店审核命令验证器。 +/// +public sealed class SubmitStoreAuditCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public SubmitStoreAuditCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/ToggleBusinessStatusCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/ToggleBusinessStatusCommandValidator.cs new file mode 100644 index 0000000..472db30 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/ToggleBusinessStatusCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 切换门店经营状态命令验证器。 +/// +public sealed class ToggleBusinessStatusCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ToggleBusinessStatusCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.BusinessStatus) + .Must(status => status is StoreBusinessStatus.Open or StoreBusinessStatus.Resting) + .WithMessage("仅允许切换营业中或休息中"); + + RuleFor(x => x.ClosureReason) + .NotNull() + .When(x => x.BusinessStatus == StoreBusinessStatus.Resting) + .WithMessage("切换休息中必须选择歇业原因"); + + RuleFor(x => x.ClosureReasonText).MaximumLength(500); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreBusinessHourCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreBusinessHourCommandValidator.cs index e7e76d5..2e6f967 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreBusinessHourCommandValidator.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreBusinessHourCommandValidator.cs @@ -15,7 +15,7 @@ public sealed class UpdateStoreBusinessHourCommandValidator : AbstractValidator< { RuleFor(x => x.BusinessHourId).GreaterThan(0); RuleFor(x => x.StoreId).GreaterThan(0); - RuleFor(x => x.StartTime).LessThan(x => x.EndTime).WithMessage("结束时间必须晚于开始时间"); + RuleFor(x => x.StartTime).NotEqual(x => x.EndTime).WithMessage("开始时间不能等于结束时间"); RuleFor(x => x.CapacityLimit).GreaterThanOrEqualTo(0).When(x => x.CapacityLimit.HasValue); RuleFor(x => x.Notes).MaximumLength(256); } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreCommandValidator.cs index d021aae..555b0b0 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreCommandValidator.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreCommandValidator.cs @@ -19,6 +19,7 @@ public sealed class UpdateStoreCommandValidator : AbstractValidator x.Name).NotEmpty().MaximumLength(128); RuleFor(x => x.Phone).MaximumLength(32); RuleFor(x => x.ManagerName).MaximumLength(64); + RuleFor(x => x.SignboardImageUrl).MaximumLength(500); RuleFor(x => x.Province).MaximumLength(64); RuleFor(x => x.City).MaximumLength(64); RuleFor(x => x.District).MaximumLength(64); diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreFeeCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreFeeCommandValidator.cs new file mode 100644 index 0000000..1f93073 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreFeeCommandValidator.cs @@ -0,0 +1,35 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新门店费用配置命令验证器。 +/// +public sealed class UpdateStoreFeeCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreFeeCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.MinimumOrderAmount).GreaterThanOrEqualTo(0).LessThanOrEqualTo(9999.99m); + RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).LessThanOrEqualTo(999.99m); + RuleFor(x => x.FreeDeliveryThreshold).GreaterThanOrEqualTo(0).When(x => x.FreeDeliveryThreshold.HasValue); + + RuleFor(x => x.FixedPackagingFee) + .NotNull() + .When(x => x.PackagingFeeMode == PackagingFeeMode.Fixed) + .WithMessage("总计打包费模式下必须填写固定打包费"); + + RuleFor(x => x.FixedPackagingFee) + .Must(fee => !fee.HasValue || fee.Value <= 99.99m) + .WithMessage("固定打包费不能超过 99.99"); + + RuleFor(x => x.FixedPackagingFee) + .Must(fee => !fee.HasValue || fee.Value >= 0) + .WithMessage("固定打包费不能为负数"); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreQualificationCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreQualificationCommandValidator.cs new file mode 100644 index 0000000..0e7a774 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreQualificationCommandValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新门店资质命令验证器。 +/// +public sealed class UpdateStoreQualificationCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreQualificationCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.QualificationId).GreaterThan(0); + RuleFor(x => x.FileUrl).MaximumLength(500).When(x => !string.IsNullOrWhiteSpace(x.FileUrl)); + RuleFor(x => x.DocumentNumber).MaximumLength(100).When(x => !string.IsNullOrWhiteSpace(x.DocumentNumber)); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0).When(x => x.SortOrder.HasValue); + RuleFor(x => x.ExpiresAt) + .Must(date => !date.HasValue || date.Value.Date > DateTime.UtcNow.Date) + .When(x => x.ExpiresAt.HasValue) + .WithMessage("证照有效期必须晚于今天"); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/Store.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/Store.cs index b4364e2..cac5aab 100644 --- a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/Store.cs +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/Store.cs @@ -53,6 +53,66 @@ public sealed class Store : MultiTenantEntityBase /// public string? BusinessLicenseImageUrl { get; set; } + /// + /// 门头招牌图 URL。 + /// + public string? SignboardImageUrl { get; set; } + + /// + /// 主体类型。 + /// + public StoreOwnershipType OwnershipType { get; set; } = StoreOwnershipType.SameEntity; + + /// + /// 审核状态。 + /// + public StoreAuditStatus AuditStatus { get; set; } = StoreAuditStatus.Draft; + + /// + /// 经营状态。 + /// + public StoreBusinessStatus BusinessStatus { get; set; } = StoreBusinessStatus.Resting; + + /// + /// 歇业原因。 + /// + public StoreClosureReason? ClosureReason { get; set; } + + /// + /// 歇业原因补充说明。 + /// + public string? ClosureReasonText { get; set; } + + /// + /// 行业类目 ID。 + /// + public long? CategoryId { get; set; } + + /// + /// 审核驳回原因。 + /// + public string? RejectionReason { get; set; } + + /// + /// 提交审核时间。 + /// + public DateTime? SubmittedAt { get; set; } + + /// + /// 审核通过时间。 + /// + public DateTime? ActivatedAt { get; set; } + + /// + /// 强制关闭时间。 + /// + public DateTime? ForceClosedAt { get; set; } + + /// + /// 强制关闭原因。 + /// + public string? ForceCloseReason { get; set; } + /// /// 门店当前运营状态。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreAuditRecord.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreAuditRecord.cs new file mode 100644 index 0000000..f77ad66 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreAuditRecord.cs @@ -0,0 +1,55 @@ +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Stores.Entities; + +/// +/// 门店审核记录。 +/// +public sealed class StoreAuditRecord : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 操作类型。 + /// + public StoreAuditAction Action { get; set; } + + /// + /// 操作前状态。 + /// + public StoreAuditStatus? PreviousStatus { get; set; } + + /// + /// 操作后状态。 + /// + public StoreAuditStatus NewStatus { get; set; } + + /// + /// 操作人 ID。 + /// + public long? OperatorId { get; set; } + + /// + /// 操作人名称。 + /// + public string OperatorName { get; set; } = string.Empty; + + /// + /// 驳回理由 ID。 + /// + public long? RejectionReasonId { get; set; } + + /// + /// 驳回理由文本。 + /// + public string? RejectionReason { get; set; } + + /// + /// 备注。 + /// + public string? Remarks { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreFee.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreFee.cs new file mode 100644 index 0000000..974a114 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreFee.cs @@ -0,0 +1,40 @@ +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Stores.Entities; + +/// +/// 门店费用配置。 +/// +public sealed class StoreFee : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 起送费(元)。 + /// + public decimal MinimumOrderAmount { get; set; } = 0m; + + /// + /// 基础配送费(元)。 + /// + public decimal BaseDeliveryFee { get; set; } = 0m; + + /// + /// 打包费模式。 + /// + public PackagingFeeMode PackagingFeeMode { get; set; } = PackagingFeeMode.Fixed; + + /// + /// 固定打包费(总计模式有效)。 + /// + public decimal FixedPackagingFee { get; set; } = 0m; + + /// + /// 免配送费门槛。 + /// + public decimal? FreeDeliveryThreshold { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreQualification.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreQualification.cs new file mode 100644 index 0000000..60f24be --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreQualification.cs @@ -0,0 +1,57 @@ +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Stores.Entities; + +/// +/// 门店资质证照。 +/// +public sealed class StoreQualification : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 资质类型。 + /// + public StoreQualificationType QualificationType { get; set; } + + /// + /// 证照文件 URL。 + /// + public string FileUrl { get; set; } = string.Empty; + + /// + /// 证照编号。 + /// + public string? DocumentNumber { get; set; } + + /// + /// 签发日期。 + /// + public DateTime? IssuedAt { get; set; } + + /// + /// 到期日期。 + /// + public DateTime? ExpiresAt { get; set; } + + /// + /// 是否已过期。 + /// + public bool IsExpired => ExpiresAt.HasValue && ExpiresAt.Value < DateTime.UtcNow; + + /// + /// 是否即将过期(30天内)。 + /// + public bool IsExpiringSoon => ExpiresAt.HasValue + && ExpiresAt.Value >= DateTime.UtcNow + && ExpiresAt.Value <= DateTime.UtcNow.AddDays(30); + + /// + /// 排序值。 + /// + public int SortOrder { get; set; } = 100; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Enums/PackagingFeeMode.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/PackagingFeeMode.cs new file mode 100644 index 0000000..3f30864 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/PackagingFeeMode.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Stores.Enums; + +/// +/// 打包费计算模式。 +/// +public enum PackagingFeeMode +{ + /// + /// 总计模式:固定单笔订单打包费。 + /// + Fixed = 0, + + /// + /// 商品计费模式:按商品累计打包费。 + /// + PerItem = 1 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreAuditAction.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreAuditAction.cs new file mode 100644 index 0000000..c5fd208 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreAuditAction.cs @@ -0,0 +1,42 @@ +namespace TakeoutSaaS.Domain.Stores.Enums; + +/// +/// 门店审核操作类型。 +/// +public enum StoreAuditAction +{ + /// + /// 提交审核。 + /// + Submit = 0, + + /// + /// 重新提交。 + /// + Resubmit = 1, + + /// + /// 审核通过。 + /// + Approve = 2, + + /// + /// 审核驳回。 + /// + Reject = 3, + + /// + /// 强制关闭。 + /// + ForceClose = 4, + + /// + /// 解除关闭。 + /// + Reopen = 5, + + /// + /// 自动激活(同主体门店)。 + /// + AutoActivate = 6 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreAuditStatus.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreAuditStatus.cs new file mode 100644 index 0000000..d72a218 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreAuditStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Stores.Enums; + +/// +/// 门店审核状态。 +/// +public enum StoreAuditStatus +{ + /// + /// 草稿,未提交审核。 + /// + Draft = 0, + + /// + /// 审核中。 + /// + Pending = 1, + + /// + /// 已激活(审核通过或自动通过)。 + /// + Activated = 2, + + /// + /// 已驳回。 + /// + Rejected = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreBusinessStatus.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreBusinessStatus.cs new file mode 100644 index 0000000..c1d49c9 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreBusinessStatus.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Stores.Enums; + +/// +/// 门店经营状态。 +/// +public enum StoreBusinessStatus +{ + /// + /// 营业中。 + /// + Open = 0, + + /// + /// 休息中(手动或自动)。 + /// + Resting = 1, + + /// + /// 强制关闭(平台风控)。 + /// + ForceClosed = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreClosureReason.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreClosureReason.cs new file mode 100644 index 0000000..d0ac4a7 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreClosureReason.cs @@ -0,0 +1,47 @@ +namespace TakeoutSaaS.Domain.Stores.Enums; + +/// +/// 门店歇业原因(关联字典表 store_closure_reason)。 +/// +public enum StoreClosureReason +{ + /// + /// 非营业时间(系统自动)。 + /// + OutOfBusinessHours = 0, + + /// + /// 设备检修。 + /// + EquipmentMaintenance = 1, + + /// + /// 老板休假。 + /// + OwnerVacation = 2, + + /// + /// 食材告罄。 + /// + OutOfStock = 3, + + /// + /// 暂停营业。 + /// + TemporarilyClosed = 4, + + /// + /// 证照过期。 + /// + LicenseExpired = 5, + + /// + /// 平台封禁。 + /// + PlatformSuspended = 6, + + /// + /// 其他原因。 + /// + Other = 99 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreOwnershipType.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreOwnershipType.cs new file mode 100644 index 0000000..f9d754f --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreOwnershipType.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Stores.Enums; + +/// +/// 门店主体类型。 +/// +public enum StoreOwnershipType +{ + /// + /// 同一主体(自营)。 + /// + SameEntity = 0, + + /// + /// 不同主体(外部入驻)。 + /// + DifferentEntity = 1 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreQualificationType.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreQualificationType.cs new file mode 100644 index 0000000..3888ac5 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreQualificationType.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Stores.Enums; + +/// +/// 门店资质类型。 +/// +public enum StoreQualificationType +{ + /// + /// 营业执照。 + /// + BusinessLicense = 0, + + /// + /// 食品经营许可证。 + /// + FoodServiceLicense = 1, + + /// + /// 门头实景照。 + /// + StorefrontPhoto = 2, + + /// + /// 店内环境照。 + /// + InteriorPhoto = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Events/StoreApprovedEvent.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Events/StoreApprovedEvent.cs new file mode 100644 index 0000000..da3199a --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Events/StoreApprovedEvent.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Stores.Events; + +/// +/// 门店审核通过事件。 +/// +public sealed class StoreApprovedEvent +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 租户 ID。 + /// + public long TenantId { get; init; } + + /// + /// 审核通过时间(UTC)。 + /// + public DateTime ApprovedAt { get; init; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Events/StoreForceClosedEvent.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Events/StoreForceClosedEvent.cs new file mode 100644 index 0000000..f2f6016 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Events/StoreForceClosedEvent.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Stores.Events; + +/// +/// 门店强制关闭事件。 +/// +public sealed class StoreForceClosedEvent +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 租户 ID。 + /// + public long TenantId { get; init; } + + /// + /// 强制关闭原因。 + /// + public string? Reason { get; init; } + + /// + /// 强制关闭时间(UTC)。 + /// + public DateTime ForceClosedAt { get; init; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Events/StoreRejectedEvent.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Events/StoreRejectedEvent.cs new file mode 100644 index 0000000..a0ab258 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Events/StoreRejectedEvent.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Stores.Events; + +/// +/// 门店审核驳回事件。 +/// +public sealed class StoreRejectedEvent +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 租户 ID。 + /// + public long TenantId { get; init; } + + /// + /// 驳回理由 ID。 + /// + public long? RejectionReasonId { get; init; } + + /// + /// 驳回理由文本。 + /// + public string? RejectionReason { get; init; } + + /// + /// 驳回时间(UTC)。 + /// + public DateTime RejectedAt { get; init; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Events/StoreSubmittedEvent.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Events/StoreSubmittedEvent.cs new file mode 100644 index 0000000..9279ebf --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Events/StoreSubmittedEvent.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Stores.Events; + +/// +/// 门店提交审核事件。 +/// +public sealed class StoreSubmittedEvent +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 租户 ID。 + /// + public long TenantId { get; init; } + + /// + /// 提交时间(UTC)。 + /// + public DateTime SubmittedAt { get; init; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs index 9a1b65c..ea27fa9 100644 --- a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs @@ -21,7 +21,26 @@ public interface IStoreRepository /// /// 按租户筛选门店列表。 /// - Task> SearchAsync(long tenantId, StoreStatus? status, CancellationToken cancellationToken = default); + Task> SearchAsync( + long tenantId, + long? merchantId, + StoreStatus? status, + StoreAuditStatus? auditStatus, + StoreBusinessStatus? businessStatus, + StoreOwnershipType? ownershipType, + string? keyword, + CancellationToken cancellationToken = default); + + /// + /// 判断指定坐标是否存在 100 米内门店。 + /// + Task ExistsStoreWithinDistanceAsync( + long merchantId, + long tenantId, + double longitude, + double latitude, + double distanceMeters, + CancellationToken cancellationToken = default); /// /// 获取指定商户集合的门店数量。 @@ -33,6 +52,56 @@ public interface IStoreRepository /// Task> GetBusinessHoursAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 获取门店费用配置。 + /// + Task GetStoreFeeAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增门店费用配置。 + /// + Task AddStoreFeeAsync(StoreFee storeFee, CancellationToken cancellationToken = default); + + /// + /// 更新门店费用配置。 + /// + Task UpdateStoreFeeAsync(StoreFee storeFee, CancellationToken cancellationToken = default); + + /// + /// 获取门店资质列表。 + /// + Task> GetQualificationsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 依据标识获取门店资质。 + /// + Task FindQualificationByIdAsync(long qualificationId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增门店资质。 + /// + Task AddQualificationAsync(StoreQualification qualification, CancellationToken cancellationToken = default); + + /// + /// 更新门店资质。 + /// + Task UpdateQualificationAsync(StoreQualification qualification, CancellationToken cancellationToken = default); + + /// + /// 删除门店资质。 + /// + Task DeleteQualificationAsync(long qualificationId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增门店审核记录。 + /// + Task AddAuditRecordAsync(StoreAuditRecord record, CancellationToken cancellationToken = default); + + /// + /// 获取门店审核记录。 + /// + Task> GetAuditRecordsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// /// 依据标识获取营业时段。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs index 08e10db..f3d6113 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.App.Stores.Services; using TakeoutSaaS.Domain.Deliveries.Repositories; using TakeoutSaaS.Domain.Inventory.Repositories; using TakeoutSaaS.Domain.Merchants.Repositories; @@ -66,6 +67,13 @@ public static class AppServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + // 2. (空行后) 门店配置服务 + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // 3. (空行后) 初始化配置与种子 services.AddOptions() .Bind(configuration.GetSection(AppSeedOptions.SectionName)) .ValidateDataAnnotations(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index 173cccb..b7ddf46 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -25,6 +25,7 @@ using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Tenancy; using TakeoutSaaS.Infrastructure.App.Persistence.Configurations; +using Microsoft.AspNetCore.Http; namespace TakeoutSaaS.Infrastructure.App.Persistence; @@ -35,8 +36,9 @@ public sealed class TakeoutAppDbContext( DbContextOptions options, ITenantProvider tenantProvider, ICurrentUserAccessor? currentUserAccessor = null, - IIdGenerator? idGenerator = null) - : TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator) + IIdGenerator? idGenerator = null, + IHttpContextAccessor? httpContextAccessor = null) + : TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator, httpContextAccessor) { /// /// 租户聚合根。 @@ -123,6 +125,18 @@ public sealed class TakeoutAppDbContext( /// public DbSet Stores => Set(); /// + /// 门店费用配置。 + /// + public DbSet StoreFees => Set(); + /// + /// 门店资质证照。 + /// + public DbSet StoreQualifications => Set(); + /// + /// 门店审核记录。 + /// + public DbSet StoreAuditRecords => Set(); + /// /// 门店营业时间。 /// public DbSet StoreBusinessHours => Set(); @@ -392,6 +406,9 @@ public sealed class TakeoutAppDbContext( ConfigureMerchantContract(modelBuilder.Entity()); ConfigureMerchantStaff(modelBuilder.Entity()); ConfigureMerchantCategory(modelBuilder.Entity()); + ConfigureStoreFee(modelBuilder.Entity()); + ConfigureStoreQualification(modelBuilder.Entity()); + ConfigureStoreAuditRecord(modelBuilder.Entity()); ConfigureStoreBusinessHour(modelBuilder.Entity()); ConfigureStoreHoliday(modelBuilder.Entity()); ConfigureStoreDeliveryZone(modelBuilder.Entity()); @@ -559,6 +576,14 @@ public sealed class TakeoutAppDbContext( builder.Property(x => x.LegalRepresentative).HasMaxLength(100); builder.Property(x => x.RegisteredAddress).HasMaxLength(500); builder.Property(x => x.BusinessLicenseImageUrl).HasMaxLength(500); + builder.Property(x => x.SignboardImageUrl).HasMaxLength(500); + builder.Property(x => x.OwnershipType).HasConversion(); + builder.Property(x => x.AuditStatus).HasConversion(); + builder.Property(x => x.BusinessStatus).HasConversion(); + builder.Property(x => x.ClosureReason).HasConversion(); + builder.Property(x => x.ClosureReasonText).HasMaxLength(500); + builder.Property(x => x.RejectionReason).HasMaxLength(500); + builder.Property(x => x.ForceCloseReason).HasMaxLength(500); builder.Property(x => x.Province).HasMaxLength(64); builder.Property(x => x.City).HasMaxLength(64); builder.Property(x => x.District).HasMaxLength(64); @@ -568,11 +593,61 @@ public sealed class TakeoutAppDbContext( builder.Property(x => x.DeliveryRadiusKm).HasPrecision(6, 2); builder.HasIndex(x => new { x.TenantId, x.MerchantId }); builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + builder.HasIndex(x => new { x.TenantId, x.AuditStatus }); + builder.HasIndex(x => new { x.TenantId, x.BusinessStatus }); + builder.HasIndex(x => new { x.TenantId, x.OwnershipType }); + builder.HasIndex(x => new { x.Longitude, x.Latitude }) + .HasFilter("\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL"); builder.HasIndex(x => new { x.MerchantId, x.BusinessLicenseNumber }) .IsUnique() .HasFilter("\"BusinessLicenseNumber\" IS NOT NULL AND \"Status\" <> 3"); } + private static void ConfigureStoreFee(EntityTypeBuilder builder) + { + builder.ToTable("store_fees"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.MinimumOrderAmount).HasPrecision(10, 2); + builder.Property(x => x.BaseDeliveryFee).HasPrecision(10, 2); + builder.Property(x => x.PackagingFeeMode).HasConversion(); + builder.Property(x => x.FixedPackagingFee).HasPrecision(10, 2); + builder.Property(x => x.FreeDeliveryThreshold).HasPrecision(10, 2); + builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique(); + builder.HasIndex(x => x.TenantId); + } + + private static void ConfigureStoreQualification(EntityTypeBuilder builder) + { + builder.ToTable("store_qualifications"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.QualificationType).HasConversion(); + builder.Property(x => x.FileUrl).HasMaxLength(500).IsRequired(); + builder.Property(x => x.DocumentNumber).HasMaxLength(100); + builder.Property(x => x.SortOrder).HasDefaultValue(100); + builder.HasIndex(x => new { x.TenantId, x.StoreId }); + builder.HasIndex(x => x.ExpiresAt) + .HasFilter("\"ExpiresAt\" IS NOT NULL"); + builder.Ignore(x => x.IsExpired); + builder.Ignore(x => x.IsExpiringSoon); + } + + private static void ConfigureStoreAuditRecord(EntityTypeBuilder builder) + { + builder.ToTable("store_audit_records"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.Action).HasConversion(); + builder.Property(x => x.PreviousStatus).HasConversion(); + builder.Property(x => x.NewStatus).HasConversion(); + builder.Property(x => x.OperatorName).HasMaxLength(100).IsRequired(); + builder.Property(x => x.RejectionReason).HasMaxLength(500); + builder.Property(x => x.Remarks).HasMaxLength(1000); + builder.HasIndex(x => new { x.TenantId, x.StoreId }); + builder.HasIndex(x => x.CreatedAt); + } + private static void ConfigureProductCategory(EntityTypeBuilder builder) { builder.ToTable("product_categories"); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs index 665e212..8e5f558 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs @@ -36,17 +36,51 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos } /// - public async Task> SearchAsync(long tenantId, StoreStatus? status, CancellationToken cancellationToken = default) + public async Task> SearchAsync( + long tenantId, + long? merchantId, + StoreStatus? status, + StoreAuditStatus? auditStatus, + StoreBusinessStatus? businessStatus, + StoreOwnershipType? ownershipType, + string? keyword, + CancellationToken cancellationToken = default) { var query = context.Stores .AsNoTracking() .Where(x => x.TenantId == tenantId); + if (merchantId.HasValue) + { + query = query.Where(x => x.MerchantId == merchantId.Value); + } + if (status.HasValue) { query = query.Where(x => x.Status == status.Value); } + if (auditStatus.HasValue) + { + query = query.Where(x => x.AuditStatus == auditStatus.Value); + } + + if (businessStatus.HasValue) + { + query = query.Where(x => x.BusinessStatus == businessStatus.Value); + } + + if (ownershipType.HasValue) + { + query = query.Where(x => x.OwnershipType == ownershipType.Value); + } + + if (!string.IsNullOrWhiteSpace(keyword)) + { + var trimmed = keyword.Trim(); + query = query.Where(x => x.Name.Contains(trimmed) || x.Code.Contains(trimmed)); + } + var stores = await query .OrderBy(x => x.Name) .ToListAsync(cancellationToken); @@ -54,6 +88,43 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return stores; } + /// + public async Task ExistsStoreWithinDistanceAsync( + long merchantId, + long tenantId, + double longitude, + double latitude, + double distanceMeters, + CancellationToken cancellationToken = default) + { + // 1. 校验距离阈值 + if (distanceMeters <= 0) + { + return false; + } + + // 2. (空行后) 拉取候选坐标 + var coordinates = await context.Stores + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.MerchantId == merchantId) + .Where(x => x.Longitude.HasValue && x.Latitude.HasValue) + .Select(x => new { Longitude = x.Longitude!.Value, Latitude = x.Latitude!.Value }) + .ToListAsync(cancellationToken); + + // 3. (空行后) 计算距离并判断是否命中 + foreach (var coordinate in coordinates) + { + var distance = CalculateDistanceMeters(latitude, longitude, coordinate.Latitude, coordinate.Longitude); + if (distance <= distanceMeters) + { + return true; + } + } + + // 4. (空行后) 返回未命中结果 + return false; + } + /// public async Task> GetStoreCountsAsync(long? tenantId, IReadOnlyCollection merchantIds, CancellationToken cancellationToken = default) { @@ -92,6 +163,91 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return hours; } + /// + public Task GetStoreFeeAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreFees + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddStoreFeeAsync(StoreFee storeFee, CancellationToken cancellationToken = default) + { + return context.StoreFees.AddAsync(storeFee, cancellationToken).AsTask(); + } + + /// + public Task UpdateStoreFeeAsync(StoreFee storeFee, CancellationToken cancellationToken = default) + { + context.StoreFees.Update(storeFee); + return Task.CompletedTask; + } + + /// + public async Task> GetQualificationsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var qualifications = await context.StoreQualifications + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.QualificationType) + .ToListAsync(cancellationToken); + return qualifications; + } + + /// + public Task FindQualificationByIdAsync(long qualificationId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreQualifications + .Where(x => x.TenantId == tenantId && x.Id == qualificationId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddQualificationAsync(StoreQualification qualification, CancellationToken cancellationToken = default) + { + return context.StoreQualifications.AddAsync(qualification, cancellationToken).AsTask(); + } + + /// + public Task UpdateQualificationAsync(StoreQualification qualification, CancellationToken cancellationToken = default) + { + context.StoreQualifications.Update(qualification); + return Task.CompletedTask; + } + + /// + public async Task DeleteQualificationAsync(long qualificationId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreQualifications + .Where(x => x.TenantId == tenantId && x.Id == qualificationId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreQualifications.Remove(existing); + } + } + + /// + public Task AddAuditRecordAsync(StoreAuditRecord record, CancellationToken cancellationToken = default) + { + return context.StoreAuditRecords.AddAsync(record, cancellationToken).AsTask(); + } + + /// + public async Task> GetAuditRecordsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var records = await context.StoreAuditRecords + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + return records; + } + /// public Task FindBusinessHourByIdAsync(long businessHourId, long tenantId, CancellationToken cancellationToken = default) { @@ -476,4 +632,20 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos context.Stores.Remove(existing); } + + private static double CalculateDistanceMeters(double latitude1, double longitude1, double latitude2, double longitude2) + { + const double earthRadius = 6371000d; + var latRad1 = DegreesToRadians(latitude1); + var latRad2 = DegreesToRadians(latitude2); + var deltaLat = DegreesToRadians(latitude2 - latitude1); + var deltaLon = DegreesToRadians(longitude2 - longitude1); + var sinLat = Math.Sin(deltaLat / 2); + var sinLon = Math.Sin(deltaLon / 2); + var a = sinLat * sinLat + Math.Cos(latRad1) * Math.Cos(latRad2) * sinLon * sinLon; + var c = 2 * Math.Asin(Math.Min(1, Math.Sqrt(a))); + return earthRadius * c; + } + + private static double DegreesToRadians(double degrees) => degrees * (Math.PI / 180d); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/DeliveryZoneService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/DeliveryZoneService.cs new file mode 100644 index 0000000..4c3f96e --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/DeliveryZoneService.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Domain.Stores.Entities; + +namespace TakeoutSaaS.Infrastructure.App.Services; + +/// +/// 配送范围检测服务实现。 +/// +public sealed class DeliveryZoneService : IDeliveryZoneService +{ + /// + public StoreDeliveryCheckResultDto CheckPointInZones( + IReadOnlyList zones, + double longitude, + double latitude) + { + // 1. 无配送区域直接返回 + if (zones is null || zones.Count == 0) + { + return new StoreDeliveryCheckResultDto { InRange = false }; + } + // 2. (空行后) 逐个检测多边形命中 + foreach (var zone in zones) + { + if (!TryReadPolygon(zone.PolygonGeoJson, out var polygon)) + { + continue; + } + if (IsPointInPolygon(polygon, longitude, latitude)) + { + return new StoreDeliveryCheckResultDto + { + InRange = true, + DeliveryZoneId = zone.Id, + DeliveryZoneName = zone.ZoneName + }; + } + } + // 3. (空行后) 未命中任何区域 + return new StoreDeliveryCheckResultDto { InRange = false }; + } + + private static bool TryReadPolygon(string geoJson, out List polygon) + { + polygon = []; + if (string.IsNullOrWhiteSpace(geoJson)) + { + return false; + } + try + { + using var document = JsonDocument.Parse(geoJson); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return false; + } + if (!root.TryGetProperty("coordinates", out var coordinatesElement) || coordinatesElement.ValueKind != JsonValueKind.Array) + { + return false; + } + if (coordinatesElement.GetArrayLength() == 0) + { + return false; + } + var ringElement = coordinatesElement[0]; + if (ringElement.ValueKind != JsonValueKind.Array) + { + return false; + } + foreach (var pointElement in ringElement.EnumerateArray()) + { + if (pointElement.ValueKind != JsonValueKind.Array || pointElement.GetArrayLength() < 2) + { + return false; + } + if (!pointElement[0].TryGetDouble(out var x) || !pointElement[1].TryGetDouble(out var y)) + { + return false; + } + polygon.Add(new Point(x, y)); + } + if (polygon.Count >= 2 && AreSamePoint(polygon[0], polygon[^1])) + { + polygon.RemoveAt(polygon.Count - 1); + } + return polygon.Count >= 3; + } + catch (JsonException) + { + return false; + } + } + + private static bool IsPointInPolygon(IReadOnlyList polygon, double x, double y) + { + var inside = false; + for (var i = 0; i < polygon.Count; i++) + { + var j = i == 0 ? polygon.Count - 1 : i - 1; + var xi = polygon[i].Longitude; + var yi = polygon[i].Latitude; + var xj = polygon[j].Longitude; + var yj = polygon[j].Latitude; + var intersect = ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi + double.Epsilon) + xi); + if (intersect) + { + inside = !inside; + } + } + return inside; + } + + private static bool AreSamePoint(Point first, Point second) + => Math.Abs(first.Longitude - second.Longitude) <= 1e-6 + && Math.Abs(first.Latitude - second.Latitude) <= 1e-6; + + private readonly record struct Point(double Longitude, double Latitude); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/GeoJsonValidationService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/GeoJsonValidationService.cs new file mode 100644 index 0000000..272acb3 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/GeoJsonValidationService.cs @@ -0,0 +1,221 @@ +using System.Text.Json; +using TakeoutSaaS.Application.App.Stores.Services; + +namespace TakeoutSaaS.Infrastructure.App.Services; + +/// +/// GeoJSON 校验服务实现。 +/// +public sealed class GeoJsonValidationService : IGeoJsonValidationService +{ + private const double CoordinateTolerance = 1e-6; + + /// + public GeoJsonValidationResult ValidatePolygon(string geoJson) + { + // 1. 基础校验 + if (string.IsNullOrWhiteSpace(geoJson)) + { + return BuildInvalid("GeoJSON 不能为空"); + } + // 2. (空行后) 解析与验证结构 + try + { + using var document = JsonDocument.Parse(geoJson); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return BuildInvalid("GeoJSON 格式错误"); + } + if (!root.TryGetProperty("type", out var typeElement) || typeElement.ValueKind != JsonValueKind.String) + { + return BuildInvalid("GeoJSON 缺少 type"); + } + var type = typeElement.GetString(); + if (!string.Equals(type, "Polygon", StringComparison.OrdinalIgnoreCase)) + { + return BuildInvalid("仅支持 Polygon 类型"); + } + if (!root.TryGetProperty("coordinates", out var coordinatesElement) || coordinatesElement.ValueKind != JsonValueKind.Array) + { + return BuildInvalid("GeoJSON 缺少 coordinates"); + } + if (coordinatesElement.GetArrayLength() == 0) + { + return BuildInvalid("GeoJSON coordinates 为空"); + } + var ringElement = coordinatesElement[0]; + if (ringElement.ValueKind != JsonValueKind.Array) + { + return BuildInvalid("GeoJSON 坐标格式错误"); + } + var points = new List(); + foreach (var pointElement in ringElement.EnumerateArray()) + { + if (pointElement.ValueKind != JsonValueKind.Array || pointElement.GetArrayLength() < 2) + { + return BuildInvalid("坐标点格式错误"); + } + if (!pointElement[0].TryGetDouble(out var longitude) || !pointElement[1].TryGetDouble(out var latitude)) + { + return BuildInvalid("坐标点必须为数值"); + } + points.Add(new Point(longitude, latitude)); + } + if (points.Count < 3) + { + return BuildInvalid("多边形至少需要 3 个点"); + } + var distinctCount = CountDistinct(points); + if (distinctCount < 3) + { + return BuildInvalid("多边形坐标点不足"); + } + var normalized = Normalize(points, out var normalizedJson); + if (normalized.Count < 4) + { + return BuildInvalid("多边形至少需要 4 个点(含闭合点)"); + } + if (HasSelfIntersection(normalized)) + { + return BuildInvalid("多边形存在自相交"); + } + return new GeoJsonValidationResult + { + IsValid = true, + NormalizedGeoJson = normalizedJson + }; + } + catch (JsonException) + { + return BuildInvalid("GeoJSON 解析失败"); + } + } + + private static GeoJsonValidationResult BuildInvalid(string message) => new() + { + IsValid = false, + ErrorMessage = message + }; + + private static int CountDistinct(IReadOnlyList points) + { + var distinct = new List(); + foreach (var point in points) + { + if (distinct.Any(existing => AreSamePoint(existing, point))) + { + continue; + } + distinct.Add(point); + } + return distinct.Count; + } + + private static List Normalize(IReadOnlyList points, out string? normalizedJson) + { + var normalized = new List(points); + if (!AreSamePoint(normalized[0], normalized[^1])) + { + normalized.Add(normalized[0]); + normalizedJson = BuildGeoJson(normalized); + return normalized; + } + normalizedJson = null; + return normalized; + } + + private static string BuildGeoJson(IReadOnlyList points) + { + var coordinates = points + .Select(point => new[] { point.Longitude, point.Latitude }) + .ToArray(); + var payload = new Dictionary + { + { "type", "Polygon" }, + { "coordinates", new[] { coordinates } } + }; + return JsonSerializer.Serialize(payload); + } + + private static bool HasSelfIntersection(IReadOnlyList points) + { + var segmentCount = points.Count - 1; + for (var i = 0; i < segmentCount; i++) + { + var a1 = points[i]; + var a2 = points[i + 1]; + for (var j = i + 1; j < segmentCount; j++) + { + if (Math.Abs(i - j) <= 1) + { + continue; + } + if (i == 0 && j == segmentCount - 1) + { + continue; + } + var b1 = points[j]; + var b2 = points[j + 1]; + if (SegmentsIntersect(a1, a2, b1, b2)) + { + return true; + } + } + } + return false; + } + + private static bool SegmentsIntersect(Point p1, Point q1, Point p2, Point q2) + { + var o1 = Orientation(p1, q1, p2); + var o2 = Orientation(p1, q1, q2); + var o3 = Orientation(p2, q2, p1); + var o4 = Orientation(p2, q2, q1); + + if (o1 != o2 && o3 != o4) + { + return true; + } + if (o1 == 0 && OnSegment(p1, p2, q1)) + { + return true; + } + if (o2 == 0 && OnSegment(p1, q2, q1)) + { + return true; + } + if (o3 == 0 && OnSegment(p2, p1, q2)) + { + return true; + } + if (o4 == 0 && OnSegment(p2, q1, q2)) + { + return true; + } + return false; + } + + private static int Orientation(Point p, Point q, Point r) + { + var value = (q.Latitude - p.Latitude) * (r.Longitude - q.Longitude) + - (q.Longitude - p.Longitude) * (r.Latitude - q.Latitude); + if (Math.Abs(value) <= CoordinateTolerance) + { + return 0; + } + return value > 0 ? 1 : 2; + } + + private static bool OnSegment(Point p, Point q, Point r) + => q.Longitude <= Math.Max(p.Longitude, r.Longitude) + CoordinateTolerance + && q.Longitude >= Math.Min(p.Longitude, r.Longitude) - CoordinateTolerance + && q.Latitude <= Math.Max(p.Latitude, r.Latitude) + CoordinateTolerance + && q.Latitude >= Math.Min(p.Latitude, r.Latitude) - CoordinateTolerance; + + private static bool AreSamePoint(Point first, Point second) + => Math.Abs(first.Longitude - second.Longitude) <= CoordinateTolerance + && Math.Abs(first.Latitude - second.Latitude) <= CoordinateTolerance; + + private readonly record struct Point(double Longitude, double Latitude); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/StoreFeeCalculationService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/StoreFeeCalculationService.cs new file mode 100644 index 0000000..5c4d4ed --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/StoreFeeCalculationService.cs @@ -0,0 +1,92 @@ +using System.Globalization; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Infrastructure.App.Services; + +/// +/// 门店费用计算服务实现。 +/// +public sealed class StoreFeeCalculationService : IStoreFeeCalculationService +{ + /// + public StoreFeeCalculationResultDto Calculate(StoreFee fee, StoreFeeCalculationRequestDto request) + { + // 1. 计算起送费满足情况 + var minimum = fee.MinimumOrderAmount; + if (request.OrderAmount < minimum) + { + var shortfall = minimum - request.OrderAmount; + var message = $"还差{shortfall.ToString("0.##", CultureInfo.InvariantCulture)}元起送"; + return new StoreFeeCalculationResultDto + { + OrderAmount = request.OrderAmount, + MinimumOrderAmount = minimum, + MeetsMinimum = false, + Shortfall = shortfall, + DeliveryFee = 0m, + PackagingFee = 0m, + PackagingFeeMode = fee.PackagingFeeMode, + TotalFee = 0m, + TotalAmount = request.OrderAmount, + Message = message + }; + } + + // 2. (空行后) 计算配送费 + var deliveryFee = fee.BaseDeliveryFee; + if (fee.FreeDeliveryThreshold.HasValue && request.OrderAmount >= fee.FreeDeliveryThreshold.Value) + { + deliveryFee = 0m; + } + + // 3. (空行后) 计算打包费 + var packagingFee = 0m; + IReadOnlyList? breakdown = null; + if (fee.PackagingFeeMode == PackagingFeeMode.Fixed) + { + packagingFee = fee.FixedPackagingFee; + } + else + { + if (request.Items.Count == 0) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "商品计费模式必须提供商品列表"); + } + var list = new List(request.Items.Count); + foreach (var item in request.Items) + { + var subtotal = item.PackagingFee * item.Quantity; + packagingFee += subtotal; + list.Add(new StoreFeeCalculationBreakdownDto + { + SkuId = item.SkuId, + Quantity = item.Quantity, + UnitFee = item.PackagingFee, + Subtotal = subtotal + }); + } + breakdown = list; + } + + // 4. (空行后) 汇总结果 + var totalFee = deliveryFee + packagingFee; + var totalAmount = request.OrderAmount + totalFee; + return new StoreFeeCalculationResultDto + { + OrderAmount = request.OrderAmount, + MinimumOrderAmount = minimum, + MeetsMinimum = true, + DeliveryFee = deliveryFee, + PackagingFee = packagingFee, + PackagingFeeMode = fee.PackagingFeeMode, + PackagingFeeBreakdown = breakdown, + TotalFee = totalFee, + TotalAmount = totalAmount + }; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/StoreSchedulerService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/StoreSchedulerService.cs new file mode 100644 index 0000000..d6c5de7 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/StoreSchedulerService.cs @@ -0,0 +1,198 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Services; + +/// +/// 门店定时任务服务实现。 +/// +public sealed class StoreSchedulerService( + TakeoutAppDbContext context, + ILogger logger) + : IStoreSchedulerService +{ + /// + public async Task AutoSwitchBusinessStatusAsync(DateTime now, CancellationToken cancellationToken) + { + // 1. 读取候选门店 + var stores = await context.Stores + .Where(store => store.DeletedAt == null + && store.AuditStatus == StoreAuditStatus.Activated + && store.BusinessStatus != StoreBusinessStatus.ForceClosed) + .ToListAsync(cancellationToken); + if (stores.Count == 0) + { + return 0; + } + + // 2. (空行后) 读取营业时段与休息日 + var storeIds = stores.Select(store => store.Id).ToArray(); + var hours = await context.StoreBusinessHours + .AsNoTracking() + .Where(hour => storeIds.Contains(hour.StoreId)) + .ToListAsync(cancellationToken); + var today = now.Date; + var holidays = await context.StoreHolidays + .AsNoTracking() + .Where(holiday => storeIds.Contains(holiday.StoreId) + && holiday.IsClosed + && holiday.Date.Date == today) + .ToListAsync(cancellationToken); + + // 3. (空行后) 构造查找表 + var hoursLookup = hours + .GroupBy(hour => hour.StoreId) + .ToDictionary(group => group.Key, group => (IReadOnlyList)group.ToList()); + var holidaySet = holidays.Select(holiday => holiday.StoreId).ToHashSet(); + + // 4. (空行后) 判定状态并更新 + var updated = 0; + foreach (var store in stores) + { + // 4.1 跳过强制关闭门店 + if (store.BusinessStatus == StoreBusinessStatus.ForceClosed) + { + continue; + } + + // 4.2 (空行后) 尊重手动歇业原因 + if (store.ClosureReason.HasValue && store.ClosureReason != StoreClosureReason.OutOfBusinessHours) + { + continue; + } + + // 4.3 (空行后) 计算营业状态 + var isHolidayClosed = holidaySet.Contains(store.Id); + var hasHours = hoursLookup.TryGetValue(store.Id, out var storeHours) && storeHours.Count > 0; + var isOpen = !isHolidayClosed && hasHours && IsWithinBusinessHours(storeHours ?? [], now); + if (isOpen) + { + if (store.BusinessStatus != StoreBusinessStatus.Open) + { + store.BusinessStatus = StoreBusinessStatus.Open; + store.ClosureReason = null; + store.ClosureReasonText = null; + updated++; + } + continue; + } + + // 4.4 (空行后) 非营业时段切换为休息 + if (store.BusinessStatus != StoreBusinessStatus.Resting || store.ClosureReason != StoreClosureReason.OutOfBusinessHours) + { + store.BusinessStatus = StoreBusinessStatus.Resting; + store.ClosureReason = StoreClosureReason.OutOfBusinessHours; + store.ClosureReasonText = "非营业时间自动休息"; + updated++; + } + } + + // 5. (空行后) 保存变更并记录日志 + if (updated > 0) + { + await context.SaveChangesAsync(cancellationToken); + } + + logger.LogInformation("定时任务:营业状态自动切换完成,更新 {UpdatedCount} 家门店", updated); + return updated; + } + + /// + public async Task CheckQualificationExpiryAsync(DateTime now, CancellationToken cancellationToken) + { + // 1. 查询过期门店 + var expiredStoreIds = await context.StoreQualifications + .AsNoTracking() + .Where(qualification => qualification.DeletedAt == null + && qualification.ExpiresAt.HasValue + && qualification.ExpiresAt.Value < now) + .Select(qualification => qualification.StoreId) + .Distinct() + .ToListAsync(cancellationToken); + if (expiredStoreIds.Count == 0) + { + return 0; + } + + // 2. (空行后) 加载门店并更新状态 + var stores = await context.Stores + .Where(store => expiredStoreIds.Contains(store.Id) + && store.DeletedAt == null + && store.AuditStatus == StoreAuditStatus.Activated + && store.BusinessStatus != StoreBusinessStatus.ForceClosed) + .ToListAsync(cancellationToken); + + var updated = 0; + foreach (var store in stores) + { + // 2.1 跳过已标记过期门店 + if (store.BusinessStatus == StoreBusinessStatus.Resting && store.ClosureReason == StoreClosureReason.LicenseExpired) + { + continue; + } + + // 2.2 (空行后) 设置资质过期状态 + store.BusinessStatus = StoreBusinessStatus.Resting; + store.ClosureReason = StoreClosureReason.LicenseExpired; + store.ClosureReasonText = "证照过期自动休息"; + updated++; + } + + // 3. (空行后) 保存变更并记录日志 + if (updated > 0) + { + await context.SaveChangesAsync(cancellationToken); + } + + logger.LogInformation("定时任务:资质过期检查完成,更新 {UpdatedCount} 家门店", updated); + return updated; + } + + private static bool IsWithinBusinessHours(IReadOnlyList hours, DateTime now) + { + // 1. 提取当前时间 + var day = now.DayOfWeek; + var time = now.TimeOfDay; + + foreach (var hour in hours) + { + if (hour.HourType == BusinessHourType.Closed) + { + continue; + } + if (hour.StartTime == hour.EndTime) + { + continue; + } + if (hour.StartTime < hour.EndTime) + { + if (hour.DayOfWeek == day && time >= hour.StartTime && time < hour.EndTime) + { + return true; + } + continue; + } + var nextDay = NextDay(hour.DayOfWeek); + if (hour.DayOfWeek == day && time >= hour.StartTime) + { + return true; + } + if (nextDay == day && time < hour.EndTime) + { + return true; + } + } + + return false; + } + + private static DayOfWeek NextDay(DayOfWeek day) + { + var next = (int)day + 1; + return next > 6 ? DayOfWeek.Sunday : (DayOfWeek)next; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251231124137_AddStoreManagementEntities.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251231124137_AddStoreManagementEntities.Designer.cs new file mode 100644 index 0000000..bac5d9d --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251231124137_AddStoreManagementEntities.Designer.cs @@ -0,0 +1,7391 @@ +// +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("20251231124137_AddStoreManagementEntities")] + partial class AddStoreManagementEntities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .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.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("ApprovedAt") + .HasColumnType("timestamp with time zone") + .HasComment("审核通过时间。"); + + b.Property("ApprovedBy") + .HasColumnType("bigint") + .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("ClaimExpiresAt") + .HasColumnType("timestamp with time zone") + .HasComment("领取过期时间。"); + + b.Property("ClaimedAt") + .HasColumnType("timestamp with time zone") + .HasComment("领取时间。"); + + b.Property("ClaimedBy") + .HasColumnType("bigint") + .HasComment("当前领取人。"); + + b.Property("ClaimedByName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .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("FrozenAt") + .HasColumnType("timestamp with time zone") + .HasComment("冻结时间。"); + + b.Property("FrozenReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("冻结原因。"); + + b.Property("IsFrozen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否冻结业务。"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasComment("入驻时间。"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次审核时间。"); + + b.Property("LastReviewedBy") + .HasColumnType("bigint") + .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("OperatingMode") + .HasColumnType("integer") + .HasComment("经营模式(同一主体/不同主体)。"); + + b.Property("Province") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在省份。"); + + b.Property("ReviewRemarks") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("审核备注或驳回原因。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .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("ClaimedBy"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("merchants", 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("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("审核通过时间。"); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("详细地址。"); + + b.Property("Announcement") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("门店公告。"); + + b.Property("AuditStatus") + .HasColumnType("integer") + .HasComment("审核状态。"); + + b.Property("BusinessHours") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("门店营业时段描述(备用字符串)。"); + + b.Property("BusinessLicenseImageUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("门店营业执照图片地址(主体不一致模式使用)。"); + + b.Property("BusinessLicenseNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasComment("门店营业执照号(主体不一致模式使用)。"); + + b.Property("BusinessStatus") + .HasColumnType("integer") + .HasComment("经营状态。"); + + b.Property("CategoryId") + .HasColumnType("bigint") + .HasComment("行业类目 ID。"); + + b.Property("City") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在城市。"); + + b.Property("ClosureReason") + .HasColumnType("integer") + .HasComment("歇业原因。"); + + b.Property("ClosureReasonText") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .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("ForceCloseReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("强制关闭原因。"); + + b.Property("ForceClosedAt") + .HasColumnType("timestamp with time zone") + .HasComment("强制关闭时间。"); + + b.Property("Latitude") + .HasColumnType("double precision") + .HasComment("纬度。"); + + b.Property("LegalRepresentative") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .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("OwnershipType") + .HasColumnType("integer") + .HasComment("主体类型。"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("联系电话。"); + + b.Property("Province") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在省份。"); + + b.Property("RegisteredAddress") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("门店注册地址(主体不一致模式使用)。"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("审核驳回原因。"); + + b.Property("SignboardImageUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("门头招牌图 URL。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("门店当前运营状态。"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone") + .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("Longitude", "Latitude") + .HasFilter("\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL"); + + b.HasIndex("MerchantId", "BusinessLicenseNumber") + .IsUnique() + .HasFilter("\"BusinessLicenseNumber\" IS NOT NULL AND \"Status\" <> 3"); + + b.HasIndex("TenantId", "AuditStatus"); + + b.HasIndex("TenantId", "BusinessStatus"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.HasIndex("TenantId", "MerchantId"); + + b.HasIndex("TenantId", "OwnershipType"); + + b.ToTable("stores", null, t => + { + t.HasComment("门店信息,承载营业配置与能力。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreAuditRecord", 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("NewStatus") + .HasColumnType("integer") + .HasComment("操作后状态。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人 ID。"); + + b.Property("OperatorName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasComment("操作人名称。"); + + b.Property("PreviousStatus") + .HasColumnType("integer") + .HasComment("操作前状态。"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("驳回理由文本。"); + + b.Property("RejectionReasonId") + .HasColumnType("bigint") + .HasComment("驳回理由 ID。"); + + b.Property("Remarks") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .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("CreatedAt"); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("store_audit_records", 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.StoreFee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BaseDeliveryFee") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,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("FixedPackagingFee") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasComment("固定打包费(总计模式有效)。"); + + b.Property("FreeDeliveryThreshold") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasComment("免配送费门槛。"); + + b.Property("MinimumOrderAmount") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasComment("起送费(元)。"); + + b.Property("PackagingFeeMode") + .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"); + + b.HasIndex("TenantId", "StoreId") + .IsUnique(); + + b.ToTable("store_fees", 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.StoreQualification", 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(100) + .HasColumnType("character varying(100)") + .HasComment("证照编号。"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasComment("到期日期。"); + + b.Property("FileUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("证照文件 URL。"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasComment("签发日期。"); + + b.Property("QualificationType") + .HasColumnType("integer") + .HasComment("资质类型。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .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("ExpiresAt") + .HasFilter("\"ExpiresAt\" IS NOT NULL"); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("store_qualifications", 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.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("OperatingMode") + .HasColumnType("integer") + .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("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasComment("实际发布时间(UTC)。"); + + b.Property("PublisherScope") + .HasColumnType("integer") + .HasComment("发布者范围。"); + + b.Property("PublisherUserId") + .HasColumnType("bigint") + .HasComment("发布者用户 ID(平台或租户后台账号)。"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasComment("撤销时间(UTC)。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("ScheduledPublishAt") + .HasColumnType("timestamp with time zone") + .HasComment("预定发布时间(UTC)。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("公告状态。"); + + b.Property("TargetParameters") + .HasColumnType("text") + .HasComment("目标受众参数(JSON)。"); + + b.Property("TargetType") + .IsRequired() + .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("Status", "EffectiveFrom") + .HasFilter("\"TenantId\" = 0"); + + b.HasIndex("TenantId", "AnnouncementType", "IsActive"); + + b.HasIndex("TenantId", "EffectiveFrom", "EffectiveTo"); + + b.HasIndex("TenantId", "Status", "EffectiveFrom"); + + 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.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.TenantQuotaUsageHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChangeAmount") + .HasColumnType("numeric") + .HasComment("变更量(可选)。"); + + b.Property("ChangeReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .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("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("LimitValue") + .HasColumnType("numeric") + .HasComment("限额值(记录时刻的快照)。"); + + b.Property("QuotaType") + .HasColumnType("integer") + .HasComment("配额类型。"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasComment("记录时间(UTC)。"); + + 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", "RecordedAt"); + + b.HasIndex("TenantId", "QuotaType", "RecordedAt"); + + b.ToTable("tenant_quota_usage_histories", 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/20251231124137_AddStoreManagementEntities.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251231124137_AddStoreManagementEntities.cs new file mode 100644 index 0000000..15d415d --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251231124137_AddStoreManagementEntities.cs @@ -0,0 +1,319 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations +{ + /// + public partial class AddStoreManagementEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ActivatedAt", + table: "stores", + type: "timestamp with time zone", + nullable: true, + comment: "审核通过时间。"); + + migrationBuilder.AddColumn( + name: "AuditStatus", + table: "stores", + type: "integer", + nullable: false, + defaultValue: 0, + comment: "审核状态。"); + + migrationBuilder.AddColumn( + name: "BusinessStatus", + table: "stores", + type: "integer", + nullable: false, + defaultValue: 0, + comment: "经营状态。"); + + migrationBuilder.AddColumn( + name: "CategoryId", + table: "stores", + type: "bigint", + nullable: true, + comment: "行业类目 ID。"); + + migrationBuilder.AddColumn( + name: "ClosureReason", + table: "stores", + type: "integer", + nullable: true, + comment: "歇业原因。"); + + migrationBuilder.AddColumn( + name: "ClosureReasonText", + table: "stores", + type: "character varying(500)", + maxLength: 500, + nullable: true, + comment: "歇业原因补充说明。"); + + migrationBuilder.AddColumn( + name: "ForceCloseReason", + table: "stores", + type: "character varying(500)", + maxLength: 500, + nullable: true, + comment: "强制关闭原因。"); + + migrationBuilder.AddColumn( + name: "ForceClosedAt", + table: "stores", + type: "timestamp with time zone", + nullable: true, + comment: "强制关闭时间。"); + + migrationBuilder.AddColumn( + name: "OwnershipType", + table: "stores", + type: "integer", + nullable: false, + defaultValue: 0, + comment: "主体类型。"); + + migrationBuilder.AddColumn( + name: "RejectionReason", + table: "stores", + type: "character varying(500)", + maxLength: 500, + nullable: true, + comment: "审核驳回原因。"); + + migrationBuilder.AddColumn( + name: "SignboardImageUrl", + table: "stores", + type: "character varying(500)", + maxLength: 500, + nullable: true, + comment: "门头招牌图 URL。"); + + migrationBuilder.AddColumn( + name: "SubmittedAt", + table: "stores", + type: "timestamp with time zone", + nullable: true, + comment: "提交审核时间。"); + + migrationBuilder.CreateTable( + name: "store_audit_records", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + Action = table.Column(type: "integer", nullable: false, comment: "操作类型。"), + PreviousStatus = table.Column(type: "integer", nullable: true, comment: "操作前状态。"), + NewStatus = table.Column(type: "integer", nullable: false, comment: "操作后状态。"), + OperatorId = table.Column(type: "bigint", nullable: true, comment: "操作人 ID。"), + OperatorName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false, comment: "操作人名称。"), + RejectionReasonId = table.Column(type: "bigint", nullable: true, comment: "驳回理由 ID。"), + RejectionReason = table.Column(type: "character varying(500)", maxLength: 500, nullable: true, comment: "驳回理由文本。"), + Remarks = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true, comment: "备注。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_store_audit_records", x => x.Id); + }, + comment: "门店审核记录。"); + + migrationBuilder.CreateTable( + name: "store_fees", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + MinimumOrderAmount = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false, comment: "起送费(元)。"), + BaseDeliveryFee = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false, comment: "基础配送费(元)。"), + PackagingFeeMode = table.Column(type: "integer", nullable: false, comment: "打包费模式。"), + FixedPackagingFee = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false, comment: "固定打包费(总计模式有效)。"), + FreeDeliveryThreshold = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true, comment: "免配送费门槛。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_store_fees", x => x.Id); + }, + comment: "门店费用配置。"); + + migrationBuilder.CreateTable( + name: "store_qualifications", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + QualificationType = table.Column(type: "integer", nullable: false, comment: "资质类型。"), + FileUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false, comment: "证照文件 URL。"), + DocumentNumber = table.Column(type: "character varying(100)", maxLength: 100, nullable: true, comment: "证照编号。"), + IssuedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "签发日期。"), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "到期日期。"), + SortOrder = table.Column(type: "integer", nullable: false, defaultValue: 100, comment: "排序值。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_store_qualifications", x => x.Id); + }, + comment: "门店资质证照。"); + + migrationBuilder.CreateIndex( + name: "IX_stores_Longitude_Latitude", + table: "stores", + columns: new[] { "Longitude", "Latitude" }, + filter: "\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_stores_TenantId_AuditStatus", + table: "stores", + columns: new[] { "TenantId", "AuditStatus" }); + + migrationBuilder.CreateIndex( + name: "IX_stores_TenantId_BusinessStatus", + table: "stores", + columns: new[] { "TenantId", "BusinessStatus" }); + + migrationBuilder.CreateIndex( + name: "IX_stores_TenantId_OwnershipType", + table: "stores", + columns: new[] { "TenantId", "OwnershipType" }); + + migrationBuilder.CreateIndex( + name: "IX_store_audit_records_CreatedAt", + table: "store_audit_records", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_store_audit_records_TenantId_StoreId", + table: "store_audit_records", + columns: new[] { "TenantId", "StoreId" }); + + migrationBuilder.CreateIndex( + name: "IX_store_fees_TenantId", + table: "store_fees", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_store_fees_TenantId_StoreId", + table: "store_fees", + columns: new[] { "TenantId", "StoreId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_store_qualifications_ExpiresAt", + table: "store_qualifications", + column: "ExpiresAt", + filter: "\"ExpiresAt\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_store_qualifications_TenantId_StoreId", + table: "store_qualifications", + columns: new[] { "TenantId", "StoreId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "store_audit_records"); + + migrationBuilder.DropTable( + name: "store_fees"); + + migrationBuilder.DropTable( + name: "store_qualifications"); + + migrationBuilder.DropIndex( + name: "IX_stores_Longitude_Latitude", + table: "stores"); + + migrationBuilder.DropIndex( + name: "IX_stores_TenantId_AuditStatus", + table: "stores"); + + migrationBuilder.DropIndex( + name: "IX_stores_TenantId_BusinessStatus", + table: "stores"); + + migrationBuilder.DropIndex( + name: "IX_stores_TenantId_OwnershipType", + table: "stores"); + + migrationBuilder.DropColumn( + name: "ActivatedAt", + table: "stores"); + + migrationBuilder.DropColumn( + name: "AuditStatus", + table: "stores"); + + migrationBuilder.DropColumn( + name: "BusinessStatus", + table: "stores"); + + migrationBuilder.DropColumn( + name: "CategoryId", + table: "stores"); + + migrationBuilder.DropColumn( + name: "ClosureReason", + table: "stores"); + + migrationBuilder.DropColumn( + name: "ClosureReasonText", + table: "stores"); + + migrationBuilder.DropColumn( + name: "ForceCloseReason", + table: "stores"); + + migrationBuilder.DropColumn( + name: "ForceClosedAt", + table: "stores"); + + migrationBuilder.DropColumn( + name: "OwnershipType", + table: "stores"); + + migrationBuilder.DropColumn( + name: "RejectionReason", + table: "stores"); + + migrationBuilder.DropColumn( + name: "SignboardImageUrl", + table: "stores"); + + migrationBuilder.DropColumn( + name: "SubmittedAt", + table: "stores"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs index 26d5026..d338a8c 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs @@ -4828,6 +4828,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("审核通过时间。"); + b.Property("Address") .HasMaxLength(256) .HasColumnType("character varying(256)") @@ -4838,6 +4842,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("character varying(512)") .HasComment("门店公告。"); + b.Property("AuditStatus") + .HasColumnType("integer") + .HasComment("审核状态。"); + b.Property("BusinessHours") .HasMaxLength(256) .HasColumnType("character varying(256)") @@ -4853,11 +4861,28 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("character varying(50)") .HasComment("门店营业执照号(主体不一致模式使用)。"); + b.Property("BusinessStatus") + .HasColumnType("integer") + .HasComment("经营状态。"); + + b.Property("CategoryId") + .HasColumnType("bigint") + .HasComment("行业类目 ID。"); + b.Property("City") .HasMaxLength(64) .HasColumnType("character varying(64)") .HasComment("所在城市。"); + b.Property("ClosureReason") + .HasColumnType("integer") + .HasComment("歇业原因。"); + + b.Property("ClosureReasonText") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("歇业原因补充说明。"); + b.Property("Code") .IsRequired() .HasMaxLength(32) @@ -4902,6 +4927,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("character varying(64)") .HasComment("区县信息。"); + b.Property("ForceCloseReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("强制关闭原因。"); + + b.Property("ForceClosedAt") + .HasColumnType("timestamp with time zone") + .HasComment("强制关闭时间。"); + b.Property("Latitude") .HasColumnType("double precision") .HasComment("纬度。"); @@ -4930,6 +4964,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("character varying(128)") .HasComment("门店名称。"); + b.Property("OwnershipType") + .HasColumnType("integer") + .HasComment("主体类型。"); + b.Property("Phone") .HasMaxLength(32) .HasColumnType("character varying(32)") @@ -4945,10 +4983,24 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("character varying(500)") .HasComment("门店注册地址(主体不一致模式使用)。"); + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("审核驳回原因。"); + + b.Property("SignboardImageUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("门头招牌图 URL。"); + b.Property("Status") .HasColumnType("integer") .HasComment("门店当前运营状态。"); + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone") + .HasComment("提交审核时间。"); + b.Property("SupportsDelivery") .HasColumnType("boolean") .HasComment("是否支持配送。"); @@ -4987,21 +5039,119 @@ namespace TakeoutSaaS.Infrastructure.Migrations b.HasKey("Id"); + b.HasIndex("Longitude", "Latitude") + .HasFilter("\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL"); + b.HasIndex("MerchantId", "BusinessLicenseNumber") .IsUnique() .HasFilter("\"BusinessLicenseNumber\" IS NOT NULL AND \"Status\" <> 3"); + b.HasIndex("TenantId", "AuditStatus"); + + b.HasIndex("TenantId", "BusinessStatus"); + b.HasIndex("TenantId", "Code") .IsUnique(); b.HasIndex("TenantId", "MerchantId"); + b.HasIndex("TenantId", "OwnershipType"); + b.ToTable("stores", null, t => { t.HasComment("门店信息,承载营业配置与能力。"); }); }); + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreAuditRecord", 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("NewStatus") + .HasColumnType("integer") + .HasComment("操作后状态。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人 ID。"); + + b.Property("OperatorName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasComment("操作人名称。"); + + b.Property("PreviousStatus") + .HasColumnType("integer") + .HasComment("操作前状态。"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("驳回理由文本。"); + + b.Property("RejectionReasonId") + .HasColumnType("bigint") + .HasComment("驳回理由 ID。"); + + b.Property("Remarks") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .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("CreatedAt"); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("store_audit_records", null, t => + { + t.HasComment("门店审核记录。"); + }); + }); + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreBusinessHour", b => { b.Property("Id") @@ -5235,6 +5385,84 @@ namespace TakeoutSaaS.Infrastructure.Migrations }); }); + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreFee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BaseDeliveryFee") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,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("FixedPackagingFee") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasComment("固定打包费(总计模式有效)。"); + + b.Property("FreeDeliveryThreshold") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasComment("免配送费门槛。"); + + b.Property("MinimumOrderAmount") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasComment("起送费(元)。"); + + b.Property("PackagingFeeMode") + .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"); + + b.HasIndex("TenantId", "StoreId") + .IsUnique(); + + b.ToTable("store_fees", null, t => + { + t.HasComment("门店费用配置。"); + }); + }); + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreHoliday", b => { b.Property("Id") @@ -5473,6 +5701,89 @@ namespace TakeoutSaaS.Infrastructure.Migrations }); }); + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreQualification", 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(100) + .HasColumnType("character varying(100)") + .HasComment("证照编号。"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasComment("到期日期。"); + + b.Property("FileUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("证照文件 URL。"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasComment("签发日期。"); + + b.Property("QualificationType") + .HasColumnType("integer") + .HasComment("资质类型。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .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("ExpiresAt") + .HasFilter("\"ExpiresAt\" IS NOT NULL"); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("store_qualifications", null, t => + { + t.HasComment("门店资质证照。"); + }); + }); + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreTable", b => { b.Property("Id") diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/BusinessStatusAutoSwitchJob.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/BusinessStatusAutoSwitchJob.cs new file mode 100644 index 0000000..30a3924 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/BusinessStatusAutoSwitchJob.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Services; + +namespace TakeoutSaaS.Module.Scheduler.Jobs; + +/// +/// 门店营业状态自动切换任务。 +/// +public sealed class BusinessStatusAutoSwitchJob( + IStoreSchedulerService schedulerService, + ILogger logger) +{ + /// + /// 执行自动切换。 + /// + public async Task ExecuteAsync() + { + // 1. 执行自动切换 + var updated = await schedulerService.AutoSwitchBusinessStatusAsync(DateTime.UtcNow, CancellationToken.None); + + // 2. (空行后) 记录执行结果 + logger.LogInformation("定时任务:门店营业状态自动切换执行完成,更新 {UpdatedCount} 家门店", updated); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/QualificationExpiryCheckJob.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/QualificationExpiryCheckJob.cs new file mode 100644 index 0000000..94ff168 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/QualificationExpiryCheckJob.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Services; + +namespace TakeoutSaaS.Module.Scheduler.Jobs; + +/// +/// 门店资质过期检查任务。 +/// +public sealed class QualificationExpiryCheckJob( + IStoreSchedulerService schedulerService, + ILogger logger) +{ + /// + /// 执行资质过期检查。 + /// + public async Task ExecuteAsync() + { + // 1. 执行资质过期检查 + var updated = await schedulerService.CheckQualificationExpiryAsync(DateTime.UtcNow, CancellationToken.None); + + // 2. (空行后) 记录执行结果 + logger.LogInformation("定时任务:门店资质过期检查执行完成,更新 {UpdatedCount} 家门店", updated); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs index 71c90f0..0fedf9c 100644 --- a/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs @@ -44,6 +44,16 @@ public sealed class RecurringJobRegistrar( job => job.ExecuteAsync(), billingOptions.OverdueBillingProcessCron); + // 4. (空行后) 门店管理自动化任务 + RecurringJob.AddOrUpdate( + "stores.business-status-auto-switch", + job => job.ExecuteAsync(), + "*/1 * * * *"); + RecurringJob.AddOrUpdate( + "stores.qualification-expiry-check", + job => job.ExecuteAsync(), + "0 2 * * *"); + return Task.CompletedTask; } }