feat: 新增财务交易流水后端模块

This commit is contained in:
2026-03-04 11:33:29 +08:00
parent 2970134200
commit d437b146d1
24 changed files with 2602 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("开始时间必须早于结束时间");
}
}

View File

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

View File

@@ -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("开始时间必须早于结束时间");
}
}

View File

@@ -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("开始时间必须早于结束时间");
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Domain.Inventory.Repositories;
@@ -53,6 +54,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IMemberRepository, EfMemberRepository>();
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
services.AddScoped<ITenantRepository, EfTenantRepository>();

View File

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

View File

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