feat: 桌码管理支持区域、批量生成与二维码导出

This commit is contained in:
2025-12-04 09:10:00 +08:00
parent 9051a024ea
commit 1a5209a8b1
33 changed files with 1343 additions and 2 deletions

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.App.Stores.Dto;
namespace TakeoutSaaS.Application.App.Stores.Commands;
/// <summary>
/// 创建桌台区域命令。
/// </summary>
public sealed record CreateStoreTableAreaCommand : IRequest<StoreTableAreaDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 区域名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 区域描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; init; } = 100;
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Stores.Commands;
/// <summary>
/// 删除桌台区域命令。
/// </summary>
public sealed record DeleteStoreTableAreaCommand : IRequest<bool>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 区域 ID。
/// </summary>
public long AreaId { get; init; }
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Stores.Commands;
/// <summary>
/// 删除桌码命令。
/// </summary>
public sealed record DeleteStoreTableCommand : IRequest<bool>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 桌台 ID。
/// </summary>
public long TableId { get; init; }
}

View File

@@ -0,0 +1,45 @@
using MediatR;
using TakeoutSaaS.Application.App.Stores.Dto;
namespace TakeoutSaaS.Application.App.Stores.Commands;
/// <summary>
/// 批量生成桌码命令。
/// </summary>
public sealed record GenerateStoreTablesCommand : IRequest<IReadOnlyList<StoreTableDto>>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 桌码前缀。
/// </summary>
public string TableCodePrefix { get; init; } = "T";
/// <summary>
/// 起始序号。
/// </summary>
public int StartNumber { get; init; } = 1;
/// <summary>
/// 生成数量。
/// </summary>
public int Count { get; init; }
/// <summary>
/// 默认容量。
/// </summary>
public int DefaultCapacity { get; init; } = 2;
/// <summary>
/// 区域 ID。
/// </summary>
public long? AreaId { get; init; }
/// <summary>
/// 标签。
/// </summary>
public string? Tags { get; init; }
}

View File

@@ -0,0 +1,35 @@
using MediatR;
using TakeoutSaaS.Application.App.Stores.Dto;
namespace TakeoutSaaS.Application.App.Stores.Commands;
/// <summary>
/// 更新桌台区域命令。
/// </summary>
public sealed record UpdateStoreTableAreaCommand : IRequest<StoreTableAreaDto?>
{
/// <summary>
/// 区域 ID。
/// </summary>
public long AreaId { get; init; }
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 区域名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 区域描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; init; } = 100;
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Domain.Stores.Enums;
namespace TakeoutSaaS.Application.App.Stores.Commands;
/// <summary>
/// 更新桌码命令。
/// </summary>
public sealed record UpdateStoreTableCommand : IRequest<StoreTableDto?>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 桌台 ID。
/// </summary>
public long TableId { get; init; }
/// <summary>
/// 区域 ID。
/// </summary>
public long? AreaId { get; init; }
/// <summary>
/// 桌码。
/// </summary>
public string TableCode { get; init; } = string.Empty;
/// <summary>
/// 容量。
/// </summary>
public int Capacity { get; init; }
/// <summary>
/// 标签。
/// </summary>
public string? Tags { get; init; }
/// <summary>
/// 状态。
/// </summary>
public StoreTableStatus Status { get; init; } = StoreTableStatus.Idle;
}

View File

@@ -0,0 +1,48 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Stores.Dto;
/// <summary>
/// 桌台区域 DTO。
/// </summary>
public sealed record StoreTableAreaDto
{
/// <summary>
/// 区域 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 门店 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long StoreId { get; init; }
/// <summary>
/// 区域名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 区域描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,65 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Stores.Dto;
/// <summary>
/// 桌台 DTO。
/// </summary>
public sealed record StoreTableDto
{
/// <summary>
/// 桌台 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 门店 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long StoreId { get; init; }
/// <summary>
/// 区域 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long? AreaId { get; init; }
/// <summary>
/// 桌码。
/// </summary>
public string TableCode { get; init; } = string.Empty;
/// <summary>
/// 容量。
/// </summary>
public int Capacity { get; init; }
/// <summary>
/// 标签。
/// </summary>
public string? Tags { get; init; }
/// <summary>
/// 状态。
/// </summary>
public StoreTableStatus Status { get; init; }
/// <summary>
/// 二维码地址。
/// </summary>
public string? QrCodeUrl { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Stores.Dto;
/// <summary>
/// 桌台二维码导出结果。
/// </summary>
public sealed record StoreTableExportResult
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; init; } = string.Empty;
/// <summary>
/// 内容类型。
/// </summary>
public string ContentType { get; init; } = string.Empty;
/// <summary>
/// 文件内容。
/// </summary>
public byte[] Content { get; init; } = [];
}

View File

@@ -0,0 +1,58 @@
using MediatR;
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.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Stores.Handlers;
/// <summary>
/// 创建桌台区域处理器。
/// </summary>
public sealed class CreateStoreTableAreaCommandHandler(
IStoreRepository storeRepository,
ITenantProvider tenantProvider,
ILogger<CreateStoreTableAreaCommandHandler> logger)
: IRequestHandler<CreateStoreTableAreaCommand, StoreTableAreaDto>
{
/// <inheritdoc />
public async Task<StoreTableAreaDto> Handle(CreateStoreTableAreaCommand request, CancellationToken cancellationToken)
{
// 1. 校验门店存在
var tenantId = tenantProvider.GetCurrentTenantId();
var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken);
if (store is null)
{
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
}
// 2. 校验区域名称唯一
var existingAreas = await storeRepository.GetTableAreasAsync(request.StoreId, tenantId, cancellationToken);
var hasDuplicate = existingAreas.Any(x => x.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase));
if (hasDuplicate)
{
throw new BusinessException(ErrorCodes.Conflict, "区域名称已存在");
}
// 3. 构建实体
var area = new StoreTableArea
{
StoreId = request.StoreId,
Name = request.Name.Trim(),
Description = request.Description?.Trim(),
SortOrder = request.SortOrder
};
// 4. 持久化
await storeRepository.AddTableAreasAsync(new[] { area }, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("创建桌台区域 {AreaId} 对应门店 {StoreId}", area.Id, request.StoreId);
// 5. 返回 DTO
return StoreMapping.ToDto(area);
}
}

View File

@@ -0,0 +1,52 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Stores.Commands;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Stores.Handlers;
/// <summary>
/// 删除桌台区域处理器。
/// </summary>
public sealed class DeleteStoreTableAreaCommandHandler(
IStoreRepository storeRepository,
ITenantProvider tenantProvider,
ILogger<DeleteStoreTableAreaCommandHandler> logger)
: IRequestHandler<DeleteStoreTableAreaCommand, bool>
{
/// <inheritdoc />
public async Task<bool> Handle(DeleteStoreTableAreaCommand request, CancellationToken cancellationToken)
{
// 1. 读取区域
var tenantId = tenantProvider.GetCurrentTenantId();
var area = await storeRepository.FindTableAreaByIdAsync(request.AreaId, tenantId, cancellationToken);
if (area is null)
{
return false;
}
// 2. 校验门店归属
if (area.StoreId != request.StoreId)
{
return false;
}
// 3. 校验区域下无桌码
var tables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken);
var hasTable = tables.Any(x => x.AreaId == request.AreaId);
if (hasTable)
{
throw new BusinessException(ErrorCodes.Conflict, "区域下仍有桌码,无法删除");
}
// 4. 删除
await storeRepository.DeleteTableAreaAsync(request.AreaId, tenantId, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("删除桌台区域 {AreaId} 对应门店 {StoreId}", request.AreaId, request.StoreId);
return true;
}
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Stores.Commands;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Stores.Handlers;
/// <summary>
/// 删除桌码处理器。
/// </summary>
public sealed class DeleteStoreTableCommandHandler(
IStoreRepository storeRepository,
ITenantProvider tenantProvider,
ILogger<DeleteStoreTableCommandHandler> logger)
: IRequestHandler<DeleteStoreTableCommand, bool>
{
/// <inheritdoc />
public async Task<bool> Handle(DeleteStoreTableCommand request, CancellationToken cancellationToken)
{
// 1. 读取桌码
var tenantId = tenantProvider.GetCurrentTenantId();
var table = await storeRepository.FindTableByIdAsync(request.TableId, tenantId, cancellationToken);
if (table is null || table.StoreId != request.StoreId)
{
return false;
}
// 2. 删除
await storeRepository.DeleteTableAsync(request.TableId, tenantId, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("删除桌码 {TableId} 对应门店 {StoreId}", request.TableId, request.StoreId);
return true;
}
}

View File

@@ -0,0 +1,86 @@
using System.IO.Compression;
using System.Linq;
using System.Text;
using MediatR;
using Microsoft.Extensions.Logging;
using QRCoder;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Application.App.Stores.Queries;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Stores.Handlers;
/// <summary>
/// 导出桌码二维码处理器。
/// </summary>
public sealed class ExportStoreTableQRCodesQueryHandler(
IStoreRepository storeRepository,
ITenantProvider tenantProvider,
ILogger<ExportStoreTableQRCodesQueryHandler> logger)
: IRequestHandler<ExportStoreTableQRCodesQuery, StoreTableExportResult?>
{
/// <inheritdoc />
public async Task<StoreTableExportResult?> Handle(ExportStoreTableQRCodesQuery request, CancellationToken cancellationToken)
{
// 1. 校验门店存在
var tenantId = tenantProvider.GetCurrentTenantId();
var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken);
if (store is null)
{
return null;
}
// 2. 获取桌码列表
var tables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken);
if (request.AreaId.HasValue)
{
tables = tables.Where(x => x.AreaId == request.AreaId.Value).ToList();
}
if (tables.Count == 0)
{
return null;
}
// 3. 生成 ZIP
var template = string.IsNullOrWhiteSpace(request.QrContentTemplate) ? "{code}" : request.QrContentTemplate!;
using var memoryStream = new MemoryStream();
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true, Encoding.UTF8))
{
foreach (var table in tables)
{
var content = BuildPayload(template, table.TableCode);
var svg = RenderSvg(content);
var entry = archive.CreateEntry($"{table.TableCode}.svg", CompressionLevel.Fastest);
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream, Encoding.UTF8);
writer.Write(svg);
}
}
// 4. 返回导出结果
var fileName = $"store_{request.StoreId}_tables_{DateTime.UtcNow:yyyyMMddHHmmss}.zip";
logger.LogInformation("导出门店 {StoreId} 桌码二维码 {Count} 个", request.StoreId, tables.Count);
return new StoreTableExportResult
{
FileName = fileName,
ContentType = "application/zip",
Content = memoryStream.ToArray()
};
}
private static string BuildPayload(string template, string tableCode)
{
var payload = template.Replace("{code}", tableCode, StringComparison.OrdinalIgnoreCase);
return string.IsNullOrWhiteSpace(payload) ? tableCode : payload;
}
private static string RenderSvg(string payload)
{
using var generator = new QRCodeGenerator();
var data = generator.CreateQrCode(payload, QRCodeGenerator.ECCLevel.Q);
var svg = new SvgQRCode(data);
return svg.GetGraphic(5);
}
}

View File

@@ -0,0 +1,73 @@
using System.Linq;
using MediatR;
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.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Stores.Handlers;
/// <summary>
/// 批量生成桌码处理器。
/// </summary>
public sealed class GenerateStoreTablesCommandHandler(
IStoreRepository storeRepository,
ITenantProvider tenantProvider,
ILogger<GenerateStoreTablesCommandHandler> logger)
: IRequestHandler<GenerateStoreTablesCommand, IReadOnlyList<StoreTableDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<StoreTableDto>> Handle(GenerateStoreTablesCommand request, CancellationToken cancellationToken)
{
// 1. 校验门店存在
var tenantId = tenantProvider.GetCurrentTenantId();
var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken);
if (store is null)
{
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
}
// 2. 校验区域归属
if (request.AreaId.HasValue)
{
var area = await storeRepository.FindTableAreaByIdAsync(request.AreaId.Value, tenantId, cancellationToken);
if (area is null || area.StoreId != request.StoreId)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "桌台区域不存在或不属于该门店");
}
}
// 3. 校验桌码唯一性
var existingTables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken);
var newCodes = Enumerable.Range(request.StartNumber, request.Count)
.Select(i => $"{request.TableCodePrefix.Trim()}{i}")
.ToList();
var conflicts = existingTables.Where(t => newCodes.Contains(t.TableCode, StringComparer.OrdinalIgnoreCase)).ToList();
if (conflicts.Count > 0)
{
throw new BusinessException(ErrorCodes.Conflict, "桌码已存在,生成失败");
}
// 4. 构建实体
var tables = newCodes.Select(code => new StoreTable
{
StoreId = request.StoreId,
AreaId = request.AreaId,
TableCode = code,
Capacity = request.DefaultCapacity,
Tags = request.Tags?.Trim()
}).ToList();
// 5. 持久化
await storeRepository.AddTablesAsync(tables, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("批量创建桌码 {Count} 条 对应门店 {StoreId}", tables.Count, request.StoreId);
// 6. 返回 DTO
return tables.Select(StoreMapping.ToDto).ToList();
}
}

View File

@@ -0,0 +1,26 @@
using System.Linq;
using MediatR;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Application.App.Stores.Queries;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Stores.Handlers;
/// <summary>
/// 桌台区域列表查询处理器。
/// </summary>
public sealed class ListStoreTableAreasQueryHandler(IStoreRepository storeRepository, ITenantProvider tenantProvider)
: IRequestHandler<ListStoreTableAreasQuery, IReadOnlyList<StoreTableAreaDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<StoreTableAreaDto>> Handle(ListStoreTableAreasQuery request, CancellationToken cancellationToken)
{
// 1. 查询区域列表
var tenantId = tenantProvider.GetCurrentTenantId();
var areas = await storeRepository.GetTableAreasAsync(request.StoreId, tenantId, cancellationToken);
// 2. 映射 DTO
return areas.Select(StoreMapping.ToDto).ToList();
}
}

View File

@@ -0,0 +1,39 @@
using System.Linq;
using MediatR;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Application.App.Stores.Queries;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Stores.Handlers;
/// <summary>
/// 桌码列表查询处理器。
/// </summary>
public sealed class ListStoreTablesQueryHandler(
IStoreRepository storeRepository,
ITenantProvider tenantProvider)
: IRequestHandler<ListStoreTablesQuery, IReadOnlyList<StoreTableDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<StoreTableDto>> Handle(ListStoreTablesQuery request, CancellationToken cancellationToken)
{
// 1. 查询桌码列表
var tenantId = tenantProvider.GetCurrentTenantId();
var tables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken);
// 2. 过滤
if (request.AreaId.HasValue)
{
tables = tables.Where(x => x.AreaId == request.AreaId.Value).ToList();
}
if (request.Status.HasValue)
{
tables = tables.Where(x => x.Status == request.Status.Value).ToList();
}
// 3. 映射 DTO
return tables.Select(StoreMapping.ToDto).ToList();
}
}

View File

@@ -0,0 +1,59 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Stores.Commands;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Stores.Handlers;
/// <summary>
/// 更新桌台区域处理器。
/// </summary>
public sealed class UpdateStoreTableAreaCommandHandler(
IStoreRepository storeRepository,
ITenantProvider tenantProvider,
ILogger<UpdateStoreTableAreaCommandHandler> logger)
: IRequestHandler<UpdateStoreTableAreaCommand, StoreTableAreaDto?>
{
/// <inheritdoc />
public async Task<StoreTableAreaDto?> Handle(UpdateStoreTableAreaCommand request, CancellationToken cancellationToken)
{
// 1. 读取区域
var tenantId = tenantProvider.GetCurrentTenantId();
var area = await storeRepository.FindTableAreaByIdAsync(request.AreaId, tenantId, cancellationToken);
if (area is null)
{
return null;
}
// 2. 校验门店归属
if (area.StoreId != request.StoreId)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "区域不属于该门店");
}
// 3. 名称唯一校验
var areas = await storeRepository.GetTableAreasAsync(request.StoreId, tenantId, cancellationToken);
var hasDuplicate = areas.Any(x => x.Id != request.AreaId && x.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase));
if (hasDuplicate)
{
throw new BusinessException(ErrorCodes.Conflict, "区域名称已存在");
}
// 4. 更新字段
area.Name = request.Name.Trim();
area.Description = request.Description?.Trim();
area.SortOrder = request.SortOrder;
// 5. 持久化
await storeRepository.UpdateTableAreaAsync(area, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("更新桌台区域 {AreaId} 对应门店 {StoreId}", area.Id, area.StoreId);
// 6. 返回 DTO
return StoreMapping.ToDto(area);
}
}

View File

@@ -0,0 +1,72 @@
using System.Linq;
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Stores.Commands;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Stores.Handlers;
/// <summary>
/// 更新桌码处理器。
/// </summary>
public sealed class UpdateStoreTableCommandHandler(
IStoreRepository storeRepository,
ITenantProvider tenantProvider,
ILogger<UpdateStoreTableCommandHandler> logger)
: IRequestHandler<UpdateStoreTableCommand, StoreTableDto?>
{
/// <inheritdoc />
public async Task<StoreTableDto?> Handle(UpdateStoreTableCommand request, CancellationToken cancellationToken)
{
// 1. 读取桌码
var tenantId = tenantProvider.GetCurrentTenantId();
var table = await storeRepository.FindTableByIdAsync(request.TableId, tenantId, cancellationToken);
if (table is null)
{
return null;
}
// 2. 校验门店归属
if (table.StoreId != request.StoreId)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "桌码不属于该门店");
}
// 3. 校验区域归属
if (request.AreaId.HasValue)
{
var area = await storeRepository.FindTableAreaByIdAsync(request.AreaId.Value, tenantId, cancellationToken);
if (area is null || area.StoreId != request.StoreId)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "桌台区域不存在或不属于该门店");
}
}
// 4. 校验桌码唯一
var tables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken);
var exists = tables.Any(x => x.Id != request.TableId && x.TableCode.Equals(request.TableCode, StringComparison.OrdinalIgnoreCase));
if (exists)
{
throw new BusinessException(ErrorCodes.Conflict, "桌码已存在");
}
// 5. 更新字段
table.AreaId = request.AreaId;
table.TableCode = request.TableCode.Trim();
table.Capacity = request.Capacity;
table.Tags = request.Tags?.Trim();
table.Status = request.Status;
// 6. 持久化
await storeRepository.UpdateTableAsync(table, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("更新桌码 {TableId} 对应门店 {StoreId}", table.Id, table.StoreId);
// 7. 返回 DTO
return StoreMapping.ToDto(table);
}
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Stores.Dto;
namespace TakeoutSaaS.Application.App.Stores.Queries;
/// <summary>
/// 导出桌码二维码查询。
/// </summary>
public sealed record ExportStoreTableQRCodesQuery : IRequest<StoreTableExportResult?>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 区域筛选。
/// </summary>
public long? AreaId { get; init; }
/// <summary>
/// 内容模板,使用 {code} 占位。
/// </summary>
public string? QrContentTemplate { get; init; }
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Stores.Dto;
namespace TakeoutSaaS.Application.App.Stores.Queries;
/// <summary>
/// 门店桌台区域列表查询。
/// </summary>
public sealed record ListStoreTableAreasQuery : IRequest<IReadOnlyList<StoreTableAreaDto>>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
}

View File

@@ -0,0 +1,26 @@
using MediatR;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Domain.Stores.Enums;
namespace TakeoutSaaS.Application.App.Stores.Queries;
/// <summary>
/// 门店桌码列表查询。
/// </summary>
public sealed record ListStoreTablesQuery : IRequest<IReadOnlyList<StoreTableDto>>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 区域筛选。
/// </summary>
public long? AreaId { get; init; }
/// <summary>
/// 状态筛选。
/// </summary>
public StoreTableStatus? Status { get; init; }
}

View File

@@ -61,4 +61,39 @@ public static class StoreMapping
Reason = holiday.Reason,
CreatedAt = holiday.CreatedAt
};
/// <summary>
/// 映射桌台区域 DTO。
/// </summary>
/// <param name="area">区域实体。</param>
/// <returns>DTO。</returns>
public static StoreTableAreaDto ToDto(StoreTableArea area) => new()
{
Id = area.Id,
TenantId = area.TenantId,
StoreId = area.StoreId,
Name = area.Name,
Description = area.Description,
SortOrder = area.SortOrder,
CreatedAt = area.CreatedAt
};
/// <summary>
/// 映射桌台 DTO。
/// </summary>
/// <param name="table">桌台实体。</param>
/// <returns>DTO。</returns>
public static StoreTableDto ToDto(StoreTable table) => new()
{
Id = table.Id,
TenantId = table.TenantId,
StoreId = table.StoreId,
AreaId = table.AreaId,
TableCode = table.TableCode,
Capacity = table.Capacity,
Tags = table.Tags,
Status = table.Status,
QrCodeUrl = table.QrCodeUrl,
CreatedAt = table.CreatedAt
};
}

View File

@@ -0,0 +1,21 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Stores.Commands;
namespace TakeoutSaaS.Application.App.Stores.Validators;
/// <summary>
/// 创建桌台区域命令验证器。
/// </summary>
public sealed class CreateStoreTableAreaCommandValidator : AbstractValidator<CreateStoreTableAreaCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public CreateStoreTableAreaCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
RuleFor(x => x.Description).MaximumLength(256);
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
}
}

View File

@@ -0,0 +1,23 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Stores.Commands;
namespace TakeoutSaaS.Application.App.Stores.Validators;
/// <summary>
/// 批量生成桌码命令验证器。
/// </summary>
public sealed class GenerateStoreTablesCommandValidator : AbstractValidator<GenerateStoreTablesCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public GenerateStoreTablesCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.TableCodePrefix).NotEmpty().MaximumLength(16);
RuleFor(x => x.StartNumber).GreaterThan(0);
RuleFor(x => x.Count).GreaterThan(0).LessThanOrEqualTo(500);
RuleFor(x => x.DefaultCapacity).GreaterThan(0).LessThanOrEqualTo(50);
RuleFor(x => x.Tags).MaximumLength(128);
}
}

View File

@@ -0,0 +1,22 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Stores.Commands;
namespace TakeoutSaaS.Application.App.Stores.Validators;
/// <summary>
/// 更新桌台区域命令验证器。
/// </summary>
public sealed class UpdateStoreTableAreaCommandValidator : AbstractValidator<UpdateStoreTableAreaCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public UpdateStoreTableAreaCommandValidator()
{
RuleFor(x => x.AreaId).GreaterThan(0);
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
RuleFor(x => x.Description).MaximumLength(256);
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
}
}

View File

@@ -0,0 +1,22 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Stores.Commands;
namespace TakeoutSaaS.Application.App.Stores.Validators;
/// <summary>
/// 更新桌码命令验证器。
/// </summary>
public sealed class UpdateStoreTableCommandValidator : AbstractValidator<UpdateStoreTableCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public UpdateStoreTableCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.TableId).GreaterThan(0);
RuleFor(x => x.TableCode).NotEmpty().MaximumLength(32);
RuleFor(x => x.Capacity).GreaterThan(0).LessThanOrEqualTo(50);
RuleFor(x => x.Tags).MaximumLength(128);
}
}