241 lines
8.6 KiB
C#
241 lines
8.6 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using TakeoutSaaS.Application.App.Stores.Services;
|
|
using TakeoutSaaS.Domain.Stores.Entities;
|
|
using TakeoutSaaS.Domain.Stores.Enums;
|
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
|
|
|
namespace TakeoutSaaS.Infrastructure.App.Services;
|
|
|
|
/// <summary>
|
|
/// 门店定时任务服务实现。
|
|
/// </summary>
|
|
public sealed class StoreSchedulerService(
|
|
TakeoutAdminDbContext context,
|
|
ILogger<StoreSchedulerService> logger)
|
|
: IStoreSchedulerService
|
|
{
|
|
/// <inheritdoc />
|
|
public async Task<int> AutoSwitchBusinessStatusAsync(DateTime now, CancellationToken cancellationToken)
|
|
{
|
|
// 1. 读取候选门店
|
|
var stores = await context.Stores
|
|
.Where(store => store.DeletedAt == null
|
|
&& store.AuditStatus == StoreAuditStatus.Activated
|
|
&& store.BusinessStatus != StoreBusinessStatus.ForceClosed)
|
|
.ToListAsync(cancellationToken);
|
|
if (stores.Count == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
// 2. (空行后) 读取营业时段与休息日
|
|
var storeIds = stores.Select(store => store.Id).ToArray();
|
|
var hours = await context.StoreBusinessHours
|
|
.AsNoTracking()
|
|
.Where(hour => storeIds.Contains(hour.StoreId))
|
|
.ToListAsync(cancellationToken);
|
|
var today = now.Date;
|
|
var holidays = await context.StoreHolidays
|
|
.AsNoTracking()
|
|
.Where(holiday => storeIds.Contains(holiday.StoreId)
|
|
&& holiday.Date <= today
|
|
&& (holiday.EndDate == null || holiday.EndDate >= today))
|
|
.ToListAsync(cancellationToken);
|
|
|
|
// 3. (空行后) 构造查找表
|
|
var hoursLookup = hours
|
|
.GroupBy(hour => hour.StoreId)
|
|
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoreBusinessHour>)group.ToList());
|
|
var holidayLookup = holidays
|
|
.GroupBy(holiday => holiday.StoreId)
|
|
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoreHoliday>)group.ToList());
|
|
|
|
// 4. (空行后) 判定状态并更新
|
|
var updated = 0;
|
|
foreach (var store in stores)
|
|
{
|
|
// 4.1 跳过强制关闭门店
|
|
if (store.BusinessStatus == StoreBusinessStatus.ForceClosed)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// 4.2 (空行后) 尊重手动歇业原因
|
|
if (store.ClosureReason.HasValue && store.ClosureReason != StoreClosureReason.OutOfBusinessHours)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// 4.3 (空行后) 计算营业状态
|
|
var storeHolidays = holidayLookup.TryGetValue(store.Id, out var matched) ? matched : [];
|
|
var nowTime = now.TimeOfDay;
|
|
var isHolidayClosed = storeHolidays.Any(holiday =>
|
|
holiday.OverrideType == OverrideType.Closed && IsWithinHolidayTime(holiday, nowTime));
|
|
var hasModifiedHours = storeHolidays.Any(holiday => holiday.OverrideType == OverrideType.ModifiedHours);
|
|
var isModifiedOpen = hasModifiedHours && storeHolidays.Any(holiday =>
|
|
holiday.OverrideType == OverrideType.ModifiedHours && IsWithinHolidayTime(holiday, nowTime));
|
|
var isTemporaryOpen = storeHolidays.Any(holiday =>
|
|
holiday.OverrideType == OverrideType.TemporaryOpen && IsWithinHolidayTime(holiday, nowTime));
|
|
var hasHours = hoursLookup.TryGetValue(store.Id, out var storeHours) && storeHours.Count > 0;
|
|
var isOpen = false;
|
|
if (isHolidayClosed)
|
|
{
|
|
isOpen = false;
|
|
}
|
|
else if (hasModifiedHours)
|
|
{
|
|
isOpen = isModifiedOpen;
|
|
}
|
|
else
|
|
{
|
|
isOpen = hasHours && IsWithinBusinessHours(storeHours ?? [], now);
|
|
if (!isOpen && isTemporaryOpen)
|
|
{
|
|
isOpen = true;
|
|
}
|
|
}
|
|
if (isOpen)
|
|
{
|
|
if (store.BusinessStatus != StoreBusinessStatus.Open)
|
|
{
|
|
store.BusinessStatus = StoreBusinessStatus.Open;
|
|
store.ClosureReason = null;
|
|
store.ClosureReasonText = null;
|
|
updated++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// 4.4 (空行后) 非营业时段切换为休息
|
|
if (store.BusinessStatus != StoreBusinessStatus.Resting || store.ClosureReason != StoreClosureReason.OutOfBusinessHours)
|
|
{
|
|
store.BusinessStatus = StoreBusinessStatus.Resting;
|
|
store.ClosureReason = StoreClosureReason.OutOfBusinessHours;
|
|
store.ClosureReasonText = "非营业时间自动休息";
|
|
updated++;
|
|
}
|
|
}
|
|
|
|
// 5. (空行后) 保存变更并记录日志
|
|
if (updated > 0)
|
|
{
|
|
await context.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
logger.LogInformation("定时任务:营业状态自动切换完成,更新 {UpdatedCount} 家门店", updated);
|
|
return updated;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<int> CheckQualificationExpiryAsync(DateTime now, CancellationToken cancellationToken)
|
|
{
|
|
// 1. 查询过期门店
|
|
var today = DateOnly.FromDateTime(now);
|
|
var expiredStoreIds = await context.StoreQualifications
|
|
.AsNoTracking()
|
|
.Where(qualification => qualification.DeletedAt == null
|
|
&& qualification.ExpiresAt.HasValue
|
|
&& qualification.ExpiresAt.Value < today)
|
|
.Select(qualification => qualification.StoreId)
|
|
.Distinct()
|
|
.ToListAsync(cancellationToken);
|
|
if (expiredStoreIds.Count == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
// 2. (空行后) 加载门店并更新状态
|
|
var stores = await context.Stores
|
|
.Where(store => expiredStoreIds.Contains(store.Id)
|
|
&& store.DeletedAt == null
|
|
&& store.AuditStatus == StoreAuditStatus.Activated
|
|
&& store.BusinessStatus != StoreBusinessStatus.ForceClosed)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var updated = 0;
|
|
foreach (var store in stores)
|
|
{
|
|
// 2.1 跳过已标记过期门店
|
|
if (store.BusinessStatus == StoreBusinessStatus.Resting && store.ClosureReason == StoreClosureReason.LicenseExpired)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// 2.2 (空行后) 设置资质过期状态
|
|
store.BusinessStatus = StoreBusinessStatus.Resting;
|
|
store.ClosureReason = StoreClosureReason.LicenseExpired;
|
|
store.ClosureReasonText = "证照过期自动休息";
|
|
updated++;
|
|
}
|
|
|
|
// 3. (空行后) 保存变更并记录日志
|
|
if (updated > 0)
|
|
{
|
|
await context.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
logger.LogInformation("定时任务:资质过期检查完成,更新 {UpdatedCount} 家门店", updated);
|
|
return updated;
|
|
}
|
|
|
|
private static bool IsWithinBusinessHours(IReadOnlyList<StoreBusinessHour> hours, DateTime now)
|
|
{
|
|
// 1. 提取当前时间
|
|
var day = now.DayOfWeek;
|
|
var time = now.TimeOfDay;
|
|
|
|
foreach (var hour in hours)
|
|
{
|
|
if (hour.HourType == BusinessHourType.Closed)
|
|
{
|
|
continue;
|
|
}
|
|
if (hour.StartTime == hour.EndTime)
|
|
{
|
|
continue;
|
|
}
|
|
if (hour.StartTime < hour.EndTime)
|
|
{
|
|
if (hour.DayOfWeek == day && time >= hour.StartTime && time < hour.EndTime)
|
|
{
|
|
return true;
|
|
}
|
|
continue;
|
|
}
|
|
var nextDay = NextDay(hour.DayOfWeek);
|
|
if (hour.DayOfWeek == day && time >= hour.StartTime)
|
|
{
|
|
return true;
|
|
}
|
|
if (nextDay == day && time < hour.EndTime)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool IsWithinHolidayTime(StoreHoliday holiday, TimeSpan time)
|
|
{
|
|
if (holiday.IsAllDay)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!holiday.StartTime.HasValue || !holiday.EndTime.HasValue)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return time >= holiday.StartTime.Value && time < holiday.EndTime.Value;
|
|
}
|
|
|
|
private static DayOfWeek NextDay(DayOfWeek day)
|
|
{
|
|
var next = (int)day + 1;
|
|
return next > 6 ? DayOfWeek.Sunday : (DayOfWeek)next;
|
|
}
|
|
}
|