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

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,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);
// 4. (空行后) 持久化并初始化费用配置
await storeRepository.AddStoreAsync(store, cancellationToken);
await storeRepository.AddStoreFeeAsync(new StoreFee
{
StoreId = store.Id,
MinimumOrderAmount = 0m,
BaseDeliveryFee = 0m,
PackagingFeeMode = PackagingFeeMode.Fixed,
FixedPackagingFee = 0m
}, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("创建门店 {StoreId} - {StoreName}", store.Id, store.Name);
// 3. 返回 DTO
return MapToDto(store);
// 5. (空行后) 返回 DTO
return StoreMapping.ToDto(store);
}
private static StoreDto MapToDto(Store store) => new()
{
Id = store.Id,
TenantId = store.TenantId,
MerchantId = store.MerchantId,
Code = store.Code,
Name = store.Name,
Phone = store.Phone,
ManagerName = store.ManagerName,
Status = store.Status,
Province = store.Province,
City = store.City,
District = store.District,
Address = store.Address,
Longitude = store.Longitude,
Latitude = store.Latitude,
Announcement = store.Announcement,
Tags = store.Tags,
DeliveryRadiusKm = store.DeliveryRadiusKm,
SupportsDineIn = store.SupportsDineIn,
SupportsPickup = store.SupportsPickup,
SupportsDelivery = store.SupportsDelivery,
SupportsReservation = store.SupportsReservation,
SupportsQueueing = store.SupportsQueueing,
CreatedAt = store.CreatedAt
};
}

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

View File

@@ -0,0 +1,35 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Stores.Commands;
using TakeoutSaaS.Domain.Stores.Enums;
namespace TakeoutSaaS.Application.App.Stores.Validators;
/// <summary>
/// 更新门店费用配置命令验证器。
/// </summary>
public sealed class UpdateStoreFeeCommandValidator : AbstractValidator<UpdateStoreFeeCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public UpdateStoreFeeCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.MinimumOrderAmount).GreaterThanOrEqualTo(0).LessThanOrEqualTo(9999.99m);
RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).LessThanOrEqualTo(999.99m);
RuleFor(x => x.FreeDeliveryThreshold).GreaterThanOrEqualTo(0).When(x => x.FreeDeliveryThreshold.HasValue);
RuleFor(x => x.FixedPackagingFee)
.NotNull()
.When(x => x.PackagingFeeMode == PackagingFeeMode.Fixed)
.WithMessage("总计打包费模式下必须填写固定打包费");
RuleFor(x => x.FixedPackagingFee)
.Must(fee => !fee.HasValue || fee.Value <= 99.99m)
.WithMessage("固定打包费不能超过 99.99");
RuleFor(x => x.FixedPackagingFee)
.Must(fee => !fee.HasValue || fee.Value >= 0)
.WithMessage("固定打包费不能为负数");
}
}

View File

@@ -0,0 +1,26 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Stores.Commands;
namespace TakeoutSaaS.Application.App.Stores.Validators;
/// <summary>
/// 更新门店资质命令验证器。
/// </summary>
public sealed class UpdateStoreQualificationCommandValidator : AbstractValidator<UpdateStoreQualificationCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public UpdateStoreQualificationCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.QualificationId).GreaterThan(0);
RuleFor(x => x.FileUrl).MaximumLength(500).When(x => !string.IsNullOrWhiteSpace(x.FileUrl));
RuleFor(x => x.DocumentNumber).MaximumLength(100).When(x => !string.IsNullOrWhiteSpace(x.DocumentNumber));
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0).When(x => x.SortOrder.HasValue);
RuleFor(x => x.ExpiresAt)
.Must(date => !date.HasValue || date.Value.Date > DateTime.UtcNow.Date)
.When(x => x.ExpiresAt.HasValue)
.WithMessage("证照有效期必须晚于今天");
}
}