Merge pull request 'feat: 新增财务交易流水后端模块' (#1) from feature/finance-transaction-module into dev
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m16s
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m16s
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,329 @@
|
|||||||
|
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水筛选请求。
|
||||||
|
/// </summary>
|
||||||
|
public class FinanceTransactionFilterRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开始日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StartDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 结束日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string? EndDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易类型(income/refund/stored_card_recharge/point_redeem)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Type { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道(delivery/pickup/dine_in)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Channel { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 支付方式(wechat/alipay/cash/card/balance)。
|
||||||
|
/// </summary>
|
||||||
|
public string? PaymentMethod { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词(流水号/订单号)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水列表请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceTransactionListRequest : FinanceTransactionFilterRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水详情请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceTransactionDetailRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易标识(sourceType:sourceId)。
|
||||||
|
/// </summary>
|
||||||
|
public string TransactionId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水列表结果。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceTransactionListResultResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceTransactionListItemResponse> Items { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总数。
|
||||||
|
/// </summary>
|
||||||
|
public int Total { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本页收入。
|
||||||
|
/// </summary>
|
||||||
|
public decimal PageIncomeAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本页退款。
|
||||||
|
/// </summary>
|
||||||
|
public decimal PageRefundAmount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水行。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceTransactionListItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 交易标识。
|
||||||
|
/// </summary>
|
||||||
|
public string TransactionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 流水号。
|
||||||
|
/// </summary>
|
||||||
|
public string TransactionNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string? OrderNo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 类型编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 类型文案。
|
||||||
|
/// </summary>
|
||||||
|
public string TypeText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道文案。
|
||||||
|
/// </summary>
|
||||||
|
public string Channel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 支付方式文案。
|
||||||
|
/// </summary>
|
||||||
|
public string PaymentMethod { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易时间。
|
||||||
|
/// </summary>
|
||||||
|
public string OccurredAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 备注。
|
||||||
|
/// </summary>
|
||||||
|
public string Remark { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否收入。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsIncome { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水统计结果。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceTransactionStatsResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 总收入。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalIncome { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总退款。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalRefund { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总笔数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水详情。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceTransactionDetailResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 交易标识。
|
||||||
|
/// </summary>
|
||||||
|
public string TransactionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 流水号。
|
||||||
|
/// </summary>
|
||||||
|
public string TransactionNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 类型编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 类型文案。
|
||||||
|
/// </summary>
|
||||||
|
public string TypeText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string? OrderNo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道文案。
|
||||||
|
/// </summary>
|
||||||
|
public string Channel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 支付方式文案。
|
||||||
|
/// </summary>
|
||||||
|
public string PaymentMethod { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易时间。
|
||||||
|
/// </summary>
|
||||||
|
public string OccurredAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 备注。
|
||||||
|
/// </summary>
|
||||||
|
public string Remark { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 顾客姓名。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 顾客手机号。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerPhone { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款单号。
|
||||||
|
/// </summary>
|
||||||
|
public string? RefundNo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款原因。
|
||||||
|
/// </summary>
|
||||||
|
public string? RefundReason { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员名称。
|
||||||
|
/// </summary>
|
||||||
|
public string? MemberName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员手机号。
|
||||||
|
/// </summary>
|
||||||
|
public string? MemberMobileMasked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 充值金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? RechargeAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 赠送金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? GiftAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? ArrivedAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分变动值。
|
||||||
|
/// </summary>
|
||||||
|
public int? PointChangeAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分变动后余额。
|
||||||
|
/// </summary>
|
||||||
|
public int? PointBalanceAfterChange { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水导出结果。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceTransactionExportResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 文件名。
|
||||||
|
/// </summary>
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件内容(Base64)。
|
||||||
|
/// </summary>
|
||||||
|
public string FileContentBase64 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||||
|
using TakeoutSaaS.Application.App.Stores.Services;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Payments.Enums;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||||
|
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
using TakeoutSaaS.Shared.Web.Api;
|
||||||
|
using TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务中心交易流水。
|
||||||
|
/// </summary>
|
||||||
|
[ApiVersion("1.0")]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/tenant/v{version:apiVersion}/finance/transaction")]
|
||||||
|
public sealed class FinanceTransactionController(
|
||||||
|
IMediator mediator,
|
||||||
|
TakeoutAppDbContext dbContext,
|
||||||
|
StoreContextService storeContextService) : BaseApiController
|
||||||
|
{
|
||||||
|
private const string ViewPermission = "tenant:finance:transaction:view";
|
||||||
|
private const string DetailPermission = "tenant:finance:transaction:detail";
|
||||||
|
private const string ExportPermission = "tenant:finance:transaction:export";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询交易流水列表。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("list")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionListResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceTransactionListResultResponse>> List(
|
||||||
|
[FromQuery] FinanceTransactionListRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 解析并校验筛选参数。
|
||||||
|
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
// 2. 发起查询并映射响应。
|
||||||
|
var result = await mediator.Send(new SearchFinanceTransactionListQuery
|
||||||
|
{
|
||||||
|
StoreId = parsed.StoreId,
|
||||||
|
StartAt = parsed.StartAt,
|
||||||
|
EndAt = parsed.EndAt,
|
||||||
|
TransactionType = parsed.TransactionType,
|
||||||
|
DeliveryType = parsed.DeliveryType,
|
||||||
|
PaymentMethod = parsed.PaymentMethod,
|
||||||
|
Keyword = request.Keyword,
|
||||||
|
Page = Math.Max(1, request.Page),
|
||||||
|
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceTransactionListResultResponse>.Ok(new FinanceTransactionListResultResponse
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(MapListItem).ToList(),
|
||||||
|
Total = result.Total,
|
||||||
|
Page = result.Page,
|
||||||
|
PageSize = result.PageSize,
|
||||||
|
PageIncomeAmount = result.PageIncomeAmount,
|
||||||
|
PageRefundAmount = result.PageRefundAmount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询交易流水统计。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("stats")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionStatsResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceTransactionStatsResponse>> Stats(
|
||||||
|
[FromQuery] FinanceTransactionFilterRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 解析并校验筛选参数。
|
||||||
|
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
// 2. 发起查询并映射响应。
|
||||||
|
var result = await mediator.Send(new GetFinanceTransactionStatsQuery
|
||||||
|
{
|
||||||
|
StoreId = parsed.StoreId,
|
||||||
|
StartAt = parsed.StartAt,
|
||||||
|
EndAt = parsed.EndAt,
|
||||||
|
TransactionType = parsed.TransactionType,
|
||||||
|
DeliveryType = parsed.DeliveryType,
|
||||||
|
PaymentMethod = parsed.PaymentMethod,
|
||||||
|
Keyword = request.Keyword
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceTransactionStatsResponse>.Ok(new FinanceTransactionStatsResponse
|
||||||
|
{
|
||||||
|
TotalIncome = result.TotalIncome,
|
||||||
|
TotalRefund = result.TotalRefund,
|
||||||
|
TotalCount = result.TotalCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询交易流水详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission, DetailPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceTransactionDetailResponse>> Detail(
|
||||||
|
[FromQuery] FinanceTransactionDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 校验门店参数与门店访问权限。
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
// 2. 解析交易复合标识。
|
||||||
|
if (!TryParseTransactionId(request.TransactionId, out var sourceType, out var sourceId))
|
||||||
|
{
|
||||||
|
return ApiResponse<FinanceTransactionDetailResponse>.Error(ErrorCodes.BadRequest, "transactionId 非法");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 查询详情并返回。
|
||||||
|
var detail = await mediator.Send(new GetFinanceTransactionDetailQuery
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
SourceType = sourceType,
|
||||||
|
SourceId = sourceId
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
if (detail is null)
|
||||||
|
{
|
||||||
|
return ApiResponse<FinanceTransactionDetailResponse>.Error(ErrorCodes.NotFound, "交易流水不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse<FinanceTransactionDetailResponse>.Ok(MapDetail(detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出交易流水 CSV。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("export")]
|
||||||
|
[PermissionAuthorize(ExportPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionExportResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceTransactionExportResponse>> Export(
|
||||||
|
[FromQuery] FinanceTransactionFilterRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 解析并校验筛选参数。
|
||||||
|
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
// 2. 发起导出并返回结果。
|
||||||
|
var result = await mediator.Send(new ExportFinanceTransactionCsvQuery
|
||||||
|
{
|
||||||
|
StoreId = parsed.StoreId,
|
||||||
|
StartAt = parsed.StartAt,
|
||||||
|
EndAt = parsed.EndAt,
|
||||||
|
TransactionType = parsed.TransactionType,
|
||||||
|
DeliveryType = parsed.DeliveryType,
|
||||||
|
PaymentMethod = parsed.PaymentMethod,
|
||||||
|
Keyword = request.Keyword
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceTransactionExportResponse>.Ok(new FinanceTransactionExportResponse
|
||||||
|
{
|
||||||
|
FileName = result.FileName,
|
||||||
|
FileContentBase64 = result.FileContentBase64,
|
||||||
|
TotalCount = result.TotalCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(long StoreId, DateTime? StartAt, DateTime? EndAt, FinanceTransactionType? TransactionType, DeliveryType? DeliveryType, PaymentMethod? PaymentMethod)> ParseFilterAsync(
|
||||||
|
FinanceTransactionFilterRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var startAt = ParseDateOrNull(request.StartDate);
|
||||||
|
var endAt = ParseDateOrNull(request.EndDate)?.AddDays(1);
|
||||||
|
if (startAt.HasValue && endAt.HasValue && startAt >= endAt)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
|
||||||
|
}
|
||||||
|
|
||||||
|
var transactionType = ParseTransactionType(request.Type);
|
||||||
|
var deliveryType = ParseDeliveryType(request.Channel);
|
||||||
|
var paymentMethod = ParsePaymentMethod(request.PaymentMethod);
|
||||||
|
|
||||||
|
return (storeId, startAt, endAt, transactionType, deliveryType, paymentMethod);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||||
|
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime? ParseDateOrNull(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.TryParseExact(
|
||||||
|
value,
|
||||||
|
"yyyy-MM-dd",
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.None,
|
||||||
|
out var parsed))
|
||||||
|
{
|
||||||
|
return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "日期格式必须为 yyyy-MM-dd");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceTransactionType? ParseTransactionType(string? value)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"income" => FinanceTransactionType.Income,
|
||||||
|
"refund" => FinanceTransactionType.Refund,
|
||||||
|
"stored_card_recharge" => FinanceTransactionType.StoredCardRecharge,
|
||||||
|
"point_redeem" => FinanceTransactionType.PointRedeem,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DeliveryType? ParseDeliveryType(string? value)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"delivery" => DeliveryType.Delivery,
|
||||||
|
"pickup" => DeliveryType.Pickup,
|
||||||
|
"dine_in" => DeliveryType.DineIn,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PaymentMethod? ParsePaymentMethod(string? value)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"wechat" => PaymentMethod.WeChatPay,
|
||||||
|
"alipay" => PaymentMethod.Alipay,
|
||||||
|
"cash" => PaymentMethod.Cash,
|
||||||
|
"card" => PaymentMethod.Card,
|
||||||
|
"balance" => PaymentMethod.Balance,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseTransactionId(string? value, out FinanceTransactionSourceType sourceType, out long sourceId)
|
||||||
|
{
|
||||||
|
sourceType = default;
|
||||||
|
sourceId = 0;
|
||||||
|
|
||||||
|
var normalized = (value ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = normalized.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length != 2)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!long.TryParse(parts[1], out sourceId) || sourceId <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceType = parts[0].ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"payment" => FinanceTransactionSourceType.PaymentRecord,
|
||||||
|
"payment_refund" => FinanceTransactionSourceType.PaymentRefundRecord,
|
||||||
|
"refund_request" => FinanceTransactionSourceType.RefundRequest,
|
||||||
|
"stored_card_recharge" => FinanceTransactionSourceType.StoredCardRechargeRecord,
|
||||||
|
"member_point" => FinanceTransactionSourceType.MemberPointLedger,
|
||||||
|
_ => default
|
||||||
|
};
|
||||||
|
|
||||||
|
return sourceType != default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceTransactionListItemResponse MapListItem(FinanceTransactionListItemDto source)
|
||||||
|
{
|
||||||
|
return new FinanceTransactionListItemResponse
|
||||||
|
{
|
||||||
|
TransactionId = source.TransactionId,
|
||||||
|
TransactionNo = source.TransactionNo,
|
||||||
|
OrderNo = source.OrderNo,
|
||||||
|
Type = source.TransactionType,
|
||||||
|
TypeText = source.TransactionTypeText,
|
||||||
|
Channel = source.ChannelText,
|
||||||
|
PaymentMethod = source.PaymentMethodText,
|
||||||
|
Amount = source.AmountSigned,
|
||||||
|
OccurredAt = source.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
Remark = source.Remark,
|
||||||
|
IsIncome = source.IsIncome
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceTransactionDetailResponse MapDetail(FinanceTransactionDetailDto source)
|
||||||
|
{
|
||||||
|
return new FinanceTransactionDetailResponse
|
||||||
|
{
|
||||||
|
TransactionId = source.TransactionId,
|
||||||
|
TransactionNo = source.TransactionNo,
|
||||||
|
Type = source.TransactionType,
|
||||||
|
TypeText = source.TransactionTypeText,
|
||||||
|
StoreId = source.StoreId.ToString(),
|
||||||
|
OrderNo = source.OrderNo,
|
||||||
|
Channel = source.ChannelText,
|
||||||
|
PaymentMethod = source.PaymentMethodText,
|
||||||
|
Amount = source.AmountSigned,
|
||||||
|
OccurredAt = source.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
Remark = source.Remark,
|
||||||
|
CustomerName = source.CustomerName,
|
||||||
|
CustomerPhone = source.CustomerPhone,
|
||||||
|
RefundNo = source.RefundNo,
|
||||||
|
RefundReason = source.RefundReason,
|
||||||
|
MemberName = source.MemberName,
|
||||||
|
MemberMobileMasked = source.MemberMobileMasked,
|
||||||
|
RechargeAmount = source.RechargeAmount,
|
||||||
|
GiftAmount = source.GiftAmount,
|
||||||
|
ArrivedAmount = source.ArrivedAmount,
|
||||||
|
PointChangeAmount = source.PointChangeAmount,
|
||||||
|
PointBalanceAfterChange = source.PointBalanceAfterChange
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水列表行。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceTransactionListItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 交易标识。
|
||||||
|
/// </summary>
|
||||||
|
public string TransactionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 流水号。
|
||||||
|
/// </summary>
|
||||||
|
public string TransactionNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string? OrderNo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易类型编码。
|
||||||
|
/// </summary>
|
||||||
|
public string TransactionType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易类型文案。
|
||||||
|
/// </summary>
|
||||||
|
public string TransactionTypeText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道文案。
|
||||||
|
/// </summary>
|
||||||
|
public string ChannelText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 支付方式文案。
|
||||||
|
/// </summary>
|
||||||
|
public string PaymentMethodText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易金额(带符号)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AmountSigned { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime OccurredAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 备注。
|
||||||
|
/// </summary>
|
||||||
|
public string Remark { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否收入。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsIncome { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水列表结果。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceTransactionListResultDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分页数据。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceTransactionListItemDto> Items { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总数。
|
||||||
|
/// </summary>
|
||||||
|
public int Total { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当前页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本页收入合计。
|
||||||
|
/// </summary>
|
||||||
|
public decimal PageIncomeAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本页退款合计。
|
||||||
|
/// </summary>
|
||||||
|
public decimal PageRefundAmount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水统计。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceTransactionStatsDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 总收入。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalIncome { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总退款。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalRefund { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易笔数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水详情。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceTransactionDetailDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 交易标识。
|
||||||
|
/// </summary>
|
||||||
|
public string TransactionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 流水号。
|
||||||
|
/// </summary>
|
||||||
|
public string TransactionNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易类型编码。
|
||||||
|
/// </summary>
|
||||||
|
public string TransactionType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易类型文案。
|
||||||
|
/// </summary>
|
||||||
|
public string TransactionTypeText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string? OrderNo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道文案。
|
||||||
|
/// </summary>
|
||||||
|
public string ChannelText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 支付方式文案。
|
||||||
|
/// </summary>
|
||||||
|
public string PaymentMethodText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易金额(带符号)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AmountSigned { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime OccurredAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 备注。
|
||||||
|
/// </summary>
|
||||||
|
public string Remark { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 顾客姓名。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 顾客手机号。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerPhone { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款单号。
|
||||||
|
/// </summary>
|
||||||
|
public string? RefundNo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款原因。
|
||||||
|
/// </summary>
|
||||||
|
public string? RefundReason { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员名称。
|
||||||
|
/// </summary>
|
||||||
|
public string? MemberName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员手机号脱敏值。
|
||||||
|
/// </summary>
|
||||||
|
public string? MemberMobileMasked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 充值金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? RechargeAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 赠送金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? GiftAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? ArrivedAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分变动值。
|
||||||
|
/// </summary>
|
||||||
|
public int? PointChangeAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分变动后余额。
|
||||||
|
/// </summary>
|
||||||
|
public int? PointBalanceAfterChange { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水导出结果。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceTransactionExportDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 文件名。
|
||||||
|
/// </summary>
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件内容(Base64)。
|
||||||
|
/// </summary>
|
||||||
|
public string FileContentBase64 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水 CSV 导出查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportFinanceTransactionCsvQueryHandler(
|
||||||
|
IFinanceTransactionRepository financeTransactionRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<ExportFinanceTransactionCsvQuery, FinanceTransactionExportDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceTransactionExportDto> Handle(ExportFinanceTransactionCsvQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 按筛选读取导出数据。
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var records = await financeTransactionRepository.ListForExportAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
request.StartAt,
|
||||||
|
request.EndAt,
|
||||||
|
request.TransactionType,
|
||||||
|
request.DeliveryType,
|
||||||
|
request.PaymentMethod,
|
||||||
|
request.Keyword,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 2. 组装 CSV 并输出 Base64。
|
||||||
|
var csv = BuildCsv(records.Select(FinanceTransactionMapping.ToListItem).ToList());
|
||||||
|
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray();
|
||||||
|
|
||||||
|
return new FinanceTransactionExportDto
|
||||||
|
{
|
||||||
|
FileName = $"交易流水_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
|
||||||
|
FileContentBase64 = Convert.ToBase64String(bytes),
|
||||||
|
TotalCount = records.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCsv(IReadOnlyList<FinanceTransactionListItemDto> rows)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("流水号,关联订单,类型,渠道,支付方式,金额,交易时间,备注");
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var cells = new[]
|
||||||
|
{
|
||||||
|
Escape(row.TransactionNo),
|
||||||
|
Escape(string.IsNullOrWhiteSpace(row.OrderNo) ? "—" : row.OrderNo!),
|
||||||
|
Escape(row.TransactionTypeText),
|
||||||
|
Escape(row.ChannelText),
|
||||||
|
Escape(row.PaymentMethodText),
|
||||||
|
Escape(FinanceTransactionMapping.FormatAmount(row.AmountSigned)),
|
||||||
|
Escape(row.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)),
|
||||||
|
Escape(string.IsNullOrWhiteSpace(row.Remark) ? "—" : row.Remark)
|
||||||
|
};
|
||||||
|
|
||||||
|
builder.AppendLine(string.Join(',', cells));
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Escape(string value)
|
||||||
|
{
|
||||||
|
if (!value.Contains('"') && !value.Contains(',') && !value.Contains('\n') && !value.Contains('\r'))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Models;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Payments.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水映射与文案转换。
|
||||||
|
/// </summary>
|
||||||
|
internal static class FinanceTransactionMapping
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 生成交易复合标识。
|
||||||
|
/// </summary>
|
||||||
|
public static string BuildTransactionId(FinanceTransactionSourceType sourceType, long sourceId)
|
||||||
|
{
|
||||||
|
var sourceCode = sourceType switch
|
||||||
|
{
|
||||||
|
FinanceTransactionSourceType.PaymentRecord => "payment",
|
||||||
|
FinanceTransactionSourceType.PaymentRefundRecord => "payment_refund",
|
||||||
|
FinanceTransactionSourceType.RefundRequest => "refund_request",
|
||||||
|
FinanceTransactionSourceType.StoredCardRechargeRecord => "stored_card_recharge",
|
||||||
|
FinanceTransactionSourceType.MemberPointLedger => "member_point",
|
||||||
|
_ => "unknown"
|
||||||
|
};
|
||||||
|
|
||||||
|
return $"{sourceCode}:{sourceId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析交易类型编码。
|
||||||
|
/// </summary>
|
||||||
|
public static string ToTransactionTypeCode(FinanceTransactionType transactionType)
|
||||||
|
{
|
||||||
|
return transactionType switch
|
||||||
|
{
|
||||||
|
FinanceTransactionType.Income => "income",
|
||||||
|
FinanceTransactionType.Refund => "refund",
|
||||||
|
FinanceTransactionType.StoredCardRecharge => "stored_card_recharge",
|
||||||
|
FinanceTransactionType.PointRedeem => "point_redeem",
|
||||||
|
_ => "unknown"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析交易类型文案。
|
||||||
|
/// </summary>
|
||||||
|
public static string ToTransactionTypeText(FinanceTransactionType transactionType)
|
||||||
|
{
|
||||||
|
return transactionType switch
|
||||||
|
{
|
||||||
|
FinanceTransactionType.Income => "收入",
|
||||||
|
FinanceTransactionType.Refund => "退款",
|
||||||
|
FinanceTransactionType.StoredCardRecharge => "储值充值",
|
||||||
|
FinanceTransactionType.PointRedeem => "积分抵扣",
|
||||||
|
_ => "未知"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析渠道文案。
|
||||||
|
/// </summary>
|
||||||
|
public static string ToChannelText(DeliveryType? deliveryType)
|
||||||
|
{
|
||||||
|
return deliveryType switch
|
||||||
|
{
|
||||||
|
DeliveryType.Delivery => "外卖",
|
||||||
|
DeliveryType.Pickup => "自提",
|
||||||
|
DeliveryType.DineIn => "堂食",
|
||||||
|
_ => "—"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析支付方式文案。
|
||||||
|
/// </summary>
|
||||||
|
public static string ToPaymentMethodText(PaymentMethod? paymentMethod)
|
||||||
|
{
|
||||||
|
return paymentMethod switch
|
||||||
|
{
|
||||||
|
PaymentMethod.WeChatPay => "微信",
|
||||||
|
PaymentMethod.Alipay => "支付宝",
|
||||||
|
PaymentMethod.Cash => "现金",
|
||||||
|
PaymentMethod.Card => "刷卡",
|
||||||
|
PaymentMethod.Balance => "储值余额",
|
||||||
|
_ => "—"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 映射列表行。
|
||||||
|
/// </summary>
|
||||||
|
public static FinanceTransactionListItemDto ToListItem(FinanceTransactionRecord source)
|
||||||
|
{
|
||||||
|
return new FinanceTransactionListItemDto
|
||||||
|
{
|
||||||
|
TransactionId = BuildTransactionId(source.SourceType, source.SourceId),
|
||||||
|
TransactionNo = source.TransactionNo ?? string.Empty,
|
||||||
|
OrderNo = source.OrderNo,
|
||||||
|
TransactionType = ToTransactionTypeCode(source.TransactionType),
|
||||||
|
TransactionTypeText = ToTransactionTypeText(source.TransactionType),
|
||||||
|
ChannelText = ToChannelText(source.DeliveryType),
|
||||||
|
PaymentMethodText = ToPaymentMethodText(source.PaymentMethod),
|
||||||
|
AmountSigned = decimal.Round(source.AmountSigned, 2, MidpointRounding.AwayFromZero),
|
||||||
|
OccurredAt = source.OccurredAt,
|
||||||
|
Remark = string.IsNullOrWhiteSpace(source.Remark) ? "—" : source.Remark.Trim(),
|
||||||
|
IsIncome = source.AmountSigned > 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 映射详情。
|
||||||
|
/// </summary>
|
||||||
|
public static FinanceTransactionDetailDto ToDetail(FinanceTransactionRecord source)
|
||||||
|
{
|
||||||
|
return new FinanceTransactionDetailDto
|
||||||
|
{
|
||||||
|
TransactionId = BuildTransactionId(source.SourceType, source.SourceId),
|
||||||
|
TransactionNo = source.TransactionNo ?? string.Empty,
|
||||||
|
TransactionType = ToTransactionTypeCode(source.TransactionType),
|
||||||
|
TransactionTypeText = ToTransactionTypeText(source.TransactionType),
|
||||||
|
StoreId = source.StoreId,
|
||||||
|
OrderNo = source.OrderNo,
|
||||||
|
ChannelText = ToChannelText(source.DeliveryType),
|
||||||
|
PaymentMethodText = ToPaymentMethodText(source.PaymentMethod),
|
||||||
|
AmountSigned = decimal.Round(source.AmountSigned, 2, MidpointRounding.AwayFromZero),
|
||||||
|
OccurredAt = source.OccurredAt,
|
||||||
|
Remark = string.IsNullOrWhiteSpace(source.Remark) ? "—" : source.Remark.Trim(),
|
||||||
|
CustomerName = string.IsNullOrWhiteSpace(source.CustomerName) ? "—" : source.CustomerName.Trim(),
|
||||||
|
CustomerPhone = string.IsNullOrWhiteSpace(source.CustomerPhone) ? "—" : source.CustomerPhone.Trim(),
|
||||||
|
RefundNo = source.RefundNo,
|
||||||
|
RefundReason = source.RefundReason,
|
||||||
|
MemberName = source.MemberName,
|
||||||
|
MemberMobileMasked = source.MemberMobileMasked,
|
||||||
|
RechargeAmount = source.RechargeAmount,
|
||||||
|
GiftAmount = source.GiftAmount,
|
||||||
|
ArrivedAmount = source.ArrivedAmount,
|
||||||
|
PointChangeAmount = source.PointChangeAmount,
|
||||||
|
PointBalanceAfterChange = source.PointBalanceAfterChange
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出金额文本。
|
||||||
|
/// </summary>
|
||||||
|
public static string FormatAmount(decimal amountSigned)
|
||||||
|
{
|
||||||
|
var rounded = decimal.Round(amountSigned, 2, MidpointRounding.AwayFromZero);
|
||||||
|
var sign = rounded >= 0 ? "+" : string.Empty;
|
||||||
|
return $"{sign}{rounded.ToString("0.00", CultureInfo.InvariantCulture)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水详情查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceTransactionDetailQueryHandler(
|
||||||
|
IFinanceTransactionRepository financeTransactionRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetFinanceTransactionDetailQuery, FinanceTransactionDetailDto?>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceTransactionDetailDto?> Handle(GetFinanceTransactionDetailQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 读取租户上下文并查询详情。
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var record = await financeTransactionRepository.GetDetailAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
request.SourceType,
|
||||||
|
request.SourceId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 2. 映射详情输出。
|
||||||
|
return record is null ? null : FinanceTransactionMapping.ToDetail(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水统计查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceTransactionStatsQueryHandler(
|
||||||
|
IFinanceTransactionRepository financeTransactionRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetFinanceTransactionStatsQuery, FinanceTransactionStatsDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceTransactionStatsDto> Handle(GetFinanceTransactionStatsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 读取租户上下文并执行统计查询。
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var snapshot = await financeTransactionRepository.GetStatsAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
request.StartAt,
|
||||||
|
request.EndAt,
|
||||||
|
request.TransactionType,
|
||||||
|
request.DeliveryType,
|
||||||
|
request.PaymentMethod,
|
||||||
|
request.Keyword,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 2. 映射统计结果。
|
||||||
|
return new FinanceTransactionStatsDto
|
||||||
|
{
|
||||||
|
TotalIncome = snapshot.TotalIncome,
|
||||||
|
TotalRefund = snapshot.TotalRefund,
|
||||||
|
TotalCount = snapshot.TotalCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水列表查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchFinanceTransactionListQueryHandler(
|
||||||
|
IFinanceTransactionRepository financeTransactionRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<SearchFinanceTransactionListQuery, FinanceTransactionListResultDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceTransactionListResultDto> Handle(SearchFinanceTransactionListQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 读取租户上下文并执行分页查询。
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var page = Math.Max(1, request.Page);
|
||||||
|
var pageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||||
|
|
||||||
|
var snapshot = await financeTransactionRepository.SearchPageAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
request.StartAt,
|
||||||
|
request.EndAt,
|
||||||
|
request.TransactionType,
|
||||||
|
request.DeliveryType,
|
||||||
|
request.PaymentMethod,
|
||||||
|
request.Keyword,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 2. 映射结果并返回。
|
||||||
|
return new FinanceTransactionListResultDto
|
||||||
|
{
|
||||||
|
Items = snapshot.Items.Select(FinanceTransactionMapping.ToListItem).ToList(),
|
||||||
|
Total = snapshot.TotalCount,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
PageIncomeAmount = snapshot.PageIncomeAmount,
|
||||||
|
PageRefundAmount = snapshot.PageRefundAmount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水 CSV 导出查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportFinanceTransactionCsvQuery : FinanceTransactionFilterQueryBase, IRequest<FinanceTransactionExportDto>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Payments.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水筛选查询基类。
|
||||||
|
/// </summary>
|
||||||
|
public abstract class FinanceTransactionFilterQueryBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开始时间(含)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? StartAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 结束时间(不含)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? EndAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易类型。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceTransactionType? TransactionType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道。
|
||||||
|
/// </summary>
|
||||||
|
public DeliveryType? DeliveryType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 支付方式。
|
||||||
|
/// </summary>
|
||||||
|
public PaymentMethod? PaymentMethod { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水详情查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceTransactionDetailQuery : IRequest<FinanceTransactionDetailDto?>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 来源类型。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceTransactionSourceType SourceType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 来源标识。
|
||||||
|
/// </summary>
|
||||||
|
public long SourceId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水统计查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceTransactionStatsQuery : FinanceTransactionFilterQueryBase, IRequest<FinanceTransactionStatsDto>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水列表查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchFinanceTransactionListQuery : FinanceTransactionFilterQueryBase, IRequest<FinanceTransactionListResultDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; init; } = 20;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水导出查询验证器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportFinanceTransactionCsvQueryValidator : AbstractValidator<ExportFinanceTransactionCsvQuery>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化验证规则。
|
||||||
|
/// </summary>
|
||||||
|
public ExportFinanceTransactionCsvQueryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||||
|
RuleFor(x => x.Keyword).MaximumLength(64);
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => !x.StartAt.HasValue || !x.EndAt.HasValue || x.StartAt < x.EndAt)
|
||||||
|
.WithMessage("开始时间必须早于结束时间");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水详情查询验证器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceTransactionDetailQueryValidator : AbstractValidator<GetFinanceTransactionDetailQuery>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化验证规则。
|
||||||
|
/// </summary>
|
||||||
|
public GetFinanceTransactionDetailQueryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||||
|
RuleFor(x => x.SourceId).GreaterThan(0);
|
||||||
|
RuleFor(x => x.SourceType)
|
||||||
|
.Must(x => x is FinanceTransactionSourceType.PaymentRecord
|
||||||
|
or FinanceTransactionSourceType.PaymentRefundRecord
|
||||||
|
or FinanceTransactionSourceType.RefundRequest
|
||||||
|
or FinanceTransactionSourceType.StoredCardRechargeRecord
|
||||||
|
or FinanceTransactionSourceType.MemberPointLedger)
|
||||||
|
.WithMessage("sourceType 非法");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水统计查询验证器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceTransactionStatsQueryValidator : AbstractValidator<GetFinanceTransactionStatsQuery>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化验证规则。
|
||||||
|
/// </summary>
|
||||||
|
public GetFinanceTransactionStatsQueryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||||
|
RuleFor(x => x.Keyword).MaximumLength(64);
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => !x.StartAt.HasValue || !x.EndAt.HasValue || x.StartAt < x.EndAt)
|
||||||
|
.WithMessage("开始时间必须早于结束时间");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Transactions.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水列表查询验证器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchFinanceTransactionListQueryValidator : AbstractValidator<SearchFinanceTransactionListQuery>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化验证规则。
|
||||||
|
/// </summary>
|
||||||
|
public SearchFinanceTransactionListQueryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||||
|
RuleFor(x => x.Page).GreaterThan(0);
|
||||||
|
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
|
||||||
|
RuleFor(x => x.Keyword).MaximumLength(64);
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => !x.StartAt.HasValue || !x.EndAt.HasValue || x.StartAt < x.EndAt)
|
||||||
|
.WithMessage("开始时间必须早于结束时间");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易来源类型。
|
||||||
|
/// </summary>
|
||||||
|
public enum FinanceTransactionSourceType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 支付流水。
|
||||||
|
/// </summary>
|
||||||
|
PaymentRecord = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道退款流水。
|
||||||
|
/// </summary>
|
||||||
|
PaymentRefundRecord = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款申请(补充来源)。
|
||||||
|
/// </summary>
|
||||||
|
RefundRequest = 3,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 储值充值记录。
|
||||||
|
/// </summary>
|
||||||
|
StoredCardRechargeRecord = 4,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员积分流水。
|
||||||
|
/// </summary>
|
||||||
|
MemberPointLedger = 5
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易类型。
|
||||||
|
/// </summary>
|
||||||
|
public enum FinanceTransactionType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 收入。
|
||||||
|
/// </summary>
|
||||||
|
Income = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款。
|
||||||
|
/// </summary>
|
||||||
|
Refund = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 储值充值。
|
||||||
|
/// </summary>
|
||||||
|
StoredCardRecharge = 3,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分抵扣。
|
||||||
|
/// </summary>
|
||||||
|
PointRedeem = 4
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Payments.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Finance.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水统一记录。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FinanceTransactionRecord
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 来源类型。
|
||||||
|
/// </summary>
|
||||||
|
public required FinanceTransactionSourceType SourceType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 来源标识。
|
||||||
|
/// </summary>
|
||||||
|
public required long SourceId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 所属门店。
|
||||||
|
/// </summary>
|
||||||
|
public required long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易单号。
|
||||||
|
/// </summary>
|
||||||
|
public string? TransactionNo { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易类型。
|
||||||
|
/// </summary>
|
||||||
|
public required FinanceTransactionType TransactionType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联订单标识。
|
||||||
|
/// </summary>
|
||||||
|
public long? OrderId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string? OrderNo { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道。
|
||||||
|
/// </summary>
|
||||||
|
public DeliveryType? DeliveryType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 支付方式。
|
||||||
|
/// </summary>
|
||||||
|
public PaymentMethod? PaymentMethod { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易金额(收入为正,退款为负)。
|
||||||
|
/// </summary>
|
||||||
|
public required decimal AmountSigned { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易时间。
|
||||||
|
/// </summary>
|
||||||
|
public required DateTime OccurredAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? Remark { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 顾客姓名。
|
||||||
|
/// </summary>
|
||||||
|
public string? CustomerName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 顾客手机号。
|
||||||
|
/// </summary>
|
||||||
|
public string? CustomerPhone { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款单号。
|
||||||
|
/// </summary>
|
||||||
|
public string? RefundNo { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款原因。
|
||||||
|
/// </summary>
|
||||||
|
public string? RefundReason { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员名称。
|
||||||
|
/// </summary>
|
||||||
|
public string? MemberName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员手机号脱敏值。
|
||||||
|
/// </summary>
|
||||||
|
public string? MemberMobileMasked { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 充值金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? RechargeAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 赠送金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? GiftAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? ArrivedAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分变动值。
|
||||||
|
/// </summary>
|
||||||
|
public int? PointChangeAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分变动后余额。
|
||||||
|
/// </summary>
|
||||||
|
public int? PointBalanceAfterChange { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水分页快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FinanceTransactionPageSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分页记录。
|
||||||
|
/// </summary>
|
||||||
|
public required IReadOnlyList<FinanceTransactionRecord> Items { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总记录数。
|
||||||
|
/// </summary>
|
||||||
|
public required int TotalCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本页收入合计。
|
||||||
|
/// </summary>
|
||||||
|
public required decimal PageIncomeAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本页退款合计。
|
||||||
|
/// </summary>
|
||||||
|
public required decimal PageRefundAmount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易流水统计快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FinanceTransactionStatsSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 总收入。
|
||||||
|
/// </summary>
|
||||||
|
public required decimal TotalIncome { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总退款。
|
||||||
|
/// </summary>
|
||||||
|
public required decimal TotalRefund { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总交易笔数。
|
||||||
|
/// </summary>
|
||||||
|
public required int TotalCount { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Models;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Payments.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务交易流水仓储契约。
|
||||||
|
/// </summary>
|
||||||
|
public interface IFinanceTransactionRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 查询交易流水分页。
|
||||||
|
/// </summary>
|
||||||
|
Task<FinanceTransactionPageSnapshot> SearchPageAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
DateTime? startAt,
|
||||||
|
DateTime? endAt,
|
||||||
|
FinanceTransactionType? transactionType,
|
||||||
|
DeliveryType? deliveryType,
|
||||||
|
PaymentMethod? paymentMethod,
|
||||||
|
string? keyword,
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询交易流水统计。
|
||||||
|
/// </summary>
|
||||||
|
Task<FinanceTransactionStatsSnapshot> GetStatsAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
DateTime? startAt,
|
||||||
|
DateTime? endAt,
|
||||||
|
FinanceTransactionType? transactionType,
|
||||||
|
DeliveryType? deliveryType,
|
||||||
|
PaymentMethod? paymentMethod,
|
||||||
|
string? keyword,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询交易流水详情。
|
||||||
|
/// </summary>
|
||||||
|
Task<FinanceTransactionRecord?> GetDetailAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
FinanceTransactionSourceType sourceType,
|
||||||
|
long sourceId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询导出数据。
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<FinanceTransactionRecord>> ListForExportAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
DateTime? startAt,
|
||||||
|
DateTime? endAt,
|
||||||
|
FinanceTransactionType? transactionType,
|
||||||
|
DeliveryType? deliveryType,
|
||||||
|
PaymentMethod? paymentMethod,
|
||||||
|
string? keyword,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using TakeoutSaaS.Application.App.Stores.Services;
|
using TakeoutSaaS.Application.App.Stores.Services;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||||
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
||||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||||
@@ -53,6 +54,7 @@ public static class AppServiceCollectionExtensions
|
|||||||
services.AddScoped<IMemberRepository, EfMemberRepository>();
|
services.AddScoped<IMemberRepository, EfMemberRepository>();
|
||||||
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
|
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
|
||||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||||
|
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();
|
||||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||||
services.AddScoped<ITenantRepository, EfTenantRepository>();
|
services.AddScoped<ITenantRepository, EfTenantRepository>();
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Models;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Payments.Enums;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务交易流水 EF Core 仓储实现。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EfFinanceTransactionRepository(TakeoutAppDbContext context) : IFinanceTransactionRepository
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceTransactionPageSnapshot> SearchPageAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
DateTime? startAt,
|
||||||
|
DateTime? endAt,
|
||||||
|
FinanceTransactionType? transactionType,
|
||||||
|
DeliveryType? deliveryType,
|
||||||
|
PaymentMethod? paymentMethod,
|
||||||
|
string? keyword,
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 构建筛选查询并读取总量。
|
||||||
|
var normalizedPage = Math.Max(1, page);
|
||||||
|
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
|
||||||
|
var query = BuildQuery(tenantId, storeId, startAt, endAt, transactionType, deliveryType, paymentMethod, keyword);
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (totalCount == 0)
|
||||||
|
{
|
||||||
|
return new FinanceTransactionPageSnapshot
|
||||||
|
{
|
||||||
|
Items = [],
|
||||||
|
TotalCount = 0,
|
||||||
|
PageIncomeAmount = 0,
|
||||||
|
PageRefundAmount = 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 分页读取记录并完成统一映射。
|
||||||
|
var rows = await query
|
||||||
|
.OrderByDescending(item => item.OccurredAt)
|
||||||
|
.ThenByDescending(item => item.SourceId)
|
||||||
|
.Skip((normalizedPage - 1) * normalizedPageSize)
|
||||||
|
.Take(normalizedPageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var items = rows.Select(MapToRecord).ToList();
|
||||||
|
|
||||||
|
// 3. 汇总本页收入与退款。
|
||||||
|
var pageIncomeAmount = items
|
||||||
|
.Where(item => item.AmountSigned > 0)
|
||||||
|
.Sum(item => item.AmountSigned);
|
||||||
|
var pageRefundAmount = items
|
||||||
|
.Where(item => item.AmountSigned < 0)
|
||||||
|
.Sum(item => Math.Abs(item.AmountSigned));
|
||||||
|
|
||||||
|
return new FinanceTransactionPageSnapshot
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
TotalCount = totalCount,
|
||||||
|
PageIncomeAmount = decimal.Round(pageIncomeAmount, 2, MidpointRounding.AwayFromZero),
|
||||||
|
PageRefundAmount = decimal.Round(pageRefundAmount, 2, MidpointRounding.AwayFromZero)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceTransactionStatsSnapshot> GetStatsAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
DateTime? startAt,
|
||||||
|
DateTime? endAt,
|
||||||
|
FinanceTransactionType? transactionType,
|
||||||
|
DeliveryType? deliveryType,
|
||||||
|
PaymentMethod? paymentMethod,
|
||||||
|
string? keyword,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 构建筛选查询并聚合总览指标。
|
||||||
|
var query = BuildQuery(tenantId, storeId, startAt, endAt, transactionType, deliveryType, paymentMethod, keyword);
|
||||||
|
|
||||||
|
var summary = await query
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
TotalIncome = group
|
||||||
|
.Where(item => item.AmountSigned > 0)
|
||||||
|
.Sum(item => item.AmountSigned),
|
||||||
|
TotalRefund = group
|
||||||
|
.Where(item => item.AmountSigned < 0)
|
||||||
|
.Sum(item => Math.Abs(item.AmountSigned)),
|
||||||
|
TotalCount = group.Count()
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (summary is null)
|
||||||
|
{
|
||||||
|
return new FinanceTransactionStatsSnapshot
|
||||||
|
{
|
||||||
|
TotalIncome = 0,
|
||||||
|
TotalRefund = 0,
|
||||||
|
TotalCount = 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FinanceTransactionStatsSnapshot
|
||||||
|
{
|
||||||
|
TotalIncome = decimal.Round(summary.TotalIncome, 2, MidpointRounding.AwayFromZero),
|
||||||
|
TotalRefund = decimal.Round(summary.TotalRefund, 2, MidpointRounding.AwayFromZero),
|
||||||
|
TotalCount = summary.TotalCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceTransactionRecord?> GetDetailAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
FinanceTransactionSourceType sourceType,
|
||||||
|
long sourceId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 按来源组合键定位唯一流水。
|
||||||
|
var query = BuildQuery(
|
||||||
|
tenantId,
|
||||||
|
storeId,
|
||||||
|
startAt: null,
|
||||||
|
endAt: null,
|
||||||
|
transactionType: null,
|
||||||
|
deliveryType: null,
|
||||||
|
paymentMethod: null,
|
||||||
|
keyword: null);
|
||||||
|
|
||||||
|
var row = await query
|
||||||
|
.Where(item => item.SourceType == sourceType && item.SourceId == sourceId)
|
||||||
|
.OrderByDescending(item => item.OccurredAt)
|
||||||
|
.ThenByDescending(item => item.SourceId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return row is null ? null : MapToRecord(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<FinanceTransactionRecord>> ListForExportAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
DateTime? startAt,
|
||||||
|
DateTime? endAt,
|
||||||
|
FinanceTransactionType? transactionType,
|
||||||
|
DeliveryType? deliveryType,
|
||||||
|
PaymentMethod? paymentMethod,
|
||||||
|
string? keyword,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 按筛选读取导出数据,限定上限保障性能。
|
||||||
|
var query = BuildQuery(tenantId, storeId, startAt, endAt, transactionType, deliveryType, paymentMethod, keyword);
|
||||||
|
|
||||||
|
var rows = await query
|
||||||
|
.OrderByDescending(item => item.OccurredAt)
|
||||||
|
.ThenByDescending(item => item.SourceId)
|
||||||
|
.Take(20_000)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return rows.Select(MapToRecord).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<TransactionProjection> BuildQuery(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
DateTime? startAt,
|
||||||
|
DateTime? endAt,
|
||||||
|
FinanceTransactionType? transactionType,
|
||||||
|
DeliveryType? deliveryType,
|
||||||
|
PaymentMethod? paymentMethod,
|
||||||
|
string? keyword)
|
||||||
|
{
|
||||||
|
// 1. 收入流水(支付成功)。
|
||||||
|
var incomeQuery =
|
||||||
|
from payment in context.PaymentRecords.AsNoTracking()
|
||||||
|
join order in context.Orders.AsNoTracking()
|
||||||
|
on payment.OrderId equals order.Id
|
||||||
|
where payment.TenantId == tenantId
|
||||||
|
&& order.TenantId == tenantId
|
||||||
|
&& payment.Status == PaymentStatus.Paid
|
||||||
|
select new TransactionProjection
|
||||||
|
{
|
||||||
|
SourceType = FinanceTransactionSourceType.PaymentRecord,
|
||||||
|
SourceId = payment.Id,
|
||||||
|
StoreId = order.StoreId,
|
||||||
|
TransactionType = FinanceTransactionType.Income,
|
||||||
|
TransactionNo = payment.TradeNo,
|
||||||
|
OrderId = order.Id,
|
||||||
|
OrderNo = order.OrderNo,
|
||||||
|
DeliveryType = order.DeliveryType,
|
||||||
|
PaymentMethod = payment.Method,
|
||||||
|
AmountSigned = payment.Amount,
|
||||||
|
OccurredAt = payment.PaidAt ?? payment.CreatedAt,
|
||||||
|
Remark = payment.Remark,
|
||||||
|
CustomerName = order.CustomerName,
|
||||||
|
CustomerPhone = order.CustomerPhone
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 渠道退款流水(优先来源)。
|
||||||
|
var channelRefundQuery =
|
||||||
|
from refund in context.PaymentRefundRecords.AsNoTracking()
|
||||||
|
join order in context.Orders.AsNoTracking()
|
||||||
|
on refund.OrderId equals order.Id
|
||||||
|
join payment in context.PaymentRecords.AsNoTracking()
|
||||||
|
on refund.PaymentRecordId equals payment.Id into paymentGroup
|
||||||
|
from matchedPayment in paymentGroup.DefaultIfEmpty()
|
||||||
|
where refund.TenantId == tenantId
|
||||||
|
&& order.TenantId == tenantId
|
||||||
|
&& refund.Status == PaymentRefundStatus.Succeeded
|
||||||
|
select new TransactionProjection
|
||||||
|
{
|
||||||
|
SourceType = FinanceTransactionSourceType.PaymentRefundRecord,
|
||||||
|
SourceId = refund.Id,
|
||||||
|
StoreId = order.StoreId,
|
||||||
|
TransactionType = FinanceTransactionType.Refund,
|
||||||
|
TransactionNo = refund.ChannelRefundId,
|
||||||
|
OrderId = order.Id,
|
||||||
|
OrderNo = order.OrderNo,
|
||||||
|
DeliveryType = order.DeliveryType,
|
||||||
|
PaymentMethod = matchedPayment == null ? null : matchedPayment.Method,
|
||||||
|
AmountSigned = 0 - refund.Amount,
|
||||||
|
OccurredAt = refund.CompletedAt ?? refund.RequestedAt,
|
||||||
|
Remark = null,
|
||||||
|
CustomerName = order.CustomerName,
|
||||||
|
CustomerPhone = order.CustomerPhone
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. 退款申请流水(补充来源:没有渠道退款记录时使用)。
|
||||||
|
var requestRefundQuery =
|
||||||
|
from refund in context.RefundRequests.AsNoTracking()
|
||||||
|
join order in context.Orders.AsNoTracking()
|
||||||
|
on refund.OrderId equals order.Id
|
||||||
|
where refund.TenantId == tenantId
|
||||||
|
&& order.TenantId == tenantId
|
||||||
|
&& refund.Status == RefundStatus.Refunded
|
||||||
|
&& !context.PaymentRefundRecords
|
||||||
|
.AsNoTracking()
|
||||||
|
.Any(channelRefund =>
|
||||||
|
channelRefund.TenantId == tenantId
|
||||||
|
&& channelRefund.OrderId == refund.OrderId
|
||||||
|
&& channelRefund.Status == PaymentRefundStatus.Succeeded)
|
||||||
|
select new TransactionProjection
|
||||||
|
{
|
||||||
|
SourceType = FinanceTransactionSourceType.RefundRequest,
|
||||||
|
SourceId = refund.Id,
|
||||||
|
StoreId = order.StoreId,
|
||||||
|
TransactionType = FinanceTransactionType.Refund,
|
||||||
|
TransactionNo = refund.RefundNo,
|
||||||
|
OrderId = order.Id,
|
||||||
|
OrderNo = order.OrderNo,
|
||||||
|
DeliveryType = order.DeliveryType,
|
||||||
|
PaymentMethod = context.PaymentRecords
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(payment => payment.TenantId == tenantId && payment.OrderId == order.Id)
|
||||||
|
.OrderByDescending(payment => payment.PaidAt ?? payment.CreatedAt)
|
||||||
|
.ThenByDescending(payment => payment.Id)
|
||||||
|
.Select(payment => (PaymentMethod?)payment.Method)
|
||||||
|
.FirstOrDefault(),
|
||||||
|
AmountSigned = 0 - refund.Amount,
|
||||||
|
OccurredAt = refund.ProcessedAt ?? refund.RequestedAt,
|
||||||
|
Remark = refund.ReviewNotes,
|
||||||
|
RefundNo = refund.RefundNo,
|
||||||
|
RefundReason = refund.Reason,
|
||||||
|
CustomerName = order.CustomerName,
|
||||||
|
CustomerPhone = order.CustomerPhone
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. 储值充值流水。
|
||||||
|
var rechargeQuery =
|
||||||
|
from recharge in context.MemberStoredCardRechargeRecords.AsNoTracking()
|
||||||
|
where recharge.TenantId == tenantId
|
||||||
|
select new TransactionProjection
|
||||||
|
{
|
||||||
|
SourceType = FinanceTransactionSourceType.StoredCardRechargeRecord,
|
||||||
|
SourceId = recharge.Id,
|
||||||
|
StoreId = recharge.StoreId,
|
||||||
|
TransactionType = FinanceTransactionType.StoredCardRecharge,
|
||||||
|
TransactionNo = recharge.RecordNo,
|
||||||
|
OrderId = null,
|
||||||
|
OrderNo = null,
|
||||||
|
DeliveryType = null,
|
||||||
|
PaymentMethod = recharge.PaymentMethod,
|
||||||
|
AmountSigned = recharge.RechargeAmount,
|
||||||
|
OccurredAt = recharge.RechargedAt,
|
||||||
|
Remark = recharge.Remark,
|
||||||
|
MemberName = recharge.MemberName,
|
||||||
|
MemberMobileMasked = recharge.MemberMobileMasked,
|
||||||
|
RechargeAmount = recharge.RechargeAmount,
|
||||||
|
GiftAmount = recharge.GiftAmount,
|
||||||
|
ArrivedAmount = recharge.ArrivedAmount
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. 积分抵扣流水(仅统计可关联订单)。
|
||||||
|
var pointRedeemQuery =
|
||||||
|
from pointLedger in context.MemberPointLedgers.AsNoTracking()
|
||||||
|
join order in context.Orders.AsNoTracking()
|
||||||
|
on pointLedger.SourceId equals (long?)order.Id
|
||||||
|
where pointLedger.TenantId == tenantId
|
||||||
|
&& order.TenantId == tenantId
|
||||||
|
&& pointLedger.SourceId.HasValue
|
||||||
|
&& pointLedger.Reason == PointChangeReason.Redeem
|
||||||
|
&& pointLedger.ChangeAmount < 0
|
||||||
|
select new TransactionProjection
|
||||||
|
{
|
||||||
|
SourceType = FinanceTransactionSourceType.MemberPointLedger,
|
||||||
|
SourceId = pointLedger.Id,
|
||||||
|
StoreId = order.StoreId,
|
||||||
|
TransactionType = FinanceTransactionType.PointRedeem,
|
||||||
|
TransactionNo = null,
|
||||||
|
OrderId = order.Id,
|
||||||
|
OrderNo = order.OrderNo,
|
||||||
|
DeliveryType = order.DeliveryType,
|
||||||
|
PaymentMethod = context.PaymentRecords
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(payment => payment.TenantId == tenantId && payment.OrderId == order.Id)
|
||||||
|
.OrderByDescending(payment => payment.PaidAt ?? payment.CreatedAt)
|
||||||
|
.ThenByDescending(payment => payment.Id)
|
||||||
|
.Select(payment => (PaymentMethod?)payment.Method)
|
||||||
|
.FirstOrDefault(),
|
||||||
|
AmountSigned = order.DiscountAmount,
|
||||||
|
OccurredAt = pointLedger.OccurredAt,
|
||||||
|
Remark = null,
|
||||||
|
PointChangeAmount = pointLedger.ChangeAmount,
|
||||||
|
PointBalanceAfterChange = pointLedger.BalanceAfterChange,
|
||||||
|
CustomerName = order.CustomerName,
|
||||||
|
CustomerPhone = order.CustomerPhone
|
||||||
|
};
|
||||||
|
|
||||||
|
// 6. 合并多来源并下推统一筛选条件。
|
||||||
|
var query = incomeQuery
|
||||||
|
.Concat(channelRefundQuery)
|
||||||
|
.Concat(requestRefundQuery)
|
||||||
|
.Concat(rechargeQuery)
|
||||||
|
.Concat(pointRedeemQuery)
|
||||||
|
.Where(item => item.StoreId == storeId);
|
||||||
|
|
||||||
|
if (startAt.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(item => item.OccurredAt >= startAt.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endAt.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(item => item.OccurredAt < endAt.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactionType.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(item => item.TransactionType == transactionType.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliveryType.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(item => item.DeliveryType == deliveryType.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentMethod.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(item => item.PaymentMethod == paymentMethod.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedKeyword = (keyword ?? string.Empty).Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||||
|
{
|
||||||
|
var like = $"%{normalizedKeyword}%";
|
||||||
|
query = query.Where(item =>
|
||||||
|
EF.Functions.ILike(item.TransactionNo ?? string.Empty, like)
|
||||||
|
|| EF.Functions.ILike(item.OrderNo ?? string.Empty, like)
|
||||||
|
|| EF.Functions.ILike(item.RefundNo ?? string.Empty, like)
|
||||||
|
|| EF.Functions.ILike(item.MemberName ?? string.Empty, like)
|
||||||
|
|| EF.Functions.ILike(item.MemberMobileMasked ?? string.Empty, like));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceTransactionRecord MapToRecord(TransactionProjection source)
|
||||||
|
{
|
||||||
|
return new FinanceTransactionRecord
|
||||||
|
{
|
||||||
|
SourceType = source.SourceType,
|
||||||
|
SourceId = source.SourceId,
|
||||||
|
StoreId = source.StoreId,
|
||||||
|
TransactionNo = ResolveTransactionNo(source),
|
||||||
|
TransactionType = source.TransactionType,
|
||||||
|
OrderId = source.OrderId,
|
||||||
|
OrderNo = source.OrderNo,
|
||||||
|
DeliveryType = source.DeliveryType,
|
||||||
|
PaymentMethod = source.PaymentMethod,
|
||||||
|
AmountSigned = decimal.Round(source.AmountSigned, 2, MidpointRounding.AwayFromZero),
|
||||||
|
OccurredAt = source.OccurredAt,
|
||||||
|
Remark = ResolveRemark(source),
|
||||||
|
CustomerName = source.CustomerName,
|
||||||
|
CustomerPhone = source.CustomerPhone,
|
||||||
|
RefundNo = source.RefundNo,
|
||||||
|
RefundReason = source.RefundReason,
|
||||||
|
MemberName = source.MemberName,
|
||||||
|
MemberMobileMasked = source.MemberMobileMasked,
|
||||||
|
RechargeAmount = source.RechargeAmount,
|
||||||
|
GiftAmount = source.GiftAmount,
|
||||||
|
ArrivedAmount = source.ArrivedAmount,
|
||||||
|
PointChangeAmount = source.PointChangeAmount,
|
||||||
|
PointBalanceAfterChange = source.PointBalanceAfterChange
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveTransactionNo(TransactionProjection source)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(source.TransactionNo))
|
||||||
|
{
|
||||||
|
return source.TransactionNo.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.SourceType switch
|
||||||
|
{
|
||||||
|
FinanceTransactionSourceType.PaymentRecord => $"PAY{source.SourceId}",
|
||||||
|
FinanceTransactionSourceType.PaymentRefundRecord => $"REFUND{source.SourceId}",
|
||||||
|
FinanceTransactionSourceType.RefundRequest => $"REFREQ{source.SourceId}",
|
||||||
|
FinanceTransactionSourceType.StoredCardRechargeRecord => $"RECHARGE{source.SourceId}",
|
||||||
|
FinanceTransactionSourceType.MemberPointLedger => $"POINT{source.SourceId}",
|
||||||
|
_ => $"TXN{source.SourceId}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveRemark(TransactionProjection source)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(source.Remark))
|
||||||
|
{
|
||||||
|
return source.Remark.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.TransactionType == FinanceTransactionType.PointRedeem && source.PointChangeAmount.HasValue)
|
||||||
|
{
|
||||||
|
return $"积分抵扣{Math.Abs(source.PointChangeAmount.Value)}分";
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.TransactionType switch
|
||||||
|
{
|
||||||
|
FinanceTransactionType.Income => "订单收款",
|
||||||
|
FinanceTransactionType.Refund => "订单退款",
|
||||||
|
FinanceTransactionType.StoredCardRecharge => "会员储值充值",
|
||||||
|
FinanceTransactionType.PointRedeem => "积分抵扣",
|
||||||
|
_ => "--"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TransactionProjection
|
||||||
|
{
|
||||||
|
public required FinanceTransactionSourceType SourceType { get; init; }
|
||||||
|
|
||||||
|
public required long SourceId { get; init; }
|
||||||
|
|
||||||
|
public required long StoreId { get; init; }
|
||||||
|
|
||||||
|
public string? TransactionNo { get; init; }
|
||||||
|
|
||||||
|
public required FinanceTransactionType TransactionType { get; init; }
|
||||||
|
|
||||||
|
public long? OrderId { get; init; }
|
||||||
|
|
||||||
|
public string? OrderNo { get; init; }
|
||||||
|
|
||||||
|
public DeliveryType? DeliveryType { get; init; }
|
||||||
|
|
||||||
|
public PaymentMethod? PaymentMethod { get; init; }
|
||||||
|
|
||||||
|
public required decimal AmountSigned { get; init; }
|
||||||
|
|
||||||
|
public required DateTime OccurredAt { get; init; }
|
||||||
|
|
||||||
|
public string? Remark { get; init; }
|
||||||
|
|
||||||
|
public string? CustomerName { get; init; }
|
||||||
|
|
||||||
|
public string? CustomerPhone { get; init; }
|
||||||
|
|
||||||
|
public string? RefundNo { get; init; }
|
||||||
|
|
||||||
|
public string? RefundReason { get; init; }
|
||||||
|
|
||||||
|
public string? MemberName { get; init; }
|
||||||
|
|
||||||
|
public string? MemberMobileMasked { get; init; }
|
||||||
|
|
||||||
|
public decimal? RechargeAmount { get; init; }
|
||||||
|
|
||||||
|
public decimal? GiftAmount { get; init; }
|
||||||
|
|
||||||
|
public decimal? ArrivedAmount { get; init; }
|
||||||
|
|
||||||
|
public int? PointChangeAmount { get; init; }
|
||||||
|
|
||||||
|
public int? PointBalanceAfterChange { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入交易流水菜单与权限定义。
|
||||||
|
/// </summary>
|
||||||
|
[DbContext(typeof(IdentityDbContext))]
|
||||||
|
[Migration("20260304093000_SeedFinanceTransactionMenuAndPermissions")]
|
||||||
|
public sealed class SeedFinanceTransactionMenuAndPermissions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_parent_permission_id bigint;
|
||||||
|
v_view_permission_id bigint;
|
||||||
|
v_detail_permission_id bigint;
|
||||||
|
v_export_permission_id bigint;
|
||||||
|
v_parent_menu_id bigint;
|
||||||
|
v_transaction_menu_id bigint;
|
||||||
|
v_permission_seed_base bigint := 840000000000000000;
|
||||||
|
v_menu_seed_base bigint := 850000000000000000;
|
||||||
|
BEGIN
|
||||||
|
-- 1. 确保财务权限分组存在。
|
||||||
|
SELECT "Id"
|
||||||
|
INTO v_parent_permission_id
|
||||||
|
FROM public.permissions
|
||||||
|
WHERE "Code" = 'group:tenant:finance'
|
||||||
|
ORDER BY "Id"
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_parent_permission_id IS NULL THEN
|
||||||
|
v_parent_permission_id := v_permission_seed_base + 1;
|
||||||
|
INSERT INTO public.permissions (
|
||||||
|
"Id", "Name", "Code", "Description",
|
||||||
|
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||||
|
"CreatedBy", "UpdatedBy", "DeletedBy",
|
||||||
|
"ParentId", "SortOrder", "Type", "Portal")
|
||||||
|
VALUES (
|
||||||
|
v_parent_permission_id, '财务中心', 'group:tenant:finance', '财务中心权限分组',
|
||||||
|
NOW(), NULL, NULL,
|
||||||
|
NULL, NULL, NULL,
|
||||||
|
0, 5000, 'group', 1)
|
||||||
|
ON CONFLICT ("Code") DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Upsert 交易流水查看权限。
|
||||||
|
INSERT INTO public.permissions (
|
||||||
|
"Id", "Name", "Code", "Description",
|
||||||
|
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||||
|
"CreatedBy", "UpdatedBy", "DeletedBy",
|
||||||
|
"ParentId", "SortOrder", "Type", "Portal")
|
||||||
|
VALUES (
|
||||||
|
v_permission_seed_base + 11, '交易流水查看', 'tenant:finance:transaction:view', '查看交易流水列表与统计',
|
||||||
|
NOW(), NULL, NULL,
|
||||||
|
NULL, NULL, NULL,
|
||||||
|
v_parent_permission_id, 5010, 'leaf', 1)
|
||||||
|
ON CONFLICT ("Code") DO UPDATE
|
||||||
|
SET "Name" = EXCLUDED."Name",
|
||||||
|
"Description" = EXCLUDED."Description",
|
||||||
|
"ParentId" = EXCLUDED."ParentId",
|
||||||
|
"SortOrder" = EXCLUDED."SortOrder",
|
||||||
|
"Type" = EXCLUDED."Type",
|
||||||
|
"Portal" = EXCLUDED."Portal",
|
||||||
|
"DeletedAt" = NULL,
|
||||||
|
"DeletedBy" = NULL,
|
||||||
|
"UpdatedAt" = NOW();
|
||||||
|
|
||||||
|
-- 3. Upsert 交易流水详情权限。
|
||||||
|
INSERT INTO public.permissions (
|
||||||
|
"Id", "Name", "Code", "Description",
|
||||||
|
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||||
|
"CreatedBy", "UpdatedBy", "DeletedBy",
|
||||||
|
"ParentId", "SortOrder", "Type", "Portal")
|
||||||
|
VALUES (
|
||||||
|
v_permission_seed_base + 12, '交易流水详情', 'tenant:finance:transaction:detail', '查看交易流水详情抽屉',
|
||||||
|
NOW(), NULL, NULL,
|
||||||
|
NULL, NULL, NULL,
|
||||||
|
v_parent_permission_id, 5020, 'leaf', 1)
|
||||||
|
ON CONFLICT ("Code") DO UPDATE
|
||||||
|
SET "Name" = EXCLUDED."Name",
|
||||||
|
"Description" = EXCLUDED."Description",
|
||||||
|
"ParentId" = EXCLUDED."ParentId",
|
||||||
|
"SortOrder" = EXCLUDED."SortOrder",
|
||||||
|
"Type" = EXCLUDED."Type",
|
||||||
|
"Portal" = EXCLUDED."Portal",
|
||||||
|
"DeletedAt" = NULL,
|
||||||
|
"DeletedBy" = NULL,
|
||||||
|
"UpdatedAt" = NOW();
|
||||||
|
|
||||||
|
-- 4. Upsert 交易流水导出权限。
|
||||||
|
INSERT INTO public.permissions (
|
||||||
|
"Id", "Name", "Code", "Description",
|
||||||
|
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||||
|
"CreatedBy", "UpdatedBy", "DeletedBy",
|
||||||
|
"ParentId", "SortOrder", "Type", "Portal")
|
||||||
|
VALUES (
|
||||||
|
v_permission_seed_base + 13, '交易流水导出', 'tenant:finance:transaction:export', '导出交易流水 CSV',
|
||||||
|
NOW(), NULL, NULL,
|
||||||
|
NULL, NULL, NULL,
|
||||||
|
v_parent_permission_id, 5030, 'leaf', 1)
|
||||||
|
ON CONFLICT ("Code") DO UPDATE
|
||||||
|
SET "Name" = EXCLUDED."Name",
|
||||||
|
"Description" = EXCLUDED."Description",
|
||||||
|
"ParentId" = EXCLUDED."ParentId",
|
||||||
|
"SortOrder" = EXCLUDED."SortOrder",
|
||||||
|
"Type" = EXCLUDED."Type",
|
||||||
|
"Portal" = EXCLUDED."Portal",
|
||||||
|
"DeletedAt" = NULL,
|
||||||
|
"DeletedBy" = NULL,
|
||||||
|
"UpdatedAt" = NOW();
|
||||||
|
|
||||||
|
-- 5. 回填权限 ID。
|
||||||
|
SELECT "Id" INTO v_view_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:transaction:view' LIMIT 1;
|
||||||
|
SELECT "Id" INTO v_detail_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:transaction:detail' LIMIT 1;
|
||||||
|
SELECT "Id" INTO v_export_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:transaction:export' LIMIT 1;
|
||||||
|
|
||||||
|
-- 6. 确保租户端财务父菜单存在。
|
||||||
|
SELECT "Id"
|
||||||
|
INTO v_parent_menu_id
|
||||||
|
FROM public.menu_definitions
|
||||||
|
WHERE "Portal" = 1 AND "Path" = '/finance' AND "DeletedAt" IS NULL
|
||||||
|
ORDER BY "Id"
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_parent_menu_id IS NULL THEN
|
||||||
|
v_parent_menu_id := v_menu_seed_base + 1;
|
||||||
|
INSERT INTO public.menu_definitions (
|
||||||
|
"Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
|
||||||
|
"IsIframe", "Link", "KeepAlive", "SortOrder",
|
||||||
|
"RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
|
||||||
|
"CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
|
||||||
|
VALUES (
|
||||||
|
v_parent_menu_id, 0, 'Finance', '/finance', 'BasicLayout', '财务中心', 'lucide:wallet',
|
||||||
|
FALSE, NULL, FALSE, 500,
|
||||||
|
'', '', '', NULL,
|
||||||
|
NOW(), NULL, NULL, NULL, NULL, NULL, 1)
|
||||||
|
ON CONFLICT ("Id") DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 7. Upsert 交易流水菜单。
|
||||||
|
SELECT "Id"
|
||||||
|
INTO v_transaction_menu_id
|
||||||
|
FROM public.menu_definitions
|
||||||
|
WHERE "Portal" = 1 AND "Path" = '/finance/transaction'
|
||||||
|
ORDER BY "DeletedAt" NULLS FIRST, "Id"
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_transaction_menu_id IS NULL THEN
|
||||||
|
v_transaction_menu_id := v_menu_seed_base + 11;
|
||||||
|
INSERT INTO public.menu_definitions (
|
||||||
|
"Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
|
||||||
|
"IsIframe", "Link", "KeepAlive", "SortOrder",
|
||||||
|
"RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
|
||||||
|
"CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
|
||||||
|
VALUES (
|
||||||
|
v_transaction_menu_id, v_parent_menu_id, 'TransactionFlow', '/finance/transaction', '/finance/transaction/index', '交易流水', 'lucide:receipt',
|
||||||
|
FALSE, NULL, TRUE, 510,
|
||||||
|
'tenant:finance:transaction:view', 'tenant:finance:transaction:view', '', NULL,
|
||||||
|
NOW(), NULL, NULL, NULL, NULL, NULL, 1)
|
||||||
|
ON CONFLICT ("Id") DO NOTHING;
|
||||||
|
ELSE
|
||||||
|
UPDATE public.menu_definitions
|
||||||
|
SET "ParentId" = v_parent_menu_id,
|
||||||
|
"Name" = 'TransactionFlow',
|
||||||
|
"Component" = '/finance/transaction/index',
|
||||||
|
"Title" = '交易流水',
|
||||||
|
"Icon" = 'lucide:receipt',
|
||||||
|
"IsIframe" = FALSE,
|
||||||
|
"Link" = NULL,
|
||||||
|
"KeepAlive" = TRUE,
|
||||||
|
"SortOrder" = 510,
|
||||||
|
"RequiredPermissions" = 'tenant:finance:transaction:view',
|
||||||
|
"MetaPermissions" = 'tenant:finance:transaction:view',
|
||||||
|
"MetaRoles" = '',
|
||||||
|
"DeletedAt" = NULL,
|
||||||
|
"DeletedBy" = NULL,
|
||||||
|
"UpdatedAt" = NOW(),
|
||||||
|
"Portal" = 1
|
||||||
|
WHERE "Id" = v_transaction_menu_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 8. 为 tenant-admin 角色直接授予新权限。
|
||||||
|
INSERT INTO public.role_permissions (
|
||||||
|
"Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
|
||||||
|
"CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
|
||||||
|
SELECT
|
||||||
|
ABS(HASHTEXTEXTENDED('tenant-admin:' || role."Id"::text || ':' || permission_id::text, 0)),
|
||||||
|
role."Id",
|
||||||
|
permission_id,
|
||||||
|
NOW(), NULL, NULL,
|
||||||
|
NULL, NULL, NULL,
|
||||||
|
role."TenantId",
|
||||||
|
1
|
||||||
|
FROM public.roles role
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT UNNEST(ARRAY[v_view_permission_id, v_detail_permission_id, v_export_permission_id]) AS permission_id
|
||||||
|
) item
|
||||||
|
WHERE role."Code" = 'tenant-admin'
|
||||||
|
AND role."DeletedAt" IS NULL
|
||||||
|
AND item.permission_id IS NOT NULL
|
||||||
|
ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
|
||||||
|
SET "DeletedAt" = NULL,
|
||||||
|
"DeletedBy" = NULL,
|
||||||
|
"UpdatedAt" = NOW(),
|
||||||
|
"Portal" = 1;
|
||||||
|
|
||||||
|
-- 9. 将旧财务权限角色映射到新交易流水查看/详情权限。
|
||||||
|
INSERT INTO public.role_permissions (
|
||||||
|
"Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
|
||||||
|
"CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
|
||||||
|
SELECT
|
||||||
|
ABS(HASHTEXTEXTENDED('legacy-view:' || source."RoleId"::text || ':' || item.permission_id::text, 0)),
|
||||||
|
source."RoleId",
|
||||||
|
item.permission_id,
|
||||||
|
NOW(), NULL, NULL,
|
||||||
|
NULL, NULL, NULL,
|
||||||
|
source."TenantId",
|
||||||
|
1
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT rp."RoleId", rp."TenantId"
|
||||||
|
FROM public.role_permissions rp
|
||||||
|
JOIN public.permissions p ON p."Id" = rp."PermissionId"
|
||||||
|
WHERE p."Code" IN ('tenant:finance:statement:view', 'tenant:finance:income:view')
|
||||||
|
AND rp."DeletedAt" IS NULL
|
||||||
|
) source
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT UNNEST(ARRAY[v_view_permission_id, v_detail_permission_id]) AS permission_id
|
||||||
|
) item
|
||||||
|
WHERE item.permission_id IS NOT NULL
|
||||||
|
ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
|
||||||
|
SET "DeletedAt" = NULL,
|
||||||
|
"DeletedBy" = NULL,
|
||||||
|
"UpdatedAt" = NOW(),
|
||||||
|
"Portal" = 1;
|
||||||
|
|
||||||
|
-- 10. 将旧导出权限角色映射到新交易流水导出权限。
|
||||||
|
INSERT INTO public.role_permissions (
|
||||||
|
"Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
|
||||||
|
"CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
|
||||||
|
SELECT
|
||||||
|
ABS(HASHTEXTEXTENDED('legacy-export:' || source."RoleId"::text || ':' || v_export_permission_id::text, 0)),
|
||||||
|
source."RoleId",
|
||||||
|
v_export_permission_id,
|
||||||
|
NOW(), NULL, NULL,
|
||||||
|
NULL, NULL, NULL,
|
||||||
|
source."TenantId",
|
||||||
|
1
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT rp."RoleId", rp."TenantId"
|
||||||
|
FROM public.role_permissions rp
|
||||||
|
JOIN public.permissions p ON p."Id" = rp."PermissionId"
|
||||||
|
WHERE p."Code" IN ('tenant:finance:statement:export', 'tenant:finance:income:export')
|
||||||
|
AND rp."DeletedAt" IS NULL
|
||||||
|
) source
|
||||||
|
WHERE v_export_permission_id IS NOT NULL
|
||||||
|
ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
|
||||||
|
SET "DeletedAt" = NULL,
|
||||||
|
"DeletedBy" = NULL,
|
||||||
|
"UpdatedAt" = NOW(),
|
||||||
|
"Portal" = 1;
|
||||||
|
END $$;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM public.role_permissions
|
||||||
|
WHERE "PermissionId" IN (
|
||||||
|
SELECT "Id"
|
||||||
|
FROM public.permissions
|
||||||
|
WHERE "Code" IN (
|
||||||
|
'tenant:finance:transaction:view',
|
||||||
|
'tenant:finance:transaction:detail',
|
||||||
|
'tenant:finance:transaction:export'));
|
||||||
|
|
||||||
|
DELETE FROM public.menu_definitions
|
||||||
|
WHERE "Portal" = 1 AND "Path" = '/finance/transaction';
|
||||||
|
|
||||||
|
DELETE FROM public.permissions
|
||||||
|
WHERE "Code" IN (
|
||||||
|
'tenant:finance:transaction:view',
|
||||||
|
'tenant:finance:transaction:detail',
|
||||||
|
'tenant:finance:transaction:export');
|
||||||
|
END $$;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user