feat(customer): account flow ledger, pay deduct balance, status counts

Add t_customer_account_flow with list API, deduct balance on order pay, credit ledger on recharge approve, and GET /order/status-counts for tab badges.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-26 19:09:43 +08:00
parent 82df5d1e79
commit d766343ffe
15 changed files with 522 additions and 2 deletions

View File

@ -0,0 +1,23 @@
-- 客户账户资金流水
CREATE TABLE IF NOT EXISTS `t_customer_account_flow` (
`Id` int NOT NULL AUTO_INCREMENT,
`CustomerId` int NOT NULL COMMENT '客户 ID',
`SerialNumber` varchar(64) NOT NULL COMMENT '流水号',
`BizType` varchar(32) NOT NULL COMMENT '业务类型 recharge/orderPay',
`BizId` int DEFAULT NULL COMMENT '关联业务主键',
`BizNo` varchar(64) DEFAULT NULL COMMENT '关联业务单号',
`Amount` decimal(18,2) NOT NULL COMMENT '变动金额,正入账负出账',
`BalanceBefore` decimal(18,2) NOT NULL COMMENT '变动前余额',
`BalanceAfter` decimal(18,2) NOT NULL COMMENT '变动后余额',
`Currency` varchar(8) NOT NULL DEFAULT 'CNY' COMMENT '币种',
`Remark` varchar(512) DEFAULT NULL COMMENT '备注',
`IsDeleted` tinyint(1) NOT NULL DEFAULT '0',
`CreateUserId` int NOT NULL DEFAULT '0',
`CreateTime` datetime NOT NULL,
`UpdateUserId` int DEFAULT NULL,
`UpdateTime` datetime DEFAULT NULL,
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_customer_account_flow_serial` (`SerialNumber`),
KEY `idx_customer_account_flow_customer_time` (`CustomerId`, `CreateTime`),
KEY `idx_customer_account_flow_biz` (`BizType`, `BizId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户账户资金流水';

View File

@ -0,0 +1,35 @@
namespace Oms.Application.Dtos.Customers;
/// <summary>
/// 客户账户流水列表项。
/// </summary>
public class CustomerAccountFlowOutput
{
public int Id { get; set; }
public int CustomerId { get; set; }
public string? CustomerCode { get; set; }
public string SerialNumber { get; set; } = string.Empty;
public string BizType { get; set; } = string.Empty;
public string? BizTypeName { get; set; }
public int? BizId { get; set; }
public string? BizNo { get; set; }
public decimal Amount { get; set; }
public decimal BalanceBefore { get; set; }
public decimal BalanceAfter { get; set; }
public string Currency { get; set; } = "CNY";
public string? Remark { get; set; }
public DateTime CreateTime { get; set; }
}

View File

@ -0,0 +1,27 @@
namespace Oms.Application.Dtos.Customers;
/// <summary>
/// 客户账户流水分页查询入参。
/// </summary>
public class CustomerAccountFlowPaginatedInput
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
/// <summary>
/// 流水号或关联单号关键词。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 业务类型:recharge / orderPay,空表示全部。
/// </summary>
public string? BizType { get; set; }
public int? CustomerId { get; set; }
public DateTime? StartTime { get; set; }
public DateTime? EndTime { get; set; }
}

View File

@ -0,0 +1,17 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 各状态订单数量统计(单次聚合查询,非分页列表)。
/// </summary>
public class OrderStatusCountsOutput
{
/// <summary>
/// 全部订单数量。
/// </summary>
public int Total { get; set; }
/// <summary>
/// 按 OrderStatus 分组的数量,key 为状态码。
/// </summary>
public Dictionary<string, int> Counts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

View File

@ -0,0 +1,129 @@
using System.Linq.Expressions;
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Customers;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.CustomersAggregate.Consts;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Services.Customers;
/// <summary>
/// 客户账户流水 API。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Customer")]
public class CustomerAccountFlowService : ApplicationService
{
private ICustomerAccountFlowRepository FlowRepository =>
LazyServiceProvider.GetRequiredService<ICustomerAccountFlowRepository>();
private ICustomerRepository CustomerRepository =>
LazyServiceProvider.GetRequiredService<ICustomerRepository>();
/// <summary>
/// 分页查询客户账户流水。
/// </summary>
[HttpGet("/customer/account-flow/list")]
public async Task<OmsPageResult<CustomerAccountFlowOutput>> GetPaginatedListAsync(
[FromQuery] CustomerAccountFlowPaginatedInput input)
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
var predicate = BuildPaginatedPredicate(input, customerId);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var entities = await FlowRepository.GetPagedListAsync(
predicate,
pageIndex,
pageSize,
new Dictionary<string, bool>
{
[nameof(CustomerAccountFlow.CreateTime)] = false,
[nameof(CustomerAccountFlow.Id)] = false
});
var total = await FlowRepository.CountAsync(predicate);
var customerIds = entities.Select(x => x.CustomerId).Distinct().ToArray();
var customers = customerIds.Length == 0
? []
: await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
var customerMap = customers.ToDictionary(x => x.Id);
var items = entities.Select(entity =>
{
customerMap.TryGetValue(entity.CustomerId, out var customer);
return new CustomerAccountFlowOutput
{
Id = entity.Id,
CustomerId = entity.CustomerId,
CustomerCode = customer?.CustomerCode,
SerialNumber = entity.SerialNumber,
BizType = entity.BizType,
BizTypeName = GetBizTypeName(entity.BizType),
BizId = entity.BizId,
BizNo = entity.BizNo,
Amount = entity.Amount,
BalanceBefore = entity.BalanceBefore,
BalanceAfter = entity.BalanceAfter,
Currency = entity.Currency,
Remark = entity.Remark,
CreateTime = entity.CreateTime
};
}).ToList();
return new OmsPageResult<CustomerAccountFlowOutput>
{
Items = items,
Total = total
};
}
private static Expression<Func<CustomerAccountFlow, bool>> BuildPaginatedPredicate(
CustomerAccountFlowPaginatedInput input,
int? customerId)
{
var keyword = input.Keyword?.Trim();
var bizType = input.BizType?.Trim();
var hasKeyword = !string.IsNullOrWhiteSpace(keyword);
var hasBizType = !string.IsNullOrWhiteSpace(bizType)
&& !string.Equals(bizType, "all", StringComparison.OrdinalIgnoreCase);
var hasCustomerFilter = customerId.HasValue;
var customerIdValue = customerId.GetValueOrDefault();
if (input.StartTime.HasValue && input.EndTime.HasValue)
{
var start = input.StartTime.Value;
var end = input.EndTime.Value;
return flow =>
(!hasCustomerFilter || flow.CustomerId == customerIdValue) &&
(!hasBizType || flow.BizType == bizType) &&
(!hasKeyword ||
flow.SerialNumber.Contains(keyword!) ||
(flow.BizNo ?? string.Empty).Contains(keyword!)) &&
flow.CreateTime >= start &&
flow.CreateTime <= end;
}
return flow =>
(!hasCustomerFilter || flow.CustomerId == customerIdValue) &&
(!hasBizType || flow.BizType == bizType) &&
(!hasKeyword ||
flow.SerialNumber.Contains(keyword!) ||
(flow.BizNo ?? string.Empty).Contains(keyword!));
}
private static string GetBizTypeName(string bizType)
{
return bizType switch
{
CustomerAccountFlowBizTypes.Recharge => "充值入账",
CustomerAccountFlowBizTypes.OrderPay => "订单支付",
_ => bizType
};
}
}

View File

@ -8,6 +8,7 @@ using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
@ -58,6 +59,28 @@ public partial class OrderService : ApplicationService
return new OmsPageResult<OrderOutput> { Items = await MapOrderOutputsAsync(orders), Total = total };
}
/// <summary>
/// 按状态聚合统计订单数量(单次查询,供 Tab 角标展示)。
/// </summary>
[HttpGet("/order/status-counts")]
public async Task<OrderStatusCountsOutput> GetStatusCountsAsync()
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
null);
var rows = await OrderRepository.GetStatusCountsAsync(customerId);
var counts = rows.ToDictionary(
x => x.Status,
x => x.Count,
StringComparer.OrdinalIgnoreCase);
return new OrderStatusCountsOutput
{
Total = rows.Sum(x => x.Count),
Counts = counts
};
}
/// <summary>
/// 查询订单详情。
/// </summary>
@ -91,6 +114,44 @@ public partial class OrderService : ApplicationService
public async Task PayAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
if (order.OrderStatus == OrderStatusCodes.PendingShip && order.PayTime.HasValue)
{
throw new BusinessException("订单已支付");
}
if (order.OrderStatus != OrderStatusCodes.PendingPayment)
{
throw new BusinessException("当前订单状态不可支付");
}
if (!order.CustomerId.HasValue)
{
throw new BusinessException("订单未关联客户,无法支付");
}
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
var payAmount = fees
.Where(x => x.Type == OrderFeeTypeCodes.Receivable)
.Sum(x => x.Amount);
if (payAmount <= 0)
{
throw new BusinessException("订单应收金额无效,无法支付");
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
if (customer == null)
{
throw new BusinessException("客户不存在");
}
var balanceMutator = LazyServiceProvider.GetRequiredService<CustomerBalanceMutator>();
await balanceMutator.DebitOrderPayAsync(
customer,
payAmount,
order.Id,
order.ServiceNo,
$"订单支付 {order.ServiceNo}");
order.PayTime = DateTime.Now;
order.OrderStatus = OrderStatusCodes.PendingShip;
await UpdateOrderAsync(order);

View File

@ -96,10 +96,15 @@ public class RechargeService : ApplicationService
record.AuditTime = now;
record.AuditBy = CurrentUser.UserName;
customer.Balance += record.RechargeAmount;
var balanceMutator = LazyServiceProvider.GetRequiredService<CustomerBalanceMutator>();
await balanceMutator.CreditRechargeAsync(
customer,
record.RechargeAmount,
record.Id,
record.SerialNumber,
input.Remark ?? "充值审核通过");
await RechargeRecordRepository.UpdateAsync(record);
await CustomerRepository.UpdateAsync(customer);
}
/// <summary>

View File

@ -0,0 +1,119 @@
using Fake.DependencyInjection;
using Fake.Application;
using Oms.Domain.CustomersAggregate.Consts;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Shared;
/// <summary>
/// 客户余额变动与流水写入。
/// </summary>
public class CustomerBalanceMutator : IScopedDependency
{
private readonly ICustomerRepository _customerRepository;
private readonly ICustomerAccountFlowRepository _flowRepository;
public CustomerBalanceMutator(
ICustomerRepository customerRepository,
ICustomerAccountFlowRepository flowRepository)
{
_customerRepository = customerRepository;
_flowRepository = flowRepository;
}
public Task CreditRechargeAsync(
Customer customer,
decimal amount,
int rechargeRecordId,
string rechargeSerialNumber,
string? remark,
CancellationToken cancellationToken = default)
{
return ApplyAsync(
customer,
amount,
CustomerAccountFlowBizTypes.Recharge,
rechargeRecordId,
rechargeSerialNumber,
remark,
cancellationToken);
}
public Task DebitOrderPayAsync(
Customer customer,
decimal amount,
int orderId,
string serviceNo,
string? remark,
CancellationToken cancellationToken = default)
{
if (amount <= 0)
{
throw new BusinessException("支付金额必须大于 0");
}
return ApplyAsync(
customer,
-amount,
CustomerAccountFlowBizTypes.OrderPay,
orderId,
serviceNo,
remark,
cancellationToken);
}
private async Task ApplyAsync(
Customer customer,
decimal signedAmount,
string bizType,
int? bizId,
string? bizNo,
string? remark,
CancellationToken cancellationToken = default)
{
if (signedAmount == 0)
{
throw new BusinessException("流水金额不能为 0");
}
var balanceBefore = customer.Balance;
var balanceAfter = balanceBefore + signedAmount;
if (balanceAfter < 0)
{
throw new BusinessException("账户余额不足");
}
customer.Balance = balanceAfter;
await _customerRepository.UpdateAsync(customer, cancellationToken);
var flow = new CustomerAccountFlow
{
CustomerId = customer.Id,
SerialNumber = await GenerateSerialNumberAsync(cancellationToken),
BizType = bizType,
BizId = bizId,
BizNo = bizNo,
Amount = signedAmount,
BalanceBefore = balanceBefore,
BalanceAfter = balanceAfter,
Currency = "CNY",
Remark = remark
};
await _flowRepository.InsertAsync(flow, cancellationToken);
}
private async Task<string> GenerateSerialNumberAsync(CancellationToken cancellationToken)
{
for (var i = 0; i < 5; i++)
{
var serialNumber = $"FLOW{DateTime.Now:yyyyMMddHHmmss}{Random.Shared.Next(100000, 999999)}";
if (!await _flowRepository.ExistsSerialNumberAsync(serialNumber, cancellationToken))
{
return serialNumber;
}
}
throw new BusinessException("账户流水号生成失败");
}
}

View File

@ -0,0 +1,11 @@
namespace Oms.Domain.CustomersAggregate.Consts;
/// <summary>
/// 客户账户流水业务类型。
/// </summary>
public static class CustomerAccountFlowBizTypes
{
public const string Recharge = "recharge";
public const string OrderPay = "orderPay";
}

View File

@ -0,0 +1,34 @@
using Oms.Domain.Shared;
using SqlSugar;
namespace Oms.Domain.CustomersAggregate.Entities;
[SugarTable("t_customer_account_flow")]
public class CustomerAccountFlow : OmsAuditedAggregateRoot
{
public int CustomerId { get; set; }
public string SerialNumber { get; set; } = string.Empty;
/// <summary>
/// 业务类型:recharge / orderPay 等。
/// </summary>
public string BizType { get; set; } = string.Empty;
public int? BizId { get; set; }
public string? BizNo { get; set; }
/// <summary>
/// 变动金额:正数为入账,负数为出账。
/// </summary>
public decimal Amount { get; set; }
public decimal BalanceBefore { get; set; }
public decimal BalanceAfter { get; set; }
public string Currency { get; set; } = "CNY";
public string? Remark { get; set; }
}

View File

@ -0,0 +1,9 @@
using Fake.Domain.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
namespace Oms.Domain.CustomersAggregate.Repositories;
public interface ICustomerAccountFlowRepository : IRepository<CustomerAccountFlow>
{
Task<bool> ExistsSerialNumberAsync(string serialNumber, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,11 @@
namespace Oms.Domain.OrdersAggregate;
/// <summary>
/// 订单按状态分组统计行。
/// </summary>
public class OrderStatusCountRow
{
public string Status { get; set; } = string.Empty;
public int Count { get; set; }
}

View File

@ -48,4 +48,6 @@ public interface IOrderRepository : IRepository<Order>
Task UpdateRecipientAsync(OrderRecipient recipient, CancellationToken cancellationToken = default);
Task<List<OrderFee>> GetFeesByOrderIdsAsync(IEnumerable<int> orderIds, CancellationToken cancellationToken = default);
Task<List<OrderStatusCountRow>> GetStatusCountsAsync(int? customerId, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,15 @@
using Fake.DependencyInjection;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
namespace Oms.SqlSugarCore.Customers;
public class CustomerAccountFlowRepository
: OmsSqlSugarRepository<CustomerAccountFlow>, ICustomerAccountFlowRepository, IScopedDependency
{
public Task<bool> ExistsSerialNumberAsync(string serialNumber, CancellationToken cancellationToken = default)
{
return AnyAsync(x => x.SerialNumber == serialNumber, cancellationToken);
}
}

View File

@ -1,8 +1,10 @@
using Fake.DependencyInjection;
using Oms.Domain.OrdersAggregate;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
using SqlSugar;
namespace Oms.SqlSugarCore.Orders;
@ -249,4 +251,24 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
.Where(x => ids.Contains(x.OrderId))
.ToListAsync(cancellationToken);
}
public async Task<List<OrderStatusCountRow>> GetStatusCountsAsync(int? customerId, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var query = db.Queryable<Order>();
if (customerId.HasValue)
{
var cid = customerId.Value;
query = query.Where(x => x.CustomerId == cid);
}
return await query
.GroupBy(x => x.OrderStatus)
.Select(x => new OrderStatusCountRow
{
Status = x.OrderStatus,
Count = SqlFunc.AggregateCount(x.Id)
})
.ToListAsync(cancellationToken);
}
}