feat(store): 扩展临时时段配置

This commit is contained in:
2026-01-20 18:41:34 +08:00
parent 3385674490
commit 32f5bbbd43
15 changed files with 7871 additions and 33 deletions

View File

@@ -1,10 +1,11 @@
using MediatR;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Domain.Stores.Enums;
namespace TakeoutSaaS.Application.App.Stores.Commands;
/// <summary>
/// 创建节假日配置命令。
/// 创建临时时段配置命令。
/// </summary>
public sealed record CreateStoreHolidayCommand : IRequest<StoreHolidayDto>
{
@@ -14,14 +15,34 @@ public sealed record CreateStoreHolidayCommand : IRequest<StoreHolidayDto>
public long StoreId { get; init; }
/// <summary>
/// 日期。
/// 开始日期。
/// </summary>
public DateTime Date { get; init; }
/// <summary>
/// 是否闭店
/// 结束日期(可选)
/// </summary>
public bool IsClosed { get; init; } = true;
public DateTime? EndDate { get; init; }
/// <summary>
/// 是否全天。
/// </summary>
public bool IsAllDay { get; init; } = true;
/// <summary>
/// 开始时间IsAllDay=false 时必填)。
/// </summary>
public TimeSpan? StartTime { get; init; }
/// <summary>
/// 结束时间IsAllDay=false 时必填)。
/// </summary>
public TimeSpan? EndTime { get; init; }
/// <summary>
/// 覆盖类型。
/// </summary>
public OverrideType OverrideType { get; init; } = OverrideType.Closed;
/// <summary>
/// 说明。

View File

@@ -1,10 +1,11 @@
using MediatR;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Domain.Stores.Enums;
namespace TakeoutSaaS.Application.App.Stores.Commands;
/// <summary>
/// 更新节假日配置命令。
/// 更新临时时段配置命令。
/// </summary>
public sealed record UpdateStoreHolidayCommand : IRequest<StoreHolidayDto?>
{
@@ -19,14 +20,34 @@ public sealed record UpdateStoreHolidayCommand : IRequest<StoreHolidayDto?>
public long StoreId { get; init; }
/// <summary>
/// 日期。
/// 开始日期。
/// </summary>
public DateTime Date { get; init; }
/// <summary>
/// 是否闭店
/// 结束日期(可选)
/// </summary>
public bool IsClosed { get; init; } = true;
public DateTime? EndDate { get; init; }
/// <summary>
/// 是否全天。
/// </summary>
public bool IsAllDay { get; init; } = true;
/// <summary>
/// 开始时间IsAllDay=false 时必填)。
/// </summary>
public TimeSpan? StartTime { get; init; }
/// <summary>
/// 结束时间IsAllDay=false 时必填)。
/// </summary>
public TimeSpan? EndTime { get; init; }
/// <summary>
/// 覆盖类型。
/// </summary>
public OverrideType OverrideType { get; init; } = OverrideType.Closed;
/// <summary>
/// 说明。

View File

@@ -1,10 +1,11 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Stores.Dto;
/// <summary>
/// 门店节假日 DTO。
/// 门店临时时段 DTO。
/// </summary>
public sealed record StoreHolidayDto
{
@@ -27,12 +28,37 @@ public sealed record StoreHolidayDto
public long StoreId { get; init; }
/// <summary>
/// 日期。
/// 开始日期。
/// </summary>
public DateTime Date { get; init; }
/// <summary>
/// 是否闭店
/// 结束日期(可选)
/// </summary>
public DateTime? EndDate { get; init; }
/// <summary>
/// 是否全天。
/// </summary>
public bool IsAllDay { get; init; }
/// <summary>
/// 开始时间。
/// </summary>
public TimeSpan? StartTime { get; init; }
/// <summary>
/// 结束时间。
/// </summary>
public TimeSpan? EndTime { get; init; }
/// <summary>
/// 覆盖类型。
/// </summary>
public OverrideType OverrideType { get; init; }
/// <summary>
/// 是否闭店(兼容)。
/// </summary>
public bool IsClosed { get; init; }

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Stores.Commands;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
@@ -39,7 +40,12 @@ public sealed class CreateStoreHolidayCommandHandler(
{
StoreId = request.StoreId,
Date = request.Date,
IsClosed = request.IsClosed,
EndDate = request.EndDate,
IsAllDay = request.IsAllDay,
StartTime = request.StartTime,
EndTime = request.EndTime,
OverrideType = request.OverrideType,
IsClosed = request.OverrideType == OverrideType.Closed,
Reason = request.Reason?.Trim()
};

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Stores.Commands;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
@@ -42,7 +43,12 @@ public sealed class UpdateStoreHolidayCommandHandler(
// 3. 更新字段
existing.Date = request.Date;
existing.IsClosed = request.IsClosed;
existing.EndDate = request.EndDate;
existing.IsAllDay = request.IsAllDay;
existing.StartTime = request.StartTime;
existing.EndTime = request.EndTime;
existing.OverrideType = request.OverrideType;
existing.IsClosed = request.OverrideType == OverrideType.Closed;
existing.Reason = request.Reason?.Trim();
// 4. 持久化

View File

@@ -150,6 +150,11 @@ public static class StoreMapping
TenantId = holiday.TenantId,
StoreId = holiday.StoreId,
Date = holiday.Date,
EndDate = holiday.EndDate,
IsAllDay = holiday.IsAllDay,
StartTime = holiday.StartTime,
EndTime = holiday.EndTime,
OverrideType = holiday.OverrideType,
IsClosed = holiday.IsClosed,
Reason = holiday.Reason,
CreatedAt = holiday.CreatedAt

View File

@@ -15,6 +15,27 @@ public sealed class CreateStoreHolidayCommandValidator : AbstractValidator<Creat
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.Date).NotEmpty();
RuleFor(x => x.Reason).MaximumLength(256);
RuleFor(x => x.EndDate)
.GreaterThanOrEqualTo(x => x.Date)
.When(x => x.EndDate.HasValue)
.WithMessage("结束日期不能早于开始日期");
RuleFor(x => x.StartTime)
.NotNull()
.When(x => !x.IsAllDay)
.WithMessage("非全天模式下必须填写开始时间");
RuleFor(x => x.EndTime)
.NotNull()
.When(x => !x.IsAllDay)
.WithMessage("非全天模式下必须填写结束时间");
RuleFor(x => x.EndTime)
.GreaterThan(x => x.StartTime)
.When(x => !x.IsAllDay && x.StartTime.HasValue && x.EndTime.HasValue)
.WithMessage("结束时间必须晚于开始时间");
RuleFor(x => x.Reason).MaximumLength(200);
}
}

View File

@@ -16,6 +16,27 @@ public sealed class UpdateStoreHolidayCommandValidator : AbstractValidator<Updat
RuleFor(x => x.HolidayId).GreaterThan(0);
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.Date).NotEmpty();
RuleFor(x => x.Reason).MaximumLength(256);
RuleFor(x => x.EndDate)
.GreaterThanOrEqualTo(x => x.Date)
.When(x => x.EndDate.HasValue)
.WithMessage("结束日期不能早于开始日期");
RuleFor(x => x.StartTime)
.NotNull()
.When(x => !x.IsAllDay)
.WithMessage("非全天模式下必须填写开始时间");
RuleFor(x => x.EndTime)
.NotNull()
.When(x => !x.IsAllDay)
.WithMessage("非全天模式下必须填写结束时间");
RuleFor(x => x.EndTime)
.GreaterThan(x => x.StartTime)
.When(x => !x.IsAllDay && x.StartTime.HasValue && x.EndTime.HasValue)
.WithMessage("结束时间必须晚于开始时间");
RuleFor(x => x.Reason).MaximumLength(200);
}
}

View File

@@ -1,9 +1,10 @@
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Stores.Entities;
/// <summary>
/// 门店休息日或特殊营业日
/// 门店临时时段配置(节假日/歇业/调整营业时间)
/// </summary>
public sealed class StoreHoliday : MultiTenantEntityBase
{
@@ -13,12 +14,37 @@ public sealed class StoreHoliday : MultiTenantEntityBase
public long StoreId { get; set; }
/// <summary>
/// 日期
/// 开始日期(原 Date 字段)
/// </summary>
public DateTime Date { get; set; }
/// <summary>
/// 是否全天闭店
/// 结束日期(可选,用于日期范围,如春节 1.28~2.4
/// </summary>
public DateTime? EndDate { get; set; }
/// <summary>
/// 是否全天生效。true=全天false=仅 StartTime~EndTime 时段。
/// </summary>
public bool IsAllDay { get; set; } = true;
/// <summary>
/// 开始时间IsAllDay=false 时使用)。
/// </summary>
public TimeSpan? StartTime { get; set; }
/// <summary>
/// 结束时间IsAllDay=false 时使用)。
/// </summary>
public TimeSpan? EndTime { get; set; }
/// <summary>
/// 覆盖类型(闭店/临时营业/调整时间)。
/// </summary>
public OverrideType OverrideType { get; set; } = OverrideType.Closed;
/// <summary>
/// 是否闭店(兼容旧数据,新逻辑请用 OverrideType
/// </summary>
public bool IsClosed { get; set; } = true;

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Stores.Enums;
/// <summary>
/// 临时时段覆盖类型。
/// </summary>
public enum OverrideType
{
/// <summary>
/// 闭店(歇业)——覆盖常规营业时段,该时间段不营业。
/// </summary>
Closed = 0,
/// <summary>
/// 临时营业——覆盖常规休息日,使门店临时营业。
/// </summary>
TemporaryOpen = 1,
/// <summary>
/// 调整营业时间——覆盖常规时段,使用自定义营业时间。
/// </summary>
ModifiedHours = 2
}

View File

@@ -18,6 +18,7 @@ using TakeoutSaaS.Domain.Products.Entities;
using TakeoutSaaS.Domain.Queues.Entities;
using TakeoutSaaS.Domain.Reservations.Entities;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Infrastructure.Common.Persistence;
@@ -959,8 +960,15 @@ public sealed class TakeoutAppDbContext(
builder.ToTable("store_holidays");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.Reason).HasMaxLength(256);
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Date }).IsUnique();
builder.Property(x => x.Date).IsRequired();
builder.Property(x => x.EndDate);
builder.Property(x => x.IsAllDay).HasDefaultValue(true);
builder.Property(x => x.StartTime);
builder.Property(x => x.EndTime);
builder.Property(x => x.OverrideType).HasDefaultValue(OverrideType.Closed);
builder.Property(x => x.IsClosed).HasDefaultValue(true);
builder.Property(x => x.Reason).HasMaxLength(200);
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Date });
}
private static void ConfigureStoreDeliveryZone(EntityTypeBuilder<StoreDeliveryZone> builder)

View File

@@ -39,15 +39,17 @@ public sealed class StoreSchedulerService(
var holidays = await context.StoreHolidays
.AsNoTracking()
.Where(holiday => storeIds.Contains(holiday.StoreId)
&& holiday.IsClosed
&& holiday.Date.Date == today)
&& 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 holidaySet = holidays.Select(holiday => holiday.StoreId).ToHashSet();
var holidayLookup = holidays
.GroupBy(holiday => holiday.StoreId)
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoreHoliday>)group.ToList());
// 4. (空行后) 判定状态并更新
var updated = 0;
@@ -66,9 +68,33 @@ public sealed class StoreSchedulerService(
}
// 4.3 (空行后) 计算营业状态
var isHolidayClosed = holidaySet.Contains(store.Id);
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 = !isHolidayClosed && hasHours && IsWithinBusinessHours(storeHours ?? [], now);
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)
@@ -191,6 +217,21 @@ public sealed class StoreSchedulerService(
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;

View File

@@ -0,0 +1,173 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ExtendStoreHolidayForTemporaryHours : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_store_holidays_TenantId_StoreId_Date",
table: "store_holidays");
migrationBuilder.AlterTable(
name: "store_holidays",
comment: "门店临时时段配置(节假日/歇业/调整营业时间)。",
oldComment: "门店休息日或特殊营业日。");
migrationBuilder.AlterColumn<string>(
name: "Reason",
table: "store_holidays",
type: "character varying(200)",
maxLength: 200,
nullable: true,
comment: "说明内容。",
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256,
oldNullable: true,
oldComment: "说明内容。");
migrationBuilder.AlterColumn<bool>(
name: "IsClosed",
table: "store_holidays",
type: "boolean",
nullable: false,
defaultValue: true,
comment: "是否闭店(兼容旧数据,新逻辑请用 OverrideType。",
oldClrType: typeof(bool),
oldType: "boolean",
oldComment: "是否全天闭店。");
migrationBuilder.AlterColumn<DateTime>(
name: "Date",
table: "store_holidays",
type: "timestamp with time zone",
nullable: false,
comment: "开始日期(原 Date 字段)。",
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldComment: "日期。");
migrationBuilder.AddColumn<DateTime>(
name: "EndDate",
table: "store_holidays",
type: "timestamp with time zone",
nullable: true,
comment: "结束日期(可选,用于日期范围,如春节 1.28~2.4)。");
migrationBuilder.AddColumn<TimeSpan>(
name: "EndTime",
table: "store_holidays",
type: "interval",
nullable: true,
comment: "结束时间IsAllDay=false 时使用)。");
migrationBuilder.AddColumn<bool>(
name: "IsAllDay",
table: "store_holidays",
type: "boolean",
nullable: false,
defaultValue: true,
comment: "是否全天生效。true=全天false=仅 StartTime~EndTime 时段。");
migrationBuilder.AddColumn<int>(
name: "OverrideType",
table: "store_holidays",
type: "integer",
nullable: false,
defaultValue: 0,
comment: "覆盖类型(闭店/临时营业/调整时间)。");
migrationBuilder.AddColumn<TimeSpan>(
name: "StartTime",
table: "store_holidays",
type: "interval",
nullable: true,
comment: "开始时间IsAllDay=false 时使用)。");
migrationBuilder.CreateIndex(
name: "IX_store_holidays_TenantId_StoreId_Date",
table: "store_holidays",
columns: new[] { "TenantId", "StoreId", "Date" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_store_holidays_TenantId_StoreId_Date",
table: "store_holidays");
migrationBuilder.DropColumn(
name: "EndDate",
table: "store_holidays");
migrationBuilder.DropColumn(
name: "EndTime",
table: "store_holidays");
migrationBuilder.DropColumn(
name: "IsAllDay",
table: "store_holidays");
migrationBuilder.DropColumn(
name: "OverrideType",
table: "store_holidays");
migrationBuilder.DropColumn(
name: "StartTime",
table: "store_holidays");
migrationBuilder.AlterTable(
name: "store_holidays",
comment: "门店休息日或特殊营业日。",
oldComment: "门店临时时段配置(节假日/歇业/调整营业时间)。");
migrationBuilder.AlterColumn<string>(
name: "Reason",
table: "store_holidays",
type: "character varying(256)",
maxLength: 256,
nullable: true,
comment: "说明内容。",
oldClrType: typeof(string),
oldType: "character varying(200)",
oldMaxLength: 200,
oldNullable: true,
oldComment: "说明内容。");
migrationBuilder.AlterColumn<bool>(
name: "IsClosed",
table: "store_holidays",
type: "boolean",
nullable: false,
comment: "是否全天闭店。",
oldClrType: typeof(bool),
oldType: "boolean",
oldDefaultValue: true,
oldComment: "是否闭店(兼容旧数据,新逻辑请用 OverrideType。");
migrationBuilder.AlterColumn<DateTime>(
name: "Date",
table: "store_holidays",
type: "timestamp with time zone",
nullable: false,
comment: "日期。",
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldComment: "开始日期(原 Date 字段)。");
migrationBuilder.CreateIndex(
name: "IX_store_holidays_TenantId_StoreId_Date",
table: "store_holidays",
columns: new[] { "TenantId", "StoreId", "Date" },
unique: true);
}
}
}

View File

@@ -5482,7 +5482,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone")
.HasComment("日期。");
.HasComment("开始日期(原 Date 字段)。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
@@ -5492,15 +5492,41 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<bool>("IsClosed")
b.Property<DateTime?>("EndDate")
.HasColumnType("timestamp with time zone")
.HasComment("结束日期(可选,用于日期范围,如春节 1.28~2.4)。");
b.Property<TimeSpan?>("EndTime")
.HasColumnType("interval")
.HasComment("结束时间IsAllDay=false 时使用)。");
b.Property<bool>("IsAllDay")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasComment("是否全天闭店。");
.HasDefaultValue(true)
.HasComment("是否全天生效。true=全天false=仅 StartTime~EndTime 时段。");
b.Property<bool>("IsClosed")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasComment("是否闭店(兼容旧数据,新逻辑请用 OverrideType。");
b.Property<int>("OverrideType")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasComment("覆盖类型(闭店/临时营业/调整时间)。");
b.Property<string>("Reason")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasComment("说明内容。");
b.Property<TimeSpan?>("StartTime")
.HasColumnType("interval")
.HasComment("开始时间IsAllDay=false 时使用)。");
b.Property<long>("StoreId")
.HasColumnType("bigint")
.HasComment("门店标识。");
@@ -5519,12 +5545,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("TenantId", "StoreId", "Date")
.IsUnique();
b.HasIndex("TenantId", "StoreId", "Date");
b.ToTable("store_holidays", null, t =>
{
t.HasComment("门店休息日或特殊营业日。");
t.HasComment("门店临时时段配置(节假日/歇业/调整营业时间)。");
});
});