feat: 完成门店管理剩余接口并补齐文档注释
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 46s

This commit is contained in:
2026-02-17 14:54:35 +08:00
parent 3a94348cca
commit 1b185af718
45 changed files with 13333 additions and 91 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

@@ -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 => "周日",
_ => "周一"
};
}
}

View File

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

View File

@@ -38,6 +38,16 @@ public sealed record CreateStoreDeliveryZoneCommand : IRequest<StoreDeliveryZone
/// </summary> /// </summary>
public int? EstimatedMinutes { get; init; } public int? EstimatedMinutes { get; init; }
/// <summary>
/// 区域颜色。
/// </summary>
public string? Color { get; init; }
/// <summary>
/// 优先级。
/// </summary>
public int Priority { get; init; } = 100;
/// <summary> /// <summary>
/// 排序。 /// 排序。
/// </summary> /// </summary>

View File

@@ -43,6 +43,16 @@ public sealed record UpdateStoreDeliveryZoneCommand : IRequest<StoreDeliveryZone
/// </summary> /// </summary>
public int? EstimatedMinutes { get; init; } public int? EstimatedMinutes { get; init; }
/// <summary>
/// 区域颜色。
/// </summary>
public string? Color { get; init; }
/// <summary>
/// 优先级。
/// </summary>
public int Priority { get; init; } = 100;
/// <summary> /// <summary>
/// 排序。 /// 排序。
/// </summary> /// </summary>

View File

@@ -48,4 +48,24 @@ public sealed record UpdateStoreFeeCommand : IRequest<StoreFeeDto>
/// 免配送费门槛。 /// 免配送费门槛。
/// </summary> /// </summary>
public decimal? FreeDeliveryThreshold { get; init; } 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; }
} }

View File

@@ -1,5 +1,6 @@
using MediatR; using MediatR;
using TakeoutSaaS.Application.App.Stores.Dto; using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Domain.Stores.Enums;
namespace TakeoutSaaS.Application.App.Stores.Commands; namespace TakeoutSaaS.Application.App.Stores.Commands;
@@ -32,4 +33,14 @@ public sealed record UpsertStorePickupSettingCommand : IRequest<StorePickupSetti
/// 单笔最大份数。 /// 单笔最大份数。
/// </summary> /// </summary>
public int? MaxQuantityPerOrder { get; init; } public int? MaxQuantityPerOrder { get; init; }
/// <summary>
/// 自提模式。
/// </summary>
public StorePickupMode Mode { get; init; } = StorePickupMode.Big;
/// <summary>
/// 精细规则 JSON。
/// </summary>
public string? FineRuleJson { get; init; }
} }

View File

@@ -51,6 +51,16 @@ public sealed record StoreDeliveryZoneDto
/// </summary> /// </summary>
public int? EstimatedMinutes { get; init; } public int? EstimatedMinutes { get; init; }
/// <summary>
/// 区域颜色。
/// </summary>
public string? Color { get; init; }
/// <summary>
/// 优先级。
/// </summary>
public int Priority { get; init; }
/// <summary> /// <summary>
/// 排序。 /// 排序。
/// </summary> /// </summary>

View File

@@ -31,6 +31,26 @@ public sealed record StoreFeeDto
/// </summary> /// </summary>
public decimal DeliveryFee { get; init; } 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>
/// 打包费模式。 /// 打包费模式。
/// </summary> /// </summary>

View File

@@ -1,4 +1,5 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization; using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Stores.Dto; namespace TakeoutSaaS.Application.App.Stores.Dto;
@@ -39,4 +40,14 @@ public sealed record StorePickupSettingDto
/// 单笔最大自提份数。 /// 单笔最大自提份数。
/// </summary> /// </summary>
public int? MaxQuantityPerOrder { get; init; } public int? MaxQuantityPerOrder { get; init; }
/// <summary>
/// 自提模式。
/// </summary>
public StorePickupMode Mode { get; init; }
/// <summary>
/// 精细规则 JSON。
/// </summary>
public string? FineRuleJson { get; init; }
} }

View File

@@ -51,6 +51,8 @@ public sealed class CreateStoreDeliveryZoneCommandHandler(
MinimumOrderAmount = request.MinimumOrderAmount, MinimumOrderAmount = request.MinimumOrderAmount,
DeliveryFee = request.DeliveryFee, DeliveryFee = request.DeliveryFee,
EstimatedMinutes = request.EstimatedMinutes, EstimatedMinutes = request.EstimatedMinutes,
Color = request.Color?.Trim(),
Priority = request.Priority,
SortOrder = request.SortOrder SortOrder = request.SortOrder
}; };

View File

@@ -40,7 +40,11 @@ public sealed class GetStoreFeeQueryHandler(
BaseDeliveryFee = 0m, BaseDeliveryFee = 0m,
PackagingFeeMode = Domain.Stores.Enums.PackagingFeeMode.Fixed, PackagingFeeMode = Domain.Stores.Enums.PackagingFeeMode.Fixed,
OrderPackagingFeeMode = Domain.Stores.Enums.OrderPackagingFeeMode.Fixed, OrderPackagingFeeMode = Domain.Stores.Enums.OrderPackagingFeeMode.Fixed,
FixedPackagingFee = 0m FixedPackagingFee = 0m,
CutleryFeeEnabled = false,
CutleryFeeAmount = 0m,
RushFeeEnabled = false,
RushFeeAmount = 0m
}; };
return StoreMapping.ToDto(fallback); return StoreMapping.ToDto(fallback);
} }

View File

@@ -31,7 +31,9 @@ public sealed class GetStorePickupSettingQueryHandler(
AllowToday = setting.AllowToday, AllowToday = setting.AllowToday,
AllowDaysAhead = setting.AllowDaysAhead, AllowDaysAhead = setting.AllowDaysAhead,
DefaultCutoffMinutes = setting.DefaultCutoffMinutes, DefaultCutoffMinutes = setting.DefaultCutoffMinutes,
MaxQuantityPerOrder = setting.MaxQuantityPerOrder MaxQuantityPerOrder = setting.MaxQuantityPerOrder,
Mode = setting.Mode,
FineRuleJson = setting.FineRuleJson
}; };
} }
} }

View File

@@ -52,6 +52,8 @@ public sealed class UpdateStoreDeliveryZoneCommandHandler(
existing.MinimumOrderAmount = request.MinimumOrderAmount; existing.MinimumOrderAmount = request.MinimumOrderAmount;
existing.DeliveryFee = request.DeliveryFee; existing.DeliveryFee = request.DeliveryFee;
existing.EstimatedMinutes = request.EstimatedMinutes; existing.EstimatedMinutes = request.EstimatedMinutes;
existing.Color = request.Color?.Trim();
existing.Priority = request.Priority;
existing.SortOrder = request.SortOrder; existing.SortOrder = request.SortOrder;
// 5. (空行后) 持久化 // 5. (空行后) 持久化

View File

@@ -71,6 +71,10 @@ public sealed class UpdateStoreFeeCommandHandler(
fee.PackagingFeeTiersJson = null; fee.PackagingFeeTiersJson = null;
} }
fee.FreeDeliveryThreshold = request.FreeDeliveryThreshold; fee.FreeDeliveryThreshold = request.FreeDeliveryThreshold;
fee.CutleryFeeEnabled = request.CutleryFeeEnabled;
fee.CutleryFeeAmount = request.CutleryFeeAmount;
fee.RushFeeEnabled = request.RushFeeEnabled;
fee.RushFeeAmount = request.RushFeeAmount;
// 4. (空行后) 保存并返回 // 4. (空行后) 保存并返回
if (isNew) if (isNew)

View File

@@ -47,6 +47,8 @@ public sealed class UpsertStorePickupSettingCommandHandler(
setting.AllowDaysAhead = request.AllowDaysAhead; setting.AllowDaysAhead = request.AllowDaysAhead;
setting.DefaultCutoffMinutes = request.DefaultCutoffMinutes; setting.DefaultCutoffMinutes = request.DefaultCutoffMinutes;
setting.MaxQuantityPerOrder = request.MaxQuantityPerOrder; setting.MaxQuantityPerOrder = request.MaxQuantityPerOrder;
setting.Mode = request.Mode;
setting.FineRuleJson = request.FineRuleJson;
await storeRepository.UpdatePickupSettingAsync(setting, cancellationToken); await storeRepository.UpdatePickupSettingAsync(setting, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken); await storeRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("更新门店 {StoreId} 自提配置", request.StoreId); logger.LogInformation("更新门店 {StoreId} 自提配置", request.StoreId);
@@ -57,7 +59,9 @@ public sealed class UpsertStorePickupSettingCommandHandler(
AllowToday = setting.AllowToday, AllowToday = setting.AllowToday,
AllowDaysAhead = setting.AllowDaysAhead, AllowDaysAhead = setting.AllowDaysAhead,
DefaultCutoffMinutes = setting.DefaultCutoffMinutes, DefaultCutoffMinutes = setting.DefaultCutoffMinutes,
MaxQuantityPerOrder = setting.MaxQuantityPerOrder MaxQuantityPerOrder = setting.MaxQuantityPerOrder,
Mode = setting.Mode,
FineRuleJson = setting.FineRuleJson
}; };
} }
} }

View File

@@ -64,6 +64,10 @@ public static class StoreMapping
StoreId = fee.StoreId, StoreId = fee.StoreId,
MinimumOrderAmount = fee.MinimumOrderAmount, MinimumOrderAmount = fee.MinimumOrderAmount,
DeliveryFee = fee.BaseDeliveryFee, DeliveryFee = fee.BaseDeliveryFee,
CutleryFeeEnabled = fee.CutleryFeeEnabled,
CutleryFeeAmount = fee.CutleryFeeAmount,
RushFeeEnabled = fee.RushFeeEnabled,
RushFeeAmount = fee.RushFeeAmount,
PackagingFeeMode = fee.PackagingFeeMode, PackagingFeeMode = fee.PackagingFeeMode,
OrderPackagingFeeMode = fee.OrderPackagingFeeMode, OrderPackagingFeeMode = fee.OrderPackagingFeeMode,
FixedPackagingFee = fee.FixedPackagingFee, FixedPackagingFee = fee.FixedPackagingFee,
@@ -137,6 +141,8 @@ public static class StoreMapping
MinimumOrderAmount = zone.MinimumOrderAmount, MinimumOrderAmount = zone.MinimumOrderAmount,
DeliveryFee = zone.DeliveryFee, DeliveryFee = zone.DeliveryFee,
EstimatedMinutes = zone.EstimatedMinutes, EstimatedMinutes = zone.EstimatedMinutes,
Color = zone.Color,
Priority = zone.Priority,
SortOrder = zone.SortOrder, SortOrder = zone.SortOrder,
CreatedAt = zone.CreatedAt CreatedAt = zone.CreatedAt
}; };

View File

@@ -19,6 +19,8 @@ public sealed class CreateStoreDeliveryZoneCommandValidator : AbstractValidator<
RuleFor(x => x.MinimumOrderAmount).GreaterThanOrEqualTo(0).When(x => x.MinimumOrderAmount.HasValue); 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.DeliveryFee).GreaterThanOrEqualTo(0).When(x => x.DeliveryFee.HasValue);
RuleFor(x => x.EstimatedMinutes).GreaterThan(0).When(x => x.EstimatedMinutes.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); RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
} }
} }

View File

@@ -20,6 +20,8 @@ public sealed class UpdateStoreDeliveryZoneCommandValidator : AbstractValidator<
RuleFor(x => x.MinimumOrderAmount).GreaterThanOrEqualTo(0).When(x => x.MinimumOrderAmount.HasValue); 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.DeliveryFee).GreaterThanOrEqualTo(0).When(x => x.DeliveryFee.HasValue);
RuleFor(x => x.EstimatedMinutes).GreaterThan(0).When(x => x.EstimatedMinutes.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); RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
} }
} }

View File

@@ -32,6 +32,14 @@ public sealed class UpdateStoreFeeCommandValidator : AbstractValidator<UpdateSto
.Must(fee => !fee.HasValue || fee.Value >= 0) .Must(fee => !fee.HasValue || fee.Value >= 0)
.WithMessage("固定打包费不能为负数"); .WithMessage("固定打包费不能为负数");
RuleFor(x => x.CutleryFeeAmount)
.GreaterThanOrEqualTo(0)
.LessThanOrEqualTo(99.99m);
RuleFor(x => x.RushFeeAmount)
.GreaterThanOrEqualTo(0)
.LessThanOrEqualTo(99.99m);
RuleFor(x => x) RuleFor(x => x)
.Custom((command, context) => .Custom((command, context) =>
{ {

View File

@@ -17,5 +17,6 @@ public sealed class UpsertStorePickupSettingCommandValidator : AbstractValidator
RuleFor(x => x.AllowDaysAhead).GreaterThanOrEqualTo(0); RuleFor(x => x.AllowDaysAhead).GreaterThanOrEqualTo(0);
RuleFor(x => x.DefaultCutoffMinutes).GreaterThanOrEqualTo(0); RuleFor(x => x.DefaultCutoffMinutes).GreaterThanOrEqualTo(0);
RuleFor(x => x.MaxQuantityPerOrder).GreaterThan(0).When(x => x.MaxQuantityPerOrder.HasValue); RuleFor(x => x.MaxQuantityPerOrder).GreaterThan(0).When(x => x.MaxQuantityPerOrder.HasValue);
RuleFor(x => x.FineRuleJson).MaximumLength(20000);
} }
} }

View File

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

View File

@@ -37,6 +37,16 @@ public sealed class StoreDeliveryZone : MultiTenantEntityBase
/// </summary> /// </summary>
public int? EstimatedMinutes { get; set; } public int? EstimatedMinutes { get; set; }
/// <summary>
/// 区域颜色。
/// </summary>
public string? Color { get; set; }
/// <summary>
/// 优先级(数值越小越优先)。
/// </summary>
public int Priority { get; set; } = 100;
/// <summary> /// <summary>
/// 排序值。 /// 排序值。
/// </summary> /// </summary>

View File

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

View File

@@ -47,4 +47,24 @@ public sealed class StoreFee : MultiTenantEntityBase
/// 免配送费门槛。 /// 免配送费门槛。
/// </summary> /// </summary>
public decimal? FreeDeliveryThreshold { get; set; } 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; }
} }

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities; using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Stores.Entities; namespace TakeoutSaaS.Domain.Stores.Entities;
@@ -33,6 +34,16 @@ public sealed class StorePickupSetting : MultiTenantEntityBase
/// </summary> /// </summary>
public int? MaxQuantityPerOrder { get; set; } public int? MaxQuantityPerOrder { get; set; }
/// <summary>
/// 自提配置模式。
/// </summary>
public StorePickupMode Mode { get; set; } = StorePickupMode.Big;
/// <summary>
/// 精细规则 JSON。
/// </summary>
public string? FineRuleJson { get; set; }
/// <summary> /// <summary>
/// 并发控制字段。 /// 并发控制字段。
/// </summary> /// </summary>

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Stores.Enums;
/// <summary>
/// 门店配送配置模式。
/// </summary>
public enum StoreDeliveryMode
{
/// <summary>
/// 多边形配送范围。
/// </summary>
Polygon = 0,
/// <summary>
/// 半径梯度配送。
/// </summary>
Radius = 1
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Stores.Enums;
/// <summary>
/// 门店自提模式。
/// </summary>
public enum StorePickupMode
{
/// <summary>
/// 大时段模式。
/// </summary>
Big = 0,
/// <summary>
/// 精细规则模式。
/// </summary>
Fine = 1
}

View File

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

View File

@@ -148,6 +148,14 @@ public sealed class TakeoutAppDbContext(
/// </summary> /// </summary>
public DbSet<StoreDeliveryZone> StoreDeliveryZones => Set<StoreDeliveryZone>(); public DbSet<StoreDeliveryZone> StoreDeliveryZones => Set<StoreDeliveryZone>();
/// <summary> /// <summary>
/// 门店配送设置。
/// </summary>
public DbSet<StoreDeliverySetting> StoreDeliverySettings => Set<StoreDeliverySetting>();
/// <summary>
/// 门店堂食设置。
/// </summary>
public DbSet<StoreDineInSetting> StoreDineInSettings => Set<StoreDineInSetting>();
/// <summary>
/// 门店桌台区域。 /// 门店桌台区域。
/// </summary> /// </summary>
public DbSet<StoreTableArea> StoreTableAreas => Set<StoreTableArea>(); public DbSet<StoreTableArea> StoreTableAreas => Set<StoreTableArea>();
@@ -168,6 +176,14 @@ public sealed class TakeoutAppDbContext(
/// </summary> /// </summary>
public DbSet<StorePickupSlot> StorePickupSlots => Set<StorePickupSlot>(); public DbSet<StorePickupSlot> StorePickupSlots => Set<StorePickupSlot>();
/// <summary> /// <summary>
/// 门店班次模板。
/// </summary>
public DbSet<StoreStaffTemplate> StoreStaffTemplates => Set<StoreStaffTemplate>();
/// <summary>
/// 门店每周排班。
/// </summary>
public DbSet<StoreStaffWeeklySchedule> StoreStaffWeeklySchedules => Set<StoreStaffWeeklySchedule>();
/// <summary>
/// 商品分类。 /// 商品分类。
/// </summary> /// </summary>
public DbSet<ProductCategory> ProductCategories => Set<ProductCategory>(); public DbSet<ProductCategory> ProductCategories => Set<ProductCategory>();
@@ -411,11 +427,15 @@ public sealed class TakeoutAppDbContext(
ConfigureStoreBusinessHour(modelBuilder.Entity<StoreBusinessHour>()); ConfigureStoreBusinessHour(modelBuilder.Entity<StoreBusinessHour>());
ConfigureStoreHoliday(modelBuilder.Entity<StoreHoliday>()); ConfigureStoreHoliday(modelBuilder.Entity<StoreHoliday>());
ConfigureStoreDeliveryZone(modelBuilder.Entity<StoreDeliveryZone>()); ConfigureStoreDeliveryZone(modelBuilder.Entity<StoreDeliveryZone>());
ConfigureStoreDeliverySetting(modelBuilder.Entity<StoreDeliverySetting>());
ConfigureStoreDineInSetting(modelBuilder.Entity<StoreDineInSetting>());
ConfigureStoreTableArea(modelBuilder.Entity<StoreTableArea>()); ConfigureStoreTableArea(modelBuilder.Entity<StoreTableArea>());
ConfigureStoreTable(modelBuilder.Entity<StoreTable>()); ConfigureStoreTable(modelBuilder.Entity<StoreTable>());
ConfigureStoreEmployeeShift(modelBuilder.Entity<StoreEmployeeShift>()); ConfigureStoreEmployeeShift(modelBuilder.Entity<StoreEmployeeShift>());
ConfigureStorePickupSetting(modelBuilder.Entity<StorePickupSetting>()); ConfigureStorePickupSetting(modelBuilder.Entity<StorePickupSetting>());
ConfigureStorePickupSlot(modelBuilder.Entity<StorePickupSlot>()); ConfigureStorePickupSlot(modelBuilder.Entity<StorePickupSlot>());
ConfigureStoreStaffTemplate(modelBuilder.Entity<StoreStaffTemplate>());
ConfigureStoreStaffWeeklySchedule(modelBuilder.Entity<StoreStaffWeeklySchedule>());
ConfigureProductCategory(modelBuilder.Entity<ProductCategory>()); ConfigureProductCategory(modelBuilder.Entity<ProductCategory>());
ConfigureProduct(modelBuilder.Entity<Product>()); ConfigureProduct(modelBuilder.Entity<Product>());
ConfigureProductAttributeGroup(modelBuilder.Entity<ProductAttributeGroup>()); ConfigureProductAttributeGroup(modelBuilder.Entity<ProductAttributeGroup>());
@@ -603,6 +623,10 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.FixedPackagingFee).HasPrecision(10, 2); builder.Property(x => x.FixedPackagingFee).HasPrecision(10, 2);
builder.Property(x => x.PackagingFeeTiersJson).HasColumnType("text"); builder.Property(x => x.PackagingFeeTiersJson).HasColumnType("text");
builder.Property(x => x.FreeDeliveryThreshold).HasPrecision(10, 2); 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 => new { x.TenantId, x.StoreId }).IsUnique();
builder.HasIndex(x => x.TenantId); 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.PolygonGeoJson).HasColumnType("text").IsRequired();
builder.Property(x => x.MinimumOrderAmount).HasPrecision(18, 2); builder.Property(x => x.MinimumOrderAmount).HasPrecision(18, 2);
builder.Property(x => x.DeliveryFee).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 }); 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) private static void ConfigureStoreTableArea(EntityTypeBuilder<StoreTableArea> builder)
{ {
builder.ToTable("store_table_areas"); builder.ToTable("store_table_areas");
@@ -1022,6 +1071,8 @@ public sealed class TakeoutAppDbContext(
builder.HasKey(x => x.Id); builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired(); builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.DefaultCutoffMinutes).HasDefaultValue(30); 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) builder.Property(x => x.RowVersion)
.IsConcurrencyToken(); .IsConcurrencyToken();
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique(); 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 }); 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) private static void ConfigureProductAttributeGroup(EntityTypeBuilder<ProductAttributeGroup> builder)
{ {
builder.ToTable("product_attribute_groups"); builder.ToTable("product_attribute_groups");

View File

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

View File

@@ -654,7 +654,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.Property<long?>("StoreId") b.Property<long?>("StoreId")
.HasColumnType("bigint") .HasColumnType("bigint")
.HasComment("所属门店(可空为平台)。"); .HasComment("所属门店(可空为系统会话)。");
b.Property<long>("TenantId") b.Property<long>("TenantId")
.HasColumnType("bigint") .HasColumnType("bigint")
@@ -1148,7 +1148,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.Property<long?>("UserId") b.Property<long?>("UserId")
.HasColumnType("bigint") .HasColumnType("bigint")
.HasComment("用户 ID如绑定平台账号)。"); .HasComment("用户 ID如绑定登录账号)。");
b.HasKey("Id"); b.HasKey("Id");
@@ -2517,13 +2517,6 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(512)") .HasColumnType("character varying(512)")
.HasComment("审核备注或驳回原因。"); .HasComment("审核备注或驳回原因。");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea")
.HasComment("并发控制版本。");
b.Property<string>("ServicePhone") b.Property<string>("ServicePhone")
.HasColumnType("text") .HasColumnType("text")
.HasComment("客服电话。"); .HasComment("客服电话。");
@@ -2552,6 +2545,12 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bigint") .HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.Property<uint>("xmin")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ClaimedBy"); b.HasIndex("ClaimedBy");
@@ -3791,7 +3790,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.Property<string>("TradeNo") b.Property<string>("TradeNo")
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasComment("平台交易号。"); .HasComment("系统交易号。");
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone") .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 => modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreDeliveryZone", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -5237,6 +5314,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Color")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasComment("区域颜色。");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。"); .HasComment("创建时间UTC。");
@@ -5272,6 +5354,12 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasComment("GeoJSON 表示的多边形范围。"); .HasComment("GeoJSON 表示的多边形范围。");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(100)
.HasComment("优先级(数值越小越优先)。");
b.Property<int>("SortOrder") b.Property<int>("SortOrder")
.HasColumnType("integer") .HasColumnType("integer")
.HasComment("排序值。"); .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 => modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreEmployeeShift", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -5407,6 +5565,17 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bigint") .HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。"); .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") b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。"); .HasComment("软删除时间UTC未删除时为 null。");
@@ -5430,18 +5599,29 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("numeric(10,2)") .HasColumnType("numeric(10,2)")
.HasComment("起送费(元)。"); .HasComment("起送费(元)。");
b.Property<int>("PackagingFeeMode")
.HasColumnType("integer")
.HasComment("打包费模式。");
b.Property<int>("OrderPackagingFeeMode") b.Property<int>("OrderPackagingFeeMode")
.HasColumnType("integer") .HasColumnType("integer")
.HasComment("订单打包费规则(按订单收费时生效)。"); .HasComment("订单打包费规则(按订单收费时生效)。");
b.Property<int>("PackagingFeeMode")
.HasColumnType("integer")
.HasComment("打包费模式。");
b.Property<string>("PackagingFeeTiersJson") b.Property<string>("PackagingFeeTiersJson")
.HasColumnType("text") .HasColumnType("text")
.HasComment("阶梯打包费配置JSON。"); .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") b.Property<long>("StoreId")
.HasColumnType("bigint") .HasColumnType("bigint")
.HasComment("门店标识。"); .HasComment("门店标识。");
@@ -5600,10 +5780,18 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bigint") .HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。"); .HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("FineRuleJson")
.HasColumnType("text")
.HasComment("精细规则 JSON。");
b.Property<int?>("MaxQuantityPerOrder") b.Property<int?>("MaxQuantityPerOrder")
.HasColumnType("integer") .HasColumnType("integer")
.HasComment("单笔自提最大份数。"); .HasComment("单笔自提最大份数。");
b.Property<int>("Mode")
.HasColumnType("integer")
.HasComment("自提配置模式。");
b.Property<byte[]>("RowVersion") b.Property<byte[]>("RowVersion")
.IsConcurrencyToken() .IsConcurrencyToken()
.IsRequired() .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 => modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreTable", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -6038,7 +6376,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.ToTable("quota_packages", null, t => b.ToTable("quota_packages", null, t =>
{ {
t.HasComment("配额包定义(平台提供的可购买配额包)。"); t.HasComment("配额包定义(系统提供的可购买配额包)。");
}); });
}); });
@@ -6191,7 +6529,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.ToTable("tenants", null, t => b.ToTable("tenants", null, t =>
{ {
t.HasComment("平台租户信息,描述租户的生命周期与基础资料。"); t.HasComment("租户信息,描述租户的生命周期与基础资料。");
}); });
}); });
@@ -6255,7 +6593,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.Property<long?>("PublisherUserId") b.Property<long?>("PublisherUserId")
.HasColumnType("bigint") .HasColumnType("bigint")
.HasComment("发布者用户 ID平台或租户后台账号)。"); .HasComment("发布者用户 ID系统或租户后台账号)。");
b.Property<DateTime?>("RevokedAt") b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
@@ -6629,7 +6967,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("boolean") .HasColumnType("boolean")
.HasComment("是否仍启用(平台控制)。"); .HasComment("是否仍启用(系统控制)。");
b.Property<bool>("IsAllowNewTenantPurchase") b.Property<bool>("IsAllowNewTenantPurchase")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -6720,7 +7058,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.ToTable("tenant_packages", null, t => 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 => modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantSubscription", b =>
{ {
b.Property<long>("Id") 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 => modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.OrderItem", b =>
{ {
b.HasOne("TakeoutSaaS.Domain.Orders.Entities.Order", null) b.HasOne("TakeoutSaaS.Domain.Orders.Entities.Order", null)