完成门店管理后端接口与任务
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.StoreAudits.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过命令。
|
||||
/// </summary>
|
||||
public sealed record ApproveStoreCommand : IRequest<StoreAuditActionResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核备注。
|
||||
/// </summary>
|
||||
public string? Remark { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.StoreAudits.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 强制关闭门店命令。
|
||||
/// </summary>
|
||||
public sealed record ForceCloseStoreCommand : IRequest<StoreAuditActionResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关闭原因。
|
||||
/// </summary>
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Remark { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.StoreAudits.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 审核驳回命令。
|
||||
/// </summary>
|
||||
public sealed record RejectStoreCommand : IRequest<StoreAuditActionResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 驳回原因 ID。
|
||||
/// </summary>
|
||||
public long RejectionReasonId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 驳回原因补充说明。
|
||||
/// </summary>
|
||||
public string? RejectionReasonText { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核备注。
|
||||
/// </summary>
|
||||
public string? Remark { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.StoreAudits.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 解除强制关闭命令。
|
||||
/// </summary>
|
||||
public sealed record ReopenStoreCommand : IRequest<StoreAuditActionResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Remark { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 待审核门店 DTO。
|
||||
/// </summary>
|
||||
public sealed record PendingStoreAuditDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店名称。
|
||||
/// </summary>
|
||||
public string StoreName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店编码。
|
||||
/// </summary>
|
||||
public string StoreCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string TenantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long MerchantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商户名称。
|
||||
/// </summary>
|
||||
public string MerchantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门头招牌图。
|
||||
/// </summary>
|
||||
public string? SignboardImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 完整地址。
|
||||
/// </summary>
|
||||
public string FullAddress { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 主体类型。
|
||||
/// </summary>
|
||||
public StoreOwnershipType OwnershipType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 提交时间。
|
||||
/// </summary>
|
||||
public DateTime? SubmittedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 等待天数。
|
||||
/// </summary>
|
||||
public int WaitingDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否超时。
|
||||
/// </summary>
|
||||
public bool IsOverdue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 资质数量。
|
||||
/// </summary>
|
||||
public int QualificationCount { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 审核/风控操作结果 DTO。
|
||||
/// </summary>
|
||||
public sealed record StoreAuditActionResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核状态。
|
||||
/// </summary>
|
||||
public StoreAuditStatus AuditStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 经营状态。
|
||||
/// </summary>
|
||||
public StoreBusinessStatus BusinessStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 驳回原因。
|
||||
/// </summary>
|
||||
public string? RejectionReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 提示信息。
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 审核统计趋势项。
|
||||
/// </summary>
|
||||
public sealed record StoreAuditDailyTrendDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 日期。
|
||||
/// </summary>
|
||||
public DateOnly Date { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 提交数量。
|
||||
/// </summary>
|
||||
public int Submitted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 通过数量。
|
||||
/// </summary>
|
||||
public int Approved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 驳回数量。
|
||||
/// </summary>
|
||||
public int Rejected { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 门店审核详情 DTO。
|
||||
/// </summary>
|
||||
public sealed record StoreAuditDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店信息。
|
||||
/// </summary>
|
||||
public StoreAuditStoreDto Store { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 租户信息。
|
||||
/// </summary>
|
||||
public StoreAuditTenantDto Tenant { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 商户信息。
|
||||
/// </summary>
|
||||
public StoreAuditMerchantDto Merchant { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 资质列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoreQualificationDto> Qualifications { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 审核记录。
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoreAuditRecordDto> AuditHistory { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 门店审核详情 - 商户信息。
|
||||
/// </summary>
|
||||
public sealed record StoreAuditMerchantDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 商户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商户名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 法人或主体名称。
|
||||
/// </summary>
|
||||
public string? LegalName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统一社会信用代码。
|
||||
/// </summary>
|
||||
public string? CreditCode { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店审核记录 DTO。
|
||||
/// </summary>
|
||||
public sealed record StoreAuditRecordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核动作。
|
||||
/// </summary>
|
||||
public StoreAuditAction Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 动作名称。
|
||||
/// </summary>
|
||||
public string ActionName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 操作人 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long? OperatorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作人名称。
|
||||
/// </summary>
|
||||
public string OperatorName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 操作前状态。
|
||||
/// </summary>
|
||||
public StoreAuditStatus? PreviousStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作后状态。
|
||||
/// </summary>
|
||||
public StoreAuditStatus NewStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 驳回理由 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long? RejectionReasonId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 驳回理由。
|
||||
/// </summary>
|
||||
public string? RejectionReasonText { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Remark { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 审核统计 DTO。
|
||||
/// </summary>
|
||||
public sealed record StoreAuditStatisticsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 待审核数量。
|
||||
/// </summary>
|
||||
public int PendingCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 超时数量。
|
||||
/// </summary>
|
||||
public int OverdueCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过数量。
|
||||
/// </summary>
|
||||
public int ApprovedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核驳回数量。
|
||||
/// </summary>
|
||||
public int RejectedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均处理时长(小时)。
|
||||
/// </summary>
|
||||
public double AvgProcessingHours { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日趋势。
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoreAuditDailyTrendDto> DailyTrend { get; init; } = [];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店审核详情 - 门店信息。
|
||||
/// </summary>
|
||||
public sealed record StoreAuditStoreDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店编码。
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
public string? Phone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门头招牌图。
|
||||
/// </summary>
|
||||
public string? SignboardImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 省份。
|
||||
/// </summary>
|
||||
public string? Province { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 城市。
|
||||
/// </summary>
|
||||
public string? City { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 区县。
|
||||
/// </summary>
|
||||
public string? District { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 详细地址。
|
||||
/// </summary>
|
||||
public string? Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 经度。
|
||||
/// </summary>
|
||||
public double? Longitude { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 纬度。
|
||||
/// </summary>
|
||||
public double? Latitude { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 主体类型。
|
||||
/// </summary>
|
||||
public StoreOwnershipType OwnershipType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核状态。
|
||||
/// </summary>
|
||||
public StoreAuditStatus AuditStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 提交时间。
|
||||
/// </summary>
|
||||
public DateTime? SubmittedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 门店审核详情 - 租户信息。
|
||||
/// </summary>
|
||||
public sealed record StoreAuditTenantDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 联系人。
|
||||
/// </summary>
|
||||
public string? ContactName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
public string? ContactPhone { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过处理器。
|
||||
/// </summary>
|
||||
public sealed class ApproveStoreCommandHandler(
|
||||
IStoreRepository storeRepository,
|
||||
IDapperExecutor dapperExecutor,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<ApproveStoreCommandHandler> logger)
|
||||
: IRequestHandler<ApproveStoreCommand, StoreAuditActionResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreAuditActionResultDto> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 强制关闭门店处理器。
|
||||
/// </summary>
|
||||
public sealed class ForceCloseStoreCommandHandler(
|
||||
IStoreRepository storeRepository,
|
||||
IDapperExecutor dapperExecutor,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<ForceCloseStoreCommandHandler> logger)
|
||||
: IRequestHandler<ForceCloseStoreCommand, StoreAuditActionResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreAuditActionResultDto> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店审核详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetStoreAuditDetailQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetStoreAuditDetailQuery, StoreAuditDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreAuditDetailDto?> 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<IReadOnlyList<StoreQualificationDto>> 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<StoreQualificationDto>();
|
||||
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<IReadOnlyList<StoreAuditRecordDto>> 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<StoreAuditRecordDto>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 审核统计查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetStoreAuditStatisticsQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetStoreAuditStatisticsQuery, StoreAuditStatisticsDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreAuditStatisticsDto> 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<IReadOnlyList<StoreAuditDailyTrendDto>> 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<StoreAuditDailyTrendDto>();
|
||||
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<int> 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<double> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 待审核门店列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class ListPendingStoreAuditsQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<ListPendingStoreAuditsQuery, PagedResult<PendingStoreAuditDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<PendingStoreAuditDto>> 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<PendingStoreAuditDto>();
|
||||
if (!reader.HasRows)
|
||||
{
|
||||
return new PagedResult<PendingStoreAuditDto>(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<PendingStoreAuditDto>(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<int> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店审核记录查询处理器。
|
||||
/// </summary>
|
||||
public sealed class ListStoreAuditRecordsQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<ListStoreAuditRecordsQuery, PagedResult<StoreAuditRecordDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<StoreAuditRecordDto>> 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<StoreAuditRecordDto>();
|
||||
if (!reader.HasRows)
|
||||
{
|
||||
return new PagedResult<StoreAuditRecordDto>(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<StoreAuditRecordDto>(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<int> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 审核驳回处理器。
|
||||
/// </summary>
|
||||
public sealed class RejectStoreCommandHandler(
|
||||
IStoreRepository storeRepository,
|
||||
IDapperExecutor dapperExecutor,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<RejectStoreCommandHandler> logger)
|
||||
: IRequestHandler<RejectStoreCommand, StoreAuditActionResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreAuditActionResultDto> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 解除强制关闭处理器。
|
||||
/// </summary>
|
||||
public sealed class ReopenStoreCommandHandler(
|
||||
IStoreRepository storeRepository,
|
||||
IDapperExecutor dapperExecutor,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<ReopenStoreCommandHandler> logger)
|
||||
: IRequestHandler<ReopenStoreCommand, StoreAuditActionResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreAuditActionResultDto> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.StoreAudits.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取门店审核详情查询。
|
||||
/// </summary>
|
||||
public sealed record GetStoreAuditDetailQuery : IRequest<StoreAuditDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.StoreAudits.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取审核统计查询。
|
||||
/// </summary>
|
||||
public sealed record GetStoreAuditStatisticsQuery : IRequest<StoreAuditStatisticsDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 起始日期。
|
||||
/// </summary>
|
||||
public DateTime? DateFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 截止日期。
|
||||
/// </summary>
|
||||
public DateTime? DateTo { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.StoreAudits.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询待审核门店列表。
|
||||
/// </summary>
|
||||
public sealed record ListPendingStoreAuditsQuery : IRequest<PagedResult<PendingStoreAuditDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 提交起始时间。
|
||||
/// </summary>
|
||||
public DateTime? SubmittedFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 提交截止时间。
|
||||
/// </summary>
|
||||
public DateTime? SubmittedTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否只显示超时。
|
||||
/// </summary>
|
||||
public bool OverdueOnly { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页数量。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 排序字段。
|
||||
/// </summary>
|
||||
public string? SortBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否降序。
|
||||
/// </summary>
|
||||
public bool SortDesc { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.StoreAudits.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询门店审核记录。
|
||||
/// </summary>
|
||||
public sealed record ListStoreAuditRecordsQuery : IRequest<PagedResult<StoreAuditRecordDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页数量。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits;
|
||||
|
||||
/// <summary>
|
||||
/// 门店审核动作名称解析器。
|
||||
/// </summary>
|
||||
public static class StoreAuditActionNameResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取动作名称。
|
||||
/// </summary>
|
||||
/// <param name="action">审核动作。</param>
|
||||
/// <returns>动作名称。</returns>
|
||||
public static string Resolve(StoreAuditAction action) => action switch
|
||||
{
|
||||
StoreAuditAction.Submit => "提交审核",
|
||||
StoreAuditAction.Resubmit => "重新提交",
|
||||
StoreAuditAction.Approve => "审核通过",
|
||||
StoreAuditAction.Reject => "审核驳回",
|
||||
StoreAuditAction.ForceClose => "强制关闭",
|
||||
StoreAuditAction.Reopen => "解除关闭",
|
||||
StoreAuditAction.AutoActivate => "自动激活",
|
||||
_ => "未知操作"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.StoreAudits.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过命令验证器。
|
||||
/// </summary>
|
||||
public sealed class ApproveStoreCommandValidator : AbstractValidator<ApproveStoreCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public ApproveStoreCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.Remark).MaximumLength(500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.StoreAudits.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 强制关闭命令验证器。
|
||||
/// </summary>
|
||||
public sealed class ForceCloseStoreCommandValidator : AbstractValidator<ForceCloseStoreCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public ForceCloseStoreCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.Reason).NotEmpty().MaximumLength(500);
|
||||
RuleFor(x => x.Remark).MaximumLength(500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.StoreAudits.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 审核驳回命令验证器。
|
||||
/// </summary>
|
||||
public sealed class RejectStoreCommandValidator : AbstractValidator<RejectStoreCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.StoreAudits.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.StoreAudits.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 解除强制关闭命令验证器。
|
||||
/// </summary>
|
||||
public sealed class ReopenStoreCommandValidator : AbstractValidator<ReopenStoreCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public ReopenStoreCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.Remark).MaximumLength(500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 批量更新营业时段命令。
|
||||
/// </summary>
|
||||
public sealed record BatchUpdateBusinessHoursCommand : IRequest<IReadOnlyList<StoreBusinessHourDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 营业时段集合。
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoreBusinessHourInputDto> Items { get; init; } = [];
|
||||
}
|
||||
@@ -7,105 +7,120 @@ namespace TakeoutSaaS.Application.App.Stores.Commands;
|
||||
/// <summary>
|
||||
/// 创建门店命令。
|
||||
/// </summary>
|
||||
public sealed class CreateStoreCommand : IRequest<StoreDto>
|
||||
public sealed record CreateStoreCommand : IRequest<StoreDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 商户 ID。
|
||||
/// </summary>
|
||||
public long MerchantId { get; set; }
|
||||
public long MerchantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店编码。
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 电话。
|
||||
/// </summary>
|
||||
public string? Phone { get; set; }
|
||||
public string? Phone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 负责人。
|
||||
/// </summary>
|
||||
public string? ManagerName { get; set; }
|
||||
public string? ManagerName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public StoreStatus Status { get; set; } = StoreStatus.Closed;
|
||||
public StoreStatus Status { get; init; } = StoreStatus.Closed;
|
||||
|
||||
/// <summary>
|
||||
/// 门头招牌图。
|
||||
/// </summary>
|
||||
public string? SignboardImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 主体类型。
|
||||
/// </summary>
|
||||
public StoreOwnershipType OwnershipType { get; init; } = StoreOwnershipType.SameEntity;
|
||||
|
||||
/// <summary>
|
||||
/// 行业类目 ID。
|
||||
/// </summary>
|
||||
public long? CategoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 省份。
|
||||
/// </summary>
|
||||
public string? Province { get; set; }
|
||||
public string? Province { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 城市。
|
||||
/// </summary>
|
||||
public string? City { get; set; }
|
||||
public string? City { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 区县。
|
||||
/// </summary>
|
||||
public string? District { get; set; }
|
||||
public string? District { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 详细地址。
|
||||
/// </summary>
|
||||
public string? Address { get; set; }
|
||||
public string? Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 经度。
|
||||
/// </summary>
|
||||
public double? Longitude { get; set; }
|
||||
public double? Longitude { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 纬度。
|
||||
/// </summary>
|
||||
public double? Latitude { get; set; }
|
||||
public double? Latitude { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 公告。
|
||||
/// </summary>
|
||||
public string? Announcement { get; set; }
|
||||
public string? Announcement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 标签。
|
||||
/// </summary>
|
||||
public string? Tags { get; set; }
|
||||
public string? Tags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配送半径。
|
||||
/// </summary>
|
||||
public decimal DeliveryRadiusKm { get; set; }
|
||||
public decimal DeliveryRadiusKm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支持堂食。
|
||||
/// </summary>
|
||||
public bool SupportsDineIn { get; set; } = true;
|
||||
public bool SupportsDineIn { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 支持自提。
|
||||
/// </summary>
|
||||
public bool SupportsPickup { get; set; } = true;
|
||||
public bool SupportsPickup { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 支持配送。
|
||||
/// </summary>
|
||||
public bool SupportsDelivery { get; set; } = true;
|
||||
public bool SupportsDelivery { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 支持预约。
|
||||
/// </summary>
|
||||
public bool SupportsReservation { get; set; }
|
||||
public bool SupportsReservation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支持排队叫号。
|
||||
/// </summary>
|
||||
public bool SupportsQueueing { get; set; }
|
||||
public bool SupportsQueueing { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 创建门店资质命令。
|
||||
/// </summary>
|
||||
public sealed record CreateStoreQualificationCommand : IRequest<StoreQualificationDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 资质类型。
|
||||
/// </summary>
|
||||
public StoreQualificationType QualificationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 证照文件 URL。
|
||||
/// </summary>
|
||||
public string FileUrl { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 证照编号。
|
||||
/// </summary>
|
||||
public string? DocumentNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 签发日期。
|
||||
/// </summary>
|
||||
public DateTime? IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期日期。
|
||||
/// </summary>
|
||||
public DateTime? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; } = 100;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 删除门店资质命令。
|
||||
/// </summary>
|
||||
public sealed record DeleteStoreQualificationCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 资质 ID。
|
||||
/// </summary>
|
||||
public long QualificationId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 提交门店审核命令。
|
||||
/// </summary>
|
||||
public sealed record SubmitStoreAuditCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 切换门店经营状态命令。
|
||||
/// </summary>
|
||||
public sealed record ToggleBusinessStatusCommand : IRequest<StoreDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标经营状态。
|
||||
/// </summary>
|
||||
public StoreBusinessStatus BusinessStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 歇业原因。
|
||||
/// </summary>
|
||||
public StoreClosureReason? ClosureReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 歇业原因补充说明。
|
||||
/// </summary>
|
||||
public string? ClosureReasonText { get; init; }
|
||||
}
|
||||
@@ -44,6 +44,16 @@ public sealed record UpdateStoreCommand : IRequest<StoreDto?>
|
||||
/// </summary>
|
||||
public StoreStatus Status { get; init; } = StoreStatus.Closed;
|
||||
|
||||
/// <summary>
|
||||
/// 门头招牌图。
|
||||
/// </summary>
|
||||
public string? SignboardImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 行业类目 ID。
|
||||
/// </summary>
|
||||
public long? CategoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 省份。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新门店费用配置命令。
|
||||
/// </summary>
|
||||
public sealed record UpdateStoreFeeCommand : IRequest<StoreFeeDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 起送费。
|
||||
/// </summary>
|
||||
public decimal MinimumOrderAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配送费。
|
||||
/// </summary>
|
||||
public decimal DeliveryFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 打包费模式。
|
||||
/// </summary>
|
||||
public PackagingFeeMode PackagingFeeMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定打包费。
|
||||
/// </summary>
|
||||
public decimal? FixedPackagingFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 免配送费门槛。
|
||||
/// </summary>
|
||||
public decimal? FreeDeliveryThreshold { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新门店资质命令。
|
||||
/// </summary>
|
||||
public sealed record UpdateStoreQualificationCommand : IRequest<StoreQualificationDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 资质 ID。
|
||||
/// </summary>
|
||||
public long QualificationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 证照文件 URL。
|
||||
/// </summary>
|
||||
public string? FileUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 证照编号。
|
||||
/// </summary>
|
||||
public string? DocumentNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 签发日期。
|
||||
/// </summary>
|
||||
public DateTime? IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期日期。
|
||||
/// </summary>
|
||||
public DateTime? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int? SortOrder { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 批量更新营业时段输入项。
|
||||
/// </summary>
|
||||
public sealed record StoreBusinessHourInputDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 星期几。
|
||||
/// </summary>
|
||||
public DayOfWeek DayOfWeek { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 时段类型。
|
||||
/// </summary>
|
||||
public BusinessHourType HourType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间。
|
||||
/// </summary>
|
||||
public TimeSpan StartTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间。
|
||||
/// </summary>
|
||||
public TimeSpan EndTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 容量限制。
|
||||
/// </summary>
|
||||
public int? CapacityLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 配送范围检测结果。
|
||||
/// </summary>
|
||||
public sealed record StoreDeliveryCheckResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否在范围内。
|
||||
/// </summary>
|
||||
public bool InRange { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 距离(公里)。
|
||||
/// </summary>
|
||||
public decimal? Distance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 命中的配送区域 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long? DeliveryZoneId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 命中的配送区域名称。
|
||||
/// </summary>
|
||||
public string? DeliveryZoneName { get; init; }
|
||||
}
|
||||
@@ -52,6 +52,67 @@ public sealed class StoreDto
|
||||
/// </summary>
|
||||
public StoreStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门头招牌图。
|
||||
/// </summary>
|
||||
public string? SignboardImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 主体类型。
|
||||
/// </summary>
|
||||
public StoreOwnershipType OwnershipType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核状态。
|
||||
/// </summary>
|
||||
public StoreAuditStatus AuditStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 经营状态。
|
||||
/// </summary>
|
||||
public StoreBusinessStatus BusinessStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 歇业原因。
|
||||
/// </summary>
|
||||
public StoreClosureReason? ClosureReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 歇业原因补充说明。
|
||||
/// </summary>
|
||||
public string? ClosureReasonText { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 行业类目 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long? CategoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核驳回原因。
|
||||
/// </summary>
|
||||
public string? RejectionReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 提交审核时间。
|
||||
/// </summary>
|
||||
public DateTime? SubmittedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过时间。
|
||||
/// </summary>
|
||||
public DateTime? ActivatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 强制关闭时间。
|
||||
/// </summary>
|
||||
public DateTime? ForceClosedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 强制关闭原因。
|
||||
/// </summary>
|
||||
public string? ForceCloseReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 省份。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 打包费拆分明细。
|
||||
/// </summary>
|
||||
public sealed record StoreFeeCalculationBreakdownDto
|
||||
{
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long SkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 单件打包费。
|
||||
/// </summary>
|
||||
public decimal UnitFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 小计。
|
||||
/// </summary>
|
||||
public decimal Subtotal { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 费用计算商品项。
|
||||
/// </summary>
|
||||
public sealed record StoreFeeCalculationItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long SkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 单件打包费。
|
||||
/// </summary>
|
||||
public decimal PackagingFee { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 费用计算请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record StoreFeeCalculationRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品金额。
|
||||
/// </summary>
|
||||
public decimal OrderAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品种类数量。
|
||||
/// </summary>
|
||||
public int? ItemCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoreFeeCalculationItemDto> Items { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 费用计算结果。
|
||||
/// </summary>
|
||||
public sealed record StoreFeeCalculationResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品金额。
|
||||
/// </summary>
|
||||
public decimal OrderAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 起送费。
|
||||
/// </summary>
|
||||
public decimal MinimumOrderAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否达到起送费。
|
||||
/// </summary>
|
||||
public bool MeetsMinimum { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 距离起送差额。
|
||||
/// </summary>
|
||||
public decimal? Shortfall { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配送费。
|
||||
/// </summary>
|
||||
public decimal DeliveryFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 打包费。
|
||||
/// </summary>
|
||||
public decimal PackagingFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 打包费模式。
|
||||
/// </summary>
|
||||
public PackagingFeeMode PackagingFeeMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 打包费拆分明细。
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoreFeeCalculationBreakdownDto>? PackagingFeeBreakdown { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 费用合计。
|
||||
/// </summary>
|
||||
public decimal TotalFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 文案提示。
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店费用配置 DTO。
|
||||
/// </summary>
|
||||
public sealed record StoreFeeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 费用配置 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 起送费。
|
||||
/// </summary>
|
||||
public decimal MinimumOrderAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配送费。
|
||||
/// </summary>
|
||||
public decimal DeliveryFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 打包费模式。
|
||||
/// </summary>
|
||||
public PackagingFeeMode PackagingFeeMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定打包费。
|
||||
/// </summary>
|
||||
public decimal FixedPackagingFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 免配送费门槛。
|
||||
/// </summary>
|
||||
public decimal? FreeDeliveryThreshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店资质预警明细 DTO。
|
||||
/// </summary>
|
||||
public sealed record StoreQualificationAlertDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 资质 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long QualificationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店名称。
|
||||
/// </summary>
|
||||
public string StoreName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店编码。
|
||||
/// </summary>
|
||||
public string StoreCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string TenantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 资质类型。
|
||||
/// </summary>
|
||||
public StoreQualificationType QualificationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间。
|
||||
/// </summary>
|
||||
public DateTime? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 距离过期天数。
|
||||
/// </summary>
|
||||
public int? DaysUntilExpiry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已过期。
|
||||
/// </summary>
|
||||
public bool IsExpired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店经营状态。
|
||||
/// </summary>
|
||||
public StoreBusinessStatus StoreBusinessStatus { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 门店资质预警分页结果 DTO。
|
||||
/// </summary>
|
||||
public sealed record StoreQualificationAlertResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 资质预警列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoreQualificationAlertDto> Items { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 当前页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总页数。
|
||||
/// </summary>
|
||||
public int TotalPages { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计汇总。
|
||||
/// </summary>
|
||||
public StoreQualificationAlertSummaryDto Summary { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 门店资质预警统计 DTO。
|
||||
/// </summary>
|
||||
public sealed record StoreQualificationAlertSummaryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 即将过期数量。
|
||||
/// </summary>
|
||||
public int ExpiringSoonCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已过期数量。
|
||||
/// </summary>
|
||||
public int ExpiredCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 资质完整性检查结果。
|
||||
/// </summary>
|
||||
public sealed class StoreQualificationCheckResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否完整。
|
||||
/// </summary>
|
||||
public bool IsComplete { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许提交审核。
|
||||
/// </summary>
|
||||
public bool CanSubmitAudit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 必要资质清单。
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoreQualificationRequirementDto> RequiredTypes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 即将过期数量。
|
||||
/// </summary>
|
||||
public int ExpiringSoonCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已过期数量。
|
||||
/// </summary>
|
||||
public int ExpiredCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 缺失类型提示。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> MissingTypes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 警告提示。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店资质 DTO。
|
||||
/// </summary>
|
||||
public sealed class StoreQualificationDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 资质 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 资质类型。
|
||||
/// </summary>
|
||||
public StoreQualificationType QualificationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 证照文件 URL。
|
||||
/// </summary>
|
||||
public string FileUrl { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 证照编号。
|
||||
/// </summary>
|
||||
public string? DocumentNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 签发日期。
|
||||
/// </summary>
|
||||
public DateTime? IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期日期。
|
||||
/// </summary>
|
||||
public DateTime? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已过期。
|
||||
/// </summary>
|
||||
public bool IsExpired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否即将过期。
|
||||
/// </summary>
|
||||
public bool IsExpiringSoon { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 距离过期天数。
|
||||
/// </summary>
|
||||
public int? DaysUntilExpiry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 门店资质完整性项。
|
||||
/// </summary>
|
||||
public sealed class StoreQualificationRequirementDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 资质类型。
|
||||
/// </summary>
|
||||
public StoreQualificationType QualificationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否必需。
|
||||
/// </summary>
|
||||
public bool IsRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已上传。
|
||||
/// </summary>
|
||||
public bool IsUploaded { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否有效。
|
||||
/// </summary>
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 上传数量。
|
||||
/// </summary>
|
||||
public int UploadedCount { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 批量更新营业时段处理器。
|
||||
/// </summary>
|
||||
public sealed class BatchUpdateBusinessHoursCommandHandler(
|
||||
IStoreRepository storeRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<BatchUpdateBusinessHoursCommandHandler> logger)
|
||||
: IRequestHandler<BatchUpdateBusinessHoursCommand, IReadOnlyList<StoreBusinessHourDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<StoreBusinessHourDto>> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店费用预览查询处理器。
|
||||
/// </summary>
|
||||
public sealed class CalculateStoreFeeQueryHandler(
|
||||
IStoreRepository storeRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
IStoreFeeCalculationService feeCalculationService)
|
||||
: IRequestHandler<CalculateStoreFeeQuery, StoreFeeCalculationResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreFeeCalculationResultDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 配送范围检测查询处理器。
|
||||
/// </summary>
|
||||
public sealed class CheckStoreDeliveryZoneQueryHandler(
|
||||
IStoreRepository storeRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
IDeliveryZoneService deliveryZoneService)
|
||||
: IRequestHandler<CheckStoreDeliveryZoneQuery, StoreDeliveryCheckResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreDeliveryCheckResultDto> 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店资质完整性检查处理器。
|
||||
/// </summary>
|
||||
public sealed class CheckStoreQualificationsQueryHandler(
|
||||
IStoreRepository storeRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<CheckStoreQualificationsQuery, StoreQualificationCheckResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreQualificationCheckResultDto> 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<string>();
|
||||
if (!hasLicense)
|
||||
{
|
||||
missingTypes.Add("营业执照/食品经营许可证");
|
||||
}
|
||||
|
||||
if (!hasStorefront)
|
||||
{
|
||||
missingTypes.Add("门头实景照");
|
||||
}
|
||||
|
||||
if (!hasInterior)
|
||||
{
|
||||
missingTypes.Add("店内环境照(至少2张)");
|
||||
}
|
||||
|
||||
var warnings = missingTypes.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: missingTypes.Select(type => $"缺少必要资质:{type}").ToArray();
|
||||
|
||||
// 4. (空行后) 组装结果
|
||||
var requirements = new List<StoreQualificationRequirementDto>
|
||||
{
|
||||
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<StoreQualificationType, List<Domain.Stores.Entities.StoreQualification>> 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<StoreQualificationType, List<Domain.Stores.Entities.StoreQualification>> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 创建门店命令处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateStoreCommandHandler(IStoreRepository storeRepository, ILogger<CreateStoreCommandHandler> logger)
|
||||
public sealed class CreateStoreCommandHandler(
|
||||
IStoreRepository storeRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<CreateStoreCommandHandler> logger)
|
||||
: IRequestHandler<CreateStoreCommand, StoreDto>
|
||||
{
|
||||
private readonly IStoreRepository _storeRepository = storeRepository;
|
||||
private readonly ILogger<CreateStoreCommandHandler> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreDto> 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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<CreateStoreDeliveryZoneCommandHandler> logger)
|
||||
: IRequestHandler<CreateStoreDeliveryZoneCommand, StoreDeliveryZoneDto>
|
||||
{
|
||||
private readonly IStoreRepository _storeRepository = storeRepository;
|
||||
private readonly ITenantProvider _tenantProvider = tenantProvider;
|
||||
private readonly ILogger<CreateStoreDeliveryZoneCommandHandler> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreDeliveryZoneDto> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 创建门店资质处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateStoreQualificationCommandHandler(
|
||||
IStoreRepository storeRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<CreateStoreQualificationCommandHandler> logger)
|
||||
: IRequestHandler<CreateStoreQualificationCommand, StoreQualificationDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreQualificationDto> 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 删除门店资质处理器。
|
||||
/// </summary>
|
||||
public sealed class DeleteStoreQualificationCommandHandler(
|
||||
IStoreRepository storeRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<DeleteStoreQualificationCommandHandler> logger)
|
||||
: IRequestHandler<DeleteStoreQualificationCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
@@ -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<GetStoreByIdQuery, StoreDto?>
|
||||
{
|
||||
private readonly IStoreRepository _storeRepository = storeRepository;
|
||||
private readonly ITenantProvider _tenantProvider = tenantProvider;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreDto?> 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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取门店费用配置处理器。
|
||||
/// </summary>
|
||||
public sealed class GetStoreFeeQueryHandler(
|
||||
IStoreRepository storeRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetStoreFeeQuery, StoreFeeDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreFeeDto?> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 资质预警查询处理器。
|
||||
/// </summary>
|
||||
public sealed class ListExpiringStoreQualificationsQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<ListExpiringStoreQualificationsQuery, StoreQualificationAlertResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreQualificationAlertResultDto> 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<StoreQualificationAlertDto> 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<StoreQualificationAlertDto> 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<StoreQualificationAlertSummaryDto> 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<int> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店资质列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class ListStoreQualificationsQueryHandler(
|
||||
IStoreRepository storeRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ListStoreQualificationsQuery, IReadOnlyList<StoreQualificationDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<StoreQualificationDto>> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<SearchStoresQuery, PagedResult<StoreDto>>
|
||||
{
|
||||
private readonly IStoreRepository _storeRepository = storeRepository;
|
||||
private readonly ITenantProvider _tenantProvider = tenantProvider;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<StoreDto>> 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<StoreDto>(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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 提交门店审核处理器。
|
||||
/// </summary>
|
||||
public sealed class SubmitStoreAuditCommandHandler(
|
||||
IStoreRepository storeRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IMediator mediator,
|
||||
ILogger<SubmitStoreAuditCommandHandler> logger)
|
||||
: IRequestHandler<SubmitStoreAuditCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> 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}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 切换门店经营状态处理器。
|
||||
/// </summary>
|
||||
public sealed class ToggleBusinessStatusCommandHandler(
|
||||
IStoreRepository storeRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<ToggleBusinessStatusCommandHandler> logger)
|
||||
: IRequestHandler<ToggleBusinessStatusCommand, StoreDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<UpdateStoreCommandHandler> logger)
|
||||
: IRequestHandler<UpdateStoreCommand, StoreDto?>
|
||||
{
|
||||
private readonly IStoreRepository _storeRepository = storeRepository;
|
||||
private readonly ITenantProvider _tenantProvider = tenantProvider;
|
||||
private readonly ILogger<UpdateStoreCommandHandler> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreDto?> 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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<UpdateStoreDeliveryZoneCommandHandler> logger)
|
||||
: IRequestHandler<UpdateStoreDeliveryZoneCommand, StoreDeliveryZoneDto?>
|
||||
{
|
||||
private readonly IStoreRepository _storeRepository = storeRepository;
|
||||
private readonly ITenantProvider _tenantProvider = tenantProvider;
|
||||
private readonly ILogger<UpdateStoreDeliveryZoneCommandHandler> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreDeliveryZoneDto?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 更新门店费用配置处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateStoreFeeCommandHandler(
|
||||
IStoreRepository storeRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<UpdateStoreFeeCommandHandler> logger)
|
||||
: IRequestHandler<UpdateStoreFeeCommand, StoreFeeDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreFeeDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 更新门店资质处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateStoreQualificationCommandHandler(
|
||||
IStoreRepository storeRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<UpdateStoreQualificationCommandHandler> logger)
|
||||
: IRequestHandler<UpdateStoreQualificationCommand, StoreQualificationDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreQualificationDto?> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 费用预览计算查询。
|
||||
/// </summary>
|
||||
public sealed record CalculateStoreFeeQuery : IRequest<StoreFeeCalculationResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品金额。
|
||||
/// </summary>
|
||||
public decimal OrderAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品种类数量。
|
||||
/// </summary>
|
||||
public int? ItemCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoreFeeCalculationItemDto> Items { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 配送范围检测查询。
|
||||
/// </summary>
|
||||
public sealed record CheckStoreDeliveryZoneQuery : IRequest<StoreDeliveryCheckResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 经度。
|
||||
/// </summary>
|
||||
public double Longitude { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 纬度。
|
||||
/// </summary>
|
||||
public double Latitude { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 检查门店资质完整性查询。
|
||||
/// </summary>
|
||||
public sealed record CheckStoreQualificationsQuery : IRequest<StoreQualificationCheckResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取门店费用配置查询。
|
||||
/// </summary>
|
||||
public sealed record GetStoreFeeQuery : IRequest<StoreFeeDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 资质预警分页查询。
|
||||
/// </summary>
|
||||
public sealed record ListExpiringStoreQualificationsQuery : IRequest<StoreQualificationAlertResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 过期阈值天数(默认 30 天)。
|
||||
/// </summary>
|
||||
public int? DaysThreshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID(可选)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否仅显示已过期。
|
||||
/// </summary>
|
||||
public bool Expired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询门店资质列表。
|
||||
/// </summary>
|
||||
public sealed record ListStoreQualificationsQuery : IRequest<IReadOnlyList<StoreQualificationDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
}
|
||||
@@ -20,6 +20,26 @@ public sealed class SearchStoresQuery : IRequest<PagedResult<StoreDto>>
|
||||
/// </summary>
|
||||
public StoreStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核状态过滤。
|
||||
/// </summary>
|
||||
public StoreAuditStatus? AuditStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 经营状态过滤。
|
||||
/// </summary>
|
||||
public StoreBusinessStatus? BusinessStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 主体类型过滤。
|
||||
/// </summary>
|
||||
public StoreOwnershipType? OwnershipType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(名称/编码)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.App.Stores.Services;
|
||||
|
||||
/// <summary>
|
||||
/// GeoJSON 校验结果。
|
||||
/// </summary>
|
||||
public sealed record GeoJsonValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否通过校验。
|
||||
/// </summary>
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 规范化后的 GeoJSON(自动闭合时输出)。
|
||||
/// </summary>
|
||||
public string? NormalizedGeoJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息。
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 配送范围检测服务。
|
||||
/// </summary>
|
||||
public interface IDeliveryZoneService
|
||||
{
|
||||
/// <summary>
|
||||
/// 检测坐标是否落在配送范围内。
|
||||
/// </summary>
|
||||
/// <param name="zones">配送区域列表。</param>
|
||||
/// <param name="longitude">经度。</param>
|
||||
/// <param name="latitude">纬度。</param>
|
||||
/// <returns>检测结果。</returns>
|
||||
StoreDeliveryCheckResultDto CheckPointInZones(
|
||||
IReadOnlyList<StoreDeliveryZone> zones,
|
||||
double longitude,
|
||||
double latitude);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TakeoutSaaS.Application.App.Stores.Services;
|
||||
|
||||
/// <summary>
|
||||
/// GeoJSON 校验服务。
|
||||
/// </summary>
|
||||
public interface IGeoJsonValidationService
|
||||
{
|
||||
/// <summary>
|
||||
/// 校验多边形 GeoJSON 并返回规范化结果。
|
||||
/// </summary>
|
||||
/// <param name="geoJson">GeoJSON 字符串。</param>
|
||||
/// <returns>校验结果。</returns>
|
||||
GeoJsonValidationResult ValidatePolygon(string geoJson);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 门店费用计算服务。
|
||||
/// </summary>
|
||||
public interface IStoreFeeCalculationService
|
||||
{
|
||||
/// <summary>
|
||||
/// 计算费用预览。
|
||||
/// </summary>
|
||||
/// <param name="fee">门店费用配置。</param>
|
||||
/// <param name="request">计算请求。</param>
|
||||
/// <returns>计算结果。</returns>
|
||||
StoreFeeCalculationResultDto Calculate(StoreFee fee, StoreFeeCalculationRequestDto request);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace TakeoutSaaS.Application.App.Stores.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 门店定时任务服务。
|
||||
/// </summary>
|
||||
public interface IStoreSchedulerService
|
||||
{
|
||||
/// <summary>
|
||||
/// 自动切换门店营业状态。
|
||||
/// </summary>
|
||||
/// <param name="now">当前时间(UTC)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新的门店数量。</returns>
|
||||
Task<int> AutoSwitchBusinessStatusAsync(DateTime now, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 检查门店资质过期并更新状态。
|
||||
/// </summary>
|
||||
/// <param name="now">当前时间(UTC)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新的门店数量。</returns>
|
||||
Task<int> CheckQualificationExpiryAsync(DateTime now, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -9,6 +9,97 @@ namespace TakeoutSaaS.Application.App.Stores;
|
||||
/// </summary>
|
||||
public static class StoreMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 映射门店 DTO。
|
||||
/// </summary>
|
||||
/// <param name="store">门店实体。</param>
|
||||
/// <returns>DTO。</returns>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 映射门店费用 DTO。
|
||||
/// </summary>
|
||||
/// <param name="fee">费用配置。</param>
|
||||
/// <returns>DTO。</returns>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 映射门店资质 DTO。
|
||||
/// </summary>
|
||||
/// <param name="qualification">资质实体。</param>
|
||||
/// <returns>DTO。</returns>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 映射营业时段 DTO。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Stores.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 批量更新营业时段命令验证器。
|
||||
/// </summary>
|
||||
public sealed class BatchUpdateBusinessHoursCommandValidator : AbstractValidator<BatchUpdateBusinessHoursCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Linq;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 营业时段校验助手。
|
||||
/// </summary>
|
||||
public static class BusinessHourValidators
|
||||
{
|
||||
/// <summary>
|
||||
/// 校验营业时段是否存在重叠。
|
||||
/// </summary>
|
||||
/// <param name="items">营业时段列表。</param>
|
||||
/// <returns>错误信息,若为空表示通过。</returns>
|
||||
public static string? ValidateOverlap(IReadOnlyList<StoreBusinessHourInputDto> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Stores.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 费用预览计算查询验证器。
|
||||
/// </summary>
|
||||
public sealed class CalculateStoreFeeQueryValidator : AbstractValidator<CalculateStoreFeeQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Stores.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 配送范围检测查询验证器。
|
||||
/// </summary>
|
||||
public sealed class CheckStoreDeliveryZoneQueryValidator : AbstractValidator<CheckStoreDeliveryZoneQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public CheckStoreDeliveryZoneQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.Longitude).InclusiveBetween(-180, 180);
|
||||
RuleFor(x => x.Latitude).InclusiveBetween(-90, 90);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,14 @@ public sealed class CreateStoreCommandValidator : AbstractValidator<CreateStoreC
|
||||
RuleFor(x => 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);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Stores.Commands;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 创建门店资质命令验证器。
|
||||
/// </summary>
|
||||
public sealed class CreateStoreQualificationCommandValidator : AbstractValidator<CreateStoreQualificationCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Stores.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 删除门店资质命令验证器。
|
||||
/// </summary>
|
||||
public sealed class DeleteStoreQualificationCommandValidator : AbstractValidator<DeleteStoreQualificationCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public DeleteStoreQualificationCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.QualificationId).GreaterThan(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Stores.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 提交门店审核命令验证器。
|
||||
/// </summary>
|
||||
public sealed class SubmitStoreAuditCommandValidator : AbstractValidator<SubmitStoreAuditCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public SubmitStoreAuditCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Stores.Commands;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 切换门店经营状态命令验证器。
|
||||
/// </summary>
|
||||
public sealed class ToggleBusinessStatusCommandValidator : AbstractValidator<ToggleBusinessStatusCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class UpdateStoreCommandValidator : AbstractValidator<UpdateStoreC
|
||||
RuleFor(x => 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);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Stores.Commands;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 更新门店费用配置命令验证器。
|
||||
/// </summary>
|
||||
public sealed class UpdateStoreFeeCommandValidator : AbstractValidator<UpdateStoreFeeCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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("固定打包费不能为负数");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Stores.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 更新门店资质命令验证器。
|
||||
/// </summary>
|
||||
public sealed class UpdateStoreQualificationCommandValidator : AbstractValidator<UpdateStoreQualificationCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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("证照有效期必须晚于今天");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user