feat: 完成门店管理剩余接口并补齐文档注释
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 46s
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 46s
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
/// <summary>
|
||||
/// 半径梯度。
|
||||
/// </summary>
|
||||
public sealed class RadiusTierDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// MinDistance。
|
||||
/// </summary>
|
||||
public decimal MinDistance { get; set; }
|
||||
/// <summary>
|
||||
/// MaxDistance。
|
||||
/// </summary>
|
||||
public decimal MaxDistance { get; set; }
|
||||
/// <summary>
|
||||
/// DeliveryFee。
|
||||
/// </summary>
|
||||
public decimal DeliveryFee { get; set; }
|
||||
/// <summary>
|
||||
/// EtaMinutes。
|
||||
/// </summary>
|
||||
public int EtaMinutes { get; set; }
|
||||
/// <summary>
|
||||
/// MinOrderAmount。
|
||||
/// </summary>
|
||||
public decimal MinOrderAmount { get; set; }
|
||||
/// <summary>
|
||||
/// Color。
|
||||
/// </summary>
|
||||
public string Color { get; set; } = "#1677ff";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 多边形区域。
|
||||
/// </summary>
|
||||
public sealed class PolygonZoneDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Name。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Color。
|
||||
/// </summary>
|
||||
public string Color { get; set; } = "#1677ff";
|
||||
/// <summary>
|
||||
/// DeliveryFee。
|
||||
/// </summary>
|
||||
public decimal DeliveryFee { get; set; }
|
||||
/// <summary>
|
||||
/// EtaMinutes。
|
||||
/// </summary>
|
||||
public int EtaMinutes { get; set; }
|
||||
/// <summary>
|
||||
/// MinOrderAmount。
|
||||
/// </summary>
|
||||
public decimal MinOrderAmount { get; set; }
|
||||
/// <summary>
|
||||
/// Priority。
|
||||
/// </summary>
|
||||
public int Priority { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通用配送设置。
|
||||
/// </summary>
|
||||
public sealed class DeliveryGeneralSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// EtaAdjustmentMinutes。
|
||||
/// </summary>
|
||||
public int EtaAdjustmentMinutes { get; set; }
|
||||
/// <summary>
|
||||
/// FreeDeliveryThreshold。
|
||||
/// </summary>
|
||||
public decimal? FreeDeliveryThreshold { get; set; }
|
||||
/// <summary>
|
||||
/// HourlyCapacityLimit。
|
||||
/// </summary>
|
||||
public int HourlyCapacityLimit { get; set; }
|
||||
/// <summary>
|
||||
/// MaxDeliveryDistance。
|
||||
/// </summary>
|
||||
public decimal MaxDeliveryDistance { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 门店配送设置聚合。
|
||||
/// </summary>
|
||||
public sealed class StoreDeliverySettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Mode。
|
||||
/// </summary>
|
||||
public string Mode { get; set; } = "radius";
|
||||
/// <summary>
|
||||
/// RadiusTiers。
|
||||
/// </summary>
|
||||
public List<RadiusTierDto> RadiusTiers { get; set; } = [];
|
||||
/// <summary>
|
||||
/// PolygonZones。
|
||||
/// </summary>
|
||||
public List<PolygonZoneDto> PolygonZones { get; set; } = [];
|
||||
/// <summary>
|
||||
/// GeneralSettings。
|
||||
/// </summary>
|
||||
public DeliveryGeneralSettingsDto GeneralSettings { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制配送设置请求。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreDeliverySettingsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SourceStoreId。
|
||||
/// </summary>
|
||||
public string SourceStoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// TargetStoreIds。
|
||||
/// </summary>
|
||||
public List<string> TargetStoreIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制结果。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreDeliverySettingsResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CopiedCount。
|
||||
/// </summary>
|
||||
public int CopiedCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
/// <summary>
|
||||
/// 堂食基础设置。
|
||||
/// </summary>
|
||||
public sealed class DineInBasicSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Enabled。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
/// <summary>
|
||||
/// DefaultDiningMinutes。
|
||||
/// </summary>
|
||||
public int DefaultDiningMinutes { get; set; }
|
||||
/// <summary>
|
||||
/// OvertimeReminderMinutes。
|
||||
/// </summary>
|
||||
public int OvertimeReminderMinutes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 堂食区域。
|
||||
/// </summary>
|
||||
public sealed class DineInAreaDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Name。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Description。
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Sort。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 堂食桌位。
|
||||
/// </summary>
|
||||
public sealed class DineInTableDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// AreaId。
|
||||
/// </summary>
|
||||
public string AreaId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Code。
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Seats。
|
||||
/// </summary>
|
||||
public int Seats { get; set; }
|
||||
/// <summary>
|
||||
/// Status。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "free";
|
||||
/// <summary>
|
||||
/// Tags。
|
||||
/// </summary>
|
||||
public List<string> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 堂食设置聚合。
|
||||
/// </summary>
|
||||
public sealed class StoreDineInSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// BasicSettings。
|
||||
/// </summary>
|
||||
public DineInBasicSettingsDto BasicSettings { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Areas。
|
||||
/// </summary>
|
||||
public List<DineInAreaDto> Areas { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Tables。
|
||||
/// </summary>
|
||||
public List<DineInTableDto> Tables { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存基础设置请求。
|
||||
/// </summary>
|
||||
public sealed class SaveStoreDineInBasicSettingsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// BasicSettings。
|
||||
/// </summary>
|
||||
public DineInBasicSettingsDto BasicSettings { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存区域请求。
|
||||
/// </summary>
|
||||
public sealed class SaveDineInAreaRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Area。
|
||||
/// </summary>
|
||||
public DineInAreaDto Area { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除区域请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteDineInAreaRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// AreaId。
|
||||
/// </summary>
|
||||
public string AreaId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存桌位请求。
|
||||
/// </summary>
|
||||
public sealed class SaveDineInTableRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Table。
|
||||
/// </summary>
|
||||
public DineInTableDto Table { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除桌位请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteDineInTableRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// TableId。
|
||||
/// </summary>
|
||||
public string TableId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量生成桌位请求。
|
||||
/// </summary>
|
||||
public sealed class BatchCreateDineInTablesRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// AreaId。
|
||||
/// </summary>
|
||||
public string AreaId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// CodePrefix。
|
||||
/// </summary>
|
||||
public string CodePrefix { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// StartNumber。
|
||||
/// </summary>
|
||||
public int StartNumber { get; set; }
|
||||
/// <summary>
|
||||
/// Count。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
/// <summary>
|
||||
/// Seats。
|
||||
/// </summary>
|
||||
public int Seats { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量生成桌位结果。
|
||||
/// </summary>
|
||||
public sealed class BatchCreateDineInTablesResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// CreatedTables。
|
||||
/// </summary>
|
||||
public List<DineInTableDto> CreatedTables { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制堂食设置请求。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreDineInSettingsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SourceStoreId。
|
||||
/// </summary>
|
||||
public string SourceStoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// TargetStoreIds。
|
||||
/// </summary>
|
||||
public List<string> TargetStoreIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制结果。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreDineInSettingsResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CopiedCount。
|
||||
/// </summary>
|
||||
public int CopiedCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
/// <summary>
|
||||
/// 阶梯包装费。
|
||||
/// </summary>
|
||||
public sealed class PackagingFeeTierDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// MinAmount。
|
||||
/// </summary>
|
||||
public decimal MinAmount { get; set; }
|
||||
/// <summary>
|
||||
/// MaxAmount。
|
||||
/// </summary>
|
||||
public decimal? MaxAmount { get; set; }
|
||||
/// <summary>
|
||||
/// Fee。
|
||||
/// </summary>
|
||||
public decimal Fee { get; set; }
|
||||
/// <summary>
|
||||
/// Sort。
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 附加费用项。
|
||||
/// </summary>
|
||||
public sealed class AdditionalFeeItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Enabled。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
/// <summary>
|
||||
/// Amount。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 其他费用。
|
||||
/// </summary>
|
||||
public sealed class StoreOtherFeesDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Cutlery。
|
||||
/// </summary>
|
||||
public AdditionalFeeItemDto Cutlery { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Rush。
|
||||
/// </summary>
|
||||
public AdditionalFeeItemDto Rush { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 门店费用设置。
|
||||
/// </summary>
|
||||
public sealed class StoreFeesSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// MinimumOrderAmount。
|
||||
/// </summary>
|
||||
public decimal MinimumOrderAmount { get; set; }
|
||||
/// <summary>
|
||||
/// BaseDeliveryFee。
|
||||
/// </summary>
|
||||
public decimal BaseDeliveryFee { get; set; }
|
||||
/// <summary>
|
||||
/// FreeDeliveryThreshold。
|
||||
/// </summary>
|
||||
public decimal? FreeDeliveryThreshold { get; set; }
|
||||
/// <summary>
|
||||
/// PackagingFeeMode。
|
||||
/// </summary>
|
||||
public string PackagingFeeMode { get; set; } = "order";
|
||||
/// <summary>
|
||||
/// OrderPackagingFeeMode。
|
||||
/// </summary>
|
||||
public string OrderPackagingFeeMode { get; set; } = "fixed";
|
||||
/// <summary>
|
||||
/// FixedPackagingFee。
|
||||
/// </summary>
|
||||
public decimal FixedPackagingFee { get; set; }
|
||||
/// <summary>
|
||||
/// PackagingFeeTiers。
|
||||
/// </summary>
|
||||
public List<PackagingFeeTierDto> PackagingFeeTiers { get; set; } = [];
|
||||
/// <summary>
|
||||
/// OtherFees。
|
||||
/// </summary>
|
||||
public StoreOtherFeesDto OtherFees { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制费用请求。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreFeesSettingsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SourceStoreId。
|
||||
/// </summary>
|
||||
public string SourceStoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// TargetStoreIds。
|
||||
/// </summary>
|
||||
public List<string> TargetStoreIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制结果。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreFeesSettingsResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CopiedCount。
|
||||
/// </summary>
|
||||
public int CopiedCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
/// <summary>
|
||||
/// 时段类型。
|
||||
/// </summary>
|
||||
public enum StoreHourSlotType
|
||||
{
|
||||
/// <summary>
|
||||
/// 营业时段。
|
||||
/// </summary>
|
||||
Business = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 配送时段。
|
||||
/// </summary>
|
||||
Delivery = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 自提时段。
|
||||
/// </summary>
|
||||
Pickup = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 特殊日期类型。
|
||||
/// </summary>
|
||||
public enum StoreHolidayType
|
||||
{
|
||||
/// <summary>
|
||||
/// 休息日。
|
||||
/// </summary>
|
||||
Closed = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 特殊营业日。
|
||||
/// </summary>
|
||||
Special = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 时段 DTO。
|
||||
/// </summary>
|
||||
public sealed class StoreHourTimeSlotDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Type。
|
||||
/// </summary>
|
||||
public int Type { get; set; }
|
||||
/// <summary>
|
||||
/// StartTime。
|
||||
/// </summary>
|
||||
public string StartTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// EndTime。
|
||||
/// </summary>
|
||||
public string EndTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Capacity。
|
||||
/// </summary>
|
||||
public int? Capacity { get; set; }
|
||||
/// <summary>
|
||||
/// Remark。
|
||||
/// </summary>
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 每日营业时间 DTO。
|
||||
/// </summary>
|
||||
public sealed class StoreHourDayHoursDto
|
||||
{
|
||||
/// <summary>
|
||||
/// DayOfWeek。
|
||||
/// </summary>
|
||||
public int DayOfWeek { get; set; }
|
||||
/// <summary>
|
||||
/// IsOpen。
|
||||
/// </summary>
|
||||
public bool IsOpen { get; set; }
|
||||
/// <summary>
|
||||
/// Slots。
|
||||
/// </summary>
|
||||
public List<StoreHourTimeSlotDto> Slots { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 特殊日期 DTO。
|
||||
/// </summary>
|
||||
public sealed class StoreHourHolidayDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// StartDate。
|
||||
/// </summary>
|
||||
public string StartDate { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// EndDate。
|
||||
/// </summary>
|
||||
public string EndDate { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Type。
|
||||
/// </summary>
|
||||
public int Type { get; set; }
|
||||
/// <summary>
|
||||
/// StartTime。
|
||||
/// </summary>
|
||||
public string? StartTime { get; set; }
|
||||
/// <summary>
|
||||
/// EndTime。
|
||||
/// </summary>
|
||||
public string? EndTime { get; set; }
|
||||
/// <summary>
|
||||
/// Reason。
|
||||
/// </summary>
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Remark。
|
||||
/// </summary>
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 门店营业时间聚合。
|
||||
/// </summary>
|
||||
public sealed class StoreHoursDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// WeeklyHours。
|
||||
/// </summary>
|
||||
public List<StoreHourDayHoursDto> WeeklyHours { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Holidays。
|
||||
/// </summary>
|
||||
public List<StoreHourHolidayDto> Holidays { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存每周时段请求。
|
||||
/// </summary>
|
||||
public sealed class SaveWeeklyHoursRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// WeeklyHours。
|
||||
/// </summary>
|
||||
public List<StoreHourDayHoursDto> WeeklyHours { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存特殊日期请求。
|
||||
/// </summary>
|
||||
public sealed class SaveHolidayRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Holiday。
|
||||
/// </summary>
|
||||
public StoreHourHolidayDto Holiday { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除特殊日期请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteHolidayRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制营业时间请求。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreHoursRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SourceStoreId。
|
||||
/// </summary>
|
||||
public string SourceStoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// TargetStoreIds。
|
||||
/// </summary>
|
||||
public List<string> TargetStoreIds { get; set; } = [];
|
||||
/// <summary>
|
||||
/// IncludeWeeklyHours。
|
||||
/// </summary>
|
||||
public bool? IncludeWeeklyHours { get; set; }
|
||||
/// <summary>
|
||||
/// IncludeHolidays。
|
||||
/// </summary>
|
||||
public bool? IncludeHolidays { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制结果。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreHoursResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CopiedCount。
|
||||
/// </summary>
|
||||
public int CopiedCount { get; set; }
|
||||
/// <summary>
|
||||
/// IncludeWeeklyHours。
|
||||
/// </summary>
|
||||
public bool IncludeWeeklyHours { get; set; }
|
||||
/// <summary>
|
||||
/// IncludeHolidays。
|
||||
/// </summary>
|
||||
public bool IncludeHolidays { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
/// <summary>
|
||||
/// 自提基础设置。
|
||||
/// </summary>
|
||||
public sealed class PickupBasicSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// AllowSameDayPickup。
|
||||
/// </summary>
|
||||
public bool AllowSameDayPickup { get; set; }
|
||||
/// <summary>
|
||||
/// BookingDays。
|
||||
/// </summary>
|
||||
public int BookingDays { get; set; }
|
||||
/// <summary>
|
||||
/// MaxItemsPerOrder。
|
||||
/// </summary>
|
||||
public int? MaxItemsPerOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 自提大时段。
|
||||
/// </summary>
|
||||
public sealed class PickupSlotDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Name。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// StartTime。
|
||||
/// </summary>
|
||||
public string StartTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// EndTime。
|
||||
/// </summary>
|
||||
public string EndTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// CutoffMinutes。
|
||||
/// </summary>
|
||||
public int CutoffMinutes { get; set; }
|
||||
/// <summary>
|
||||
/// Capacity。
|
||||
/// </summary>
|
||||
public int Capacity { get; set; }
|
||||
/// <summary>
|
||||
/// ReservedCount。
|
||||
/// </summary>
|
||||
public int ReservedCount { get; set; }
|
||||
/// <summary>
|
||||
/// DayOfWeeks。
|
||||
/// </summary>
|
||||
public List<int> DayOfWeeks { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Enabled。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 精细规则。
|
||||
/// </summary>
|
||||
public sealed class PickupFineRuleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// IntervalMinutes。
|
||||
/// </summary>
|
||||
public int IntervalMinutes { get; set; }
|
||||
/// <summary>
|
||||
/// SlotCapacity。
|
||||
/// </summary>
|
||||
public int SlotCapacity { get; set; }
|
||||
/// <summary>
|
||||
/// DayStartTime。
|
||||
/// </summary>
|
||||
public string DayStartTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// DayEndTime。
|
||||
/// </summary>
|
||||
public string DayEndTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// MinAdvanceHours。
|
||||
/// </summary>
|
||||
public int MinAdvanceHours { get; set; }
|
||||
/// <summary>
|
||||
/// DayOfWeeks。
|
||||
/// </summary>
|
||||
public List<int> DayOfWeeks { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预览时段。
|
||||
/// </summary>
|
||||
public sealed class PickupPreviewSlotDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Time。
|
||||
/// </summary>
|
||||
public string Time { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Status。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "available";
|
||||
/// <summary>
|
||||
/// RemainingCount。
|
||||
/// </summary>
|
||||
public int RemainingCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预览日期。
|
||||
/// </summary>
|
||||
public sealed class PickupPreviewDayDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Date。
|
||||
/// </summary>
|
||||
public string Date { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Label。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// SubLabel。
|
||||
/// </summary>
|
||||
public string SubLabel { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Slots。
|
||||
/// </summary>
|
||||
public List<PickupPreviewSlotDto> Slots { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 门店自提设置。
|
||||
/// </summary>
|
||||
public sealed class StorePickupSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Mode。
|
||||
/// </summary>
|
||||
public string Mode { get; set; } = "big";
|
||||
/// <summary>
|
||||
/// BasicSettings。
|
||||
/// </summary>
|
||||
public PickupBasicSettingsDto BasicSettings { get; set; } = new();
|
||||
/// <summary>
|
||||
/// BigSlots。
|
||||
/// </summary>
|
||||
public List<PickupSlotDto> BigSlots { get; set; } = [];
|
||||
/// <summary>
|
||||
/// FineRule。
|
||||
/// </summary>
|
||||
public PickupFineRuleDto FineRule { get; set; } = new();
|
||||
/// <summary>
|
||||
/// PreviewDays。
|
||||
/// </summary>
|
||||
public List<PickupPreviewDayDto> PreviewDays { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存基础设置请求。
|
||||
/// </summary>
|
||||
public sealed class SavePickupBasicSettingsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Mode。
|
||||
/// </summary>
|
||||
public string? Mode { get; set; }
|
||||
/// <summary>
|
||||
/// BasicSettings。
|
||||
/// </summary>
|
||||
public PickupBasicSettingsDto BasicSettings { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存大时段请求。
|
||||
/// </summary>
|
||||
public sealed class SavePickupSlotsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Mode。
|
||||
/// </summary>
|
||||
public string? Mode { get; set; }
|
||||
/// <summary>
|
||||
/// Slots。
|
||||
/// </summary>
|
||||
public List<PickupSlotDto> Slots { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存精细规则请求。
|
||||
/// </summary>
|
||||
public sealed class SavePickupFineRuleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Mode。
|
||||
/// </summary>
|
||||
public string? Mode { get; set; }
|
||||
/// <summary>
|
||||
/// FineRule。
|
||||
/// </summary>
|
||||
public PickupFineRuleDto FineRule { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制自提设置请求。
|
||||
/// </summary>
|
||||
public sealed class CopyStorePickupSettingsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SourceStoreId。
|
||||
/// </summary>
|
||||
public string SourceStoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// TargetStoreIds。
|
||||
/// </summary>
|
||||
public List<string> TargetStoreIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制结果。
|
||||
/// </summary>
|
||||
public sealed class CopyStorePickupSettingsResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CopiedCount。
|
||||
/// </summary>
|
||||
public int CopiedCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
/// <summary>
|
||||
/// 分页结构。
|
||||
/// </summary>
|
||||
public sealed class PaginatedResultDto<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Items。
|
||||
/// </summary>
|
||||
public List<T> Items { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Total。
|
||||
/// </summary>
|
||||
public int Total { get; set; }
|
||||
/// <summary>
|
||||
/// Page。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
/// <summary>
|
||||
/// PageSize。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工档案。
|
||||
/// </summary>
|
||||
public sealed class StoreStaffItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Name。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Phone。
|
||||
/// </summary>
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Email。
|
||||
/// </summary>
|
||||
public string Email { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// RoleType。
|
||||
/// </summary>
|
||||
public string RoleType { get; set; } = "cashier";
|
||||
/// <summary>
|
||||
/// Status。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "active";
|
||||
/// <summary>
|
||||
/// Permissions。
|
||||
/// </summary>
|
||||
public List<string> Permissions { get; set; } = [];
|
||||
/// <summary>
|
||||
/// AvatarColor。
|
||||
/// </summary>
|
||||
public string AvatarColor { get; set; } = "#1677ff";
|
||||
/// <summary>
|
||||
/// HiredAt。
|
||||
/// </summary>
|
||||
public string HiredAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 班次时间段模板。
|
||||
/// </summary>
|
||||
public sealed class ShiftTemplateItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StartTime。
|
||||
/// </summary>
|
||||
public string StartTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// EndTime。
|
||||
/// </summary>
|
||||
public string EndTime { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 门店班次模板。
|
||||
/// </summary>
|
||||
public sealed class StoreShiftTemplatesDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Morning。
|
||||
/// </summary>
|
||||
public ShiftTemplateItemDto Morning { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Evening。
|
||||
/// </summary>
|
||||
public ShiftTemplateItemDto Evening { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Full。
|
||||
/// </summary>
|
||||
public ShiftTemplateItemDto Full { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工单日排班。
|
||||
/// </summary>
|
||||
public sealed class StaffDayShiftDto
|
||||
{
|
||||
/// <summary>
|
||||
/// DayOfWeek。
|
||||
/// </summary>
|
||||
public int DayOfWeek { get; set; }
|
||||
/// <summary>
|
||||
/// ShiftType。
|
||||
/// </summary>
|
||||
public string ShiftType { get; set; } = "off";
|
||||
/// <summary>
|
||||
/// StartTime。
|
||||
/// </summary>
|
||||
public string StartTime { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// EndTime。
|
||||
/// </summary>
|
||||
public string EndTime { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工排班。
|
||||
/// </summary>
|
||||
public sealed class StaffScheduleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StaffId。
|
||||
/// </summary>
|
||||
public string StaffId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Shifts。
|
||||
/// </summary>
|
||||
public List<StaffDayShiftDto> Shifts { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 门店排班聚合。
|
||||
/// </summary>
|
||||
public sealed class StoreStaffScheduleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// WeekStartDate。
|
||||
/// </summary>
|
||||
public string WeekStartDate { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Templates。
|
||||
/// </summary>
|
||||
public StoreShiftTemplatesDto Templates { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Schedules。
|
||||
/// </summary>
|
||||
public List<StaffScheduleDto> Schedules { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存员工请求。
|
||||
/// </summary>
|
||||
public sealed class SaveStoreStaffRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Id。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
/// <summary>
|
||||
/// Name。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Phone。
|
||||
/// </summary>
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Email。
|
||||
/// </summary>
|
||||
public string Email { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// RoleType。
|
||||
/// </summary>
|
||||
public string RoleType { get; set; } = "cashier";
|
||||
/// <summary>
|
||||
/// Status。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "active";
|
||||
/// <summary>
|
||||
/// Permissions。
|
||||
/// </summary>
|
||||
public List<string> Permissions { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除员工请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteStoreStaffRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// StaffId。
|
||||
/// </summary>
|
||||
public string StaffId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存班次模板请求。
|
||||
/// </summary>
|
||||
public sealed class SaveStoreStaffTemplatesRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Templates。
|
||||
/// </summary>
|
||||
public StoreShiftTemplatesDto Templates { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存个人排班请求。
|
||||
/// </summary>
|
||||
public sealed class SaveStoreStaffPersonalScheduleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// StaffId。
|
||||
/// </summary>
|
||||
public string StaffId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Shifts。
|
||||
/// </summary>
|
||||
public List<StaffDayShiftDto> Shifts { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存周排班请求。
|
||||
/// </summary>
|
||||
public sealed class SaveStoreStaffWeeklyScheduleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// StoreId。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Schedules。
|
||||
/// </summary>
|
||||
public List<StaffScheduleDto> Schedules { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制排班请求。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreStaffScheduleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SourceStoreId。
|
||||
/// </summary>
|
||||
public string SourceStoreId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// TargetStoreIds。
|
||||
/// </summary>
|
||||
public List<string> TargetStoreIds { get; set; } = [];
|
||||
/// <summary>
|
||||
/// CopyScope。
|
||||
/// </summary>
|
||||
public string CopyScope { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制排班结果。
|
||||
/// </summary>
|
||||
public sealed class CopyStoreStaffScheduleResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CopiedCount。
|
||||
/// </summary>
|
||||
public int CopiedCount { get; set; }
|
||||
/// <summary>
|
||||
/// CopyScope。
|
||||
/// </summary>
|
||||
public string CopyScope { get; set; } = "template_and_schedule";
|
||||
}
|
||||
379
src/Api/TakeoutSaaS.TenantApi/Controllers/StoreApiHelpers.cs
Normal file
379
src/Api/TakeoutSaaS.TenantApi/Controllers/StoreApiHelpers.cs
Normal file
@@ -0,0 +1,379 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
internal static class StoreApiHelpers
|
||||
{
|
||||
private static readonly string[] AvatarColors =
|
||||
[
|
||||
"#f56a00",
|
||||
"#7265e6",
|
||||
"#52c41a",
|
||||
"#fa8c16",
|
||||
"#1890ff",
|
||||
"#bfbfbf",
|
||||
"#13c2c2",
|
||||
"#eb2f96"
|
||||
];
|
||||
|
||||
public static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static (long TenantId, long MerchantId) GetTenantMerchantContext(StoreContextService storeContextService)
|
||||
{
|
||||
var (_, tenantId, merchantId) = storeContextService.GetRequiredContext();
|
||||
return (tenantId, merchantId);
|
||||
}
|
||||
|
||||
public static long ParseRequiredSnowflake(string? value, string fieldName)
|
||||
{
|
||||
if (!long.TryParse(value, out var id) || id <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 非法");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public static long? ParseSnowflakeOrNull(string? value)
|
||||
{
|
||||
return long.TryParse(value, out var id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
public static List<long> ParseSnowflakeList(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return values
|
||||
.Select(ParseSnowflakeOrNull)
|
||||
.Where(id => id.HasValue)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static TimeSpan ParseRequiredTime(string? value, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) ||
|
||||
!TimeSpan.TryParseExact(value, "hh\\:mm", CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 时间格式必须为 HH:mm");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
public static string ToHHmm(TimeSpan value)
|
||||
{
|
||||
return value.ToString("hh\\:mm", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static string? ToHHmm(TimeSpan? value)
|
||||
{
|
||||
return value.HasValue ? ToHHmm(value.Value) : null;
|
||||
}
|
||||
|
||||
public static DateTime ParseDateOnly(string? value, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) ||
|
||||
!DateTime.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 日期格式必须为 yyyy-MM-dd");
|
||||
}
|
||||
|
||||
return parsed.Date;
|
||||
}
|
||||
|
||||
public static string ToDateOnly(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static DayOfWeek UiDayOfWeekToDotNet(int uiDayOfWeek)
|
||||
{
|
||||
return uiDayOfWeek switch
|
||||
{
|
||||
0 => DayOfWeek.Monday,
|
||||
1 => DayOfWeek.Tuesday,
|
||||
2 => DayOfWeek.Wednesday,
|
||||
3 => DayOfWeek.Thursday,
|
||||
4 => DayOfWeek.Friday,
|
||||
5 => DayOfWeek.Saturday,
|
||||
6 => DayOfWeek.Sunday,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "dayOfWeek 必须在 0-6 之间")
|
||||
};
|
||||
}
|
||||
|
||||
public static int DotNetDayOfWeekToUi(DayOfWeek dayOfWeek)
|
||||
{
|
||||
return dayOfWeek switch
|
||||
{
|
||||
DayOfWeek.Monday => 0,
|
||||
DayOfWeek.Tuesday => 1,
|
||||
DayOfWeek.Wednesday => 2,
|
||||
DayOfWeek.Thursday => 3,
|
||||
DayOfWeek.Friday => 4,
|
||||
DayOfWeek.Saturday => 5,
|
||||
DayOfWeek.Sunday => 6,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
public static string SerializeWeekdays(IEnumerable<int>? uiDaysOfWeek)
|
||||
{
|
||||
var normalized = (uiDaysOfWeek ?? [])
|
||||
.Distinct()
|
||||
.Where(day => day is >= 0 and <= 6)
|
||||
.OrderBy(day => day)
|
||||
.ToList();
|
||||
|
||||
return normalized.Count == 0 ? string.Empty : string.Join(',', normalized);
|
||||
}
|
||||
|
||||
public static List<int> DeserializeWeekdays(string? storedValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storedValue))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var values = storedValue
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(item => int.TryParse(item, out var parsed) ? parsed : -1)
|
||||
.Select(day =>
|
||||
{
|
||||
// 兼容旧数据(1-7)
|
||||
if (day is >= 1 and <= 7)
|
||||
{
|
||||
return day == 7 ? 6 : day - 1;
|
||||
}
|
||||
|
||||
return day;
|
||||
})
|
||||
.Where(day => day is >= 0 and <= 6)
|
||||
.Distinct()
|
||||
.OrderBy(day => day)
|
||||
.ToList();
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
public static BusinessHourType ToBusinessHourType(int slotType)
|
||||
{
|
||||
return slotType switch
|
||||
{
|
||||
1 => BusinessHourType.Normal,
|
||||
2 => BusinessHourType.PickupOrDelivery,
|
||||
3 => BusinessHourType.ReservationOnly,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "slot.type 非法")
|
||||
};
|
||||
}
|
||||
|
||||
public static int ToSlotType(BusinessHourType hourType)
|
||||
{
|
||||
return hourType switch
|
||||
{
|
||||
BusinessHourType.Normal => 1,
|
||||
BusinessHourType.PickupOrDelivery => 2,
|
||||
BusinessHourType.ReservationOnly => 3,
|
||||
_ => 1
|
||||
};
|
||||
}
|
||||
|
||||
public static StaffRoleType ToStaffRoleType(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"manager" => StaffRoleType.Admin,
|
||||
"cashier" => StaffRoleType.FrontDesk,
|
||||
"chef" => StaffRoleType.Kitchen,
|
||||
"courier" => StaffRoleType.Courier,
|
||||
_ => StaffRoleType.FrontDesk
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToStaffRoleTypeText(StaffRoleType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
StaffRoleType.Admin => "manager",
|
||||
StaffRoleType.FrontDesk => "cashier",
|
||||
StaffRoleType.Kitchen => "chef",
|
||||
StaffRoleType.Courier => "courier",
|
||||
StaffRoleType.Operator => "manager",
|
||||
_ => "cashier"
|
||||
};
|
||||
}
|
||||
|
||||
public static StaffStatus ToStaffStatus(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"active" => StaffStatus.Active,
|
||||
"leave" => StaffStatus.Disabled,
|
||||
"resigned" => StaffStatus.Resigned,
|
||||
_ => StaffStatus.Active
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToStaffStatusText(StaffStatus value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
StaffStatus.Active => "active",
|
||||
StaffStatus.Disabled => "leave",
|
||||
StaffStatus.Resigned => "resigned",
|
||||
_ => "active"
|
||||
};
|
||||
}
|
||||
|
||||
public static StoreTableStatus ToTableStatus(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"free" => StoreTableStatus.Idle,
|
||||
"disabled" => StoreTableStatus.Disabled,
|
||||
"dining" => StoreTableStatus.Occupied,
|
||||
"reserved" => StoreTableStatus.Cleaning,
|
||||
_ => StoreTableStatus.Idle
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToTableStatusText(StoreTableStatus value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
StoreTableStatus.Idle => "free",
|
||||
StoreTableStatus.Disabled => "disabled",
|
||||
StoreTableStatus.Occupied => "dining",
|
||||
StoreTableStatus.Cleaning => "reserved",
|
||||
_ => "free"
|
||||
};
|
||||
}
|
||||
|
||||
public static StorePickupMode ToPickupMode(string? value)
|
||||
{
|
||||
return string.Equals(value, "fine", StringComparison.OrdinalIgnoreCase)
|
||||
? StorePickupMode.Fine
|
||||
: StorePickupMode.Big;
|
||||
}
|
||||
|
||||
public static string ToPickupModeText(StorePickupMode value)
|
||||
{
|
||||
return value == StorePickupMode.Fine ? "fine" : "big";
|
||||
}
|
||||
|
||||
public static StoreDeliveryMode ToDeliveryMode(string? value)
|
||||
{
|
||||
return string.Equals(value, "polygon", StringComparison.OrdinalIgnoreCase)
|
||||
? StoreDeliveryMode.Polygon
|
||||
: StoreDeliveryMode.Radius;
|
||||
}
|
||||
|
||||
public static string ToDeliveryModeText(StoreDeliveryMode value)
|
||||
{
|
||||
return value == StoreDeliveryMode.Polygon ? "polygon" : "radius";
|
||||
}
|
||||
|
||||
public static StoreStaffShiftType ToShiftType(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"morning" => StoreStaffShiftType.Morning,
|
||||
"evening" => StoreStaffShiftType.Evening,
|
||||
"full" => StoreStaffShiftType.Full,
|
||||
"off" => StoreStaffShiftType.Off,
|
||||
_ => StoreStaffShiftType.Off
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToShiftTypeText(StoreStaffShiftType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
StoreStaffShiftType.Morning => "morning",
|
||||
StoreStaffShiftType.Evening => "evening",
|
||||
StoreStaffShiftType.Full => "full",
|
||||
StoreStaffShiftType.Off => "off",
|
||||
_ => "off"
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<Store> EnsureStoreAccessibleAsync(
|
||||
TakeoutAppDbContext dbContext,
|
||||
long tenantId,
|
||||
long merchantId,
|
||||
long storeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var store = await dbContext.Stores
|
||||
.FirstOrDefaultAsync(x => x.Id == storeId && x.TenantId == tenantId && x.MerchantId == merchantId, cancellationToken);
|
||||
|
||||
if (store is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "门店不存在或无权限访问");
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
public static async Task<HashSet<long>> FilterAccessibleStoreIdsAsync(
|
||||
TakeoutAppDbContext dbContext,
|
||||
long tenantId,
|
||||
long merchantId,
|
||||
IEnumerable<long> storeIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ids = storeIds.Distinct().ToList();
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId && ids.Contains(x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToHashSetAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public static string ResolveAvatarColor(string? seed)
|
||||
{
|
||||
var source = string.IsNullOrWhiteSpace(seed) ? "store-staff" : seed;
|
||||
var hash = 0;
|
||||
foreach (var ch in source)
|
||||
{
|
||||
hash = (hash * 31 + ch) & int.MaxValue;
|
||||
}
|
||||
|
||||
return AvatarColors[hash % AvatarColors.Length];
|
||||
}
|
||||
|
||||
public static string ResolveWeekStartDate(string? requestedWeekStartDate)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(requestedWeekStartDate) &&
|
||||
DateTime.TryParseExact(requestedWeekStartDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
|
||||
{
|
||||
return ToDateOnly(parsed);
|
||||
}
|
||||
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var diff = today.DayOfWeek == DayOfWeek.Sunday ? -6 : 1 - (int)today.DayOfWeek;
|
||||
var monday = today.AddDays(diff);
|
||||
return ToDateOnly(monday);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 门店配送设置模块。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||
public sealed class StoreDeliveryController(
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取门店配送设置。
|
||||
/// </summary>
|
||||
[HttpGet("delivery")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreDeliverySettingsDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreDeliverySettingsDto>> Get([FromQuery] string storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var setting = await dbContext.StoreDeliverySettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
var polygonZones = await dbContext.StoreDeliveryZones
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.Priority)
|
||||
.ThenBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var radiusTiers = ParseRadiusTiers(setting?.RadiusTiersJson);
|
||||
return ApiResponse<StoreDeliverySettingsDto>.Ok(new StoreDeliverySettingsDto
|
||||
{
|
||||
StoreId = parsedStoreId.ToString(),
|
||||
Mode = StoreApiHelpers.ToDeliveryModeText(setting?.Mode ?? StoreDeliveryMode.Radius),
|
||||
RadiusTiers = radiusTiers,
|
||||
PolygonZones = polygonZones.Select(MapPolygonZone).ToList(),
|
||||
GeneralSettings = new DeliveryGeneralSettingsDto
|
||||
{
|
||||
EtaAdjustmentMinutes = setting?.EtaAdjustmentMinutes ?? 10,
|
||||
FreeDeliveryThreshold = setting?.FreeDeliveryThreshold ?? 30m,
|
||||
HourlyCapacityLimit = setting?.HourlyCapacityLimit ?? 50,
|
||||
MaxDeliveryDistance = setting?.MaxDeliveryDistance ?? 5m
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存门店配送设置。
|
||||
/// </summary>
|
||||
[HttpPost("delivery/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Save([FromBody] StoreDeliverySettingsDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var setting = await dbContext.StoreDeliverySettings
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
if (setting is null)
|
||||
{
|
||||
setting = new StoreDeliverySetting
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.StoreDeliverySettings.AddAsync(setting, cancellationToken);
|
||||
}
|
||||
|
||||
setting.Mode = StoreApiHelpers.ToDeliveryMode(request.Mode);
|
||||
setting.EtaAdjustmentMinutes = Math.Clamp(request.GeneralSettings.EtaAdjustmentMinutes, 0, 240);
|
||||
setting.FreeDeliveryThreshold = request.GeneralSettings.FreeDeliveryThreshold;
|
||||
setting.HourlyCapacityLimit = Math.Clamp(request.GeneralSettings.HourlyCapacityLimit, 1, 9999);
|
||||
setting.MaxDeliveryDistance = Math.Max(0m, request.GeneralSettings.MaxDeliveryDistance);
|
||||
setting.RadiusTiersJson = JsonSerializer.Serialize(NormalizeRadiusTiers(request.RadiusTiers), StoreApiHelpers.JsonOptions);
|
||||
|
||||
var existingZones = await dbContext.StoreDeliveryZones
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingZoneMap = existingZones.ToDictionary(x => x.Id);
|
||||
var retainedIds = new HashSet<long>();
|
||||
|
||||
foreach (var zone in request.PolygonZones ?? [])
|
||||
{
|
||||
var zoneId = StoreApiHelpers.ParseSnowflakeOrNull(zone.Id);
|
||||
StoreDeliveryZone? entity = null;
|
||||
if (zoneId.HasValue && existingZoneMap.TryGetValue(zoneId.Value, out var existing))
|
||||
{
|
||||
entity = existing;
|
||||
retainedIds.Add(existing.Id);
|
||||
}
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
entity = new StoreDeliveryZone
|
||||
{
|
||||
StoreId = parsedStoreId,
|
||||
PolygonGeoJson = "{}"
|
||||
};
|
||||
await dbContext.StoreDeliveryZones.AddAsync(entity, cancellationToken);
|
||||
}
|
||||
|
||||
entity.ZoneName = zone.Name?.Trim() ?? string.Empty;
|
||||
entity.Color = string.IsNullOrWhiteSpace(zone.Color) ? "#1677ff" : zone.Color.Trim();
|
||||
entity.MinimumOrderAmount = Math.Max(0m, zone.MinOrderAmount);
|
||||
entity.DeliveryFee = Math.Max(0m, zone.DeliveryFee);
|
||||
entity.EstimatedMinutes = Math.Max(1, zone.EtaMinutes);
|
||||
entity.Priority = Math.Max(1, zone.Priority);
|
||||
entity.SortOrder = entity.Priority;
|
||||
}
|
||||
|
||||
var toDelete = existingZones
|
||||
.Where(x => !retainedIds.Contains(x.Id))
|
||||
.ToList();
|
||||
dbContext.StoreDeliveryZones.RemoveRange(toDelete);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制门店配送设置。
|
||||
/// </summary>
|
||||
[HttpPost("delivery/copy")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CopyStoreDeliverySettingsResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CopyStoreDeliverySettingsResult>> Copy([FromBody] CopyStoreDeliverySettingsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.SourceStoreId, "sourceStoreId");
|
||||
var targetStoreIds = StoreApiHelpers.ParseSnowflakeList(request.TargetStoreIds);
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, sourceStoreId, cancellationToken);
|
||||
|
||||
var accessibleTargetIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
targetStoreIds,
|
||||
cancellationToken);
|
||||
accessibleTargetIds.Remove(sourceStoreId);
|
||||
|
||||
if (accessibleTargetIds.Count == 0)
|
||||
{
|
||||
return ApiResponse<CopyStoreDeliverySettingsResult>.Ok(new CopyStoreDeliverySettingsResult
|
||||
{
|
||||
CopiedCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
var sourceSetting = await dbContext.StoreDeliverySettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == sourceStoreId, cancellationToken);
|
||||
var sourceZones = await dbContext.StoreDeliveryZones
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var targetSettings = await dbContext.StoreDeliverySettings
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
var targetSettingMap = targetSettings.ToDictionary(x => x.StoreId);
|
||||
|
||||
foreach (var targetStoreId in accessibleTargetIds)
|
||||
{
|
||||
if (!targetSettingMap.TryGetValue(targetStoreId, out var targetSetting))
|
||||
{
|
||||
targetSetting = new StoreDeliverySetting
|
||||
{
|
||||
StoreId = targetStoreId
|
||||
};
|
||||
await dbContext.StoreDeliverySettings.AddAsync(targetSetting, cancellationToken);
|
||||
}
|
||||
|
||||
targetSetting.Mode = sourceSetting?.Mode ?? StoreDeliveryMode.Radius;
|
||||
targetSetting.EtaAdjustmentMinutes = sourceSetting?.EtaAdjustmentMinutes ?? 10;
|
||||
targetSetting.FreeDeliveryThreshold = sourceSetting?.FreeDeliveryThreshold ?? 30m;
|
||||
targetSetting.HourlyCapacityLimit = sourceSetting?.HourlyCapacityLimit ?? 50;
|
||||
targetSetting.MaxDeliveryDistance = sourceSetting?.MaxDeliveryDistance ?? 5m;
|
||||
targetSetting.RadiusTiersJson = sourceSetting?.RadiusTiersJson;
|
||||
}
|
||||
|
||||
var targetZones = await dbContext.StoreDeliveryZones
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StoreDeliveryZones.RemoveRange(targetZones);
|
||||
|
||||
var clonedZones = accessibleTargetIds
|
||||
.SelectMany(targetStoreId => sourceZones.Select(zone => new StoreDeliveryZone
|
||||
{
|
||||
StoreId = targetStoreId,
|
||||
ZoneName = zone.ZoneName,
|
||||
PolygonGeoJson = zone.PolygonGeoJson,
|
||||
MinimumOrderAmount = zone.MinimumOrderAmount,
|
||||
DeliveryFee = zone.DeliveryFee,
|
||||
EstimatedMinutes = zone.EstimatedMinutes,
|
||||
Color = zone.Color,
|
||||
Priority = zone.Priority,
|
||||
SortOrder = zone.SortOrder
|
||||
}))
|
||||
.ToList();
|
||||
if (clonedZones.Count > 0)
|
||||
{
|
||||
await dbContext.StoreDeliveryZones.AddRangeAsync(clonedZones, cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ApiResponse<CopyStoreDeliverySettingsResult>.Ok(new CopyStoreDeliverySettingsResult
|
||||
{
|
||||
CopiedCount = accessibleTargetIds.Count
|
||||
});
|
||||
}
|
||||
|
||||
private static List<RadiusTierDto> ParseRadiusTiers(string? raw)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<List<RadiusTierDto>>(raw, StoreApiHelpers.JsonOptions);
|
||||
if (parsed is not null && parsed.Count > 0)
|
||||
{
|
||||
return NormalizeRadiusTiers(parsed);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略配置反序列化异常并回落默认值
|
||||
}
|
||||
}
|
||||
|
||||
return NormalizeRadiusTiers(new List<RadiusTierDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "tier-1",
|
||||
MinDistance = 0m,
|
||||
MaxDistance = 1m,
|
||||
DeliveryFee = 3m,
|
||||
EtaMinutes = 20,
|
||||
MinOrderAmount = 15m,
|
||||
Color = "#52c41a"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "tier-2",
|
||||
MinDistance = 1m,
|
||||
MaxDistance = 3m,
|
||||
DeliveryFee = 5m,
|
||||
EtaMinutes = 35,
|
||||
MinOrderAmount = 20m,
|
||||
Color = "#faad14"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "tier-3",
|
||||
MinDistance = 3m,
|
||||
MaxDistance = 5m,
|
||||
DeliveryFee = 8m,
|
||||
EtaMinutes = 50,
|
||||
MinOrderAmount = 25m,
|
||||
Color = "#ff4d4f"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static List<RadiusTierDto> NormalizeRadiusTiers(IEnumerable<RadiusTierDto>? source)
|
||||
{
|
||||
return (source ?? [])
|
||||
.Select((tier, index) =>
|
||||
{
|
||||
var minDistance = Math.Max(0m, tier.MinDistance);
|
||||
var maxDistance = Math.Max(minDistance + 0.01m, tier.MaxDistance);
|
||||
return new RadiusTierDto
|
||||
{
|
||||
Id = string.IsNullOrWhiteSpace(tier.Id) ? $"tier-{index + 1}" : tier.Id,
|
||||
MinDistance = minDistance,
|
||||
MaxDistance = maxDistance,
|
||||
DeliveryFee = Math.Max(0m, tier.DeliveryFee),
|
||||
EtaMinutes = Math.Max(1, tier.EtaMinutes),
|
||||
MinOrderAmount = Math.Max(0m, tier.MinOrderAmount),
|
||||
Color = string.IsNullOrWhiteSpace(tier.Color) ? "#1677ff" : tier.Color
|
||||
};
|
||||
})
|
||||
.OrderBy(x => x.MinDistance)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static PolygonZoneDto MapPolygonZone(StoreDeliveryZone source)
|
||||
{
|
||||
return new PolygonZoneDto
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.ZoneName,
|
||||
Color = source.Color ?? "#1677ff",
|
||||
DeliveryFee = source.DeliveryFee ?? 0m,
|
||||
EtaMinutes = source.EstimatedMinutes ?? 20,
|
||||
MinOrderAmount = source.MinimumOrderAmount ?? 0m,
|
||||
Priority = source.Priority
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 门店堂食管理模块。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||
public sealed class StoreDineInController(
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取门店堂食设置。
|
||||
/// </summary>
|
||||
[HttpGet("dinein")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreDineInSettingsDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreDineInSettingsDto>> Get([FromQuery] string storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var basic = await dbContext.StoreDineInSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
var areas = await dbContext.StoreTableAreas
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
var tables = await dbContext.StoreTables
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.TableCode)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return ApiResponse<StoreDineInSettingsDto>.Ok(new StoreDineInSettingsDto
|
||||
{
|
||||
StoreId = parsedStoreId.ToString(),
|
||||
BasicSettings = new DineInBasicSettingsDto
|
||||
{
|
||||
Enabled = basic?.Enabled ?? true,
|
||||
DefaultDiningMinutes = basic?.DefaultDiningMinutes ?? 90,
|
||||
OvertimeReminderMinutes = basic?.OvertimeReminderMinutes ?? 10
|
||||
},
|
||||
Areas = areas.Select(MapArea).ToList(),
|
||||
Tables = tables.Select(MapTable).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存门店堂食基础设置。
|
||||
/// </summary>
|
||||
[HttpPost("dinein/basic/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> SaveBasic([FromBody] SaveStoreDineInBasicSettingsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var basic = await dbContext.StoreDineInSettings
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
if (basic is null)
|
||||
{
|
||||
basic = new StoreDineInSetting
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.StoreDineInSettings.AddAsync(basic, cancellationToken);
|
||||
}
|
||||
|
||||
basic.Enabled = request.BasicSettings.Enabled;
|
||||
basic.DefaultDiningMinutes = Math.Clamp(request.BasicSettings.DefaultDiningMinutes, 1, 999);
|
||||
basic.OvertimeReminderMinutes = Math.Clamp(request.BasicSettings.OvertimeReminderMinutes, 0, 999);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存堂食区域。
|
||||
/// </summary>
|
||||
[HttpPost("dinein/area/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DineInAreaDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DineInAreaDto>> SaveArea([FromBody] SaveDineInAreaRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var areaName = request.Area.Name?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(areaName))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "区域名称不能为空");
|
||||
}
|
||||
|
||||
var areaId = StoreApiHelpers.ParseSnowflakeOrNull(request.Area.Id);
|
||||
StoreTableArea? area = null;
|
||||
if (areaId.HasValue)
|
||||
{
|
||||
area = await dbContext.StoreTableAreas.FirstOrDefaultAsync(
|
||||
x => x.Id == areaId.Value && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (area is null)
|
||||
{
|
||||
area = new StoreTableArea
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.StoreTableAreas.AddAsync(area, cancellationToken);
|
||||
}
|
||||
|
||||
area.Name = areaName;
|
||||
area.Description = request.Area.Description?.Trim();
|
||||
area.SortOrder = Math.Max(1, request.Area.Sort);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<DineInAreaDto>.Ok(MapArea(area));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除堂食区域。
|
||||
/// </summary>
|
||||
[HttpPost("dinein/area/delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteArea([FromBody] DeleteDineInAreaRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var parsedAreaId = StoreApiHelpers.ParseRequiredSnowflake(request.AreaId, "areaId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var hasTables = await dbContext.StoreTables
|
||||
.AnyAsync(x => x.TenantId == tenantId && x.StoreId == parsedStoreId && x.AreaId == parsedAreaId, cancellationToken);
|
||||
if (hasTables)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "该区域仍有桌位,请先迁移或删除桌位");
|
||||
}
|
||||
|
||||
var area = await dbContext.StoreTableAreas.FirstOrDefaultAsync(
|
||||
x => x.Id == parsedAreaId && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
if (area is null)
|
||||
{
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
dbContext.StoreTableAreas.Remove(area);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存堂食桌位。
|
||||
/// </summary>
|
||||
[HttpPost("dinein/table/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DineInTableDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DineInTableDto>> SaveTable([FromBody] SaveDineInTableRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var parsedAreaId = StoreApiHelpers.ParseRequiredSnowflake(request.Table.AreaId, "table.areaId");
|
||||
var areaExists = await dbContext.StoreTableAreas
|
||||
.AnyAsync(x => x.Id == parsedAreaId && x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
if (!areaExists)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "区域不存在");
|
||||
}
|
||||
|
||||
var tableCode = (request.Table.Code ?? string.Empty).Trim().ToUpperInvariant();
|
||||
if (string.IsNullOrWhiteSpace(tableCode))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "桌位编号不能为空");
|
||||
}
|
||||
|
||||
var tableId = StoreApiHelpers.ParseSnowflakeOrNull(request.Table.Id);
|
||||
StoreTable? table = null;
|
||||
if (tableId.HasValue)
|
||||
{
|
||||
table = await dbContext.StoreTables.FirstOrDefaultAsync(
|
||||
x => x.Id == tableId.Value && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var duplicateCode = await dbContext.StoreTables.AnyAsync(
|
||||
x => x.TenantId == tenantId
|
||||
&& x.StoreId == parsedStoreId
|
||||
&& x.TableCode == tableCode
|
||||
&& (!tableId.HasValue || x.Id != tableId.Value),
|
||||
cancellationToken);
|
||||
if (duplicateCode)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "桌位编号已存在");
|
||||
}
|
||||
|
||||
if (table is null)
|
||||
{
|
||||
table = new StoreTable
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.StoreTables.AddAsync(table, cancellationToken);
|
||||
}
|
||||
|
||||
table.AreaId = parsedAreaId;
|
||||
table.TableCode = tableCode;
|
||||
table.Capacity = Math.Clamp(request.Table.Seats, 1, 20);
|
||||
table.Status = StoreApiHelpers.ToTableStatus(request.Table.Status);
|
||||
table.Tags = string.Join(',', (request.Table.Tags ?? [])
|
||||
.Select(x => x.Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct());
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<DineInTableDto>.Ok(MapTable(table));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除堂食桌位。
|
||||
/// </summary>
|
||||
[HttpPost("dinein/table/delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteTable([FromBody] DeleteDineInTableRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var parsedTableId = StoreApiHelpers.ParseRequiredSnowflake(request.TableId, "tableId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var table = await dbContext.StoreTables.FirstOrDefaultAsync(
|
||||
x => x.Id == parsedTableId && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
if (table is null)
|
||||
{
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
dbContext.StoreTables.Remove(table);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量生成堂食桌位。
|
||||
/// </summary>
|
||||
[HttpPost("dinein/table/batch-create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchCreateDineInTablesResultDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BatchCreateDineInTablesResultDto>> BatchCreateTables(
|
||||
[FromBody] BatchCreateDineInTablesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var parsedAreaId = StoreApiHelpers.ParseRequiredSnowflake(request.AreaId, "areaId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var areaExists = await dbContext.StoreTableAreas
|
||||
.AnyAsync(x => x.Id == parsedAreaId && x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
if (!areaExists)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "区域不存在");
|
||||
}
|
||||
|
||||
var count = Math.Clamp(request.Count, 1, 50);
|
||||
var startNumber = Math.Clamp(request.StartNumber, 1, 9999);
|
||||
var seats = Math.Clamp(request.Seats, 1, 20);
|
||||
var prefix = string.IsNullOrWhiteSpace(request.CodePrefix) ? "A" : request.CodePrefix.Trim().ToUpperInvariant();
|
||||
var width = Math.Max(2, (startNumber + count - 1).ToString().Length);
|
||||
|
||||
var existingCodes = await dbContext.StoreTables
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.Select(x => x.TableCode)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingCodeSet = new HashSet<string>(existingCodes, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var created = new List<StoreTable>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var code = $"{prefix}{(startNumber + i).ToString().PadLeft(width, '0')}";
|
||||
if (existingCodeSet.Contains(code))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var table = new StoreTable
|
||||
{
|
||||
StoreId = parsedStoreId,
|
||||
AreaId = parsedAreaId,
|
||||
TableCode = code,
|
||||
Capacity = seats,
|
||||
Status = StoreApiHelpers.ToTableStatus("free"),
|
||||
Tags = null
|
||||
};
|
||||
created.Add(table);
|
||||
existingCodeSet.Add(code);
|
||||
}
|
||||
|
||||
if (created.Count > 0)
|
||||
{
|
||||
await dbContext.StoreTables.AddRangeAsync(created, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return ApiResponse<BatchCreateDineInTablesResultDto>.Ok(new BatchCreateDineInTablesResultDto
|
||||
{
|
||||
CreatedTables = created.Select(MapTable).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制堂食设置。
|
||||
/// </summary>
|
||||
[HttpPost("dinein/copy")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CopyStoreDineInSettingsResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CopyStoreDineInSettingsResult>> Copy([FromBody] CopyStoreDineInSettingsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.SourceStoreId, "sourceStoreId");
|
||||
var targetStoreIds = StoreApiHelpers.ParseSnowflakeList(request.TargetStoreIds);
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, sourceStoreId, cancellationToken);
|
||||
|
||||
var accessibleTargetIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
targetStoreIds,
|
||||
cancellationToken);
|
||||
accessibleTargetIds.Remove(sourceStoreId);
|
||||
|
||||
if (accessibleTargetIds.Count == 0)
|
||||
{
|
||||
return ApiResponse<CopyStoreDineInSettingsResult>.Ok(new CopyStoreDineInSettingsResult
|
||||
{
|
||||
CopiedCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
var sourceBasic = await dbContext.StoreDineInSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == sourceStoreId, cancellationToken);
|
||||
var sourceAreas = await dbContext.StoreTableAreas
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var sourceTables = await dbContext.StoreTables
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.OrderBy(x => x.TableCode)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var targetStoreId in accessibleTargetIds)
|
||||
{
|
||||
var targetBasic = await dbContext.StoreDineInSettings
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == targetStoreId, cancellationToken);
|
||||
if (targetBasic is null)
|
||||
{
|
||||
targetBasic = new StoreDineInSetting
|
||||
{
|
||||
StoreId = targetStoreId
|
||||
};
|
||||
await dbContext.StoreDineInSettings.AddAsync(targetBasic, cancellationToken);
|
||||
}
|
||||
|
||||
targetBasic.Enabled = sourceBasic?.Enabled ?? true;
|
||||
targetBasic.DefaultDiningMinutes = sourceBasic?.DefaultDiningMinutes ?? 90;
|
||||
targetBasic.OvertimeReminderMinutes = sourceBasic?.OvertimeReminderMinutes ?? 10;
|
||||
|
||||
var targetTables = await dbContext.StoreTables
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == targetStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var targetAreas = await dbContext.StoreTableAreas
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == targetStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StoreTables.RemoveRange(targetTables);
|
||||
dbContext.StoreTableAreas.RemoveRange(targetAreas);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var createdAreas = sourceAreas.Select(area => new StoreTableArea
|
||||
{
|
||||
StoreId = targetStoreId,
|
||||
Name = area.Name,
|
||||
Description = area.Description,
|
||||
SortOrder = area.SortOrder
|
||||
}).ToList();
|
||||
if (createdAreas.Count > 0)
|
||||
{
|
||||
await dbContext.StoreTableAreas.AddRangeAsync(createdAreas, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var areaIdMap = new Dictionary<long, long>();
|
||||
for (var index = 0; index < sourceAreas.Count && index < createdAreas.Count; index++)
|
||||
{
|
||||
areaIdMap[sourceAreas[index].Id] = createdAreas[index].Id;
|
||||
}
|
||||
|
||||
var createdTables = sourceTables.Select(table => new StoreTable
|
||||
{
|
||||
StoreId = targetStoreId,
|
||||
AreaId = table.AreaId.HasValue && areaIdMap.TryGetValue(table.AreaId.Value, out var mappedAreaId)
|
||||
? mappedAreaId
|
||||
: null,
|
||||
TableCode = table.TableCode,
|
||||
Capacity = table.Capacity,
|
||||
Status = table.Status,
|
||||
Tags = table.Tags
|
||||
}).ToList();
|
||||
if (createdTables.Count > 0)
|
||||
{
|
||||
await dbContext.StoreTables.AddRangeAsync(createdTables, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return ApiResponse<CopyStoreDineInSettingsResult>.Ok(new CopyStoreDineInSettingsResult
|
||||
{
|
||||
CopiedCount = accessibleTargetIds.Count
|
||||
});
|
||||
}
|
||||
|
||||
private static DineInAreaDto MapArea(StoreTableArea source)
|
||||
{
|
||||
return new DineInAreaDto
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
Description = source.Description ?? string.Empty,
|
||||
Sort = source.SortOrder
|
||||
};
|
||||
}
|
||||
|
||||
private static DineInTableDto MapTable(StoreTable source)
|
||||
{
|
||||
return new DineInTableDto
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
AreaId = source.AreaId?.ToString() ?? string.Empty,
|
||||
Code = source.TableCode,
|
||||
Seats = source.Capacity,
|
||||
Status = StoreApiHelpers.ToTableStatusText(source.Status),
|
||||
Tags = string.IsNullOrWhiteSpace(source.Tags)
|
||||
? []
|
||||
: source.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
220
src/Api/TakeoutSaaS.TenantApi/Controllers/StoreFeesController.cs
Normal file
220
src/Api/TakeoutSaaS.TenantApi/Controllers/StoreFeesController.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Stores.Commands;
|
||||
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.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 门店费用设置模块。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||
public sealed class StoreFeesController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取门店费用设置。
|
||||
/// </summary>
|
||||
[HttpGet("fees")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreFeesSettingsDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreFeesSettingsDto>> Get([FromQuery] string storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var fee = await mediator.Send(new GetStoreFeeQuery
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
}, cancellationToken);
|
||||
|
||||
var response = MapFeeSettings(parsedStoreId, fee);
|
||||
return ApiResponse<StoreFeesSettingsDto>.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存门店费用设置。
|
||||
/// </summary>
|
||||
[HttpPost("fees/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreFeesSettingsDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreFeesSettingsDto>> Save([FromBody] StoreFeesSettingsDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new UpdateStoreFeeCommand
|
||||
{
|
||||
StoreId = parsedStoreId,
|
||||
MinimumOrderAmount = request.MinimumOrderAmount,
|
||||
DeliveryFee = request.BaseDeliveryFee,
|
||||
FreeDeliveryThreshold = request.FreeDeliveryThreshold,
|
||||
PackagingFeeMode = ParsePackagingFeeMode(request.PackagingFeeMode),
|
||||
OrderPackagingFeeMode = ParseOrderPackagingFeeMode(request.OrderPackagingFeeMode),
|
||||
FixedPackagingFee = request.FixedPackagingFee,
|
||||
PackagingFeeTiers = (request.PackagingFeeTiers ?? [])
|
||||
.OrderBy(x => x.Sort)
|
||||
.ThenBy(x => x.MinAmount)
|
||||
.Select(x => new StoreFeeTierDto
|
||||
{
|
||||
MinPrice = x.MinAmount,
|
||||
MaxPrice = x.MaxAmount,
|
||||
Fee = x.Fee
|
||||
})
|
||||
.ToList(),
|
||||
CutleryFeeEnabled = request.OtherFees.Cutlery.Enabled,
|
||||
CutleryFeeAmount = request.OtherFees.Cutlery.Amount,
|
||||
RushFeeEnabled = request.OtherFees.Rush.Enabled,
|
||||
RushFeeAmount = request.OtherFees.Rush.Amount
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<StoreFeesSettingsDto>.Ok(MapFeeSettings(parsedStoreId, result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制费用设置。
|
||||
/// </summary>
|
||||
[HttpPost("fees/copy")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CopyStoreFeesSettingsResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CopyStoreFeesSettingsResult>> Copy([FromBody] CopyStoreFeesSettingsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.SourceStoreId, "sourceStoreId");
|
||||
var targetStoreIds = StoreApiHelpers.ParseSnowflakeList(request.TargetStoreIds);
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, sourceStoreId, cancellationToken);
|
||||
|
||||
var accessibleTargetIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
targetStoreIds,
|
||||
cancellationToken);
|
||||
accessibleTargetIds.Remove(sourceStoreId);
|
||||
|
||||
if (accessibleTargetIds.Count == 0)
|
||||
{
|
||||
return ApiResponse<CopyStoreFeesSettingsResult>.Ok(new CopyStoreFeesSettingsResult
|
||||
{
|
||||
CopiedCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
var sourceFee = await dbContext.StoreFees
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == sourceStoreId, cancellationToken);
|
||||
|
||||
var targetFees = await dbContext.StoreFees
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
var targetFeeMap = targetFees.ToDictionary(x => x.StoreId);
|
||||
|
||||
foreach (var targetStoreId in accessibleTargetIds)
|
||||
{
|
||||
if (!targetFeeMap.TryGetValue(targetStoreId, out var targetFee))
|
||||
{
|
||||
targetFee = new StoreFee
|
||||
{
|
||||
StoreId = targetStoreId
|
||||
};
|
||||
await dbContext.StoreFees.AddAsync(targetFee, cancellationToken);
|
||||
}
|
||||
|
||||
targetFee.MinimumOrderAmount = sourceFee?.MinimumOrderAmount ?? 0m;
|
||||
targetFee.BaseDeliveryFee = sourceFee?.BaseDeliveryFee ?? 0m;
|
||||
targetFee.FreeDeliveryThreshold = sourceFee?.FreeDeliveryThreshold;
|
||||
targetFee.PackagingFeeMode = sourceFee?.PackagingFeeMode ?? PackagingFeeMode.Fixed;
|
||||
targetFee.OrderPackagingFeeMode = sourceFee?.OrderPackagingFeeMode ?? OrderPackagingFeeMode.Fixed;
|
||||
targetFee.FixedPackagingFee = sourceFee?.FixedPackagingFee ?? 0m;
|
||||
targetFee.PackagingFeeTiersJson = sourceFee?.PackagingFeeTiersJson;
|
||||
targetFee.CutleryFeeEnabled = sourceFee?.CutleryFeeEnabled ?? false;
|
||||
targetFee.CutleryFeeAmount = sourceFee?.CutleryFeeAmount ?? 0m;
|
||||
targetFee.RushFeeEnabled = sourceFee?.RushFeeEnabled ?? false;
|
||||
targetFee.RushFeeAmount = sourceFee?.RushFeeAmount ?? 0m;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ApiResponse<CopyStoreFeesSettingsResult>.Ok(new CopyStoreFeesSettingsResult
|
||||
{
|
||||
CopiedCount = accessibleTargetIds.Count
|
||||
});
|
||||
}
|
||||
|
||||
private static StoreFeesSettingsDto MapFeeSettings(long storeId, StoreFeeDto? source)
|
||||
{
|
||||
var tiers = (source?.PackagingFeeTiers ?? [])
|
||||
.OrderBy(x => x.MinPrice)
|
||||
.Select((tier, index) => new PackagingFeeTierDto
|
||||
{
|
||||
Id = $"tier-{index + 1}",
|
||||
MinAmount = tier.MinPrice,
|
||||
MaxAmount = tier.MaxPrice,
|
||||
Fee = tier.Fee,
|
||||
Sort = index + 1
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new StoreFeesSettingsDto
|
||||
{
|
||||
StoreId = storeId.ToString(),
|
||||
MinimumOrderAmount = source?.MinimumOrderAmount ?? 0m,
|
||||
BaseDeliveryFee = source?.DeliveryFee ?? 0m,
|
||||
FreeDeliveryThreshold = source?.FreeDeliveryThreshold,
|
||||
PackagingFeeMode = ToPackagingFeeModeText(source?.PackagingFeeMode ?? PackagingFeeMode.Fixed),
|
||||
OrderPackagingFeeMode = ToOrderPackagingFeeModeText(source?.OrderPackagingFeeMode ?? OrderPackagingFeeMode.Fixed),
|
||||
FixedPackagingFee = source?.FixedPackagingFee ?? 0m,
|
||||
PackagingFeeTiers = tiers,
|
||||
OtherFees = new StoreOtherFeesDto
|
||||
{
|
||||
Cutlery = new AdditionalFeeItemDto
|
||||
{
|
||||
Enabled = source?.CutleryFeeEnabled ?? false,
|
||||
Amount = source?.CutleryFeeAmount ?? 0m
|
||||
},
|
||||
Rush = new AdditionalFeeItemDto
|
||||
{
|
||||
Enabled = source?.RushFeeEnabled ?? false,
|
||||
Amount = source?.RushFeeAmount ?? 0m
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static PackagingFeeMode ParsePackagingFeeMode(string? value)
|
||||
{
|
||||
return string.Equals(value, "item", StringComparison.OrdinalIgnoreCase)
|
||||
? PackagingFeeMode.PerItem
|
||||
: PackagingFeeMode.Fixed;
|
||||
}
|
||||
|
||||
private static string ToPackagingFeeModeText(PackagingFeeMode value)
|
||||
{
|
||||
return value == PackagingFeeMode.PerItem ? "item" : "order";
|
||||
}
|
||||
|
||||
private static OrderPackagingFeeMode ParseOrderPackagingFeeMode(string? value)
|
||||
{
|
||||
return string.Equals(value, "tiered", StringComparison.OrdinalIgnoreCase)
|
||||
? OrderPackagingFeeMode.Tiered
|
||||
: OrderPackagingFeeMode.Fixed;
|
||||
}
|
||||
|
||||
private static string ToOrderPackagingFeeModeText(OrderPackagingFeeMode value)
|
||||
{
|
||||
return value == OrderPackagingFeeMode.Tiered ? "tiered" : "fixed";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 门店营业时间模块。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||
public sealed class StoreHoursController(
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取门店营业时间。
|
||||
/// </summary>
|
||||
[HttpGet("hours")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreHoursDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreHoursDto>> Get([FromQuery] string storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var hours = await dbContext.StoreBusinessHours
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.DayOfWeek)
|
||||
.ThenBy(x => x.StartTime)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var holidays = await dbContext.StoreHolidays
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.Date)
|
||||
.ThenBy(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var weeklyHours = Enumerable.Range(0, 7)
|
||||
.Select(day =>
|
||||
{
|
||||
var slots = hours
|
||||
.Where(x => StoreApiHelpers.DotNetDayOfWeekToUi(x.DayOfWeek) == day)
|
||||
.Select(MapSlot)
|
||||
.ToList();
|
||||
|
||||
return new StoreHourDayHoursDto
|
||||
{
|
||||
DayOfWeek = day,
|
||||
IsOpen = slots.Count > 0,
|
||||
Slots = slots
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var result = new StoreHoursDto
|
||||
{
|
||||
StoreId = parsedStoreId.ToString(),
|
||||
WeeklyHours = weeklyHours,
|
||||
Holidays = holidays.Select(MapHoliday).ToList()
|
||||
};
|
||||
|
||||
return ApiResponse<StoreHoursDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存每周营业时间。
|
||||
/// </summary>
|
||||
[HttpPost("hours/weekly")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> SaveWeekly([FromBody] SaveWeeklyHoursRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var existingHours = await dbContext.StoreBusinessHours
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
dbContext.StoreBusinessHours.RemoveRange(existingHours);
|
||||
|
||||
var toCreate = new List<StoreBusinessHour>();
|
||||
foreach (var day in request.WeeklyHours ?? [])
|
||||
{
|
||||
if (day.DayOfWeek is < 0 or > 6 || !day.IsOpen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var slot in day.Slots ?? [])
|
||||
{
|
||||
var startTime = StoreApiHelpers.ParseRequiredTime(slot.StartTime, "slot.startTime");
|
||||
var endTime = StoreApiHelpers.ParseRequiredTime(slot.EndTime, "slot.endTime");
|
||||
if (startTime >= endTime)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "营业时段开始时间必须早于结束时间");
|
||||
}
|
||||
|
||||
var hourType = StoreApiHelpers.ToBusinessHourType(slot.Type);
|
||||
toCreate.Add(new StoreBusinessHour
|
||||
{
|
||||
StoreId = parsedStoreId,
|
||||
DayOfWeek = StoreApiHelpers.UiDayOfWeekToDotNet(day.DayOfWeek),
|
||||
HourType = hourType,
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
CapacityLimit = hourType == BusinessHourType.PickupOrDelivery ? slot.Capacity : null,
|
||||
Notes = string.IsNullOrWhiteSpace(slot.Remark) ? null : slot.Remark.Trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (toCreate.Count > 0)
|
||||
{
|
||||
await dbContext.StoreBusinessHours.AddRangeAsync(toCreate, cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存特殊日期。
|
||||
/// </summary>
|
||||
[HttpPost("hours/holiday")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreHourHolidayDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreHourHolidayDto>> SaveHoliday([FromBody] SaveHolidayRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var holidayInput = request.Holiday;
|
||||
var startDate = StoreApiHelpers.ParseDateOnly(holidayInput.StartDate, "holiday.startDate");
|
||||
var endDate = StoreApiHelpers.ParseDateOnly(string.IsNullOrWhiteSpace(holidayInput.EndDate) ? holidayInput.StartDate : holidayInput.EndDate, "holiday.endDate");
|
||||
if (startDate > endDate)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "holiday.startDate 不能晚于 holiday.endDate");
|
||||
}
|
||||
|
||||
var type = holidayInput.Type == 2 ? StoreHolidayType.Special : StoreHolidayType.Closed;
|
||||
var hasTimeRange = type == StoreHolidayType.Special
|
||||
&& !string.IsNullOrWhiteSpace(holidayInput.StartTime)
|
||||
&& !string.IsNullOrWhiteSpace(holidayInput.EndTime);
|
||||
|
||||
var startTime = hasTimeRange ? StoreApiHelpers.ParseRequiredTime(holidayInput.StartTime, "holiday.startTime") : (TimeSpan?)null;
|
||||
var endTime = hasTimeRange ? StoreApiHelpers.ParseRequiredTime(holidayInput.EndTime, "holiday.endTime") : (TimeSpan?)null;
|
||||
if (hasTimeRange && startTime >= endTime)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "holiday.startTime 必须早于 holiday.endTime");
|
||||
}
|
||||
|
||||
var holidayId = StoreApiHelpers.ParseSnowflakeOrNull(holidayInput.Id);
|
||||
var entity = holidayId.HasValue
|
||||
? await dbContext.StoreHolidays.FirstOrDefaultAsync(
|
||||
x => x.Id == holidayId.Value && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken)
|
||||
: null;
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
entity = new StoreHoliday
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.StoreHolidays.AddAsync(entity, cancellationToken);
|
||||
}
|
||||
|
||||
entity.Date = startDate;
|
||||
entity.EndDate = endDate;
|
||||
entity.IsAllDay = !hasTimeRange;
|
||||
entity.StartTime = hasTimeRange ? startTime : null;
|
||||
entity.EndTime = hasTimeRange ? endTime : null;
|
||||
entity.OverrideType = type == StoreHolidayType.Closed
|
||||
? OverrideType.Closed
|
||||
: hasTimeRange
|
||||
? OverrideType.ModifiedHours
|
||||
: OverrideType.TemporaryOpen;
|
||||
entity.IsClosed = type == StoreHolidayType.Closed;
|
||||
entity.Reason = string.IsNullOrWhiteSpace(holidayInput.Reason) ? null : holidayInput.Reason.Trim();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var result = MapHoliday(entity);
|
||||
result.Remark = holidayInput.Remark;
|
||||
return ApiResponse<StoreHourHolidayDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除特殊日期。
|
||||
/// </summary>
|
||||
[HttpPost("hours/holiday/delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteHoliday([FromBody] DeleteHolidayRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var holidayId = StoreApiHelpers.ParseRequiredSnowflake(request.Id, "id");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
|
||||
var holiday = await dbContext.StoreHolidays
|
||||
.FirstOrDefaultAsync(x => x.Id == holidayId && x.TenantId == tenantId, cancellationToken);
|
||||
|
||||
if (holiday is null)
|
||||
{
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
var hasAccess = await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.AnyAsync(x => x.Id == holiday.StoreId && x.TenantId == tenantId && x.MerchantId == merchantId, cancellationToken);
|
||||
if (!hasAccess)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "特殊日期不存在或无权限访问");
|
||||
}
|
||||
|
||||
dbContext.StoreHolidays.Remove(holiday);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制营业时间到其他门店。
|
||||
/// </summary>
|
||||
[HttpPost("hours/copy")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CopyStoreHoursResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CopyStoreHoursResult>> Copy([FromBody] CopyStoreHoursRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.SourceStoreId, "sourceStoreId");
|
||||
var targetStoreIds = StoreApiHelpers.ParseSnowflakeList(request.TargetStoreIds);
|
||||
var includeWeeklyHours = request.IncludeWeeklyHours ?? true;
|
||||
var includeHolidays = request.IncludeHolidays ?? true;
|
||||
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, sourceStoreId, cancellationToken);
|
||||
|
||||
var accessibleTargetIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
targetStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
accessibleTargetIds.Remove(sourceStoreId);
|
||||
if (accessibleTargetIds.Count == 0)
|
||||
{
|
||||
return ApiResponse<CopyStoreHoursResult>.Ok(new CopyStoreHoursResult
|
||||
{
|
||||
CopiedCount = 0,
|
||||
IncludeWeeklyHours = includeWeeklyHours,
|
||||
IncludeHolidays = includeHolidays
|
||||
});
|
||||
}
|
||||
|
||||
if (includeWeeklyHours)
|
||||
{
|
||||
var sourceHours = await dbContext.StoreBusinessHours
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var targetHours = await dbContext.StoreBusinessHours
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StoreBusinessHours.RemoveRange(targetHours);
|
||||
|
||||
var clonedHours = accessibleTargetIds
|
||||
.SelectMany(targetStoreId => sourceHours.Select(hour => new StoreBusinessHour
|
||||
{
|
||||
StoreId = targetStoreId,
|
||||
DayOfWeek = hour.DayOfWeek,
|
||||
HourType = hour.HourType,
|
||||
StartTime = hour.StartTime,
|
||||
EndTime = hour.EndTime,
|
||||
CapacityLimit = hour.CapacityLimit,
|
||||
Notes = hour.Notes
|
||||
}))
|
||||
.ToList();
|
||||
|
||||
if (clonedHours.Count > 0)
|
||||
{
|
||||
await dbContext.StoreBusinessHours.AddRangeAsync(clonedHours, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeHolidays)
|
||||
{
|
||||
var sourceHolidays = await dbContext.StoreHolidays
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var targetHolidays = await dbContext.StoreHolidays
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StoreHolidays.RemoveRange(targetHolidays);
|
||||
|
||||
var clonedHolidays = accessibleTargetIds
|
||||
.SelectMany(targetStoreId => sourceHolidays.Select(holiday => new StoreHoliday
|
||||
{
|
||||
StoreId = targetStoreId,
|
||||
Date = holiday.Date,
|
||||
EndDate = holiday.EndDate,
|
||||
IsAllDay = holiday.IsAllDay,
|
||||
StartTime = holiday.StartTime,
|
||||
EndTime = holiday.EndTime,
|
||||
OverrideType = holiday.OverrideType,
|
||||
IsClosed = holiday.IsClosed,
|
||||
Reason = holiday.Reason
|
||||
}))
|
||||
.ToList();
|
||||
|
||||
if (clonedHolidays.Count > 0)
|
||||
{
|
||||
await dbContext.StoreHolidays.AddRangeAsync(clonedHolidays, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ApiResponse<CopyStoreHoursResult>.Ok(new CopyStoreHoursResult
|
||||
{
|
||||
CopiedCount = accessibleTargetIds.Count,
|
||||
IncludeWeeklyHours = includeWeeklyHours,
|
||||
IncludeHolidays = includeHolidays
|
||||
});
|
||||
}
|
||||
|
||||
private static StoreHourTimeSlotDto MapSlot(StoreBusinessHour source)
|
||||
{
|
||||
return new StoreHourTimeSlotDto
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Type = StoreApiHelpers.ToSlotType(source.HourType),
|
||||
StartTime = StoreApiHelpers.ToHHmm(source.StartTime),
|
||||
EndTime = StoreApiHelpers.ToHHmm(source.EndTime),
|
||||
Capacity = source.CapacityLimit,
|
||||
Remark = source.Notes
|
||||
};
|
||||
}
|
||||
|
||||
private static StoreHourHolidayDto MapHoliday(StoreHoliday source)
|
||||
{
|
||||
var type = source.OverrideType == OverrideType.Closed || source.IsClosed
|
||||
? StoreHolidayType.Closed
|
||||
: StoreHolidayType.Special;
|
||||
|
||||
return new StoreHourHolidayDto
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
StartDate = StoreApiHelpers.ToDateOnly(source.Date),
|
||||
EndDate = StoreApiHelpers.ToDateOnly(source.EndDate ?? source.Date),
|
||||
Type = (int)type,
|
||||
StartTime = type == StoreHolidayType.Special ? StoreApiHelpers.ToHHmm(source.StartTime) : null,
|
||||
EndTime = type == StoreHolidayType.Special ? StoreApiHelpers.ToHHmm(source.EndTime) : null,
|
||||
Reason = source.Reason ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private enum StoreHolidayType
|
||||
{
|
||||
Closed = 1,
|
||||
Special = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 门店自提设置模块。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||
public sealed class StorePickupController(
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取门店自提设置。
|
||||
/// </summary>
|
||||
[HttpGet("pickup")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StorePickupSettingsDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StorePickupSettingsDto>> Get([FromQuery] string storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var setting = await dbContext.StorePickupSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == parsedStoreId, cancellationToken);
|
||||
|
||||
var slots = await dbContext.StorePickupSlots
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.StartTime)
|
||||
.ThenBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var fineRule = ParseFineRule(setting?.FineRuleJson);
|
||||
var previewDays = BuildPreviewDays(fineRule);
|
||||
|
||||
var response = new StorePickupSettingsDto
|
||||
{
|
||||
StoreId = parsedStoreId.ToString(),
|
||||
Mode = StoreApiHelpers.ToPickupModeText(setting?.Mode ?? StorePickupMode.Big),
|
||||
BasicSettings = new PickupBasicSettingsDto
|
||||
{
|
||||
AllowSameDayPickup = setting?.AllowToday ?? true,
|
||||
BookingDays = setting?.AllowDaysAhead ?? 3,
|
||||
MaxItemsPerOrder = setting?.MaxQuantityPerOrder ?? 20
|
||||
},
|
||||
BigSlots = slots.Select(slot => new PickupSlotDto
|
||||
{
|
||||
Id = slot.Id.ToString(),
|
||||
Name = slot.Name,
|
||||
StartTime = StoreApiHelpers.ToHHmm(slot.StartTime),
|
||||
EndTime = StoreApiHelpers.ToHHmm(slot.EndTime),
|
||||
CutoffMinutes = slot.CutoffMinutes,
|
||||
Capacity = slot.Capacity,
|
||||
ReservedCount = slot.ReservedCount,
|
||||
DayOfWeeks = StoreApiHelpers.DeserializeWeekdays(slot.Weekdays),
|
||||
Enabled = slot.IsEnabled
|
||||
}).ToList(),
|
||||
FineRule = fineRule,
|
||||
PreviewDays = previewDays
|
||||
};
|
||||
|
||||
return ApiResponse<StorePickupSettingsDto>.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存自提基础设置。
|
||||
/// </summary>
|
||||
[HttpPost("pickup/basic/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> SaveBasic([FromBody] SavePickupBasicSettingsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var setting = await EnsurePickupSettingAsync(tenantId, parsedStoreId, cancellationToken);
|
||||
setting.AllowToday = request.BasicSettings.AllowSameDayPickup;
|
||||
setting.AllowDaysAhead = Math.Clamp(request.BasicSettings.BookingDays, 1, 30);
|
||||
setting.MaxQuantityPerOrder = request.BasicSettings.MaxItemsPerOrder;
|
||||
setting.Mode = request.Mode is null ? setting.Mode : StoreApiHelpers.ToPickupMode(request.Mode);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存自提大时段。
|
||||
/// </summary>
|
||||
[HttpPost("pickup/slots/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> SaveSlots([FromBody] SavePickupSlotsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var setting = await EnsurePickupSettingAsync(tenantId, parsedStoreId, cancellationToken);
|
||||
setting.Mode = request.Mode is null ? setting.Mode : StoreApiHelpers.ToPickupMode(request.Mode);
|
||||
|
||||
var existingSlots = await dbContext.StorePickupSlots
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StorePickupSlots.RemoveRange(existingSlots);
|
||||
|
||||
var toCreate = new List<StorePickupSlot>();
|
||||
foreach (var slot in request.Slots ?? [])
|
||||
{
|
||||
var startTime = StoreApiHelpers.ParseRequiredTime(slot.StartTime, "slot.startTime");
|
||||
var endTime = StoreApiHelpers.ParseRequiredTime(slot.EndTime, "slot.endTime");
|
||||
if (startTime >= endTime)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var capacity = Math.Max(0, slot.Capacity);
|
||||
toCreate.Add(new StorePickupSlot
|
||||
{
|
||||
StoreId = parsedStoreId,
|
||||
Name = string.IsNullOrWhiteSpace(slot.Name) ? "时段" : slot.Name.Trim(),
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
CutoffMinutes = Math.Max(0, slot.CutoffMinutes),
|
||||
Capacity = capacity,
|
||||
ReservedCount = Math.Clamp(slot.ReservedCount, 0, capacity),
|
||||
Weekdays = StoreApiHelpers.SerializeWeekdays(slot.DayOfWeeks),
|
||||
IsEnabled = slot.Enabled
|
||||
});
|
||||
}
|
||||
|
||||
if (toCreate.Count > 0)
|
||||
{
|
||||
await dbContext.StorePickupSlots.AddRangeAsync(toCreate, cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存自提精细规则。
|
||||
/// </summary>
|
||||
[HttpPost("pickup/fine-rule/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> SaveFineRule([FromBody] SavePickupFineRuleRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var setting = await EnsurePickupSettingAsync(tenantId, parsedStoreId, cancellationToken);
|
||||
setting.Mode = request.Mode is null ? setting.Mode : StoreApiHelpers.ToPickupMode(request.Mode);
|
||||
|
||||
var normalizedRule = NormalizeFineRule(request.FineRule);
|
||||
setting.FineRuleJson = JsonSerializer.Serialize(normalizedRule, StoreApiHelpers.JsonOptions);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制自提设置。
|
||||
/// </summary>
|
||||
[HttpPost("pickup/copy")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CopyStorePickupSettingsResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CopyStorePickupSettingsResult>> Copy([FromBody] CopyStorePickupSettingsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.SourceStoreId, "sourceStoreId");
|
||||
var targetStoreIds = StoreApiHelpers.ParseSnowflakeList(request.TargetStoreIds);
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, sourceStoreId, cancellationToken);
|
||||
|
||||
var accessibleTargetIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
targetStoreIds,
|
||||
cancellationToken);
|
||||
accessibleTargetIds.Remove(sourceStoreId);
|
||||
|
||||
if (accessibleTargetIds.Count == 0)
|
||||
{
|
||||
return ApiResponse<CopyStorePickupSettingsResult>.Ok(new CopyStorePickupSettingsResult
|
||||
{
|
||||
CopiedCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
var sourceSetting = await dbContext.StorePickupSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == sourceStoreId, cancellationToken);
|
||||
var sourceSlots = await dbContext.StorePickupSlots
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var targetSettings = await dbContext.StorePickupSettings
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
var targetSettingMap = targetSettings.ToDictionary(x => x.StoreId);
|
||||
|
||||
foreach (var targetStoreId in accessibleTargetIds)
|
||||
{
|
||||
if (!targetSettingMap.TryGetValue(targetStoreId, out var targetSetting))
|
||||
{
|
||||
targetSetting = new StorePickupSetting
|
||||
{
|
||||
StoreId = targetStoreId
|
||||
};
|
||||
await dbContext.StorePickupSettings.AddAsync(targetSetting, cancellationToken);
|
||||
}
|
||||
|
||||
targetSetting.AllowToday = sourceSetting?.AllowToday ?? true;
|
||||
targetSetting.AllowDaysAhead = sourceSetting?.AllowDaysAhead ?? 3;
|
||||
targetSetting.DefaultCutoffMinutes = sourceSetting?.DefaultCutoffMinutes ?? 30;
|
||||
targetSetting.MaxQuantityPerOrder = sourceSetting?.MaxQuantityPerOrder ?? 20;
|
||||
targetSetting.Mode = sourceSetting?.Mode ?? StorePickupMode.Big;
|
||||
targetSetting.FineRuleJson = sourceSetting?.FineRuleJson;
|
||||
}
|
||||
|
||||
var targetSlots = await dbContext.StorePickupSlots
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StorePickupSlots.RemoveRange(targetSlots);
|
||||
|
||||
var clonedSlots = accessibleTargetIds
|
||||
.SelectMany(targetStoreId => sourceSlots.Select(slot => new StorePickupSlot
|
||||
{
|
||||
StoreId = targetStoreId,
|
||||
Name = slot.Name,
|
||||
StartTime = slot.StartTime,
|
||||
EndTime = slot.EndTime,
|
||||
CutoffMinutes = slot.CutoffMinutes,
|
||||
Capacity = slot.Capacity,
|
||||
ReservedCount = slot.ReservedCount,
|
||||
Weekdays = slot.Weekdays,
|
||||
IsEnabled = slot.IsEnabled
|
||||
}))
|
||||
.ToList();
|
||||
|
||||
if (clonedSlots.Count > 0)
|
||||
{
|
||||
await dbContext.StorePickupSlots.AddRangeAsync(clonedSlots, cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ApiResponse<CopyStorePickupSettingsResult>.Ok(new CopyStorePickupSettingsResult
|
||||
{
|
||||
CopiedCount = accessibleTargetIds.Count
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<StorePickupSetting> EnsurePickupSettingAsync(long tenantId, long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var setting = await dbContext.StorePickupSettings
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == storeId, cancellationToken);
|
||||
if (setting is not null)
|
||||
{
|
||||
return setting;
|
||||
}
|
||||
|
||||
setting = new StorePickupSetting
|
||||
{
|
||||
StoreId = storeId
|
||||
};
|
||||
await dbContext.StorePickupSettings.AddAsync(setting, cancellationToken);
|
||||
return setting;
|
||||
}
|
||||
|
||||
private static PickupFineRuleDto ParseFineRule(string? raw)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<PickupFineRuleDto>(raw, StoreApiHelpers.JsonOptions);
|
||||
if (parsed is not null)
|
||||
{
|
||||
return NormalizeFineRule(parsed);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略反序列化异常,回落默认配置
|
||||
}
|
||||
}
|
||||
|
||||
return new PickupFineRuleDto
|
||||
{
|
||||
IntervalMinutes = 30,
|
||||
SlotCapacity = 5,
|
||||
DayStartTime = "09:00",
|
||||
DayEndTime = "20:30",
|
||||
MinAdvanceHours = 2,
|
||||
DayOfWeeks = [0, 1, 2, 3, 4, 5, 6]
|
||||
};
|
||||
}
|
||||
|
||||
private static PickupFineRuleDto NormalizeFineRule(PickupFineRuleDto source)
|
||||
{
|
||||
var start = string.IsNullOrWhiteSpace(source.DayStartTime) ? "09:00" : source.DayStartTime;
|
||||
var end = string.IsNullOrWhiteSpace(source.DayEndTime) ? "20:30" : source.DayEndTime;
|
||||
|
||||
return new PickupFineRuleDto
|
||||
{
|
||||
IntervalMinutes = Math.Clamp(source.IntervalMinutes, 5, 180),
|
||||
SlotCapacity = Math.Clamp(source.SlotCapacity, 1, 999),
|
||||
DayStartTime = StoreApiHelpers.ToHHmm(StoreApiHelpers.ParseRequiredTime(start, "fineRule.dayStartTime")),
|
||||
DayEndTime = StoreApiHelpers.ToHHmm(StoreApiHelpers.ParseRequiredTime(end, "fineRule.dayEndTime")),
|
||||
MinAdvanceHours = Math.Clamp(source.MinAdvanceHours, 0, 72),
|
||||
DayOfWeeks = (source.DayOfWeeks ?? [])
|
||||
.Distinct()
|
||||
.Where(x => x is >= 0 and <= 6)
|
||||
.OrderBy(x => x)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static List<PickupPreviewDayDto> BuildPreviewDays(PickupFineRuleDto fineRule)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var startMinutes = StoreApiHelpers.ParseRequiredTime(fineRule.DayStartTime, "fineRule.dayStartTime").Hours * 60
|
||||
+ StoreApiHelpers.ParseRequiredTime(fineRule.DayStartTime, "fineRule.dayStartTime").Minutes;
|
||||
var endMinutes = StoreApiHelpers.ParseRequiredTime(fineRule.DayEndTime, "fineRule.dayEndTime").Hours * 60
|
||||
+ StoreApiHelpers.ParseRequiredTime(fineRule.DayEndTime, "fineRule.dayEndTime").Minutes;
|
||||
if (fineRule.IntervalMinutes <= 0 || endMinutes <= startMinutes)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var results = new List<PickupPreviewDayDto>();
|
||||
for (var offset = 0; offset < 3; offset++)
|
||||
{
|
||||
var date = now.Date.AddDays(offset);
|
||||
var uiDayOfWeek = StoreApiHelpers.DotNetDayOfWeekToUi(date.DayOfWeek);
|
||||
var enabled = fineRule.DayOfWeeks.Contains(uiDayOfWeek);
|
||||
var dateText = StoreApiHelpers.ToDateOnly(date);
|
||||
|
||||
var subLabel = $"{GetWeekdayLabel(uiDayOfWeek)} {(offset == 0 ? "今天" : offset == 1 ? "明天" : "后天")}";
|
||||
var slots = enabled
|
||||
? BuildPreviewSlots(date, startMinutes, endMinutes, fineRule.IntervalMinutes, fineRule.SlotCapacity, fineRule.MinAdvanceHours)
|
||||
: [];
|
||||
|
||||
results.Add(new PickupPreviewDayDto
|
||||
{
|
||||
Date = dateText,
|
||||
Label = $"{date.Month}/{date.Day}",
|
||||
SubLabel = subLabel,
|
||||
Slots = slots
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static List<PickupPreviewSlotDto> BuildPreviewSlots(
|
||||
DateTime date,
|
||||
int startMinutes,
|
||||
int endMinutes,
|
||||
int intervalMinutes,
|
||||
int slotCapacity,
|
||||
int minAdvanceHours)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var results = new List<PickupPreviewSlotDto>();
|
||||
for (var minutes = startMinutes; minutes <= endMinutes; minutes += intervalMinutes)
|
||||
{
|
||||
var slotHour = minutes / 60;
|
||||
var slotMinute = minutes % 60;
|
||||
var slotTime = new TimeSpan(slotHour, slotMinute, 0);
|
||||
var slotDateTime = date.Date.Add(slotTime);
|
||||
var hash = GetStableHash($"{StoreApiHelpers.ToDateOnly(date)}|{slotHour:00}:{slotMinute:00}");
|
||||
var booked = hash % (slotCapacity + 1);
|
||||
if (hash % 7 == 0)
|
||||
{
|
||||
booked = slotCapacity;
|
||||
}
|
||||
else if (hash % 5 == 0)
|
||||
{
|
||||
booked = Math.Max(0, slotCapacity - 1);
|
||||
}
|
||||
|
||||
var remaining = Math.Max(0, slotCapacity - booked);
|
||||
var status = slotDateTime <= now.AddHours(minAdvanceHours)
|
||||
? "expired"
|
||||
: remaining == 0
|
||||
? "full"
|
||||
: remaining <= 1
|
||||
? "almost"
|
||||
: "available";
|
||||
|
||||
results.Add(new PickupPreviewSlotDto
|
||||
{
|
||||
Time = $"{slotHour:00}:{slotMinute:00}",
|
||||
RemainingCount = remaining,
|
||||
Status = status
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static int GetStableHash(string value)
|
||||
{
|
||||
var hash = 0;
|
||||
foreach (var ch in value)
|
||||
{
|
||||
hash = (hash * 31 + ch) & int.MaxValue;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static string GetWeekdayLabel(int dayOfWeek)
|
||||
{
|
||||
return dayOfWeek switch
|
||||
{
|
||||
0 => "周一",
|
||||
1 => "周二",
|
||||
2 => "周三",
|
||||
3 => "周四",
|
||||
4 => "周五",
|
||||
5 => "周六",
|
||||
6 => "周日",
|
||||
_ => "周一"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Store;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 门店员工与排班模块。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||
public sealed class StoreStaffController(
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取员工分页列表。
|
||||
/// </summary>
|
||||
[HttpGet("staff")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PaginatedResultDto<StoreStaffItemDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PaginatedResultDto<StoreStaffItemDto>>> List(
|
||||
[FromQuery] string storeId,
|
||||
[FromQuery] string? keyword,
|
||||
[FromQuery] string? roleType,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 10,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var normalizedKeyword = keyword?.Trim();
|
||||
StaffRoleType? normalizedRoleType = string.IsNullOrWhiteSpace(roleType) ? null : StoreApiHelpers.ToStaffRoleType(roleType);
|
||||
StaffStatus? normalizedStatus = string.IsNullOrWhiteSpace(status) ? null : StoreApiHelpers.ToStaffStatus(status);
|
||||
var normalizedPage = Math.Max(1, page);
|
||||
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
|
||||
|
||||
var query = dbContext.MerchantStaff
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
{
|
||||
var lowered = normalizedKeyword.ToLowerInvariant();
|
||||
query = query.Where(x =>
|
||||
x.Name.ToLower().Contains(lowered) ||
|
||||
x.Phone.Contains(normalizedKeyword) ||
|
||||
(x.Email != null && x.Email.ToLower().Contains(lowered)));
|
||||
}
|
||||
|
||||
if (normalizedRoleType.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.RoleType == normalizedRoleType.Value);
|
||||
}
|
||||
|
||||
if (normalizedStatus.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == normalizedStatus.Value);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var staffs = await query
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ThenBy(x => x.Name)
|
||||
.Skip((normalizedPage - 1) * normalizedPageSize)
|
||||
.Take(normalizedPageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var result = new PaginatedResultDto<StoreStaffItemDto>
|
||||
{
|
||||
Items = staffs.Select(MapStaff).ToList(),
|
||||
Total = total,
|
||||
Page = normalizedPage,
|
||||
PageSize = normalizedPageSize
|
||||
};
|
||||
|
||||
return ApiResponse<PaginatedResultDto<StoreStaffItemDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存员工。
|
||||
/// </summary>
|
||||
[HttpPost("staff/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreStaffItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreStaffItemDto>> Save([FromBody] SaveStoreStaffRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
var store = await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var staffId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id);
|
||||
var roleType = StoreApiHelpers.ToStaffRoleType(request.RoleType);
|
||||
var staffStatus = StoreApiHelpers.ToStaffStatus(request.Status);
|
||||
var permissions = NormalizePermissions(request.Permissions, roleType);
|
||||
|
||||
MerchantStaff? entity = null;
|
||||
if (staffId.HasValue)
|
||||
{
|
||||
entity = await dbContext.MerchantStaff.FirstOrDefaultAsync(
|
||||
x => x.Id == staffId.Value && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
entity = new MerchantStaff
|
||||
{
|
||||
MerchantId = store.MerchantId,
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.MerchantStaff.AddAsync(entity, cancellationToken);
|
||||
}
|
||||
|
||||
entity.Name = request.Name.Trim();
|
||||
entity.Phone = request.Phone.Trim();
|
||||
entity.Email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim();
|
||||
entity.RoleType = roleType;
|
||||
entity.Status = staffStatus;
|
||||
entity.PermissionsJson = JsonSerializer.Serialize(permissions, StoreApiHelpers.JsonOptions);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var template = await GetOrCreateTemplateDtoAsync(tenantId, parsedStoreId, cancellationToken);
|
||||
await EnsureStaffScheduleAsync(tenantId, parsedStoreId, entity, template, cancellationToken);
|
||||
|
||||
return ApiResponse<StoreStaffItemDto>.Ok(MapStaff(entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除员工。
|
||||
/// </summary>
|
||||
[HttpPost("staff/delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Delete([FromBody] DeleteStoreStaffRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var staffId = StoreApiHelpers.ParseRequiredSnowflake(request.StaffId, "staffId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var staff = await dbContext.MerchantStaff.FirstOrDefaultAsync(
|
||||
x => x.Id == staffId && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
if (staff is null)
|
||||
{
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
dbContext.MerchantStaff.Remove(staff);
|
||||
|
||||
var schedules = await dbContext.StoreStaffWeeklySchedules
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId && x.StaffId == staffId)
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StoreStaffWeeklySchedules.RemoveRange(schedules);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取门店排班配置。
|
||||
/// </summary>
|
||||
[HttpGet("staff/schedule")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreStaffScheduleDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreStaffScheduleDto>> GetSchedule(
|
||||
[FromQuery] string storeId,
|
||||
[FromQuery] string? weekStartDate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var template = await GetOrCreateTemplateDtoAsync(tenantId, parsedStoreId, cancellationToken);
|
||||
var staffs = await dbContext.MerchantStaff
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ThenBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var scheduleRows = await dbContext.StoreStaffWeeklySchedules
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var scheduleMap = scheduleRows
|
||||
.GroupBy(x => x.StaffId)
|
||||
.ToDictionary(x => x.Key, x => x.ToList());
|
||||
|
||||
var schedules = staffs.Select(staff =>
|
||||
{
|
||||
var shifts = scheduleMap.TryGetValue(staff.Id, out var rows)
|
||||
? NormalizeRowsToShifts(rows, staff, template)
|
||||
: CreateDefaultWeekByRole(staff.RoleType, template);
|
||||
|
||||
if (staff.Status == StaffStatus.Resigned)
|
||||
{
|
||||
shifts = CreateOffWeekShifts();
|
||||
}
|
||||
|
||||
return new StaffScheduleDto
|
||||
{
|
||||
StaffId = staff.Id.ToString(),
|
||||
Shifts = shifts
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return ApiResponse<StoreStaffScheduleDto>.Ok(new StoreStaffScheduleDto
|
||||
{
|
||||
StoreId = parsedStoreId.ToString(),
|
||||
WeekStartDate = StoreApiHelpers.ResolveWeekStartDate(weekStartDate),
|
||||
Templates = template,
|
||||
Schedules = schedules
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存班次模板。
|
||||
/// </summary>
|
||||
[HttpPost("staff/template/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreShiftTemplatesDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreShiftTemplatesDto>> SaveTemplate(
|
||||
[FromBody] SaveStoreStaffTemplatesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var normalizedTemplate = NormalizeTemplate(request.Templates);
|
||||
var templateEntity = await dbContext.StoreStaffTemplates.FirstOrDefaultAsync(
|
||||
x => x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
if (templateEntity is null)
|
||||
{
|
||||
templateEntity = new StoreStaffTemplate
|
||||
{
|
||||
StoreId = parsedStoreId
|
||||
};
|
||||
await dbContext.StoreStaffTemplates.AddAsync(templateEntity, cancellationToken);
|
||||
}
|
||||
|
||||
templateEntity.MorningStartTime = StoreApiHelpers.ParseRequiredTime(normalizedTemplate.Morning.StartTime, "templates.morning.startTime");
|
||||
templateEntity.MorningEndTime = StoreApiHelpers.ParseRequiredTime(normalizedTemplate.Morning.EndTime, "templates.morning.endTime");
|
||||
templateEntity.EveningStartTime = StoreApiHelpers.ParseRequiredTime(normalizedTemplate.Evening.StartTime, "templates.evening.startTime");
|
||||
templateEntity.EveningEndTime = StoreApiHelpers.ParseRequiredTime(normalizedTemplate.Evening.EndTime, "templates.evening.endTime");
|
||||
templateEntity.FullStartTime = StoreApiHelpers.ParseRequiredTime(normalizedTemplate.Full.StartTime, "templates.full.startTime");
|
||||
templateEntity.FullEndTime = StoreApiHelpers.ParseRequiredTime(normalizedTemplate.Full.EndTime, "templates.full.endTime");
|
||||
|
||||
var scheduleRows = await dbContext.StoreStaffWeeklySchedules
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var row in scheduleRows)
|
||||
{
|
||||
var (start, end) = ResolveShiftTimeRange(row.ShiftType, normalizedTemplate);
|
||||
row.StartTime = start;
|
||||
row.EndTime = end;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ApiResponse<StoreShiftTemplatesDto>.Ok(normalizedTemplate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存单员工排班。
|
||||
/// </summary>
|
||||
[HttpPost("staff/schedule/personal/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StaffScheduleDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StaffScheduleDto>> SavePersonalSchedule(
|
||||
[FromBody] SaveStoreStaffPersonalScheduleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var parsedStaffId = StoreApiHelpers.ParseRequiredSnowflake(request.StaffId, "staffId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var staff = await dbContext.MerchantStaff.FirstOrDefaultAsync(
|
||||
x => x.Id == parsedStaffId && x.TenantId == tenantId && x.StoreId == parsedStoreId,
|
||||
cancellationToken);
|
||||
if (staff is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "员工不存在");
|
||||
}
|
||||
|
||||
var template = await GetOrCreateTemplateDtoAsync(tenantId, parsedStoreId, cancellationToken);
|
||||
var existingRows = await dbContext.StoreStaffWeeklySchedules
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId && x.StaffId == parsedStaffId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var fallback = NormalizeRowsToShifts(existingRows, staff, template);
|
||||
var shifts = staff.Status == StaffStatus.Resigned
|
||||
? CreateOffWeekShifts()
|
||||
: NormalizeShifts(request.Shifts, fallback, template);
|
||||
|
||||
dbContext.StoreStaffWeeklySchedules.RemoveRange(existingRows);
|
||||
await dbContext.StoreStaffWeeklySchedules.AddRangeAsync(
|
||||
ToWeeklyEntities(parsedStoreId, parsedStaffId, shifts),
|
||||
cancellationToken);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ApiResponse<StaffScheduleDto>.Ok(new StaffScheduleDto
|
||||
{
|
||||
StaffId = parsedStaffId.ToString(),
|
||||
Shifts = shifts
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存门店周排班。
|
||||
/// </summary>
|
||||
[HttpPost("staff/schedule/weekly/save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreStaffScheduleDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreStaffScheduleDto>> SaveWeeklySchedule(
|
||||
[FromBody] SaveStoreStaffWeeklyScheduleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, "storeId");
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||
|
||||
var staffs = await dbContext.MerchantStaff
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ThenBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
var staffMap = staffs.ToDictionary(x => x.Id);
|
||||
|
||||
var template = await GetOrCreateTemplateDtoAsync(tenantId, parsedStoreId, cancellationToken);
|
||||
var existingRows = await dbContext.StoreStaffWeeklySchedules
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == parsedStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingMap = existingRows
|
||||
.GroupBy(x => x.StaffId)
|
||||
.ToDictionary(
|
||||
x => x.Key,
|
||||
x => NormalizeRowsToShifts(x.ToList(), staffMap.GetValueOrDefault(x.Key), template));
|
||||
|
||||
var incomingMap = new Dictionary<long, List<StaffDayShiftDto>>();
|
||||
foreach (var schedule in request.Schedules ?? [])
|
||||
{
|
||||
var staffId = StoreApiHelpers.ParseSnowflakeOrNull(schedule.StaffId);
|
||||
if (!staffId.HasValue || !staffMap.TryGetValue(staffId.Value, out var staff))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (staff.Status == StaffStatus.Resigned)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fallback = existingMap.GetValueOrDefault(staffId.Value) ?? CreateDefaultWeekByRole(staff.RoleType, template);
|
||||
incomingMap[staffId.Value] = NormalizeShifts(schedule.Shifts, fallback, template);
|
||||
}
|
||||
|
||||
var finalSchedules = new List<StaffScheduleDto>();
|
||||
foreach (var staff in staffs)
|
||||
{
|
||||
List<StaffDayShiftDto> shifts;
|
||||
if (staff.Status == StaffStatus.Resigned)
|
||||
{
|
||||
shifts = CreateOffWeekShifts();
|
||||
}
|
||||
else if (incomingMap.TryGetValue(staff.Id, out var incomingShifts))
|
||||
{
|
||||
shifts = incomingShifts;
|
||||
}
|
||||
else
|
||||
{
|
||||
shifts = existingMap.GetValueOrDefault(staff.Id) ?? CreateDefaultWeekByRole(staff.RoleType, template);
|
||||
}
|
||||
|
||||
finalSchedules.Add(new StaffScheduleDto
|
||||
{
|
||||
StaffId = staff.Id.ToString(),
|
||||
Shifts = shifts
|
||||
});
|
||||
}
|
||||
|
||||
dbContext.StoreStaffWeeklySchedules.RemoveRange(existingRows);
|
||||
var entities = finalSchedules
|
||||
.SelectMany(x => ToWeeklyEntities(parsedStoreId, long.Parse(x.StaffId), x.Shifts))
|
||||
.ToList();
|
||||
await dbContext.StoreStaffWeeklySchedules.AddRangeAsync(entities, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ApiResponse<StoreStaffScheduleDto>.Ok(new StoreStaffScheduleDto
|
||||
{
|
||||
StoreId = parsedStoreId.ToString(),
|
||||
WeekStartDate = StoreApiHelpers.ResolveWeekStartDate(null),
|
||||
Templates = template,
|
||||
Schedules = finalSchedules
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制班次模板与排班。
|
||||
/// </summary>
|
||||
[HttpPost("staff/copy")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CopyStoreStaffScheduleResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CopyStoreStaffScheduleResult>> Copy([FromBody] CopyStoreStaffScheduleRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.SourceStoreId, "sourceStoreId");
|
||||
var targetStoreIds = StoreApiHelpers.ParseSnowflakeList(request.TargetStoreIds);
|
||||
var copyScope = string.IsNullOrWhiteSpace(request.CopyScope) ? "template_and_schedule" : request.CopyScope;
|
||||
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, sourceStoreId, cancellationToken);
|
||||
|
||||
if (!string.Equals(copyScope, "template_and_schedule", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ApiResponse<CopyStoreStaffScheduleResult>.Ok(new CopyStoreStaffScheduleResult
|
||||
{
|
||||
CopiedCount = 0,
|
||||
CopyScope = "template_and_schedule"
|
||||
});
|
||||
}
|
||||
|
||||
var accessibleTargetIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
targetStoreIds,
|
||||
cancellationToken);
|
||||
accessibleTargetIds.Remove(sourceStoreId);
|
||||
|
||||
if (accessibleTargetIds.Count == 0)
|
||||
{
|
||||
return ApiResponse<CopyStoreStaffScheduleResult>.Ok(new CopyStoreStaffScheduleResult
|
||||
{
|
||||
CopiedCount = 0,
|
||||
CopyScope = "template_and_schedule"
|
||||
});
|
||||
}
|
||||
|
||||
var sourceTemplate = await GetOrCreateTemplateDtoAsync(tenantId, sourceStoreId, cancellationToken);
|
||||
var sourceRows = await dbContext.StoreStaffWeeklySchedules
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var sourceStaffs = await dbContext.MerchantStaff
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == sourceStoreId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var sourceStaffMap = sourceStaffs.ToDictionary(x => x.Id);
|
||||
var sourceScheduleMap = sourceRows
|
||||
.GroupBy(x => x.StaffId)
|
||||
.ToDictionary(
|
||||
x => x.Key,
|
||||
x => NormalizeRowsToShifts(x.ToList(), sourceStaffMap.GetValueOrDefault(x.Key), sourceTemplate));
|
||||
var sourceScheduleSequence = sourceStaffs
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ThenBy(x => x.Name)
|
||||
.ThenBy(x => x.Id)
|
||||
.Select(staff => staff.Status == StaffStatus.Resigned
|
||||
? CreateOffWeekShifts()
|
||||
: sourceScheduleMap.GetValueOrDefault(staff.Id) ?? CreateDefaultWeekByRole(staff.RoleType, sourceTemplate))
|
||||
.ToList();
|
||||
|
||||
var targetTemplates = await dbContext.StoreStaffTemplates
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
var targetTemplateMap = targetTemplates.ToDictionary(x => x.StoreId);
|
||||
|
||||
foreach (var targetStoreId in accessibleTargetIds)
|
||||
{
|
||||
if (!targetTemplateMap.TryGetValue(targetStoreId, out var targetTemplateEntity))
|
||||
{
|
||||
targetTemplateEntity = new StoreStaffTemplate
|
||||
{
|
||||
StoreId = targetStoreId
|
||||
};
|
||||
await dbContext.StoreStaffTemplates.AddAsync(targetTemplateEntity, cancellationToken);
|
||||
}
|
||||
|
||||
targetTemplateEntity.MorningStartTime = StoreApiHelpers.ParseRequiredTime(sourceTemplate.Morning.StartTime, "templates.morning.startTime");
|
||||
targetTemplateEntity.MorningEndTime = StoreApiHelpers.ParseRequiredTime(sourceTemplate.Morning.EndTime, "templates.morning.endTime");
|
||||
targetTemplateEntity.EveningStartTime = StoreApiHelpers.ParseRequiredTime(sourceTemplate.Evening.StartTime, "templates.evening.startTime");
|
||||
targetTemplateEntity.EveningEndTime = StoreApiHelpers.ParseRequiredTime(sourceTemplate.Evening.EndTime, "templates.evening.endTime");
|
||||
targetTemplateEntity.FullStartTime = StoreApiHelpers.ParseRequiredTime(sourceTemplate.Full.StartTime, "templates.full.startTime");
|
||||
targetTemplateEntity.FullEndTime = StoreApiHelpers.ParseRequiredTime(sourceTemplate.Full.EndTime, "templates.full.endTime");
|
||||
}
|
||||
|
||||
var targetRows = await dbContext.StoreStaffWeeklySchedules
|
||||
.Where(x => x.TenantId == tenantId && accessibleTargetIds.Contains(x.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.StoreStaffWeeklySchedules.RemoveRange(targetRows);
|
||||
|
||||
var targetStaffs = await dbContext.MerchantStaff
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId.HasValue && accessibleTargetIds.Contains(x.StoreId.Value))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var entities = new List<StoreStaffWeeklySchedule>();
|
||||
foreach (var targetStoreId in accessibleTargetIds)
|
||||
{
|
||||
var targetStoreStaffs = targetStaffs
|
||||
.Where(x => x.StoreId == targetStoreId)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ThenBy(x => x.Name)
|
||||
.ThenBy(x => x.Id)
|
||||
.ToList();
|
||||
|
||||
for (var index = 0; index < targetStoreStaffs.Count; index++)
|
||||
{
|
||||
var staff = targetStoreStaffs[index];
|
||||
var shifts = staff.Status == StaffStatus.Resigned
|
||||
? CreateOffWeekShifts()
|
||||
: sourceScheduleSequence.Count > 0
|
||||
? sourceScheduleSequence[index % sourceScheduleSequence.Count]
|
||||
: CreateDefaultWeekByRole(staff.RoleType, sourceTemplate);
|
||||
|
||||
entities.AddRange(ToWeeklyEntities(targetStoreId, staff.Id, shifts));
|
||||
}
|
||||
}
|
||||
|
||||
if (entities.Count > 0)
|
||||
{
|
||||
await dbContext.StoreStaffWeeklySchedules.AddRangeAsync(entities, cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ApiResponse<CopyStoreStaffScheduleResult>.Ok(new CopyStoreStaffScheduleResult
|
||||
{
|
||||
CopiedCount = accessibleTargetIds.Count,
|
||||
CopyScope = "template_and_schedule"
|
||||
});
|
||||
}
|
||||
private static StoreStaffItemDto MapStaff(MerchantStaff source)
|
||||
{
|
||||
var permissions = ParsePermissions(source.PermissionsJson, source.RoleType);
|
||||
var hiredAt = source.CreatedAt == default ? DateTime.UtcNow : source.CreatedAt;
|
||||
|
||||
return new StoreStaffItemDto
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
Phone = source.Phone,
|
||||
Email = source.Email ?? string.Empty,
|
||||
RoleType = StoreApiHelpers.ToStaffRoleTypeText(source.RoleType),
|
||||
Status = StoreApiHelpers.ToStaffStatusText(source.Status),
|
||||
Permissions = permissions,
|
||||
AvatarColor = StoreApiHelpers.ResolveAvatarColor($"{source.Name}-{source.Id}"),
|
||||
HiredAt = StoreApiHelpers.ToDateOnly(hiredAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string> ParsePermissions(string? rawJson, StaffRoleType roleType)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(rawJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<List<string>>(rawJson, StoreApiHelpers.JsonOptions);
|
||||
if (parsed is not null)
|
||||
{
|
||||
return NormalizePermissions(parsed, roleType);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略权限反序列化异常并回落默认值
|
||||
}
|
||||
}
|
||||
|
||||
return roleType is StaffRoleType.Admin or StaffRoleType.Operator ? ["全部权限"] : [];
|
||||
}
|
||||
|
||||
private static List<string> NormalizePermissions(IEnumerable<string>? source, StaffRoleType roleType)
|
||||
{
|
||||
var normalized = (source ?? [])
|
||||
.Select(x => x?.Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(x => x!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (roleType is StaffRoleType.Admin or StaffRoleType.Operator && normalized.Count == 0)
|
||||
{
|
||||
normalized.Add("全部权限");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private async Task<StoreShiftTemplatesDto> GetOrCreateTemplateDtoAsync(long tenantId, long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await dbContext.StoreStaffTemplates
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StoreId == storeId, cancellationToken);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
return CreateDefaultTemplate();
|
||||
}
|
||||
|
||||
return new StoreShiftTemplatesDto
|
||||
{
|
||||
Morning = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = StoreApiHelpers.ToHHmm(entity.MorningStartTime),
|
||||
EndTime = StoreApiHelpers.ToHHmm(entity.MorningEndTime)
|
||||
},
|
||||
Evening = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = StoreApiHelpers.ToHHmm(entity.EveningStartTime),
|
||||
EndTime = StoreApiHelpers.ToHHmm(entity.EveningEndTime)
|
||||
},
|
||||
Full = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = StoreApiHelpers.ToHHmm(entity.FullStartTime),
|
||||
EndTime = StoreApiHelpers.ToHHmm(entity.FullEndTime)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async Task EnsureStaffScheduleAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
MerchantStaff staff,
|
||||
StoreShiftTemplatesDto template,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existingRows = await dbContext.StoreStaffWeeklySchedules
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.StaffId == staff.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (existingRows.Count == 0)
|
||||
{
|
||||
var initialShifts = staff.Status == StaffStatus.Resigned
|
||||
? CreateOffWeekShifts()
|
||||
: CreateDefaultWeekByRole(staff.RoleType, template);
|
||||
await dbContext.StoreStaffWeeklySchedules.AddRangeAsync(
|
||||
ToWeeklyEntities(storeId, staff.Id, initialShifts),
|
||||
cancellationToken);
|
||||
}
|
||||
else if (staff.Status == StaffStatus.Resigned)
|
||||
{
|
||||
dbContext.StoreStaffWeeklySchedules.RemoveRange(existingRows);
|
||||
await dbContext.StoreStaffWeeklySchedules.AddRangeAsync(
|
||||
ToWeeklyEntities(storeId, staff.Id, CreateOffWeekShifts()),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static StoreShiftTemplatesDto NormalizeTemplate(StoreShiftTemplatesDto source)
|
||||
{
|
||||
var fallback = CreateDefaultTemplate();
|
||||
return new StoreShiftTemplatesDto
|
||||
{
|
||||
Morning = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = NormalizeTime(source.Morning.StartTime, fallback.Morning.StartTime),
|
||||
EndTime = NormalizeTime(source.Morning.EndTime, fallback.Morning.EndTime)
|
||||
},
|
||||
Evening = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = NormalizeTime(source.Evening.StartTime, fallback.Evening.StartTime),
|
||||
EndTime = NormalizeTime(source.Evening.EndTime, fallback.Evening.EndTime)
|
||||
},
|
||||
Full = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = NormalizeTime(source.Full.StartTime, fallback.Full.StartTime),
|
||||
EndTime = NormalizeTime(source.Full.EndTime, fallback.Full.EndTime)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static List<StaffDayShiftDto> NormalizeRowsToShifts(
|
||||
List<StoreStaffWeeklySchedule> rows,
|
||||
MerchantStaff? staff,
|
||||
StoreShiftTemplatesDto template)
|
||||
{
|
||||
var fallback = staff is null ? CreateOffWeekShifts() : CreateDefaultWeekByRole(staff.RoleType, template);
|
||||
var rowMap = rows.ToDictionary(x => x.DayOfWeek, x => x);
|
||||
var results = new List<StaffDayShiftDto>();
|
||||
|
||||
for (var day = 0; day < 7; day++)
|
||||
{
|
||||
if (!rowMap.TryGetValue(day, out var row))
|
||||
{
|
||||
results.Add(fallback[day]);
|
||||
continue;
|
||||
}
|
||||
|
||||
var shiftType = StoreApiHelpers.ToShiftTypeText(row.ShiftType);
|
||||
if (row.ShiftType == StoreStaffShiftType.Off)
|
||||
{
|
||||
results.Add(new StaffDayShiftDto
|
||||
{
|
||||
DayOfWeek = day,
|
||||
ShiftType = shiftType,
|
||||
StartTime = string.Empty,
|
||||
EndTime = string.Empty
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var (defaultStart, defaultEnd) = ResolveShiftTimeRange(row.ShiftType, template);
|
||||
results.Add(new StaffDayShiftDto
|
||||
{
|
||||
DayOfWeek = day,
|
||||
ShiftType = shiftType,
|
||||
StartTime = StoreApiHelpers.ToHHmm(row.StartTime ?? defaultStart ?? TimeSpan.Zero),
|
||||
EndTime = StoreApiHelpers.ToHHmm(row.EndTime ?? defaultEnd ?? TimeSpan.Zero)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static List<StaffDayShiftDto> NormalizeShifts(
|
||||
IEnumerable<StaffDayShiftDto>? source,
|
||||
List<StaffDayShiftDto> fallback,
|
||||
StoreShiftTemplatesDto template)
|
||||
{
|
||||
var inputMap = (source ?? [])
|
||||
.Where(x => x.DayOfWeek is >= 0 and <= 6)
|
||||
.GroupBy(x => x.DayOfWeek)
|
||||
.ToDictionary(x => x.Key, x => x.Last());
|
||||
|
||||
var normalized = new List<StaffDayShiftDto>();
|
||||
for (var day = 0; day < 7; day++)
|
||||
{
|
||||
var fallbackShift = fallback.FirstOrDefault(x => x.DayOfWeek == day) ?? new StaffDayShiftDto
|
||||
{
|
||||
DayOfWeek = day,
|
||||
ShiftType = "off",
|
||||
StartTime = string.Empty,
|
||||
EndTime = string.Empty
|
||||
};
|
||||
|
||||
if (!inputMap.TryGetValue(day, out var input))
|
||||
{
|
||||
normalized.Add(new StaffDayShiftDto
|
||||
{
|
||||
DayOfWeek = day,
|
||||
ShiftType = fallbackShift.ShiftType,
|
||||
StartTime = fallbackShift.StartTime,
|
||||
EndTime = fallbackShift.EndTime
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var shiftType = StoreApiHelpers.ToShiftType(input.ShiftType);
|
||||
if (shiftType == StoreStaffShiftType.Off)
|
||||
{
|
||||
normalized.Add(new StaffDayShiftDto
|
||||
{
|
||||
DayOfWeek = day,
|
||||
ShiftType = "off",
|
||||
StartTime = string.Empty,
|
||||
EndTime = string.Empty
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var (defaultStart, defaultEnd) = ResolveShiftTimeRange(shiftType, template);
|
||||
normalized.Add(new StaffDayShiftDto
|
||||
{
|
||||
DayOfWeek = day,
|
||||
ShiftType = StoreApiHelpers.ToShiftTypeText(shiftType),
|
||||
StartTime = NormalizeTime(input.StartTime, defaultStart.HasValue ? StoreApiHelpers.ToHHmm(defaultStart.Value) : fallbackShift.StartTime),
|
||||
EndTime = NormalizeTime(input.EndTime, defaultEnd.HasValue ? StoreApiHelpers.ToHHmm(defaultEnd.Value) : fallbackShift.EndTime)
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IEnumerable<StoreStaffWeeklySchedule> ToWeeklyEntities(
|
||||
long storeId,
|
||||
long staffId,
|
||||
IEnumerable<StaffDayShiftDto> shifts)
|
||||
{
|
||||
foreach (var shift in shifts)
|
||||
{
|
||||
var shiftType = StoreApiHelpers.ToShiftType(shift.ShiftType);
|
||||
var (defaultStart, defaultEnd) = ResolveShiftTimeRange(shiftType, CreateDefaultTemplate());
|
||||
var startTime = shiftType == StoreStaffShiftType.Off
|
||||
? null
|
||||
: (TimeSpan?)StoreApiHelpers.ParseRequiredTime(
|
||||
NormalizeTime(shift.StartTime, defaultStart.HasValue ? StoreApiHelpers.ToHHmm(defaultStart.Value) : "09:00"),
|
||||
"shift.startTime");
|
||||
var endTime = shiftType == StoreStaffShiftType.Off
|
||||
? null
|
||||
: (TimeSpan?)StoreApiHelpers.ParseRequiredTime(
|
||||
NormalizeTime(shift.EndTime, defaultEnd.HasValue ? StoreApiHelpers.ToHHmm(defaultEnd.Value) : "21:00"),
|
||||
"shift.endTime");
|
||||
|
||||
yield return new StoreStaffWeeklySchedule
|
||||
{
|
||||
StoreId = storeId,
|
||||
StaffId = staffId,
|
||||
DayOfWeek = shift.DayOfWeek,
|
||||
ShiftType = shiftType,
|
||||
StartTime = startTime,
|
||||
EndTime = endTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static List<StaffDayShiftDto> CreateDefaultWeekByRole(StaffRoleType roleType, StoreShiftTemplatesDto template)
|
||||
{
|
||||
var pattern = roleType switch
|
||||
{
|
||||
StaffRoleType.Admin or StaffRoleType.Operator => new[] { "full", "full", "full", "full", "full", "morning", "off" },
|
||||
StaffRoleType.FrontDesk => new[] { "morning", "morning", "off", "morning", "evening", "full", "full" },
|
||||
StaffRoleType.Courier => new[] { "morning", "evening", "morning", "evening", "morning", "evening", "off" },
|
||||
StaffRoleType.Kitchen => new[] { "full", "full", "evening", "off", "full", "full", "morning" },
|
||||
_ => new[] { "morning", "morning", "off", "morning", "evening", "full", "full" }
|
||||
};
|
||||
|
||||
var results = new List<StaffDayShiftDto>();
|
||||
for (var day = 0; day < 7; day++)
|
||||
{
|
||||
var shiftType = StoreApiHelpers.ToShiftType(pattern[day]);
|
||||
var (start, end) = ResolveShiftTimeRange(shiftType, template);
|
||||
results.Add(new StaffDayShiftDto
|
||||
{
|
||||
DayOfWeek = day,
|
||||
ShiftType = StoreApiHelpers.ToShiftTypeText(shiftType),
|
||||
StartTime = start.HasValue ? StoreApiHelpers.ToHHmm(start.Value) : string.Empty,
|
||||
EndTime = end.HasValue ? StoreApiHelpers.ToHHmm(end.Value) : string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static List<StaffDayShiftDto> CreateOffWeekShifts()
|
||||
{
|
||||
return Enumerable.Range(0, 7).Select(day => new StaffDayShiftDto
|
||||
{
|
||||
DayOfWeek = day,
|
||||
ShiftType = "off",
|
||||
StartTime = string.Empty,
|
||||
EndTime = string.Empty
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static (TimeSpan? Start, TimeSpan? End) ResolveShiftTimeRange(StoreStaffShiftType shiftType, StoreShiftTemplatesDto template)
|
||||
{
|
||||
return shiftType switch
|
||||
{
|
||||
StoreStaffShiftType.Morning => (
|
||||
StoreApiHelpers.ParseRequiredTime(template.Morning.StartTime, "templates.morning.startTime"),
|
||||
StoreApiHelpers.ParseRequiredTime(template.Morning.EndTime, "templates.morning.endTime")),
|
||||
StoreStaffShiftType.Evening => (
|
||||
StoreApiHelpers.ParseRequiredTime(template.Evening.StartTime, "templates.evening.startTime"),
|
||||
StoreApiHelpers.ParseRequiredTime(template.Evening.EndTime, "templates.evening.endTime")),
|
||||
StoreStaffShiftType.Full => (
|
||||
StoreApiHelpers.ParseRequiredTime(template.Full.StartTime, "templates.full.startTime"),
|
||||
StoreApiHelpers.ParseRequiredTime(template.Full.EndTime, "templates.full.endTime")),
|
||||
_ => (null, null)
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeTime(string? value, string fallback)
|
||||
{
|
||||
return TimeSpan.TryParseExact(value, "hh\\:mm", null, out var parsed)
|
||||
? StoreApiHelpers.ToHHmm(parsed)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static StoreShiftTemplatesDto CreateDefaultTemplate()
|
||||
{
|
||||
return new StoreShiftTemplatesDto
|
||||
{
|
||||
Morning = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = "09:00",
|
||||
EndTime = "14:00"
|
||||
},
|
||||
Evening = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = "14:00",
|
||||
EndTime = "21:00"
|
||||
},
|
||||
Full = new ShiftTemplateItemDto
|
||||
{
|
||||
StartTime = "09:00",
|
||||
EndTime = "21:00"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user