完成门店管理后端接口与任务

This commit is contained in:
2026-01-01 07:26:14 +08:00
parent dc9f6136d6
commit fc55003d3d
131 changed files with 15333 additions and 201 deletions

View File

@@ -0,0 +1,222 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.StoreAudits.Commands;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
using TakeoutSaaS.Application.App.StoreAudits.Queries;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 门店审核与风控管理(平台)。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/platform/store-audits")]
[Route("api/admin/v{version:apiVersion}/platform/store-audits")]
public sealed class StoreAuditsController(IMediator mediator, ITenantContextAccessor tenantContextAccessor) : BaseApiController
{
/// <summary>
/// 查询待审核门店列表。
/// </summary>
/// <returns>待审核门店分页列表。</returns>
[HttpGet("pending")]
[PermissionAuthorize("store-audit:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PendingStoreAuditDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<PendingStoreAuditDto>>> ListPending(
[FromQuery] ListPendingStoreAuditsQuery query,
CancellationToken cancellationToken)
{
// 1. 查询待审核门店列表
var result = await ExecuteAsPlatformAsync(() => mediator.Send(query, cancellationToken));
// 2. 返回分页结果
return ApiResponse<PagedResult<PendingStoreAuditDto>>.Ok(result);
}
/// <summary>
/// 获取门店审核详情。
/// </summary>
/// <param name="storeId">门店 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>审核详情。</returns>
[HttpGet("{storeId:long}")]
[PermissionAuthorize("store-audit:read")]
[ProducesResponseType(typeof(ApiResponse<StoreAuditDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<StoreAuditDetailDto>> GetDetail(long storeId, CancellationToken cancellationToken)
{
// 1. 获取审核详情
var result = await ExecuteAsPlatformAsync(() =>
mediator.Send(new GetStoreAuditDetailQuery { StoreId = storeId }, cancellationToken));
// 2. 返回详情或未找到
return result is null
? ApiResponse<StoreAuditDetailDto>.Error(ErrorCodes.NotFound, "门店不存在")
: ApiResponse<StoreAuditDetailDto>.Ok(result);
}
/// <summary>
/// 审核通过。
/// </summary>
/// <param name="storeId">门店 ID。</param>
/// <param name="command">审核命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>操作结果。</returns>
[HttpPost("{storeId:long}/approve")]
[PermissionAuthorize("store-audit:approve")]
[ProducesResponseType(typeof(ApiResponse<StoreAuditActionResultDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreAuditActionResultDto>> Approve(
long storeId,
[FromBody, Required] ApproveStoreCommand command,
CancellationToken cancellationToken)
{
// 1. 执行审核通过
var request = command with { StoreId = storeId };
var result = await ExecuteAsPlatformAsync(() => mediator.Send(request, cancellationToken));
// 2. 返回结果
return ApiResponse<StoreAuditActionResultDto>.Ok(result);
}
/// <summary>
/// 审核驳回。
/// </summary>
/// <param name="storeId">门店 ID。</param>
/// <param name="command">驳回命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>操作结果。</returns>
[HttpPost("{storeId:long}/reject")]
[PermissionAuthorize("store-audit:reject")]
[ProducesResponseType(typeof(ApiResponse<StoreAuditActionResultDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreAuditActionResultDto>> Reject(
long storeId,
[FromBody, Required] RejectStoreCommand command,
CancellationToken cancellationToken)
{
// 1. 执行审核驳回
var request = command with { StoreId = storeId };
var result = await ExecuteAsPlatformAsync(() => mediator.Send(request, cancellationToken));
// 2. 返回结果
return ApiResponse<StoreAuditActionResultDto>.Ok(result);
}
/// <summary>
/// 查询审核记录。
/// </summary>
/// <param name="storeId">门店 ID。</param>
/// <param name="page">页码。</param>
/// <param name="pageSize">每页数量。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>审核记录分页列表。</returns>
[HttpGet("{storeId:long}/records")]
[PermissionAuthorize("store-audit:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<StoreAuditRecordDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<StoreAuditRecordDto>>> ListRecords(
long storeId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
// 1. 执行记录查询
var query = new ListStoreAuditRecordsQuery
{
StoreId = storeId,
Page = page,
PageSize = pageSize
};
var result = await ExecuteAsPlatformAsync(() => mediator.Send(query, cancellationToken));
// 2. 返回分页结果
return ApiResponse<PagedResult<StoreAuditRecordDto>>.Ok(result);
}
/// <summary>
/// 获取审核统计数据。
/// </summary>
/// <param name="query">查询参数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>统计数据。</returns>
[HttpGet("statistics")]
[PermissionAuthorize("store-audit:read")]
[ProducesResponseType(typeof(ApiResponse<StoreAuditStatisticsDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreAuditStatisticsDto>> GetStatistics(
[FromQuery] GetStoreAuditStatisticsQuery query,
CancellationToken cancellationToken)
{
// 1. 执行统计查询
var result = await ExecuteAsPlatformAsync(() => mediator.Send(query, cancellationToken));
// 2. 返回统计结果
return ApiResponse<StoreAuditStatisticsDto>.Ok(result);
}
/// <summary>
/// 强制关闭门店。
/// </summary>
/// <param name="storeId">门店 ID。</param>
/// <param name="command">关闭命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>操作结果。</returns>
[HttpPost("{storeId:long}/force-close")]
[PermissionAuthorize("store-audit:force-close")]
[ProducesResponseType(typeof(ApiResponse<StoreAuditActionResultDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreAuditActionResultDto>> ForceClose(
long storeId,
[FromBody, Required] ForceCloseStoreCommand command,
CancellationToken cancellationToken)
{
// 1. 执行强制关闭
var request = command with { StoreId = storeId };
var result = await ExecuteAsPlatformAsync(() => mediator.Send(request, cancellationToken));
// 2. 返回结果
return ApiResponse<StoreAuditActionResultDto>.Ok(result);
}
/// <summary>
/// 解除强制关闭。
/// </summary>
/// <param name="storeId">门店 ID。</param>
/// <param name="command">解除命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>操作结果。</returns>
[HttpPost("{storeId:long}/reopen")]
[PermissionAuthorize("store-audit:force-close")]
[ProducesResponseType(typeof(ApiResponse<StoreAuditActionResultDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreAuditActionResultDto>> Reopen(
long storeId,
[FromBody, Required] ReopenStoreCommand command,
CancellationToken cancellationToken)
{
// 1. 执行解除强制关闭
var request = command with { StoreId = storeId };
var result = await ExecuteAsPlatformAsync(() => mediator.Send(request, cancellationToken));
// 2. 返回结果
return ApiResponse<StoreAuditActionResultDto>.Ok(result);
}
private async Task<T> ExecuteAsPlatformAsync<T>(Func<Task<T>> action)
{
var original = tenantContextAccessor.Current;
tenantContextAccessor.Current = new TenantContext(0, null, "platform");
// 1. (空行后) 切换到平台上下文执行
try
{
return await action();
}
finally
{
tenantContextAccessor.Current = original;
}
}
}

View File

@@ -0,0 +1,60 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Application.App.Stores.Queries;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 门店资质预警(平台)。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/platform/store-qualifications")]
[Route("api/admin/v{version:apiVersion}/platform/store-qualifications")]
public sealed class StoreQualificationsController(
IMediator mediator,
ITenantContextAccessor tenantContextAccessor)
: BaseApiController
{
/// <summary>
/// 查询资质即将过期/已过期列表。
/// </summary>
/// <param name="query">查询参数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>资质预警分页结果。</returns>
[HttpGet("expiring")]
[PermissionAuthorize("store-qualification:read")]
[ProducesResponseType(typeof(ApiResponse<StoreQualificationAlertResultDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreQualificationAlertResultDto>> ListExpiring(
[FromQuery] ListExpiringStoreQualificationsQuery query,
CancellationToken cancellationToken)
{
// 1. 查询资质预警
var result = await ExecuteAsPlatformAsync(() => mediator.Send(query, cancellationToken));
// 2. (空行后) 返回结果
return ApiResponse<StoreQualificationAlertResultDto>.Ok(result);
}
private async Task<T> ExecuteAsPlatformAsync<T>(Func<Task<T>> action)
{
var original = tenantContextAccessor.Current;
tenantContextAccessor.Current = new TenantContext(0, null, "platform");
// 1. (空行后) 切换到平台上下文执行
try
{
return await action();
}
finally
{
tenantContextAccessor.Current = original;
}
}
}

View File

@@ -47,6 +47,10 @@ public sealed class StoresController(IMediator mediator) : BaseApiController
public async Task<ApiResponse<PagedResult<StoreDto>>> List(
[FromQuery] long? merchantId,
[FromQuery] StoreStatus? status,
[FromQuery] StoreAuditStatus? auditStatus,
[FromQuery] StoreBusinessStatus? businessStatus,
[FromQuery] StoreOwnershipType? ownershipType,
[FromQuery] string? keyword,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortBy = null,
@@ -58,6 +62,10 @@ public sealed class StoresController(IMediator mediator) : BaseApiController
{
MerchantId = merchantId,
Status = status,
AuditStatus = auditStatus,
BusinessStatus = businessStatus,
OwnershipType = ownershipType,
Keyword = keyword,
Page = page,
PageSize = pageSize,
SortBy = sortBy,
@@ -131,6 +139,170 @@ public sealed class StoresController(IMediator mediator) : BaseApiController
: ApiResponse<object>.Error(ErrorCodes.NotFound, "门店不存在");
}
/// <summary>
/// 提交门店审核。
/// </summary>
[HttpPost("{storeId:long}/submit")]
[PermissionAuthorize("store:update")]
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
public async Task<ApiResponse<bool>> SubmitAudit(long storeId, [FromBody] SubmitStoreAuditCommand command, CancellationToken cancellationToken)
{
// 1. 绑定门店 ID
if (command.StoreId == 0)
{
command = command with { StoreId = storeId };
}
// 2. (空行后) 执行提交
var result = await mediator.Send(command, cancellationToken);
// 3. (空行后) 返回结果
return ApiResponse<bool>.Ok(result);
}
/// <summary>
/// 切换门店经营状态。
/// </summary>
[HttpPost("{storeId:long}/business-status")]
[PermissionAuthorize("store:update")]
[ProducesResponseType(typeof(ApiResponse<StoreDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreDto>> ToggleBusinessStatus(long storeId, [FromBody] ToggleBusinessStatusCommand command, CancellationToken cancellationToken)
{
// 1. 绑定门店 ID
if (command.StoreId == 0)
{
command = command with { StoreId = storeId };
}
// 2. (空行后) 执行切换
var result = await mediator.Send(command, cancellationToken);
// 3. (空行后) 返回结果
return ApiResponse<StoreDto>.Ok(result);
}
/// <summary>
/// 查询门店资质列表。
/// </summary>
[HttpGet("{storeId:long}/qualifications")]
[PermissionAuthorize("store:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<StoreQualificationDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<StoreQualificationDto>>> ListQualifications(long storeId, CancellationToken cancellationToken)
{
// 1. 查询资质列表
var result = await mediator.Send(new ListStoreQualificationsQuery { StoreId = storeId }, cancellationToken);
// 2. 返回结果
return ApiResponse<IReadOnlyList<StoreQualificationDto>>.Ok(result);
}
/// <summary>
/// 检查门店资质完整性。
/// </summary>
[HttpGet("{storeId:long}/qualifications/check")]
[PermissionAuthorize("store:read")]
[ProducesResponseType(typeof(ApiResponse<StoreQualificationCheckResultDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreQualificationCheckResultDto>> CheckQualifications(long storeId, CancellationToken cancellationToken)
{
// 1. 执行检查
var result = await mediator.Send(new CheckStoreQualificationsQuery { StoreId = storeId }, cancellationToken);
// 2. 返回检查结果
return ApiResponse<StoreQualificationCheckResultDto>.Ok(result);
}
/// <summary>
/// 新增门店资质。
/// </summary>
[HttpPost("{storeId:long}/qualifications")]
[PermissionAuthorize("store:update")]
[ProducesResponseType(typeof(ApiResponse<StoreQualificationDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreQualificationDto>> CreateQualification(long storeId, [FromBody] CreateStoreQualificationCommand command, CancellationToken cancellationToken)
{
// 1. 绑定门店 ID
if (command.StoreId == 0)
{
command = command with { StoreId = storeId };
}
// 2. (空行后) 执行创建
var result = await mediator.Send(command, cancellationToken);
// 3. (空行后) 返回结果
return ApiResponse<StoreQualificationDto>.Ok(result);
}
/// <summary>
/// 更新门店资质。
/// </summary>
[HttpPut("{storeId:long}/qualifications/{qualificationId:long}")]
[PermissionAuthorize("store:update")]
[ProducesResponseType(typeof(ApiResponse<StoreQualificationDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<StoreQualificationDto>> UpdateQualification(
long storeId,
long qualificationId,
[FromBody] UpdateStoreQualificationCommand command,
CancellationToken cancellationToken)
{
// 1. 绑定资质 ID
if (command.StoreId == 0 || command.QualificationId == 0)
{
command = command with { StoreId = storeId, QualificationId = qualificationId };
}
// 2. (空行后) 执行更新
var result = await mediator.Send(command, cancellationToken);
// 3. (空行后) 返回结果或 404
return result is null
? ApiResponse<StoreQualificationDto>.Error(ErrorCodes.NotFound, "资质不存在")
: ApiResponse<StoreQualificationDto>.Ok(result);
}
/// <summary>
/// 删除门店资质。
/// </summary>
[HttpDelete("{storeId:long}/qualifications/{qualificationId:long}")]
[PermissionAuthorize("store:update")]
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
public async Task<ApiResponse<bool>> DeleteQualification(long storeId, long qualificationId, CancellationToken cancellationToken)
{
// 1. 执行删除
var result = await mediator.Send(new DeleteStoreQualificationCommand
{
StoreId = storeId,
QualificationId = qualificationId
}, cancellationToken);
// 2. 返回结果
return ApiResponse<bool>.Ok(result);
}
/// <summary>
/// 批量更新营业时段。
/// </summary>
[HttpPut("{storeId:long}/business-hours/batch")]
[PermissionAuthorize("store:update")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<StoreBusinessHourDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<StoreBusinessHourDto>>> BatchUpdateBusinessHours(
long storeId,
[FromBody] BatchUpdateBusinessHoursCommand command,
CancellationToken cancellationToken)
{
// 1. 绑定门店 ID
if (command.StoreId == 0)
{
command = command with { StoreId = storeId };
}
// 2. (空行后) 执行批量更新
var result = await mediator.Send(command, cancellationToken);
// 3. (空行后) 返回结果
return ApiResponse<IReadOnlyList<StoreBusinessHourDto>>.Ok(result);
}
/// <summary>
/// 查询门店营业时段。
/// </summary>
@@ -259,6 +431,90 @@ public sealed class StoresController(IMediator mediator) : BaseApiController
: ApiResponse<object>.Error(ErrorCodes.NotFound, "配送区域不存在");
}
/// <summary>
/// 配送范围检测。
/// </summary>
[HttpPost("{storeId:long}/delivery-check")]
[PermissionAuthorize("store:read")]
[ProducesResponseType(typeof(ApiResponse<StoreDeliveryCheckResultDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreDeliveryCheckResultDto>> CheckDeliveryZone(
long storeId,
[FromBody] CheckStoreDeliveryZoneQuery query,
CancellationToken cancellationToken)
{
// 1. 绑定门店 ID
if (query.StoreId == 0)
{
query = query with { StoreId = storeId };
}
// 2. (空行后) 执行检测
var result = await mediator.Send(query, cancellationToken);
// 3. (空行后) 返回结果
return ApiResponse<StoreDeliveryCheckResultDto>.Ok(result);
}
/// <summary>
/// 获取门店费用配置。
/// </summary>
[HttpGet("{storeId:long}/fee")]
[PermissionAuthorize("store:read")]
[ProducesResponseType(typeof(ApiResponse<StoreFeeDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreFeeDto>> GetFee(long storeId, CancellationToken cancellationToken)
{
// 1. 查询费用配置
var result = await mediator.Send(new GetStoreFeeQuery { StoreId = storeId }, cancellationToken);
// 2. 返回结果
return ApiResponse<StoreFeeDto>.Ok(result ?? new StoreFeeDto());
}
/// <summary>
/// 更新门店费用配置。
/// </summary>
[HttpPut("{storeId:long}/fee")]
[PermissionAuthorize("store:update")]
[ProducesResponseType(typeof(ApiResponse<StoreFeeDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreFeeDto>> UpdateFee(long storeId, [FromBody] UpdateStoreFeeCommand command, CancellationToken cancellationToken)
{
// 1. 绑定门店 ID
if (command.StoreId == 0)
{
command = command with { StoreId = storeId };
}
// 2. (空行后) 执行更新
var result = await mediator.Send(command, cancellationToken);
// 3. (空行后) 返回结果
return ApiResponse<StoreFeeDto>.Ok(result);
}
/// <summary>
/// 门店费用预览。
/// </summary>
[HttpPost("{storeId:long}/fee/calculate")]
[PermissionAuthorize("store:read")]
[ProducesResponseType(typeof(ApiResponse<StoreFeeCalculationResultDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreFeeCalculationResultDto>> CalculateFee(
long storeId,
[FromBody] CalculateStoreFeeQuery query,
CancellationToken cancellationToken)
{
// 1. 绑定门店 ID
if (query.StoreId == 0)
{
query = query with { StoreId = storeId };
}
// 2. (空行后) 执行计算
var result = await mediator.Send(query, cancellationToken);
// 3. (空行后) 返回结果
return ApiResponse<StoreFeeCalculationResultDto>.Ok(result);
}
/// <summary>
/// 查询门店节假日。
/// </summary>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 => "自动激活",
_ => "未知操作"
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
// 3. 返回 DTO
return MapToDto(store);
}
private static StoreDto MapToDto(Store store) => new()
// 4. (空行后) 持久化并初始化费用配置
await storeRepository.AddStoreAsync(store, cancellationToken);
await storeRepository.AddStoreFeeAsync(new StoreFee
{
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
};
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);
// 5. (空行后) 返回 DTO
return StoreMapping.ToDto(store);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More