diff --git a/sql/migrate_t_customer_account_flow.sql b/sql/migrate_t_customer_account_flow.sql new file mode 100644 index 0000000..9e9078b --- /dev/null +++ b/sql/migrate_t_customer_account_flow.sql @@ -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='客户账户资金流水'; diff --git a/src/Oms.Application/Dtos/Customers/CustomerAccountFlowOutput.cs b/src/Oms.Application/Dtos/Customers/CustomerAccountFlowOutput.cs new file mode 100644 index 0000000..1997c6c --- /dev/null +++ b/src/Oms.Application/Dtos/Customers/CustomerAccountFlowOutput.cs @@ -0,0 +1,35 @@ +namespace Oms.Application.Dtos.Customers; + +/// +/// 客户账户流水列表项。 +/// +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; } +} diff --git a/src/Oms.Application/Dtos/Customers/CustomerAccountFlowPaginatedInput.cs b/src/Oms.Application/Dtos/Customers/CustomerAccountFlowPaginatedInput.cs new file mode 100644 index 0000000..50198ad --- /dev/null +++ b/src/Oms.Application/Dtos/Customers/CustomerAccountFlowPaginatedInput.cs @@ -0,0 +1,27 @@ +namespace Oms.Application.Dtos.Customers; + +/// +/// 客户账户流水分页查询入参。 +/// +public class CustomerAccountFlowPaginatedInput +{ + public int Page { get; set; } = 1; + + public int PageSize { get; set; } = 20; + + /// + /// 流水号或关联单号关键词。 + /// + public string? Keyword { get; set; } + + /// + /// 业务类型:recharge / orderPay,空表示全部。 + /// + public string? BizType { get; set; } + + public int? CustomerId { get; set; } + + public DateTime? StartTime { get; set; } + + public DateTime? EndTime { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderStatusCountsOutput.cs b/src/Oms.Application/Dtos/Orders/OrderStatusCountsOutput.cs new file mode 100644 index 0000000..42991af --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderStatusCountsOutput.cs @@ -0,0 +1,17 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 各状态订单数量统计(单次聚合查询,非分页列表)。 +/// +public class OrderStatusCountsOutput +{ + /// + /// 全部订单数量。 + /// + public int Total { get; set; } + + /// + /// 按 OrderStatus 分组的数量,key 为状态码。 + /// + public Dictionary Counts { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Oms.Application/Services/Customers/CustomerAccountFlowService.cs b/src/Oms.Application/Services/Customers/CustomerAccountFlowService.cs new file mode 100644 index 0000000..1ae82e2 --- /dev/null +++ b/src/Oms.Application/Services/Customers/CustomerAccountFlowService.cs @@ -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; + +/// +/// 客户账户流水 API。 +/// +[ApiExplorerSettings(GroupName = "OMS-Customer")] +public class CustomerAccountFlowService : ApplicationService +{ + private ICustomerAccountFlowRepository FlowRepository => + LazyServiceProvider.GetRequiredService(); + + private ICustomerRepository CustomerRepository => + LazyServiceProvider.GetRequiredService(); + + /// + /// 分页查询客户账户流水。 + /// + [HttpGet("/customer/account-flow/list")] + public async Task> 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 + { + [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 + { + Items = items, + Total = total + }; + } + + private static Expression> 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 + }; + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index d582717..1b794bb 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -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 { Items = await MapOrderOutputsAsync(orders), Total = total }; } + /// + /// 按状态聚合统计订单数量(单次查询,供 Tab 角标展示)。 + /// + [HttpGet("/order/status-counts")] + public async Task 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 + }; + } + /// /// 查询订单详情。 /// @@ -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(); + await balanceMutator.DebitOrderPayAsync( + customer, + payAmount, + order.Id, + order.ServiceNo, + $"订单支付 {order.ServiceNo}"); + order.PayTime = DateTime.Now; order.OrderStatus = OrderStatusCodes.PendingShip; await UpdateOrderAsync(order); diff --git a/src/Oms.Application/Services/Recharge/RechargeService.cs b/src/Oms.Application/Services/Recharge/RechargeService.cs index bba5176..d624f5f 100644 --- a/src/Oms.Application/Services/Recharge/RechargeService.cs +++ b/src/Oms.Application/Services/Recharge/RechargeService.cs @@ -96,10 +96,15 @@ public class RechargeService : ApplicationService record.AuditTime = now; record.AuditBy = CurrentUser.UserName; - customer.Balance += record.RechargeAmount; + var balanceMutator = LazyServiceProvider.GetRequiredService(); + await balanceMutator.CreditRechargeAsync( + customer, + record.RechargeAmount, + record.Id, + record.SerialNumber, + input.Remark ?? "充值审核通过"); await RechargeRecordRepository.UpdateAsync(record); - await CustomerRepository.UpdateAsync(customer); } /// diff --git a/src/Oms.Application/Shared/CustomerBalanceMutator.cs b/src/Oms.Application/Shared/CustomerBalanceMutator.cs new file mode 100644 index 0000000..0e7c839 --- /dev/null +++ b/src/Oms.Application/Shared/CustomerBalanceMutator.cs @@ -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; + +/// +/// 客户余额变动与流水写入。 +/// +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 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("账户流水号生成失败"); + } +} diff --git a/src/Oms.Domain/CustomersAggregate/Consts/CustomerAccountFlowBizTypes.cs b/src/Oms.Domain/CustomersAggregate/Consts/CustomerAccountFlowBizTypes.cs new file mode 100644 index 0000000..eecdc63 --- /dev/null +++ b/src/Oms.Domain/CustomersAggregate/Consts/CustomerAccountFlowBizTypes.cs @@ -0,0 +1,11 @@ +namespace Oms.Domain.CustomersAggregate.Consts; + +/// +/// 客户账户流水业务类型。 +/// +public static class CustomerAccountFlowBizTypes +{ + public const string Recharge = "recharge"; + + public const string OrderPay = "orderPay"; +} diff --git a/src/Oms.Domain/CustomersAggregate/Entities/CustomerAccountFlow.cs b/src/Oms.Domain/CustomersAggregate/Entities/CustomerAccountFlow.cs new file mode 100644 index 0000000..0083f01 --- /dev/null +++ b/src/Oms.Domain/CustomersAggregate/Entities/CustomerAccountFlow.cs @@ -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; + + /// + /// 业务类型:recharge / orderPay 等。 + /// + public string BizType { get; set; } = string.Empty; + + 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; } +} diff --git a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerAccountFlowRepository.cs b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerAccountFlowRepository.cs new file mode 100644 index 0000000..936ce1c --- /dev/null +++ b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerAccountFlowRepository.cs @@ -0,0 +1,9 @@ +using Fake.Domain.Repositories; +using Oms.Domain.CustomersAggregate.Entities; + +namespace Oms.Domain.CustomersAggregate.Repositories; + +public interface ICustomerAccountFlowRepository : IRepository +{ + Task ExistsSerialNumberAsync(string serialNumber, CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Domain/OrdersAggregate/OrderStatusCountRow.cs b/src/Oms.Domain/OrdersAggregate/OrderStatusCountRow.cs new file mode 100644 index 0000000..8a0e00c --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/OrderStatusCountRow.cs @@ -0,0 +1,11 @@ +namespace Oms.Domain.OrdersAggregate; + +/// +/// 订单按状态分组统计行。 +/// +public class OrderStatusCountRow +{ + public string Status { get; set; } = string.Empty; + + public int Count { get; set; } +} diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index 6587900..d59444b 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -48,4 +48,6 @@ public interface IOrderRepository : IRepository Task UpdateRecipientAsync(OrderRecipient recipient, CancellationToken cancellationToken = default); Task> GetFeesByOrderIdsAsync(IEnumerable orderIds, CancellationToken cancellationToken = default); + + Task> GetStatusCountsAsync(int? customerId, CancellationToken cancellationToken = default); } diff --git a/src/Oms.SqlSugarCore/Customers/CustomerAccountFlowRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerAccountFlowRepository.cs new file mode 100644 index 0000000..13efb8d --- /dev/null +++ b/src/Oms.SqlSugarCore/Customers/CustomerAccountFlowRepository.cs @@ -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, ICustomerAccountFlowRepository, IScopedDependency +{ + public Task ExistsSerialNumberAsync(string serialNumber, CancellationToken cancellationToken = default) + { + return AnyAsync(x => x.SerialNumber == serialNumber, cancellationToken); + } +} diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index 8bd25c1..24403c2 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -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, IOrderRepository, I .Where(x => ids.Contains(x.OrderId)) .ToListAsync(cancellationToken); } + + public async Task> GetStatusCountsAsync(int? customerId, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var query = db.Queryable(); + 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); + } }