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"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,16 @@ public sealed record CreateStoreDeliveryZoneCommand : IRequest<StoreDeliveryZone
|
||||
/// </summary>
|
||||
public int? EstimatedMinutes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 区域颜色。
|
||||
/// </summary>
|
||||
public string? Color { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 优先级。
|
||||
/// </summary>
|
||||
public int Priority { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
|
||||
@@ -43,6 +43,16 @@ public sealed record UpdateStoreDeliveryZoneCommand : IRequest<StoreDeliveryZone
|
||||
/// </summary>
|
||||
public int? EstimatedMinutes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 区域颜色。
|
||||
/// </summary>
|
||||
public string? Color { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 优先级。
|
||||
/// </summary>
|
||||
public int Priority { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
|
||||
@@ -48,4 +48,24 @@ public sealed record UpdateStoreFeeCommand : IRequest<StoreFeeDto>
|
||||
/// 免配送费门槛。
|
||||
/// </summary>
|
||||
public decimal? FreeDeliveryThreshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 餐具费是否启用。
|
||||
/// </summary>
|
||||
public bool CutleryFeeEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 餐具费金额。
|
||||
/// </summary>
|
||||
public decimal CutleryFeeAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加急费是否启用。
|
||||
/// </summary>
|
||||
public bool RushFeeEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加急费金额。
|
||||
/// </summary>
|
||||
public decimal RushFeeAmount { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Commands;
|
||||
|
||||
@@ -32,4 +33,14 @@ public sealed record UpsertStorePickupSettingCommand : IRequest<StorePickupSetti
|
||||
/// 单笔最大份数。
|
||||
/// </summary>
|
||||
public int? MaxQuantityPerOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 自提模式。
|
||||
/// </summary>
|
||||
public StorePickupMode Mode { get; init; } = StorePickupMode.Big;
|
||||
|
||||
/// <summary>
|
||||
/// 精细规则 JSON。
|
||||
/// </summary>
|
||||
public string? FineRuleJson { get; init; }
|
||||
}
|
||||
|
||||
@@ -51,6 +51,16 @@ public sealed record StoreDeliveryZoneDto
|
||||
/// </summary>
|
||||
public int? EstimatedMinutes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 区域颜色。
|
||||
/// </summary>
|
||||
public string? Color { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 优先级。
|
||||
/// </summary>
|
||||
public int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
|
||||
@@ -31,6 +31,26 @@ public sealed record StoreFeeDto
|
||||
/// </summary>
|
||||
public decimal DeliveryFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 餐具费是否启用。
|
||||
/// </summary>
|
||||
public bool CutleryFeeEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 餐具费金额。
|
||||
/// </summary>
|
||||
public decimal CutleryFeeAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加急费是否启用。
|
||||
/// </summary>
|
||||
public bool RushFeeEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加急费金额。
|
||||
/// </summary>
|
||||
public decimal RushFeeAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 打包费模式。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores.Dto;
|
||||
@@ -39,4 +40,14 @@ public sealed record StorePickupSettingDto
|
||||
/// 单笔最大自提份数。
|
||||
/// </summary>
|
||||
public int? MaxQuantityPerOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 自提模式。
|
||||
/// </summary>
|
||||
public StorePickupMode Mode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 精细规则 JSON。
|
||||
/// </summary>
|
||||
public string? FineRuleJson { get; init; }
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ public sealed class CreateStoreDeliveryZoneCommandHandler(
|
||||
MinimumOrderAmount = request.MinimumOrderAmount,
|
||||
DeliveryFee = request.DeliveryFee,
|
||||
EstimatedMinutes = request.EstimatedMinutes,
|
||||
Color = request.Color?.Trim(),
|
||||
Priority = request.Priority,
|
||||
SortOrder = request.SortOrder
|
||||
};
|
||||
|
||||
|
||||
@@ -40,7 +40,11 @@ public sealed class GetStoreFeeQueryHandler(
|
||||
BaseDeliveryFee = 0m,
|
||||
PackagingFeeMode = Domain.Stores.Enums.PackagingFeeMode.Fixed,
|
||||
OrderPackagingFeeMode = Domain.Stores.Enums.OrderPackagingFeeMode.Fixed,
|
||||
FixedPackagingFee = 0m
|
||||
FixedPackagingFee = 0m,
|
||||
CutleryFeeEnabled = false,
|
||||
CutleryFeeAmount = 0m,
|
||||
RushFeeEnabled = false,
|
||||
RushFeeAmount = 0m
|
||||
};
|
||||
return StoreMapping.ToDto(fallback);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ public sealed class GetStorePickupSettingQueryHandler(
|
||||
AllowToday = setting.AllowToday,
|
||||
AllowDaysAhead = setting.AllowDaysAhead,
|
||||
DefaultCutoffMinutes = setting.DefaultCutoffMinutes,
|
||||
MaxQuantityPerOrder = setting.MaxQuantityPerOrder
|
||||
MaxQuantityPerOrder = setting.MaxQuantityPerOrder,
|
||||
Mode = setting.Mode,
|
||||
FineRuleJson = setting.FineRuleJson
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ public sealed class UpdateStoreDeliveryZoneCommandHandler(
|
||||
existing.MinimumOrderAmount = request.MinimumOrderAmount;
|
||||
existing.DeliveryFee = request.DeliveryFee;
|
||||
existing.EstimatedMinutes = request.EstimatedMinutes;
|
||||
existing.Color = request.Color?.Trim();
|
||||
existing.Priority = request.Priority;
|
||||
existing.SortOrder = request.SortOrder;
|
||||
|
||||
// 5. (空行后) 持久化
|
||||
|
||||
@@ -71,6 +71,10 @@ public sealed class UpdateStoreFeeCommandHandler(
|
||||
fee.PackagingFeeTiersJson = null;
|
||||
}
|
||||
fee.FreeDeliveryThreshold = request.FreeDeliveryThreshold;
|
||||
fee.CutleryFeeEnabled = request.CutleryFeeEnabled;
|
||||
fee.CutleryFeeAmount = request.CutleryFeeAmount;
|
||||
fee.RushFeeEnabled = request.RushFeeEnabled;
|
||||
fee.RushFeeAmount = request.RushFeeAmount;
|
||||
|
||||
// 4. (空行后) 保存并返回
|
||||
if (isNew)
|
||||
|
||||
@@ -47,6 +47,8 @@ public sealed class UpsertStorePickupSettingCommandHandler(
|
||||
setting.AllowDaysAhead = request.AllowDaysAhead;
|
||||
setting.DefaultCutoffMinutes = request.DefaultCutoffMinutes;
|
||||
setting.MaxQuantityPerOrder = request.MaxQuantityPerOrder;
|
||||
setting.Mode = request.Mode;
|
||||
setting.FineRuleJson = request.FineRuleJson;
|
||||
await storeRepository.UpdatePickupSettingAsync(setting, cancellationToken);
|
||||
await storeRepository.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("更新门店 {StoreId} 自提配置", request.StoreId);
|
||||
@@ -57,7 +59,9 @@ public sealed class UpsertStorePickupSettingCommandHandler(
|
||||
AllowToday = setting.AllowToday,
|
||||
AllowDaysAhead = setting.AllowDaysAhead,
|
||||
DefaultCutoffMinutes = setting.DefaultCutoffMinutes,
|
||||
MaxQuantityPerOrder = setting.MaxQuantityPerOrder
|
||||
MaxQuantityPerOrder = setting.MaxQuantityPerOrder,
|
||||
Mode = setting.Mode,
|
||||
FineRuleJson = setting.FineRuleJson
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,10 @@ public static class StoreMapping
|
||||
StoreId = fee.StoreId,
|
||||
MinimumOrderAmount = fee.MinimumOrderAmount,
|
||||
DeliveryFee = fee.BaseDeliveryFee,
|
||||
CutleryFeeEnabled = fee.CutleryFeeEnabled,
|
||||
CutleryFeeAmount = fee.CutleryFeeAmount,
|
||||
RushFeeEnabled = fee.RushFeeEnabled,
|
||||
RushFeeAmount = fee.RushFeeAmount,
|
||||
PackagingFeeMode = fee.PackagingFeeMode,
|
||||
OrderPackagingFeeMode = fee.OrderPackagingFeeMode,
|
||||
FixedPackagingFee = fee.FixedPackagingFee,
|
||||
@@ -137,6 +141,8 @@ public static class StoreMapping
|
||||
MinimumOrderAmount = zone.MinimumOrderAmount,
|
||||
DeliveryFee = zone.DeliveryFee,
|
||||
EstimatedMinutes = zone.EstimatedMinutes,
|
||||
Color = zone.Color,
|
||||
Priority = zone.Priority,
|
||||
SortOrder = zone.SortOrder,
|
||||
CreatedAt = zone.CreatedAt
|
||||
};
|
||||
|
||||
@@ -19,6 +19,8 @@ public sealed class CreateStoreDeliveryZoneCommandValidator : AbstractValidator<
|
||||
RuleFor(x => x.MinimumOrderAmount).GreaterThanOrEqualTo(0).When(x => x.MinimumOrderAmount.HasValue);
|
||||
RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).When(x => x.DeliveryFee.HasValue);
|
||||
RuleFor(x => x.EstimatedMinutes).GreaterThan(0).When(x => x.EstimatedMinutes.HasValue);
|
||||
RuleFor(x => x.Color).MaximumLength(32);
|
||||
RuleFor(x => x.Priority).GreaterThanOrEqualTo(0);
|
||||
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ public sealed class UpdateStoreDeliveryZoneCommandValidator : AbstractValidator<
|
||||
RuleFor(x => x.MinimumOrderAmount).GreaterThanOrEqualTo(0).When(x => x.MinimumOrderAmount.HasValue);
|
||||
RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).When(x => x.DeliveryFee.HasValue);
|
||||
RuleFor(x => x.EstimatedMinutes).GreaterThan(0).When(x => x.EstimatedMinutes.HasValue);
|
||||
RuleFor(x => x.Color).MaximumLength(32);
|
||||
RuleFor(x => x.Priority).GreaterThanOrEqualTo(0);
|
||||
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@ public sealed class UpdateStoreFeeCommandValidator : AbstractValidator<UpdateSto
|
||||
.Must(fee => !fee.HasValue || fee.Value >= 0)
|
||||
.WithMessage("固定打包费不能为负数");
|
||||
|
||||
RuleFor(x => x.CutleryFeeAmount)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.LessThanOrEqualTo(99.99m);
|
||||
|
||||
RuleFor(x => x.RushFeeAmount)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.LessThanOrEqualTo(99.99m);
|
||||
|
||||
RuleFor(x => x)
|
||||
.Custom((command, context) =>
|
||||
{
|
||||
|
||||
@@ -17,5 +17,6 @@ public sealed class UpsertStorePickupSettingCommandValidator : AbstractValidator
|
||||
RuleFor(x => x.AllowDaysAhead).GreaterThanOrEqualTo(0);
|
||||
RuleFor(x => x.DefaultCutoffMinutes).GreaterThanOrEqualTo(0);
|
||||
RuleFor(x => x.MaxQuantityPerOrder).GreaterThan(0).When(x => x.MaxQuantityPerOrder.HasValue);
|
||||
RuleFor(x => x.FineRuleJson).MaximumLength(20000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Stores.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 门店配送设置聚合。
|
||||
/// </summary>
|
||||
public sealed class StoreDeliverySetting : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 配送模式。
|
||||
/// </summary>
|
||||
public StoreDeliveryMode Mode { get; set; } = StoreDeliveryMode.Polygon;
|
||||
|
||||
/// <summary>
|
||||
/// 配送时效加成(分钟)。
|
||||
/// </summary>
|
||||
public int EtaAdjustmentMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 免配送费门槛。
|
||||
/// </summary>
|
||||
public decimal? FreeDeliveryThreshold { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每小时配送上限。
|
||||
/// </summary>
|
||||
public int HourlyCapacityLimit { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 最大配送距离(公里)。
|
||||
/// </summary>
|
||||
public decimal MaxDeliveryDistance { get; set; } = 5m;
|
||||
|
||||
/// <summary>
|
||||
/// 半径梯度配置 JSON。
|
||||
/// </summary>
|
||||
public string? RadiusTiersJson { get; set; }
|
||||
}
|
||||
@@ -37,6 +37,16 @@ public sealed class StoreDeliveryZone : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public int? EstimatedMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 区域颜色。
|
||||
/// </summary>
|
||||
public string? Color { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优先级(数值越小越优先)。
|
||||
/// </summary>
|
||||
public int Priority { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Stores.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 门店堂食基础设置。
|
||||
/// </summary>
|
||||
public sealed class StoreDineInSetting : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用堂食。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 默认用餐时长(分钟)。
|
||||
/// </summary>
|
||||
public int DefaultDiningMinutes { get; set; } = 90;
|
||||
|
||||
/// <summary>
|
||||
/// 超时提醒阈值(分钟)。
|
||||
/// </summary>
|
||||
public int OvertimeReminderMinutes { get; set; } = 10;
|
||||
}
|
||||
@@ -47,4 +47,24 @@ public sealed class StoreFee : MultiTenantEntityBase
|
||||
/// 免配送费门槛。
|
||||
/// </summary>
|
||||
public decimal? FreeDeliveryThreshold { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用餐具费。
|
||||
/// </summary>
|
||||
public bool CutleryFeeEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 餐具费金额。
|
||||
/// </summary>
|
||||
public decimal CutleryFeeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用加急费。
|
||||
/// </summary>
|
||||
public bool RushFeeEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 加急费金额。
|
||||
/// </summary>
|
||||
public decimal RushFeeAmount { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Stores.Entities;
|
||||
@@ -33,6 +34,16 @@ public sealed class StorePickupSetting : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public int? MaxQuantityPerOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 自提配置模式。
|
||||
/// </summary>
|
||||
public StorePickupMode Mode { get; set; } = StorePickupMode.Big;
|
||||
|
||||
/// <summary>
|
||||
/// 精细规则 JSON。
|
||||
/// </summary>
|
||||
public string? FineRuleJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Stores.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 门店员工班次模板。
|
||||
/// </summary>
|
||||
public sealed class StoreStaffTemplate : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 早班开始时间。
|
||||
/// </summary>
|
||||
public TimeSpan MorningStartTime { get; set; } = new(9, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// 早班结束时间。
|
||||
/// </summary>
|
||||
public TimeSpan MorningEndTime { get; set; } = new(14, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// 晚班开始时间。
|
||||
/// </summary>
|
||||
public TimeSpan EveningStartTime { get; set; } = new(14, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// 晚班结束时间。
|
||||
/// </summary>
|
||||
public TimeSpan EveningEndTime { get; set; } = new(21, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// 全天班开始时间。
|
||||
/// </summary>
|
||||
public TimeSpan FullStartTime { get; set; } = new(9, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// 全天班结束时间。
|
||||
/// </summary>
|
||||
public TimeSpan FullEndTime { get; set; } = new(21, 0, 0);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Stores.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 门店员工每周排班。
|
||||
/// </summary>
|
||||
public sealed class StoreStaffWeeklySchedule : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工 ID。
|
||||
/// </summary>
|
||||
public long StaffId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 星期(0=周一,6=周日)。
|
||||
/// </summary>
|
||||
public int DayOfWeek { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 班次类型。
|
||||
/// </summary>
|
||||
public StoreStaffShiftType ShiftType { get; set; } = StoreStaffShiftType.Off;
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间(休息时为空)。
|
||||
/// </summary>
|
||||
public TimeSpan? StartTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间(休息时为空)。
|
||||
/// </summary>
|
||||
public TimeSpan? EndTime { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 门店配送配置模式。
|
||||
/// </summary>
|
||||
public enum StoreDeliveryMode
|
||||
{
|
||||
/// <summary>
|
||||
/// 多边形配送范围。
|
||||
/// </summary>
|
||||
Polygon = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 半径梯度配送。
|
||||
/// </summary>
|
||||
Radius = 1
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 门店自提模式。
|
||||
/// </summary>
|
||||
public enum StorePickupMode
|
||||
{
|
||||
/// <summary>
|
||||
/// 大时段模式。
|
||||
/// </summary>
|
||||
Big = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 精细规则模式。
|
||||
/// </summary>
|
||||
Fine = 1
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 员工班次类型。
|
||||
/// </summary>
|
||||
public enum StoreStaffShiftType
|
||||
{
|
||||
/// <summary>
|
||||
/// 早班。
|
||||
/// </summary>
|
||||
Morning = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 晚班。
|
||||
/// </summary>
|
||||
Evening = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 全天班。
|
||||
/// </summary>
|
||||
Full = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 休息。
|
||||
/// </summary>
|
||||
Off = 3
|
||||
}
|
||||
@@ -148,6 +148,14 @@ public sealed class TakeoutAppDbContext(
|
||||
/// </summary>
|
||||
public DbSet<StoreDeliveryZone> StoreDeliveryZones => Set<StoreDeliveryZone>();
|
||||
/// <summary>
|
||||
/// 门店配送设置。
|
||||
/// </summary>
|
||||
public DbSet<StoreDeliverySetting> StoreDeliverySettings => Set<StoreDeliverySetting>();
|
||||
/// <summary>
|
||||
/// 门店堂食设置。
|
||||
/// </summary>
|
||||
public DbSet<StoreDineInSetting> StoreDineInSettings => Set<StoreDineInSetting>();
|
||||
/// <summary>
|
||||
/// 门店桌台区域。
|
||||
/// </summary>
|
||||
public DbSet<StoreTableArea> StoreTableAreas => Set<StoreTableArea>();
|
||||
@@ -168,6 +176,14 @@ public sealed class TakeoutAppDbContext(
|
||||
/// </summary>
|
||||
public DbSet<StorePickupSlot> StorePickupSlots => Set<StorePickupSlot>();
|
||||
/// <summary>
|
||||
/// 门店班次模板。
|
||||
/// </summary>
|
||||
public DbSet<StoreStaffTemplate> StoreStaffTemplates => Set<StoreStaffTemplate>();
|
||||
/// <summary>
|
||||
/// 门店每周排班。
|
||||
/// </summary>
|
||||
public DbSet<StoreStaffWeeklySchedule> StoreStaffWeeklySchedules => Set<StoreStaffWeeklySchedule>();
|
||||
/// <summary>
|
||||
/// 商品分类。
|
||||
/// </summary>
|
||||
public DbSet<ProductCategory> ProductCategories => Set<ProductCategory>();
|
||||
@@ -411,11 +427,15 @@ public sealed class TakeoutAppDbContext(
|
||||
ConfigureStoreBusinessHour(modelBuilder.Entity<StoreBusinessHour>());
|
||||
ConfigureStoreHoliday(modelBuilder.Entity<StoreHoliday>());
|
||||
ConfigureStoreDeliveryZone(modelBuilder.Entity<StoreDeliveryZone>());
|
||||
ConfigureStoreDeliverySetting(modelBuilder.Entity<StoreDeliverySetting>());
|
||||
ConfigureStoreDineInSetting(modelBuilder.Entity<StoreDineInSetting>());
|
||||
ConfigureStoreTableArea(modelBuilder.Entity<StoreTableArea>());
|
||||
ConfigureStoreTable(modelBuilder.Entity<StoreTable>());
|
||||
ConfigureStoreEmployeeShift(modelBuilder.Entity<StoreEmployeeShift>());
|
||||
ConfigureStorePickupSetting(modelBuilder.Entity<StorePickupSetting>());
|
||||
ConfigureStorePickupSlot(modelBuilder.Entity<StorePickupSlot>());
|
||||
ConfigureStoreStaffTemplate(modelBuilder.Entity<StoreStaffTemplate>());
|
||||
ConfigureStoreStaffWeeklySchedule(modelBuilder.Entity<StoreStaffWeeklySchedule>());
|
||||
ConfigureProductCategory(modelBuilder.Entity<ProductCategory>());
|
||||
ConfigureProduct(modelBuilder.Entity<Product>());
|
||||
ConfigureProductAttributeGroup(modelBuilder.Entity<ProductAttributeGroup>());
|
||||
@@ -603,6 +623,10 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.Property(x => x.FixedPackagingFee).HasPrecision(10, 2);
|
||||
builder.Property(x => x.PackagingFeeTiersJson).HasColumnType("text");
|
||||
builder.Property(x => x.FreeDeliveryThreshold).HasPrecision(10, 2);
|
||||
builder.Property(x => x.CutleryFeeEnabled).HasDefaultValue(false);
|
||||
builder.Property(x => x.CutleryFeeAmount).HasPrecision(10, 2);
|
||||
builder.Property(x => x.RushFeeEnabled).HasDefaultValue(false);
|
||||
builder.Property(x => x.RushFeeAmount).HasPrecision(10, 2);
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
}
|
||||
@@ -981,9 +1005,34 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.Property(x => x.PolygonGeoJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.MinimumOrderAmount).HasPrecision(18, 2);
|
||||
builder.Property(x => x.DeliveryFee).HasPrecision(18, 2);
|
||||
builder.Property(x => x.Color).HasMaxLength(32);
|
||||
builder.Property(x => x.Priority).HasDefaultValue(100);
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ZoneName });
|
||||
}
|
||||
|
||||
private static void ConfigureStoreDeliverySetting(EntityTypeBuilder<StoreDeliverySetting> builder)
|
||||
{
|
||||
builder.ToTable("store_delivery_settings");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.Mode).HasConversion<int>();
|
||||
builder.Property(x => x.FreeDeliveryThreshold).HasPrecision(10, 2);
|
||||
builder.Property(x => x.MaxDeliveryDistance).HasPrecision(10, 2);
|
||||
builder.Property(x => x.RadiusTiersJson).HasColumnType("text");
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureStoreDineInSetting(EntityTypeBuilder<StoreDineInSetting> builder)
|
||||
{
|
||||
builder.ToTable("store_dinein_settings");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.Enabled).HasDefaultValue(true);
|
||||
builder.Property(x => x.DefaultDiningMinutes).HasDefaultValue(90);
|
||||
builder.Property(x => x.OvertimeReminderMinutes).HasDefaultValue(10);
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureStoreTableArea(EntityTypeBuilder<StoreTableArea> builder)
|
||||
{
|
||||
builder.ToTable("store_table_areas");
|
||||
@@ -1022,6 +1071,8 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.DefaultCutoffMinutes).HasDefaultValue(30);
|
||||
builder.Property(x => x.Mode).HasConversion<int>();
|
||||
builder.Property(x => x.FineRuleJson).HasColumnType("text");
|
||||
builder.Property(x => x.RowVersion)
|
||||
.IsConcurrencyToken();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
|
||||
@@ -1040,6 +1091,34 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name });
|
||||
}
|
||||
|
||||
private static void ConfigureStoreStaffTemplate(EntityTypeBuilder<StoreStaffTemplate> builder)
|
||||
{
|
||||
builder.ToTable("store_staff_templates");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.MorningStartTime).IsRequired();
|
||||
builder.Property(x => x.MorningEndTime).IsRequired();
|
||||
builder.Property(x => x.EveningStartTime).IsRequired();
|
||||
builder.Property(x => x.EveningEndTime).IsRequired();
|
||||
builder.Property(x => x.FullStartTime).IsRequired();
|
||||
builder.Property(x => x.FullEndTime).IsRequired();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureStoreStaffWeeklySchedule(EntityTypeBuilder<StoreStaffWeeklySchedule> builder)
|
||||
{
|
||||
builder.ToTable("store_staff_weekly_schedules");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.StaffId).IsRequired();
|
||||
builder.Property(x => x.DayOfWeek).IsRequired();
|
||||
builder.Property(x => x.ShiftType).HasConversion<int>();
|
||||
builder.Property(x => x.StartTime);
|
||||
builder.Property(x => x.EndTime);
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.StaffId, x.DayOfWeek }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.DayOfWeek });
|
||||
}
|
||||
|
||||
private static void ConfigureProductAttributeGroup(EntityTypeBuilder<ProductAttributeGroup> builder)
|
||||
{
|
||||
builder.ToTable("product_attribute_groups");
|
||||
|
||||
7751
src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260217063434_AddStoreExtendedSettings.Designer.cs
generated
Normal file
7751
src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260217063434_AddStoreExtendedSettings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddStoreExtendedSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FineRuleJson",
|
||||
table: "store_pickup_settings",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
comment: "精细规则 JSON。");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Mode",
|
||||
table: "store_pickup_settings",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
comment: "自提配置模式。");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "CutleryFeeAmount",
|
||||
table: "store_fees",
|
||||
type: "numeric(10,2)",
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: false,
|
||||
defaultValue: 0m,
|
||||
comment: "餐具费金额。");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CutleryFeeEnabled",
|
||||
table: "store_fees",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false,
|
||||
comment: "是否启用餐具费。");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "RushFeeAmount",
|
||||
table: "store_fees",
|
||||
type: "numeric(10,2)",
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: false,
|
||||
defaultValue: 0m,
|
||||
comment: "加急费金额。");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "RushFeeEnabled",
|
||||
table: "store_fees",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false,
|
||||
comment: "是否启用加急费。");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Color",
|
||||
table: "store_delivery_zones",
|
||||
type: "character varying(32)",
|
||||
maxLength: 32,
|
||||
nullable: true,
|
||||
comment: "区域颜色。");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Priority",
|
||||
table: "store_delivery_zones",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 100,
|
||||
comment: "优先级(数值越小越优先)。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "store_delivery_settings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
|
||||
Mode = table.Column<int>(type: "integer", nullable: false, comment: "配送模式。"),
|
||||
EtaAdjustmentMinutes = table.Column<int>(type: "integer", nullable: false, comment: "配送时效加成(分钟)。"),
|
||||
FreeDeliveryThreshold = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true, comment: "免配送费门槛。"),
|
||||
HourlyCapacityLimit = table.Column<int>(type: "integer", nullable: false, comment: "每小时配送上限。"),
|
||||
MaxDeliveryDistance = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false, comment: "最大配送距离(公里)。"),
|
||||
RadiusTiersJson = table.Column<string>(type: "text", nullable: true, comment: "半径梯度配置 JSON。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_store_delivery_settings", x => x.Id);
|
||||
},
|
||||
comment: "门店配送设置聚合。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "store_dinein_settings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
|
||||
Enabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true, comment: "是否启用堂食。"),
|
||||
DefaultDiningMinutes = table.Column<int>(type: "integer", nullable: false, defaultValue: 90, comment: "默认用餐时长(分钟)。"),
|
||||
OvertimeReminderMinutes = table.Column<int>(type: "integer", nullable: false, defaultValue: 10, comment: "超时提醒阈值(分钟)。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_store_dinein_settings", x => x.Id);
|
||||
},
|
||||
comment: "门店堂食基础设置。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "store_staff_templates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
|
||||
MorningStartTime = table.Column<TimeSpan>(type: "interval", nullable: false, comment: "早班开始时间。"),
|
||||
MorningEndTime = table.Column<TimeSpan>(type: "interval", nullable: false, comment: "早班结束时间。"),
|
||||
EveningStartTime = table.Column<TimeSpan>(type: "interval", nullable: false, comment: "晚班开始时间。"),
|
||||
EveningEndTime = table.Column<TimeSpan>(type: "interval", nullable: false, comment: "晚班结束时间。"),
|
||||
FullStartTime = table.Column<TimeSpan>(type: "interval", nullable: false, comment: "全天班开始时间。"),
|
||||
FullEndTime = table.Column<TimeSpan>(type: "interval", nullable: false, comment: "全天班结束时间。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_store_staff_templates", x => x.Id);
|
||||
},
|
||||
comment: "门店员工班次模板。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "store_staff_weekly_schedules",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
|
||||
StaffId = table.Column<long>(type: "bigint", nullable: false, comment: "员工 ID。"),
|
||||
DayOfWeek = table.Column<int>(type: "integer", nullable: false, comment: "星期(0=周一,6=周日)。"),
|
||||
ShiftType = table.Column<int>(type: "integer", nullable: false, comment: "班次类型。"),
|
||||
StartTime = table.Column<TimeSpan>(type: "interval", nullable: true, comment: "开始时间(休息时为空)。"),
|
||||
EndTime = table.Column<TimeSpan>(type: "interval", nullable: true, comment: "结束时间(休息时为空)。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_store_staff_weekly_schedules", x => x.Id);
|
||||
},
|
||||
comment: "门店员工每周排班。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenant_visibility_role_rules",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
QuotaVisibleRoleCodes = table.Column<string[]>(type: "text[]", nullable: false, comment: "配额可见角色编码集合。"),
|
||||
BillingVisibleRoleCodes = table.Column<string[]>(type: "text[]", nullable: false, comment: "账单可见角色编码集合。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: false, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_tenant_visibility_role_rules", x => x.Id);
|
||||
},
|
||||
comment: "租户账单/配额可见角色规则。");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_store_delivery_settings_TenantId_StoreId",
|
||||
table: "store_delivery_settings",
|
||||
columns: new[] { "TenantId", "StoreId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_store_dinein_settings_TenantId_StoreId",
|
||||
table: "store_dinein_settings",
|
||||
columns: new[] { "TenantId", "StoreId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_store_staff_templates_TenantId_StoreId",
|
||||
table: "store_staff_templates",
|
||||
columns: new[] { "TenantId", "StoreId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_store_staff_weekly_schedules_TenantId_StoreId_DayOfWeek",
|
||||
table: "store_staff_weekly_schedules",
|
||||
columns: new[] { "TenantId", "StoreId", "DayOfWeek" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_store_staff_weekly_schedules_TenantId_StoreId_StaffId_DayOf~",
|
||||
table: "store_staff_weekly_schedules",
|
||||
columns: new[] { "TenantId", "StoreId", "StaffId", "DayOfWeek" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_tenant_visibility_role_rules_TenantId",
|
||||
table: "tenant_visibility_role_rules",
|
||||
column: "TenantId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "store_delivery_settings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "store_dinein_settings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "store_staff_templates");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "store_staff_weekly_schedules");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenant_visibility_role_rules");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FineRuleJson",
|
||||
table: "store_pickup_settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Mode",
|
||||
table: "store_pickup_settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CutleryFeeAmount",
|
||||
table: "store_fees");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CutleryFeeEnabled",
|
||||
table: "store_fees");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RushFeeAmount",
|
||||
table: "store_fees");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RushFeeEnabled",
|
||||
table: "store_fees");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Color",
|
||||
table: "store_delivery_zones");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Priority",
|
||||
table: "store_delivery_zones");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -654,7 +654,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
|
||||
b.Property<long?>("StoreId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属门店(可空为平台)。");
|
||||
.HasComment("所属门店(可空为系统会话)。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
@@ -1148,7 +1148,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
|
||||
b.Property<long?>("UserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("用户 ID(如绑定平台账号)。");
|
||||
.HasComment("用户 ID(如绑定登录账号)。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
@@ -2517,13 +2517,6 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("审核备注或驳回原因。");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasComment("并发控制版本。");
|
||||
|
||||
b.Property<string>("ServicePhone")
|
||||
.HasColumnType("text")
|
||||
.HasComment("客服电话。");
|
||||
@@ -2552,6 +2545,12 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<uint>("xmin")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClaimedBy");
|
||||
@@ -3791,7 +3790,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
b.Property<string>("TradeNo")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("平台交易号。");
|
||||
.HasComment("系统交易号。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
@@ -5228,6 +5227,84 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreDeliverySetting", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<int>("EtaAdjustmentMinutes")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("配送时效加成(分钟)。");
|
||||
|
||||
b.Property<decimal?>("FreeDeliveryThreshold")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)")
|
||||
.HasComment("免配送费门槛。");
|
||||
|
||||
b.Property<int>("HourlyCapacityLimit")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("每小时配送上限。");
|
||||
|
||||
b.Property<decimal>("MaxDeliveryDistance")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)")
|
||||
.HasComment("最大配送距离(公里)。");
|
||||
|
||||
b.Property<int>("Mode")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("配送模式。");
|
||||
|
||||
b.Property<string>("RadiusTiersJson")
|
||||
.HasColumnType("text")
|
||||
.HasComment("半径梯度配置 JSON。");
|
||||
|
||||
b.Property<long>("StoreId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("门店 ID。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "StoreId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("store_delivery_settings", null, t =>
|
||||
{
|
||||
t.HasComment("门店配送设置聚合。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreDeliveryZone", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -5237,6 +5314,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Color")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasComment("区域颜色。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
@@ -5272,6 +5354,12 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("text")
|
||||
.HasComment("GeoJSON 表示的多边形范围。");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(100)
|
||||
.HasComment("优先级(数值越小越优先)。");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("排序值。");
|
||||
@@ -5308,6 +5396,76 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreDineInSetting", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<int>("DefaultDiningMinutes")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(90)
|
||||
.HasComment("默认用餐时长(分钟)。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasComment("是否启用堂食。");
|
||||
|
||||
b.Property<int>("OvertimeReminderMinutes")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(10)
|
||||
.HasComment("超时提醒阈值(分钟)。");
|
||||
|
||||
b.Property<long>("StoreId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("门店 ID。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "StoreId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("store_dinein_settings", null, t =>
|
||||
{
|
||||
t.HasComment("门店堂食基础设置。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreEmployeeShift", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -5407,6 +5565,17 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<decimal>("CutleryFeeAmount")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)")
|
||||
.HasComment("餐具费金额。");
|
||||
|
||||
b.Property<bool>("CutleryFeeEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasComment("是否启用餐具费。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
@@ -5430,18 +5599,29 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("numeric(10,2)")
|
||||
.HasComment("起送费(元)。");
|
||||
|
||||
b.Property<int>("PackagingFeeMode")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("打包费模式。");
|
||||
|
||||
b.Property<int>("OrderPackagingFeeMode")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("订单打包费规则(按订单收费时生效)。");
|
||||
|
||||
b.Property<int>("PackagingFeeMode")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("打包费模式。");
|
||||
|
||||
b.Property<string>("PackagingFeeTiersJson")
|
||||
.HasColumnType("text")
|
||||
.HasComment("阶梯打包费配置(JSON)。");
|
||||
|
||||
b.Property<decimal>("RushFeeAmount")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)")
|
||||
.HasComment("加急费金额。");
|
||||
|
||||
b.Property<bool>("RushFeeEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasComment("是否启用加急费。");
|
||||
|
||||
b.Property<long>("StoreId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("门店标识。");
|
||||
@@ -5600,10 +5780,18 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("FineRuleJson")
|
||||
.HasColumnType("text")
|
||||
.HasComment("精细规则 JSON。");
|
||||
|
||||
b.Property<int?>("MaxQuantityPerOrder")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("单笔自提最大份数。");
|
||||
|
||||
b.Property<int>("Mode")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("自提配置模式。");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
@@ -5817,6 +6005,156 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreStaffTemplate", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<TimeSpan>("EveningEndTime")
|
||||
.HasColumnType("interval")
|
||||
.HasComment("晚班结束时间。");
|
||||
|
||||
b.Property<TimeSpan>("EveningStartTime")
|
||||
.HasColumnType("interval")
|
||||
.HasComment("晚班开始时间。");
|
||||
|
||||
b.Property<TimeSpan>("FullEndTime")
|
||||
.HasColumnType("interval")
|
||||
.HasComment("全天班结束时间。");
|
||||
|
||||
b.Property<TimeSpan>("FullStartTime")
|
||||
.HasColumnType("interval")
|
||||
.HasComment("全天班开始时间。");
|
||||
|
||||
b.Property<TimeSpan>("MorningEndTime")
|
||||
.HasColumnType("interval")
|
||||
.HasComment("早班结束时间。");
|
||||
|
||||
b.Property<TimeSpan>("MorningStartTime")
|
||||
.HasColumnType("interval")
|
||||
.HasComment("早班开始时间。");
|
||||
|
||||
b.Property<long>("StoreId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("门店 ID。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "StoreId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("store_staff_templates", null, t =>
|
||||
{
|
||||
t.HasComment("门店员工班次模板。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreStaffWeeklySchedule", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<int>("DayOfWeek")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("星期(0=周一,6=周日)。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<TimeSpan?>("EndTime")
|
||||
.HasColumnType("interval")
|
||||
.HasComment("结束时间(休息时为空)。");
|
||||
|
||||
b.Property<int>("ShiftType")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("班次类型。");
|
||||
|
||||
b.Property<long>("StaffId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("员工 ID。");
|
||||
|
||||
b.Property<TimeSpan?>("StartTime")
|
||||
.HasColumnType("interval")
|
||||
.HasComment("开始时间(休息时为空)。");
|
||||
|
||||
b.Property<long>("StoreId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("门店 ID。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "StoreId", "DayOfWeek");
|
||||
|
||||
b.HasIndex("TenantId", "StoreId", "StaffId", "DayOfWeek")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("store_staff_weekly_schedules", null, t =>
|
||||
{
|
||||
t.HasComment("门店员工每周排班。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreTable", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -6038,7 +6376,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
|
||||
b.ToTable("quota_packages", null, t =>
|
||||
{
|
||||
t.HasComment("配额包定义(平台提供的可购买配额包)。");
|
||||
t.HasComment("配额包定义(系统提供的可购买配额包)。");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6191,7 +6529,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
|
||||
b.ToTable("tenants", null, t =>
|
||||
{
|
||||
t.HasComment("平台租户信息,描述租户的生命周期与基础资料。");
|
||||
t.HasComment("租户信息,描述租户的生命周期与基础资料。");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6255,7 +6593,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
|
||||
b.Property<long?>("PublisherUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("发布者用户 ID(平台或租户后台账号)。");
|
||||
.HasComment("发布者用户 ID(系统或租户后台账号)。");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
@@ -6629,7 +6967,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否仍启用(平台控制)。");
|
||||
.HasComment("是否仍启用(系统控制)。");
|
||||
|
||||
b.Property<bool>("IsAllowNewTenantPurchase")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -6720,7 +7058,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
|
||||
b.ToTable("tenant_packages", null, t =>
|
||||
{
|
||||
t.HasComment("平台提供的租户套餐定义。");
|
||||
t.HasComment("系统提供的租户套餐定义。");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7051,75 +7389,6 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantReviewClaim", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("ClaimedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("领取时间(UTC)。");
|
||||
|
||||
b.Property<long>("ClaimedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("领取人用户 ID。");
|
||||
|
||||
b.Property<string>("ClaimedByName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("领取人名称(展示用快照)。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<DateTime?>("ReleasedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("释放时间(UTC),未释放时为 null。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("被领取的租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClaimedBy");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"ReleasedAt\" IS NULL AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("tenant_review_claims", null, t =>
|
||||
{
|
||||
t.HasComment("租户入驻审核领取记录(防止多管理员并发审核)。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantSubscription", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -7407,6 +7676,64 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantVisibilityRoleRule", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.PrimitiveCollection<string[]>("BillingVisibleRoleCodes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasComment("账单可见角色编码集合。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.PrimitiveCollection<string[]>("QuotaVisibleRoleCodes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasComment("配额可见角色编码集合。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("tenant_visibility_role_rules", null, t =>
|
||||
{
|
||||
t.HasComment("租户账单/配额可见角色规则。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.OrderItem", b =>
|
||||
{
|
||||
b.HasOne("TakeoutSaaS.Domain.Orders.Entities.Order", null)
|
||||
|
||||
Reference in New Issue
Block a user