微信登录和创建应收和应付费用
This commit is contained in:
parent
c1219e0238
commit
1c5a38ad73
5
db.sql
5
db.sql
@ -60,6 +60,10 @@ CREATE TABLE `t_order` (
|
||||
`ChannelCode` VARCHAR(128) NULL COMMENT '渠道编码',
|
||||
`ConsigneeCountry` VARCHAR(8) NULL COMMENT '国家二字码',
|
||||
`CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive',
|
||||
`ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式',
|
||||
`PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因',
|
||||
`SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本',
|
||||
`SalesProductPriceVersion` VARCHAR(64) NULL COMMENT '销售产品价格快照版本',
|
||||
`OrderStatus` VARCHAR(32) NOT NULL COMMENT '订单状态',
|
||||
`WarehouseId` INT NULL COMMENT '仓库ID',
|
||||
`CustomerId` INT NULL COMMENT '客户ID',
|
||||
@ -280,6 +284,7 @@ CREATE TABLE `t_order_fee` (
|
||||
`DownstreamProvider` VARCHAR(128) NULL COMMENT '下游服务商',
|
||||
`PriceNo` VARCHAR(64) NULL COMMENT '价格编号',
|
||||
`PriceEffectiveTime` DATETIME NULL COMMENT '价格编号生效时间',
|
||||
`ChargingStage` VARCHAR(16) NULL COMMENT '计费阶段:forecast/inbound/outbound',
|
||||
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
|
||||
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
11
sql/parcel_billing.sql
Normal file
11
sql/parcel_billing.sql
Normal file
@ -0,0 +1,11 @@
|
||||
-- 小包预报计费:订单版本字段 + 费用计费阶段
|
||||
-- 若列已存在可跳过对应语句
|
||||
|
||||
ALTER TABLE `t_order`
|
||||
ADD COLUMN IF NOT EXISTS `ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式' AFTER `CargoAttribute`,
|
||||
ADD COLUMN IF NOT EXISTS `PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因' AFTER `IsRemote`,
|
||||
ADD COLUMN IF NOT EXISTS `SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本' AFTER `PredeclareFailReason`,
|
||||
ADD COLUMN IF NOT EXISTS `SalesProductPriceVersion` VARCHAR(64) NULL COMMENT '销售产品价格快照版本' AFTER `SalesProductRulesVersion`;
|
||||
|
||||
ALTER TABLE `t_order_fee`
|
||||
ADD COLUMN IF NOT EXISTS `ChargingStage` VARCHAR(16) NULL COMMENT '计费阶段:forecast/inbound/outbound' AFTER `PriceEffectiveTime`;
|
||||
5
sql/wechat_login.sql
Normal file
5
sql/wechat_login.sql
Normal file
@ -0,0 +1,5 @@
|
||||
-- 微信登录:客户 openId 唯一索引(允许多个 NULL)
|
||||
-- 执行前请确认索引不存在
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_customer_wechat_openid
|
||||
ON t_customer (WechatOpenid);
|
||||
@ -0,0 +1,22 @@
|
||||
namespace Oms.Application.Dtos.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// 服务渠道分组列表查询入参。
|
||||
/// </summary>
|
||||
public class ServiceChannelGroupListInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 关键词(分组名称模糊匹配)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 应用 ID。
|
||||
/// </summary>
|
||||
public int? AppId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// API 服务商 ID。
|
||||
/// </summary>
|
||||
public int? ApiServiceProviderId { get; set; }
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
namespace Oms.Application.Dtos.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// 服务渠道分组列表出参。
|
||||
/// </summary>
|
||||
public class ServiceChannelGroupListOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 主键。
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 应用 ID。
|
||||
/// </summary>
|
||||
public int AppId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// API 服务商 ID。
|
||||
/// </summary>
|
||||
public int ApiServiceProviderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime? UpdateTime { get; set; }
|
||||
}
|
||||
@ -40,6 +40,11 @@ public class OrderParcelCreateInput
|
||||
/// </summary>
|
||||
public string? CargoAttribute { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 出口报关方式。
|
||||
/// </summary>
|
||||
public string? ExportCustomsMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 仓库主键。
|
||||
/// </summary>
|
||||
|
||||
@ -64,4 +64,24 @@ public class OrderPriceCheckOutput
|
||||
/// 总成本数值。
|
||||
/// </summary>
|
||||
public decimal TotalCostNum { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 产品编码。
|
||||
/// </summary>
|
||||
public string? ProductCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 绑定的规则快照版本。
|
||||
/// </summary>
|
||||
public string? RulesVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 绑定的价格快照版本。
|
||||
/// </summary>
|
||||
public string? PriceVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否偏远。
|
||||
/// </summary>
|
||||
public bool IsRemote { get; set; }
|
||||
}
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
using Oms.Application.Dtos.Channels;
|
||||
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 销售产品价格发布快照(与渠道发布 JSON 同构)。
|
||||
/// </summary>
|
||||
public class SalesProductPricePublishSnapshotDto
|
||||
{
|
||||
public int SalesProductId { get; set; }
|
||||
|
||||
public List<SalesProductPriceListOutput> Prices { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 销售产品规则发布快照(与渠道发布 JSON 同构)。
|
||||
/// </summary>
|
||||
public class SalesProductRulesPublishSnapshotDto
|
||||
{
|
||||
public SalesProductSaveInput? Product { get; set; }
|
||||
|
||||
public List<SalesProductAllocationProviderOutput> Providers { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 绑定到订单的快照版本信息。
|
||||
/// </summary>
|
||||
public class ProductSnapshotBinding
|
||||
{
|
||||
public string RulesVersion { get; set; } = string.Empty;
|
||||
|
||||
public string PriceVersion { get; set; } = string.Empty;
|
||||
|
||||
public DateTime RulesEffectiveTime { get; set; }
|
||||
|
||||
public DateTime PriceEffectiveTime { get; set; }
|
||||
|
||||
public SalesProductRulesPublishSnapshotDto Rules { get; set; } = new();
|
||||
|
||||
public SalesProductPricePublishSnapshotDto Price { get; set; } = new();
|
||||
}
|
||||
@ -22,4 +22,8 @@
|
||||
<PackageReference Include="MiniExcel" Version="1.43.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Oms.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -276,6 +276,19 @@ public partial class ServiceChannelService
|
||||
entity.IsActive = input.IsActive;
|
||||
}
|
||||
|
||||
private static ServiceChannelGroupListOutput MapGroupListOutput(ServiceChannelGroup entity)
|
||||
{
|
||||
return new ServiceChannelGroupListOutput
|
||||
{
|
||||
Id = entity.Id,
|
||||
Name = entity.Name,
|
||||
AppId = entity.AppId,
|
||||
ApiServiceProviderId = entity.ApiServiceProviderId,
|
||||
CreateTime = entity.CreateTime,
|
||||
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
|
||||
};
|
||||
}
|
||||
|
||||
private static ServiceChannelListOutput MapListOutput(ServiceChannel entity, IReadOnlyDictionary<int, string> groupMap)
|
||||
{
|
||||
return new ServiceChannelListOutput
|
||||
|
||||
@ -125,6 +125,27 @@ public partial class ServiceChannelService : ApplicationService
|
||||
await ServiceChannelRepository.DeleteAsync(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询服务渠道分组列表。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:分组名称、应用 ID、API 服务商 ID 等筛选条件。</param>
|
||||
/// <returns>服务渠道分组列表,供下拉与左侧树筛选使用。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 按 keyword、appId、apiServiceProviderId 构造筛选条件。
|
||||
/// 2. 查询未删除分组并按 id 倒序返回。
|
||||
/// 3. 无数据时返回空数组。
|
||||
/// </remarks>
|
||||
[HttpGet("/channel/service-channel/group/list")]
|
||||
public async Task<List<ServiceChannelGroupListOutput>> GetGroupListAsync([FromQuery] ServiceChannelGroupListInput input)
|
||||
{
|
||||
var predicate = BuildGroupListPredicate(input);
|
||||
var entities = await ServiceChannelGroupRepository.GetListAsync(
|
||||
predicate,
|
||||
new Dictionary<string, bool> { [nameof(ServiceChannelGroup.Id)] = false });
|
||||
return entities.Select(MapGroupListOutput).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增服务渠道分组。
|
||||
/// </summary>
|
||||
@ -465,4 +486,17 @@ public partial class ServiceChannelService : ApplicationService
|
||||
(string.IsNullOrWhiteSpace(keyword) || entity.ChannelCode.Contains(keyword) || entity.ChannelName.Contains(keyword)) &&
|
||||
(!hasIsActiveFilter || entity.IsActive == isActiveValue);
|
||||
}
|
||||
|
||||
private static Expression<Func<ServiceChannelGroup, bool>> BuildGroupListPredicate(ServiceChannelGroupListInput input)
|
||||
{
|
||||
var keyword = input.Keyword?.Trim();
|
||||
var hasAppId = input.AppId.HasValue;
|
||||
var appId = input.AppId.GetValueOrDefault();
|
||||
var hasApiServiceProviderId = input.ApiServiceProviderId.HasValue;
|
||||
var apiServiceProviderId = input.ApiServiceProviderId.GetValueOrDefault();
|
||||
return entity =>
|
||||
(string.IsNullOrWhiteSpace(keyword) || entity.Name.Contains(keyword)) &&
|
||||
(!hasAppId || entity.AppId == appId) &&
|
||||
(!hasApiServiceProviderId || entity.ApiServiceProviderId == apiServiceProviderId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,41 +111,6 @@ public partial class OrderService
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<OrderPriceCheckOutput>> BuildPriceCheckOutputsAsync(OrderPriceCheckInput input)
|
||||
{
|
||||
var products = await SalesProductRepository.GetListAsync(
|
||||
x => x.IsActive == Oms.Domain.Shared.Enums.EnableStatus.Enabled &&
|
||||
(string.IsNullOrWhiteSpace(input.ShippingMethod) || x.ProductName.Contains(input.ShippingMethod) || x.ProductCode.Contains(input.ShippingMethod)));
|
||||
var warehouses = await WarehouseAddressRepository.GetListAsync();
|
||||
var warehouse = warehouses.OrderBy(x => x.Id).FirstOrDefault();
|
||||
var volumetricWeight = input.Length.HasValue && input.Width.HasValue && input.Height.HasValue
|
||||
? Math.Round((input.Length.Value * input.Width.Value * input.Height.Value) / 6000M, 3)
|
||||
: 0;
|
||||
var chargeableWeight = Math.Max(input.CargoWeight ?? 0, volumetricWeight);
|
||||
|
||||
return products.Select(product =>
|
||||
{
|
||||
var freight = Math.Round(chargeableWeight * 62M, 2);
|
||||
var otherFees = 5M;
|
||||
var totalCost = freight + otherFees;
|
||||
return new OrderPriceCheckOutput
|
||||
{
|
||||
Id = product.Id,
|
||||
ShippingMethod = product.ProductName,
|
||||
Warehouse = warehouse?.WarehouseName,
|
||||
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
|
||||
VolumetricWeight = volumetricWeight,
|
||||
ChargeableWeight = chargeableWeight,
|
||||
CostFormula = $"(62*{chargeableWeight})+{otherFees}",
|
||||
Freight = freight,
|
||||
OtherFees = otherFees,
|
||||
TotalCost = totalCost,
|
||||
MaxDeliveryDays = product.MaxTimeliness,
|
||||
TotalCostNum = totalCost
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<OmsPageResult<OrderTemplateOutput>> GetTemplateOutputsAsync(OrderTemplateListInput input)
|
||||
{
|
||||
var page = OmsDocHelper.NormalizePage(input.Page);
|
||||
@ -394,177 +359,6 @@ public partial class OrderService
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(OrderParcelCreateInput input)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(input.RequestId))
|
||||
{
|
||||
var existed = await OrderRepository.GetByRequestIdAsync(input.RequestId);
|
||||
if (existed != null)
|
||||
{
|
||||
return new OrderParcelCreateOutput
|
||||
{
|
||||
ClientNo = existed.ClientNo,
|
||||
ServiceNo = existed.ServiceNo,
|
||||
TrackNo = existed.TrackNo,
|
||||
LabelUrl = await CreateOrderPrintFileAsync((await MapOrderOutputsAsync([existed])).First())
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var serviceNo = input.ServiceNo?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(serviceNo))
|
||||
{
|
||||
throw new BusinessException("运单号不能为空");
|
||||
}
|
||||
|
||||
if (await OrderRepository.HasServiceNoAsync(serviceNo))
|
||||
{
|
||||
throw new BusinessException("运单号已存在");
|
||||
}
|
||||
|
||||
var order = await OrderRepository.InsertAsync(BuildOrderEntity(input, serviceNo));
|
||||
await SaveOrderChildrenAsync(order, input);
|
||||
return new OrderParcelCreateOutput
|
||||
{
|
||||
ClientNo = order.ClientNo,
|
||||
ServiceNo = order.ServiceNo,
|
||||
TrackNo = order.TrackNo,
|
||||
LabelUrl = await CreateOrderPrintFileAsync((await MapOrderOutputsAsync([order])).First())
|
||||
};
|
||||
}
|
||||
|
||||
private Order BuildOrderEntity(OrderParcelCreateInput input, string serviceNo)
|
||||
{
|
||||
return new Order
|
||||
{
|
||||
RequestId = input.RequestId,
|
||||
ClientNo = input.ClientNo,
|
||||
TrackNo = input.TrackNo,
|
||||
ServiceNo = serviceNo,
|
||||
ProductCode = input.ProductCode,
|
||||
ChannelCode = input.ChannelCode,
|
||||
CargoAttribute = input.CargoAttribute ?? CargoAttributeCodes.General,
|
||||
WarehouseId = input.WarehouseId,
|
||||
CustomerId = input.CustomerId,
|
||||
IsCod = input.IsCod ? (byte)1 : (byte)0,
|
||||
CodCurrency = input.CodCurrency,
|
||||
CodSum = input.CodSum,
|
||||
DeclaredCurrency = input.DeclaredCurrency,
|
||||
DeclaredSum = input.DeclaredSum,
|
||||
Quantity = input.Quantity,
|
||||
IsIntercept = 0,
|
||||
IsRemote = 0,
|
||||
OrderStatus = OrderStatusCodes.PendingPayment
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SaveOrderChildrenAsync(Order order, OrderParcelCreateInput input)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
if (input.Sender != null)
|
||||
{
|
||||
var sender = await OrderRepository.InsertSenderAsync(new OrderSender
|
||||
{
|
||||
OrderId = order.Id,
|
||||
SenderName = input.Sender.SenderName,
|
||||
Company = input.Sender.Company,
|
||||
Country = input.Sender.CountryCode,
|
||||
Province = input.Sender.Province,
|
||||
City = input.Sender.City,
|
||||
ZipCode = input.Sender.ZipCode,
|
||||
StreetAddress1 = input.Sender.StreetAddress1,
|
||||
StreetAddress2 = input.Sender.StreetAddress2,
|
||||
StreetAddress3 = input.Sender.StreetAddress3,
|
||||
HouseNumber = input.Sender.HouseNumber,
|
||||
Phone = input.Sender.Phone,
|
||||
TelePhone = input.Sender.TelePhone,
|
||||
Email = input.Sender.Email,
|
||||
IsDefault = 0
|
||||
});
|
||||
order.SenderId = sender.Id;
|
||||
}
|
||||
|
||||
if (input.Receiver != null)
|
||||
{
|
||||
var recipient = await OrderRepository.InsertRecipientAsync(new OrderRecipient
|
||||
{
|
||||
OrderId = order.Id,
|
||||
RecipientsName = input.Receiver.RecipientsName,
|
||||
CompanyName = input.Receiver.CompanyName,
|
||||
Country = input.Receiver.CountryCode,
|
||||
Phone = input.Receiver.Phone,
|
||||
TelePhone = input.Receiver.TelePhone,
|
||||
ZipCode = input.Receiver.ZipCode,
|
||||
Province = input.Receiver.Province,
|
||||
City = input.Receiver.City,
|
||||
StreetNumber = input.Receiver.StreetNumber,
|
||||
StreetAddress1 = input.Receiver.StreetAddress1,
|
||||
StreetAddress2 = input.Receiver.StreetAddress2,
|
||||
StreetAddress3 = input.Receiver.StreetAddress3,
|
||||
Email = input.Receiver.Email,
|
||||
TaxType = input.Receiver.TaxType,
|
||||
TaxNo = input.Receiver.TaxNo,
|
||||
CertificateType = input.Receiver.CertificateType,
|
||||
CertificateNo = input.Receiver.CertificateNo,
|
||||
Eori = input.Receiver.Eori,
|
||||
Vat = input.Receiver.Vat
|
||||
});
|
||||
order.RecipientId = recipient.Id;
|
||||
}
|
||||
|
||||
if (input.Items.Count > 0)
|
||||
{
|
||||
await OrderRepository.InsertDeclarationsAsync(input.Items.Select(x => new OrderDeclaration
|
||||
{
|
||||
OrderId = order.Id,
|
||||
CnName = x.CnName,
|
||||
EnName = x.EnName,
|
||||
Sku = x.Sku,
|
||||
Unit = x.Unit,
|
||||
UnitPrice = x.UnitPrice,
|
||||
UnitWeight = x.UnitWeight,
|
||||
Quantity = x.Quantity,
|
||||
Amount = x.Amount,
|
||||
Weight = x.Weight,
|
||||
HsCode = x.ExHsCode ?? x.ImHsCode,
|
||||
ImHsCode = x.ImHsCode,
|
||||
ExHsCode = x.ExHsCode,
|
||||
SkuImage = x.SkuImage,
|
||||
Category = x.Category,
|
||||
Brand = x.Brand,
|
||||
BrandType = x.BrandType,
|
||||
IsMagnetic = x.IsMagnetic ? (byte)1 : (byte)0,
|
||||
Distribution = x.Distribution,
|
||||
Material = x.Material,
|
||||
EnMaterial = x.EnMaterial,
|
||||
Purpose = x.Purpose,
|
||||
Attributes = x.Attributes,
|
||||
OriginCountry = x.OriginCountry,
|
||||
SalesLink = x.SalesLink,
|
||||
Model = x.Model,
|
||||
Specifications = x.Specifications,
|
||||
Comment = x.Comment
|
||||
}));
|
||||
}
|
||||
|
||||
await OrderRepository.InsertWeightsAsync([
|
||||
new OrderWeight
|
||||
{
|
||||
OrderId = order.Id,
|
||||
Type = OrderWeightTypeCodes.Forecast,
|
||||
Length = input.Length,
|
||||
Width = input.Width,
|
||||
Height = input.Height,
|
||||
ActualWeight = input.ActualWeight,
|
||||
VolumetricWeight = input.VolumetricWeight,
|
||||
ChargeableWeight = input.ChargeableWeight,
|
||||
Cubic = input.Cubic
|
||||
}
|
||||
]);
|
||||
|
||||
await OrderRepository.UpdateAsync(order);
|
||||
}
|
||||
|
||||
private async Task<string> CreateOrderPrintFileAsync(OrderOutput order)
|
||||
{
|
||||
var directory = Path.Combine(GetWebRootDirectory(), "uploads", "prints", "orders");
|
||||
|
||||
@ -0,0 +1,693 @@
|
||||
using Fake.Application;
|
||||
using Oms.Application.Dtos.Channels;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.ChannelsAggregate.Entities;
|
||||
using Oms.Domain.ChannelsAggregate.Repositories;
|
||||
using Oms.Domain.CustomersAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
using Oms.Domain.Shared.Enums;
|
||||
using Oms.Domain.SystemAggregate.Repositories;
|
||||
|
||||
namespace Oms.Application.Services.Orders;
|
||||
|
||||
public partial class OrderService
|
||||
{
|
||||
private const string PriceOperatorType = "price";
|
||||
private const string RulesOperatorType = "rules";
|
||||
private const int ParcelBatchMaxCount = 2000;
|
||||
|
||||
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||||
private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>();
|
||||
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
|
||||
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
|
||||
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
|
||||
|
||||
private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(OrderParcelCreateInput input)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(input.RequestId))
|
||||
{
|
||||
var existed = await OrderRepository.GetByRequestIdAsync(input.RequestId);
|
||||
if (existed != null)
|
||||
{
|
||||
return await BuildParcelCreateOutputAsync(existed);
|
||||
}
|
||||
}
|
||||
|
||||
var bindTime = DateTime.Now;
|
||||
await ValidateParcelCreateInputAsync(input);
|
||||
|
||||
var product = await SalesProductRepository.GetByProductCodeAsync(input.ProductCode!.Trim());
|
||||
if (product == null || product.IsActive != EnableStatus.Enabled)
|
||||
{
|
||||
throw new BusinessException("销售产品不存在或未启用");
|
||||
}
|
||||
|
||||
var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
|
||||
var country = await CountryRepository.FirstOrDefaultAsync(x => x.Id == input.Receiver!.CountryCode);
|
||||
if (country == null)
|
||||
{
|
||||
throw new BusinessException("收件国家不存在");
|
||||
}
|
||||
|
||||
if (await IsBlacklistHitAsync(
|
||||
product,
|
||||
country.CountryCode,
|
||||
input.Receiver?.Province,
|
||||
input.Receiver?.City,
|
||||
input.Receiver?.ZipCode))
|
||||
{
|
||||
throw new BusinessException("收件地址命中黑名单区域");
|
||||
}
|
||||
|
||||
var serviceNo = await ResolveServiceNoAsync(input.ServiceNo);
|
||||
var order = BuildDraftOrderEntity(input, product, country.CountryCode, snapshotBinding, serviceNo);
|
||||
order = await OrderRepository.InsertAsync(order);
|
||||
|
||||
var weights = BuildInitialWeightRows(order.Id, input);
|
||||
await SaveParcelChildrenAsync(order, input, weights);
|
||||
|
||||
try
|
||||
{
|
||||
order.IsRemote = await IsRemoteHitAsync(
|
||||
product,
|
||||
country.CountryCode,
|
||||
input.Receiver?.Province,
|
||||
input.Receiver?.City,
|
||||
input.Receiver?.ZipCode) ? (byte)1 : (byte)0;
|
||||
|
||||
var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast);
|
||||
await ApplyChargeableWeightAsync(product, forecast);
|
||||
SyncWeightRows(weights, forecast);
|
||||
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
|
||||
|
||||
var zoneName = await MatchPriceZoneAsync(
|
||||
product,
|
||||
country.CountryCode,
|
||||
input.Receiver?.Province,
|
||||
input.Receiver?.City,
|
||||
input.Receiver?.ZipCode);
|
||||
if (string.IsNullOrWhiteSpace(zoneName))
|
||||
{
|
||||
throw new BusinessException("价格分区未匹配(规则快照)");
|
||||
}
|
||||
|
||||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
|
||||
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
|
||||
snapshotBinding.Price,
|
||||
snapshotBinding.PriceVersion,
|
||||
snapshotBinding.PriceEffectiveTime,
|
||||
forecast.ChargeableWeight ?? 0,
|
||||
zoneName,
|
||||
order.CargoAttribute ?? CargoAttributeCodes.General,
|
||||
customer?.CustomerGroup);
|
||||
|
||||
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshotBinding.Price))
|
||||
{
|
||||
throw new BusinessException("产品卖价快照未配置或未匹配到有效价格");
|
||||
}
|
||||
|
||||
if (fees.Count > 0)
|
||||
{
|
||||
await OrderRepository.UpsertFeesAsync(order.Id, fees);
|
||||
}
|
||||
|
||||
order.OrderStatus = OrderStatusCodes.Predeclared;
|
||||
order.PredeclareFailReason = string.Empty;
|
||||
order.IsRemote = order.IsRemote;
|
||||
order.UpdateTime = DateTime.Now;
|
||||
await OrderRepository.UpdateAsync(order);
|
||||
|
||||
return await BuildParcelCreateOutputAsync(order);
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
await MarkParcelDraftFailedAsync(order, ex.Message);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await MarkParcelDraftFailedAsync(order, ex.Message);
|
||||
throw new BusinessException(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ValidateParcelCreateInputAsync(OrderParcelCreateInput input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.ProductCode))
|
||||
{
|
||||
throw new BusinessException("产品代码不能为空");
|
||||
}
|
||||
|
||||
if (!input.WarehouseId.HasValue || input.WarehouseId <= 0)
|
||||
{
|
||||
throw new BusinessException("仓库ID不能为空");
|
||||
}
|
||||
|
||||
if (!input.CustomerId.HasValue || input.CustomerId <= 0)
|
||||
{
|
||||
throw new BusinessException("客户ID不能为空");
|
||||
}
|
||||
|
||||
if (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue || !input.ActualWeight.HasValue
|
||||
|| input.Length <= 0 || input.Width <= 0 || input.Height <= 0 || input.ActualWeight <= 0)
|
||||
{
|
||||
throw new BusinessException("件重尺必须大于0");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.CargoAttribute)
|
||||
|| input.CargoAttribute is not (CargoAttributeCodes.General or CargoAttributeCodes.Electronics or CargoAttributeCodes.Sensitive))
|
||||
{
|
||||
throw new BusinessException("货物属性不合法");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(input.ExportCustomsMode)
|
||||
&& !ExportCustomsModeCodes.All.Contains(input.ExportCustomsMode))
|
||||
{
|
||||
throw new BusinessException("出口报关方式不合法");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.DeclaredCurrency))
|
||||
{
|
||||
throw new BusinessException("申报币种不能为空");
|
||||
}
|
||||
|
||||
if (!input.Quantity.HasValue || input.Quantity <= 0)
|
||||
{
|
||||
throw new BusinessException("包裹数量不能为空");
|
||||
}
|
||||
|
||||
if (input.Receiver == null
|
||||
|| string.IsNullOrWhiteSpace(input.Receiver.RecipientsName)
|
||||
|| !input.Receiver.CountryCode.HasValue
|
||||
|| string.IsNullOrWhiteSpace(input.Receiver.ZipCode)
|
||||
|| string.IsNullOrWhiteSpace(input.Receiver.StreetAddress1))
|
||||
{
|
||||
throw new BusinessException("收件人信息不完整");
|
||||
}
|
||||
|
||||
if (input.Sender == null || string.IsNullOrWhiteSpace(input.Sender.SenderName))
|
||||
{
|
||||
throw new BusinessException("发件人姓名不能为空");
|
||||
}
|
||||
|
||||
if (input.Items.Count == 0)
|
||||
{
|
||||
throw new BusinessException("申报明细不能为空");
|
||||
}
|
||||
|
||||
foreach (var item in input.Items)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.EnName)
|
||||
|| !item.UnitPrice.HasValue
|
||||
|| !item.UnitWeight.HasValue
|
||||
|| !item.Quantity.HasValue
|
||||
|| item.Quantity <= 0)
|
||||
{
|
||||
throw new BusinessException("申报明细必填字段不完整");
|
||||
}
|
||||
}
|
||||
|
||||
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId.Value);
|
||||
if (warehouse == null)
|
||||
{
|
||||
throw new BusinessException("仓库不存在");
|
||||
}
|
||||
|
||||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId.Value);
|
||||
if (customer == null || customer.Status != CustomerStatus.Normal)
|
||||
{
|
||||
throw new BusinessException("客户不存在或状态不可用");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ProductSnapshotBinding> ResolveSnapshotBindingAsync(SalesProduct product, DateTime bindTime)
|
||||
{
|
||||
var rulesRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, RulesOperatorType, bindTime);
|
||||
var priceRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, PriceOperatorType, bindTime);
|
||||
|
||||
SalesProductRulesPublishSnapshotDto rulesSnapshot;
|
||||
if (rulesRecord == null)
|
||||
{
|
||||
rulesSnapshot = await BuildLiveRulesSnapshotAsync(product);
|
||||
}
|
||||
else
|
||||
{
|
||||
rulesSnapshot = OmsDocHelper.DeserializeJson<SalesProductRulesPublishSnapshotDto>(rulesRecord.Content)
|
||||
?? throw new BusinessException("未命中规则快照版本(内存/Redis/DB)");
|
||||
}
|
||||
|
||||
SalesProductPricePublishSnapshotDto priceSnapshot;
|
||||
if (priceRecord == null)
|
||||
{
|
||||
priceSnapshot = await BuildLivePriceSnapshotAsync(product);
|
||||
if (priceSnapshot.Prices.Count == 0)
|
||||
{
|
||||
throw new BusinessException("未命中价格快照版本(内存/Redis/DB)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
priceSnapshot = OmsDocHelper.DeserializeJson<SalesProductPricePublishSnapshotDto>(priceRecord.Content)
|
||||
?? throw new BusinessException("未命中价格快照版本(内存/Redis/DB)");
|
||||
if (priceSnapshot.Prices.Count == 0)
|
||||
{
|
||||
throw new BusinessException("未命中价格快照版本(内存/Redis/DB)");
|
||||
}
|
||||
}
|
||||
|
||||
if (rulesRecord == null && !product.WeightRuleId.HasValue)
|
||||
{
|
||||
throw new BusinessException("未命中规则快照版本(内存/Redis/DB)");
|
||||
}
|
||||
|
||||
return new ProductSnapshotBinding
|
||||
{
|
||||
RulesVersion = rulesRecord?.Version ?? $"LIVE-{product.Id}",
|
||||
PriceVersion = priceRecord?.Version ?? $"LIVE-{product.Id}",
|
||||
RulesEffectiveTime = rulesRecord?.PublishTime ?? bindTime,
|
||||
PriceEffectiveTime = priceRecord?.PublishTime ?? bindTime,
|
||||
Rules = rulesSnapshot,
|
||||
Price = priceSnapshot
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SalesProductRulesPublishSnapshotDto> BuildLiveRulesSnapshotAsync(SalesProduct product)
|
||||
{
|
||||
return new SalesProductRulesPublishSnapshotDto
|
||||
{
|
||||
Product = new SalesProductSaveInput
|
||||
{
|
||||
Id = product.Id,
|
||||
ProductCode = product.ProductCode,
|
||||
ProductName = product.ProductName,
|
||||
BlacklistAreaId = product.BlacklistAreaId,
|
||||
RemoteAreaId = product.RemoteAreaId,
|
||||
PriceAreaId = product.PriceAreaId,
|
||||
WeightRuleId = product.WeightRuleId,
|
||||
WeightRoundingId = product.WeightRoundingId,
|
||||
IsActive = product.IsActive,
|
||||
Scheme = product.Scheme
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SalesProductPricePublishSnapshotDto> BuildLivePriceSnapshotAsync(SalesProduct product)
|
||||
{
|
||||
var prices = await SalesProductRepository.GetPricesAsync(product.Id);
|
||||
var priceIds = prices.Select(x => x.Id).ToArray();
|
||||
var factorMap = (await SalesProductRepository.GetPriceFactorsAsync(priceIds))
|
||||
.GroupBy(x => x.PriceId)
|
||||
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList());
|
||||
var detailMap = (await SalesProductRepository.GetPriceDetailsAsync(priceIds))
|
||||
.GroupBy(x => x.PriceId)
|
||||
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList());
|
||||
|
||||
return new SalesProductPricePublishSnapshotDto
|
||||
{
|
||||
SalesProductId = product.Id,
|
||||
Prices = prices.Select(price => new SalesProductPriceListOutput
|
||||
{
|
||||
Id = price.Id,
|
||||
SalesProductId = price.SalesProductId,
|
||||
SerialNumber = price.SerialNumber,
|
||||
Status = (byte)price.Status,
|
||||
PriceName = price.PriceName,
|
||||
CostItem = price.CostItem,
|
||||
CostUnit = price.CostUnit,
|
||||
Currency = price.Currency,
|
||||
ChargingTime = price.ChargingTime,
|
||||
ChargingCondition = price.ChargingCondition,
|
||||
Factors = factorMap.TryGetValue(price.Id, out var factors)
|
||||
? factors.Select(MapPriceFactorOutput).ToList()
|
||||
: [],
|
||||
Details = detailMap.TryGetValue(price.Id, out var details)
|
||||
? details.Select(MapPriceDetailOutput).ToList()
|
||||
: []
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static ChannelPriceFactorOutput MapPriceFactorOutput(SalesProductPriceFactor entity)
|
||||
{
|
||||
return new ChannelPriceFactorOutput
|
||||
{
|
||||
Id = entity.Id,
|
||||
PriceId = entity.PriceId,
|
||||
SerialNumber = entity.SerialNumber,
|
||||
Factor = entity.Factor,
|
||||
FactorValues = OmsDocHelper.DeserializeJson<List<string>>(entity.FactorValues) ?? []
|
||||
};
|
||||
}
|
||||
|
||||
private static ChannelPriceDetailOutput MapPriceDetailOutput(SalesProductPriceDetail entity)
|
||||
{
|
||||
return new ChannelPriceDetailOutput
|
||||
{
|
||||
Id = entity.Id,
|
||||
PriceId = entity.PriceId,
|
||||
FactorValuesJson = OmsDocHelper.DeserializeJsonObject(entity.FactorValuesJson),
|
||||
CalculationFormula = entity.CalculationFormula,
|
||||
CalculatedValue = entity.CalculatedValue,
|
||||
CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(entity.CustomerGroupValues)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<bool> IsBlacklistHitAsync(
|
||||
SalesProduct product,
|
||||
string countryCode,
|
||||
string? province,
|
||||
string? city,
|
||||
string? zipCode)
|
||||
{
|
||||
if (!product.BlacklistAreaId.HasValue || product.BlacklistAreaId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.BlacklistAreaId.Value);
|
||||
return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
|
||||
rule,
|
||||
countryCode,
|
||||
province,
|
||||
city,
|
||||
zipCode));
|
||||
}
|
||||
|
||||
private async Task<bool> IsRemoteHitAsync(
|
||||
SalesProduct product,
|
||||
string countryCode,
|
||||
string? province,
|
||||
string? city,
|
||||
string? zipCode)
|
||||
{
|
||||
if (!product.RemoteAreaId.HasValue || product.RemoteAreaId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.RemoteAreaId.Value);
|
||||
return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
|
||||
rule,
|
||||
countryCode,
|
||||
province,
|
||||
city,
|
||||
zipCode));
|
||||
}
|
||||
|
||||
private async Task<string?> MatchPriceZoneAsync(
|
||||
SalesProduct product,
|
||||
string countryCode,
|
||||
string? province,
|
||||
string? city,
|
||||
string? zipCode)
|
||||
{
|
||||
if (!product.PriceAreaId.HasValue || product.PriceAreaId <= 0)
|
||||
{
|
||||
return "默认分区";
|
||||
}
|
||||
|
||||
var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == product.PriceAreaId.Value);
|
||||
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.PriceAreaId.Value);
|
||||
var matched = rules.FirstOrDefault(rule => ParcelBillingCalculator.IsAreaMatched(
|
||||
rule,
|
||||
countryCode,
|
||||
province,
|
||||
city,
|
||||
zipCode));
|
||||
return ParcelBillingCalculator.ResolveZoneName(matched, area);
|
||||
}
|
||||
|
||||
private async Task ApplyChargeableWeightAsync(SalesProduct product, OrderWeight forecast)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!product.WeightRuleId.HasValue || product.WeightRuleId <= 0)
|
||||
{
|
||||
throw new BusinessException("未配置计重规则");
|
||||
}
|
||||
|
||||
var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRuleId.Value);
|
||||
if (weightRule == null)
|
||||
{
|
||||
throw new BusinessException("未配置计重规则");
|
||||
}
|
||||
|
||||
var ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(product.WeightRuleId.Value);
|
||||
var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos);
|
||||
forecast.VolumetricWeight = volumetricWeight;
|
||||
|
||||
if (!product.WeightRoundingId.HasValue || product.WeightRoundingId <= 0)
|
||||
{
|
||||
throw new BusinessException("未配置计重进位");
|
||||
}
|
||||
|
||||
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRoundingId.Value);
|
||||
if (rounding == null)
|
||||
{
|
||||
throw new BusinessException("未配置计重进位");
|
||||
}
|
||||
|
||||
var ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(product.WeightRoundingId.Value);
|
||||
forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
|
||||
}
|
||||
catch (BusinessException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
throw new BusinessException(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<OrderWeight> BuildInitialWeightRows(int orderId, OrderParcelCreateInput input)
|
||||
{
|
||||
var cubic = ParcelBillingCalculator.CalculateCubic(input.Length!.Value, input.Width!.Value, input.Height!.Value);
|
||||
var forecast = new OrderWeight
|
||||
{
|
||||
OrderId = orderId,
|
||||
Type = OrderWeightTypeCodes.Forecast,
|
||||
Length = input.Length,
|
||||
Width = input.Width,
|
||||
Height = input.Height,
|
||||
ActualWeight = input.ActualWeight,
|
||||
VolumetricWeight = input.VolumetricWeight ?? 0,
|
||||
ChargeableWeight = input.ChargeableWeight ?? input.ActualWeight,
|
||||
Cubic = input.Cubic ?? cubic
|
||||
};
|
||||
|
||||
return
|
||||
[
|
||||
forecast,
|
||||
CloneWeightRow(orderId, OrderWeightTypeCodes.Receive, forecast),
|
||||
CloneWeightRow(orderId, OrderWeightTypeCodes.Ship, forecast),
|
||||
CloneWeightRow(orderId, OrderWeightTypeCodes.Downstream, forecast)
|
||||
];
|
||||
}
|
||||
|
||||
private static OrderWeight CloneWeightRow(int orderId, string type, OrderWeight source) =>
|
||||
new()
|
||||
{
|
||||
OrderId = orderId,
|
||||
Type = type,
|
||||
Length = source.Length,
|
||||
Width = source.Width,
|
||||
Height = source.Height,
|
||||
ActualWeight = source.ActualWeight,
|
||||
VolumetricWeight = source.VolumetricWeight,
|
||||
ChargeableWeight = source.ChargeableWeight,
|
||||
Cubic = source.Cubic
|
||||
};
|
||||
|
||||
private static void SyncWeightRows(List<OrderWeight> weights, OrderWeight forecast)
|
||||
{
|
||||
foreach (var weight in weights)
|
||||
{
|
||||
weight.Length = forecast.Length;
|
||||
weight.Width = forecast.Width;
|
||||
weight.Height = forecast.Height;
|
||||
weight.ActualWeight = forecast.ActualWeight;
|
||||
weight.VolumetricWeight = forecast.VolumetricWeight;
|
||||
weight.ChargeableWeight = forecast.ChargeableWeight;
|
||||
weight.Cubic = forecast.Cubic;
|
||||
}
|
||||
}
|
||||
|
||||
private static Order BuildDraftOrderEntity(
|
||||
OrderParcelCreateInput input,
|
||||
SalesProduct product,
|
||||
string consigneeCountry,
|
||||
ProductSnapshotBinding snapshotBinding,
|
||||
string serviceNo)
|
||||
{
|
||||
return new Order
|
||||
{
|
||||
RequestId = input.RequestId,
|
||||
ClientNo = input.ClientNo,
|
||||
TrackNo = input.TrackNo,
|
||||
ServiceNo = serviceNo,
|
||||
ProductCode = input.ProductCode,
|
||||
ChannelCode = input.ChannelCode ?? product.ProductCode,
|
||||
ConsigneeCountry = consigneeCountry,
|
||||
CargoAttribute = input.CargoAttribute ?? CargoAttributeCodes.General,
|
||||
ExportCustomsMode = input.ExportCustomsMode,
|
||||
WarehouseId = input.WarehouseId,
|
||||
CustomerId = input.CustomerId,
|
||||
IsCod = input.IsCod ? (byte)1 : (byte)0,
|
||||
CodCurrency = input.CodCurrency,
|
||||
CodSum = input.CodSum,
|
||||
DeclaredCurrency = input.DeclaredCurrency,
|
||||
DeclaredSum = input.DeclaredSum,
|
||||
Quantity = input.Quantity,
|
||||
IsIntercept = 0,
|
||||
IsRemote = 0,
|
||||
PredeclareFailReason = string.Empty,
|
||||
SalesProductRulesVersion = snapshotBinding.RulesVersion,
|
||||
SalesProductPriceVersion = snapshotBinding.PriceVersion,
|
||||
OrderStatus = OrderStatusCodes.Draft
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SaveParcelChildrenAsync(Order order, OrderParcelCreateInput input, List<OrderWeight> weights)
|
||||
{
|
||||
if (input.Sender != null)
|
||||
{
|
||||
var sender = await OrderRepository.InsertSenderAsync(new OrderSender
|
||||
{
|
||||
OrderId = order.Id,
|
||||
SenderName = input.Sender.SenderName,
|
||||
Company = input.Sender.Company,
|
||||
Country = input.Sender.CountryCode,
|
||||
Province = input.Sender.Province,
|
||||
City = input.Sender.City,
|
||||
ZipCode = input.Sender.ZipCode,
|
||||
StreetAddress1 = input.Sender.StreetAddress1,
|
||||
StreetAddress2 = input.Sender.StreetAddress2,
|
||||
StreetAddress3 = input.Sender.StreetAddress3,
|
||||
HouseNumber = input.Sender.HouseNumber,
|
||||
Phone = input.Sender.Phone,
|
||||
TelePhone = input.Sender.TelePhone,
|
||||
Email = input.Sender.Email,
|
||||
IsDefault = 0
|
||||
});
|
||||
order.SenderId = sender.Id;
|
||||
}
|
||||
|
||||
if (input.Receiver != null)
|
||||
{
|
||||
var recipient = await OrderRepository.InsertRecipientAsync(new OrderRecipient
|
||||
{
|
||||
OrderId = order.Id,
|
||||
RecipientsName = input.Receiver.RecipientsName,
|
||||
CompanyName = input.Receiver.CompanyName,
|
||||
Country = input.Receiver.CountryCode,
|
||||
Phone = input.Receiver.Phone,
|
||||
TelePhone = input.Receiver.TelePhone,
|
||||
ZipCode = input.Receiver.ZipCode,
|
||||
Province = input.Receiver.Province,
|
||||
City = input.Receiver.City,
|
||||
StreetNumber = input.Receiver.StreetNumber,
|
||||
StreetAddress1 = input.Receiver.StreetAddress1,
|
||||
StreetAddress2 = input.Receiver.StreetAddress2,
|
||||
StreetAddress3 = input.Receiver.StreetAddress3,
|
||||
Email = input.Receiver.Email,
|
||||
TaxType = input.Receiver.TaxType,
|
||||
TaxNo = input.Receiver.TaxNo,
|
||||
CertificateType = input.Receiver.CertificateType,
|
||||
CertificateNo = input.Receiver.CertificateNo,
|
||||
Eori = input.Receiver.Eori,
|
||||
Vat = input.Receiver.Vat
|
||||
});
|
||||
order.RecipientId = recipient.Id;
|
||||
}
|
||||
|
||||
if (input.Items.Count > 0)
|
||||
{
|
||||
await OrderRepository.InsertDeclarationsAsync(input.Items.Select(x => new OrderDeclaration
|
||||
{
|
||||
OrderId = order.Id,
|
||||
CnName = x.CnName,
|
||||
EnName = x.EnName,
|
||||
Sku = x.Sku,
|
||||
Unit = x.Unit,
|
||||
UnitPrice = x.UnitPrice,
|
||||
UnitWeight = x.UnitWeight,
|
||||
Quantity = x.Quantity,
|
||||
Amount = x.Amount,
|
||||
Weight = x.Weight,
|
||||
HsCode = x.ExHsCode ?? x.ImHsCode,
|
||||
ImHsCode = x.ImHsCode,
|
||||
ExHsCode = x.ExHsCode,
|
||||
SkuImage = x.SkuImage,
|
||||
Category = x.Category,
|
||||
Brand = x.Brand,
|
||||
BrandType = x.BrandType,
|
||||
IsMagnetic = x.IsMagnetic ? (byte)1 : (byte)0,
|
||||
Distribution = x.Distribution,
|
||||
Material = x.Material,
|
||||
EnMaterial = x.EnMaterial,
|
||||
Purpose = x.Purpose,
|
||||
Attributes = x.Attributes,
|
||||
OriginCountry = x.OriginCountry,
|
||||
SalesLink = x.SalesLink,
|
||||
Model = x.Model,
|
||||
Specifications = x.Specifications,
|
||||
Comment = x.Comment
|
||||
}));
|
||||
}
|
||||
|
||||
await OrderRepository.InsertWeightsAsync(weights);
|
||||
await OrderRepository.UpdateAsync(order);
|
||||
}
|
||||
|
||||
private async Task<string> ResolveServiceNoAsync(string? serviceNo)
|
||||
{
|
||||
var normalized = serviceNo?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
if (await OrderRepository.HasServiceNoAsync(normalized))
|
||||
{
|
||||
throw new BusinessException("运单号已存在");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var generated = $"LP{DateTime.Now:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}";
|
||||
if (!await OrderRepository.HasServiceNoAsync(generated))
|
||||
{
|
||||
return generated;
|
||||
}
|
||||
}
|
||||
|
||||
throw new BusinessException("运单号生成失败");
|
||||
}
|
||||
|
||||
private async Task MarkParcelDraftFailedAsync(Order order, string reason)
|
||||
{
|
||||
order.OrderStatus = OrderStatusCodes.Draft;
|
||||
order.PredeclareFailReason = reason;
|
||||
order.UpdateTime = DateTime.Now;
|
||||
await OrderRepository.UpdateAsync(order);
|
||||
}
|
||||
|
||||
private async Task<OrderParcelCreateOutput> BuildParcelCreateOutputAsync(Order order)
|
||||
{
|
||||
var mapped = (await MapOrderOutputsAsync([order])).First();
|
||||
return new OrderParcelCreateOutput
|
||||
{
|
||||
ClientNo = order.ClientNo,
|
||||
ServiceNo = order.ServiceNo,
|
||||
TrackNo = order.TrackNo,
|
||||
LabelUrl = order.OrderStatus == OrderStatusCodes.Predeclared
|
||||
? await CreateOrderPrintFileAsync(mapped)
|
||||
: string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
206
src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
Normal file
206
src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
Normal file
@ -0,0 +1,206 @@
|
||||
using Fake.Application;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Domain.ChannelsAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.Shared.Enums;
|
||||
|
||||
namespace Oms.Application.Services.Orders;
|
||||
|
||||
public partial class OrderService
|
||||
{
|
||||
private async Task<List<OrderPriceCheckOutput>> BuildPriceCheckOutputsAsync(OrderPriceCheckInput input)
|
||||
{
|
||||
ValidatePriceCheckInput(input);
|
||||
var bindTime = DateTime.Now;
|
||||
var countryCode = await ResolveDestinationCountryCodeAsync(input.DestinationCountry);
|
||||
var cargoAttribute = ParcelBillingCalculator.MapCargoTypeToAttribute(input.CargoType);
|
||||
var quoteContext = new ParcelQuoteContext
|
||||
{
|
||||
CountryCode = countryCode,
|
||||
Province = input.DestinationProvince,
|
||||
PostalCode = input.PostalCode,
|
||||
CargoAttribute = cargoAttribute,
|
||||
Length = input.Length!.Value,
|
||||
Width = input.Width!.Value,
|
||||
Height = input.Height!.Value,
|
||||
ActualWeight = input.CargoWeight!.Value
|
||||
};
|
||||
|
||||
var customerGroup = await ResolveCurrentCustomerGroupAsync();
|
||||
var shippingKeyword = input.ShippingMethod?.Trim();
|
||||
var products = await SalesProductRepository.GetListAsync(x =>
|
||||
x.IsActive == EnableStatus.Enabled &&
|
||||
(string.IsNullOrWhiteSpace(shippingKeyword)
|
||||
|| x.ProductName.Contains(shippingKeyword)
|
||||
|| x.ProductCode.Contains(shippingKeyword)));
|
||||
|
||||
var warehouses = await WarehouseAddressRepository.GetListAsync();
|
||||
var defaultWarehouse = warehouses.OrderBy(x => x.Id).FirstOrDefault();
|
||||
var outputs = new List<OrderPriceCheckOutput>();
|
||||
|
||||
foreach (var product in products.OrderBy(x => x.Id))
|
||||
{
|
||||
var quote = await TryComputeProductQuoteAsync(product, quoteContext, customerGroup, bindTime);
|
||||
if (quote == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var (freight, otherFees, totalCost, costFormula) = ParcelBillingCalculator.SummarizeFees(quote.Fees);
|
||||
outputs.Add(new OrderPriceCheckOutput
|
||||
{
|
||||
Id = quote.ProductId,
|
||||
ProductCode = quote.ProductCode,
|
||||
ShippingMethod = quote.ProductName,
|
||||
Warehouse = defaultWarehouse?.WarehouseName,
|
||||
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
|
||||
VolumetricWeight = quote.VolumetricWeight,
|
||||
ChargeableWeight = quote.ChargeableWeight,
|
||||
CostFormula = costFormula,
|
||||
Freight = freight,
|
||||
OtherFees = otherFees,
|
||||
TotalCost = totalCost,
|
||||
MaxDeliveryDays = product.MaxTimeliness,
|
||||
TotalCostNum = totalCost,
|
||||
RulesVersion = quote.RulesVersion,
|
||||
PriceVersion = quote.PriceVersion,
|
||||
IsRemote = quote.IsRemote
|
||||
});
|
||||
}
|
||||
|
||||
return outputs;
|
||||
}
|
||||
|
||||
private static void ValidatePriceCheckInput(OrderPriceCheckInput input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.DestinationCountry))
|
||||
{
|
||||
throw new BusinessException("目的国不能为空");
|
||||
}
|
||||
|
||||
if (!input.CargoWeight.HasValue || input.CargoWeight <= 0)
|
||||
{
|
||||
throw new BusinessException("货物重量必须大于0");
|
||||
}
|
||||
|
||||
if (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue
|
||||
|| input.Length <= 0 || input.Width <= 0 || input.Height <= 0)
|
||||
{
|
||||
throw new BusinessException("长宽高必须大于0");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveDestinationCountryCodeAsync(string destinationCountry)
|
||||
{
|
||||
var trimmed = destinationCountry.Trim();
|
||||
var country = await CountryRepository.FirstOrDefaultAsync(x =>
|
||||
x.CountryCode == trimmed || x.ChineseName == trimmed || x.EnglishName == trimmed);
|
||||
return country?.CountryCode ?? trimmed.ToUpperInvariant();
|
||||
}
|
||||
|
||||
private async Task<CustomerGroup?> ResolveCurrentCustomerGroupAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = CurrentUser.GetId<int>();
|
||||
if (userId <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == userId);
|
||||
return customer?.CustomerGroup;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 对单个销售产品执行预报试算(与 parcel/create 同源,不落库)。
|
||||
/// </summary>
|
||||
private async Task<ParcelQuoteResult?> TryComputeProductQuoteAsync(
|
||||
SalesProduct product,
|
||||
ParcelQuoteContext context,
|
||||
CustomerGroup? customerGroup,
|
||||
DateTime bindTime)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (product.IsActive != EnableStatus.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
|
||||
if (await IsBlacklistHitAsync(
|
||||
product,
|
||||
context.CountryCode,
|
||||
context.Province,
|
||||
context.City,
|
||||
context.PostalCode))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
|
||||
await ApplyChargeableWeightAsync(product, forecast);
|
||||
|
||||
var zoneName = await MatchPriceZoneAsync(
|
||||
product,
|
||||
context.CountryCode,
|
||||
context.Province,
|
||||
context.City,
|
||||
context.PostalCode);
|
||||
if (string.IsNullOrWhiteSpace(zoneName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
|
||||
snapshotBinding.Price,
|
||||
snapshotBinding.PriceVersion,
|
||||
snapshotBinding.PriceEffectiveTime,
|
||||
forecast.ChargeableWeight ?? 0,
|
||||
zoneName,
|
||||
context.CargoAttribute,
|
||||
customerGroup);
|
||||
|
||||
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshotBinding.Price))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var isRemote = await IsRemoteHitAsync(
|
||||
product,
|
||||
context.CountryCode,
|
||||
context.Province,
|
||||
context.City,
|
||||
context.PostalCode);
|
||||
|
||||
return new ParcelQuoteResult
|
||||
{
|
||||
ProductId = product.Id,
|
||||
ProductCode = product.ProductCode,
|
||||
ProductName = product.ProductName,
|
||||
RulesVersion = snapshotBinding.RulesVersion,
|
||||
PriceVersion = snapshotBinding.PriceVersion,
|
||||
VolumetricWeight = forecast.VolumetricWeight ?? 0,
|
||||
ChargeableWeight = forecast.ChargeableWeight ?? 0,
|
||||
ZoneName = zoneName,
|
||||
IsRemote = isRemote,
|
||||
Fees = fees
|
||||
};
|
||||
}
|
||||
catch (BusinessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -354,6 +354,11 @@ public partial class OrderService : ApplicationService
|
||||
throw new BusinessException("批量创建请求不能为空");
|
||||
}
|
||||
|
||||
if (input.Count > ParcelBatchMaxCount)
|
||||
{
|
||||
throw new BusinessException($"单次批量创建不能超过{ParcelBatchMaxCount}条");
|
||||
}
|
||||
|
||||
var batchNo = $"PCB{DateTime.Now:yyyyMMddHHmmssfff}";
|
||||
var items = new List<OrderParcelBatchCreateItemOutput>(input.Count);
|
||||
var successCount = 0;
|
||||
|
||||
597
src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
Normal file
597
src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
Normal file
@ -0,0 +1,597 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Oms.Application.Dtos.Channels;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.ChannelsAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
|
||||
namespace Oms.Application.Services.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 小包预报:计重、分区匹配、卖价算价。
|
||||
/// </summary>
|
||||
internal static class ParcelBillingCalculator
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static decimal CalculateCubic(decimal length, decimal width, decimal height) =>
|
||||
Math.Round(length * width * height / 1_000_000M, 6, MidpointRounding.AwayFromZero);
|
||||
|
||||
public static (decimal VolumetricWeight, decimal RawBillableWeight) CalculateRawBillableWeight(
|
||||
OrderWeight forecast,
|
||||
IReadOnlyList<WeightRuleInfo> rules)
|
||||
{
|
||||
var length = forecast.Length ?? 0;
|
||||
var width = forecast.Width ?? 0;
|
||||
var height = forecast.Height ?? 0;
|
||||
var gw = forecast.ActualWeight ?? 0;
|
||||
if (gw <= 0 || length <= 0 || width <= 0 || height <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("件重尺数据不完整");
|
||||
}
|
||||
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("计重规则明细为空");
|
||||
}
|
||||
|
||||
foreach (var rule in rules.OrderBy(x => x.Id))
|
||||
{
|
||||
if (!decimal.TryParse(rule.VolumetricFactor, NumberStyles.Any, CultureInfo.InvariantCulture, out var factor) || factor <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var vw = Math.Round(length * width * height / factor, 3, MidpointRounding.AwayFromZero);
|
||||
if (!EvaluateCondition(rule.Condition, gw, vw))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var raw = string.Equals(rule.BillableWeight, "gw", StringComparison.OrdinalIgnoreCase) ? gw : vw;
|
||||
return (vw, raw);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("计重规则条件均未命中");
|
||||
}
|
||||
|
||||
public static decimal ApplyRounding(decimal rawWeight, IReadOnlyList<WeightRoundingInfo> ranges)
|
||||
{
|
||||
var hit = ranges
|
||||
.OrderBy(x => x.Id)
|
||||
.FirstOrDefault(x => rawWeight >= x.StartValue && rawWeight <= x.EndValue);
|
||||
if (hit == null)
|
||||
{
|
||||
throw new InvalidOperationException("计重进位区间未匹配");
|
||||
}
|
||||
|
||||
if (!decimal.TryParse(hit.Public, NumberStyles.Any, CultureInfo.InvariantCulture, out var unit) || unit <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("计重进位值无效");
|
||||
}
|
||||
|
||||
var units = Math.Ceiling(rawWeight / unit);
|
||||
return Math.Round(units * unit, 3, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
public static bool IsAreaMatched(
|
||||
KpiAreaInfo rule,
|
||||
string countryCode,
|
||||
string? province,
|
||||
string? city,
|
||||
string? zipCode)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(rule.CountryCode)
|
||||
&& !string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.Province)
|
||||
&& !string.Equals(rule.Province, province, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.City)
|
||||
&& !string.Equals(rule.City, city, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.LeftPostcode) || !string.IsNullOrWhiteSpace(rule.RightPostcode))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(zipCode))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsPostcodeInRange(zipCode, rule.LeftPostcode, rule.RightPostcode))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static string? ResolveZoneName(KpiAreaInfo? matched, KpiArea? area)
|
||||
{
|
||||
if (matched == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(matched.Province))
|
||||
{
|
||||
return matched.Province;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(matched.City))
|
||||
{
|
||||
return matched.City;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(matched.CountryName))
|
||||
{
|
||||
return matched.CountryName;
|
||||
}
|
||||
|
||||
return area?.Name;
|
||||
}
|
||||
|
||||
public static List<OrderFee> CalculateForecastReceivableFees(
|
||||
SalesProductPricePublishSnapshotDto priceSnapshot,
|
||||
string priceVersion,
|
||||
DateTime priceEffectiveTime,
|
||||
decimal chargeableWeight,
|
||||
string zoneName,
|
||||
string cargoAttribute,
|
||||
CustomerGroup? customerGroup)
|
||||
{
|
||||
var items = priceSnapshot.Prices.Select(p => new BillingPriceItem(
|
||||
p.Status, p.ChargingTime, p.CostItem, p.Currency, p.Details));
|
||||
return CalculateForecastFees(
|
||||
items,
|
||||
OrderFeeTypeCodes.Receivable,
|
||||
priceVersion,
|
||||
priceEffectiveTime,
|
||||
chargeableWeight,
|
||||
zoneName,
|
||||
cargoAttribute,
|
||||
customerGroup);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预报阶段应付费用(买价快照,计费重取 downstream 行,调用方传入对应重量)。
|
||||
/// </summary>
|
||||
public static List<OrderFee> CalculateForecastPayableFees(
|
||||
IReadOnlyList<ServiceChannelPriceListOutput> prices,
|
||||
string priceVersion,
|
||||
DateTime priceEffectiveTime,
|
||||
decimal chargeableWeight,
|
||||
string zoneName,
|
||||
string cargoAttribute,
|
||||
CustomerGroup? customerGroup)
|
||||
{
|
||||
var items = prices.Select(p => new BillingPriceItem(
|
||||
p.Status, p.ChargingTime, p.CostItem, p.Currency, p.Details));
|
||||
return CalculateForecastFees(
|
||||
items,
|
||||
OrderFeeTypeCodes.Payable,
|
||||
priceVersion,
|
||||
priceEffectiveTime,
|
||||
chargeableWeight,
|
||||
zoneName,
|
||||
cargoAttribute,
|
||||
customerGroup);
|
||||
}
|
||||
|
||||
public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
|
||||
priceSnapshot.Prices.Any(x => x.Status == 1 && IsForecastChargingTime(x.ChargingTime));
|
||||
|
||||
public static bool HasEnabledForecastPayablePriceItems(IReadOnlyList<ServiceChannelPriceListOutput> prices) =>
|
||||
prices.Any(x => x.Status == 1 && IsForecastChargingTime(x.ChargingTime));
|
||||
|
||||
public static string NormalizeChargingTime(string? chargingTime)
|
||||
{
|
||||
var value = chargingTime?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return OrderFeeChargingStageCodes.Forecast;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
"预报" => OrderFeeChargingStageCodes.Forecast,
|
||||
"入库" => OrderFeeChargingStageCodes.Inbound,
|
||||
"出库" => OrderFeeChargingStageCodes.Outbound,
|
||||
_ => value.ToLowerInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
public static string GetCargoAttributeLabel(string? cargoAttribute) => cargoAttribute switch
|
||||
{
|
||||
CargoAttributeCodes.Electronics => "3c&带电",
|
||||
CargoAttributeCodes.Sensitive => "敏感货",
|
||||
_ => "普货"
|
||||
};
|
||||
|
||||
public static string MapCargoTypeToAttribute(string? cargoType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cargoType))
|
||||
{
|
||||
return CargoAttributeCodes.General;
|
||||
}
|
||||
|
||||
var value = cargoType.Trim();
|
||||
if (value.Contains("敏感", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CargoAttributeCodes.Sensitive;
|
||||
}
|
||||
|
||||
if (value.Contains("带电", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Contains("3c", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("electronics", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CargoAttributeCodes.Electronics;
|
||||
}
|
||||
|
||||
if (value.Equals(CargoAttributeCodes.General, StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals(CargoAttributeCodes.Electronics, StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals(CargoAttributeCodes.Sensitive, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return value.ToLowerInvariant();
|
||||
}
|
||||
|
||||
return CargoAttributeCodes.General;
|
||||
}
|
||||
|
||||
public static (decimal Freight, decimal OtherFees, decimal TotalCost, string CostFormula) SummarizeFees(
|
||||
IReadOnlyList<OrderFee> fees)
|
||||
{
|
||||
if (fees.Count == 0)
|
||||
{
|
||||
return (0, 0, 0, string.Empty);
|
||||
}
|
||||
|
||||
var freight = fees
|
||||
.Where(x => x.CostItem.Equals("freight", StringComparison.OrdinalIgnoreCase)
|
||||
|| x.CostItem.Contains("运费", StringComparison.OrdinalIgnoreCase))
|
||||
.Sum(x => x.Amount);
|
||||
if (freight <= 0)
|
||||
{
|
||||
freight = fees[0].Amount;
|
||||
}
|
||||
|
||||
var total = fees.Sum(x => x.Amount);
|
||||
var otherFees = Math.Max(0, total - freight);
|
||||
var formula = string.Join(" + ", fees.Select(x => $"{x.CostItem}:{x.Amount:0.####}"));
|
||||
return (freight, otherFees, total, formula);
|
||||
}
|
||||
|
||||
public static OrderWeight BuildForecastWeight(ParcelQuoteContext context) =>
|
||||
new()
|
||||
{
|
||||
Type = OrderWeightTypeCodes.Forecast,
|
||||
Length = context.Length,
|
||||
Width = context.Width,
|
||||
Height = context.Height,
|
||||
ActualWeight = context.ActualWeight,
|
||||
Cubic = CalculateCubic(context.Length, context.Width, context.Height)
|
||||
};
|
||||
|
||||
private static List<OrderFee> CalculateForecastFees(
|
||||
IEnumerable<BillingPriceItem> priceItems,
|
||||
string feeType,
|
||||
string priceVersion,
|
||||
DateTime priceEffectiveTime,
|
||||
decimal chargeableWeight,
|
||||
string zoneName,
|
||||
string cargoAttribute,
|
||||
CustomerGroup? customerGroup)
|
||||
{
|
||||
var fees = new List<OrderFee>();
|
||||
foreach (var priceItem in priceItems.Where(x => x.Status == 1))
|
||||
{
|
||||
if (!IsForecastChargingTime(priceItem.ChargingTime))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var detail = MatchPriceDetail(priceItem.Details, zoneName, cargoAttribute, chargeableWeight);
|
||||
if (detail == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var amount = CalculateDetailAmount(detail, chargeableWeight, customerGroup);
|
||||
fees.Add(new OrderFee
|
||||
{
|
||||
Type = feeType,
|
||||
CostItem = priceItem.CostItem,
|
||||
Amount = amount,
|
||||
Currency = priceItem.Currency,
|
||||
PriceNo = priceVersion,
|
||||
PriceEffectiveTime = priceEffectiveTime,
|
||||
ChargingStage = OrderFeeChargingStageCodes.Forecast
|
||||
});
|
||||
}
|
||||
|
||||
return fees;
|
||||
}
|
||||
|
||||
private readonly record struct BillingPriceItem(
|
||||
byte Status,
|
||||
string? ChargingTime,
|
||||
string CostItem,
|
||||
string Currency,
|
||||
List<ChannelPriceDetailOutput> Details);
|
||||
|
||||
private static bool IsForecastChargingTime(string? chargingTime)
|
||||
{
|
||||
var normalized = NormalizeChargingTime(chargingTime);
|
||||
return normalized is OrderFeeChargingStageCodes.Forecast or "";
|
||||
}
|
||||
|
||||
private static ChannelPriceDetailOutput? MatchPriceDetail(
|
||||
IReadOnlyList<ChannelPriceDetailOutput> details,
|
||||
string zoneName,
|
||||
string cargoAttribute,
|
||||
decimal chargeableWeight)
|
||||
{
|
||||
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
|
||||
foreach (var detail in details)
|
||||
{
|
||||
var factors = ParseFactorDictionary(detail.FactorValuesJson);
|
||||
if (!MatchFactorValue(factors, "分区", zoneName)
|
||||
&& !MatchFactorValue(factors, "价格分区", zoneName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!MatchFactorValue(factors, "货物类型", cargoLabel)
|
||||
&& factors.ContainsKey("货物类型"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!MatchWeightRange(factors, chargeableWeight))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseFactorDictionary(object? factorValuesJson)
|
||||
{
|
||||
if (factorValuesJson == null)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (factorValuesJson is JsonElement element)
|
||||
{
|
||||
return element.EnumerateObject()
|
||||
.ToDictionary(x => x.Name, x => x.Value.ToString(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(factorValuesJson, JsonOptions);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions)
|
||||
?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool MatchFactorValue(Dictionary<string, string> factors, string key, string expected)
|
||||
{
|
||||
if (!factors.TryGetValue(key, out var actual) || string.IsNullOrWhiteSpace(actual))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(actual.Trim(), expected, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool MatchWeightRange(Dictionary<string, string> factors, decimal chargeableWeight)
|
||||
{
|
||||
if (!factors.TryGetValue("重量", out var rangeText) || string.IsNullOrWhiteSpace(rangeText))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var parts = rangeText.Split('-', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!decimal.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var start))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!decimal.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var end))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return chargeableWeight >= start && chargeableWeight <= end;
|
||||
}
|
||||
|
||||
private static decimal CalculateDetailAmount(
|
||||
ChannelPriceDetailOutput detail,
|
||||
decimal chargeableWeight,
|
||||
CustomerGroup? customerGroup)
|
||||
{
|
||||
var formula = detail.CalculationFormula?.Trim() ?? string.Empty;
|
||||
if (formula is "单价" or "固定价")
|
||||
{
|
||||
return ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0;
|
||||
}
|
||||
|
||||
if (formula is "续单价" or "续重价")
|
||||
{
|
||||
return CalculateContinuePrice(detail, chargeableWeight, customerGroup);
|
||||
}
|
||||
|
||||
return detail.CalculatedValue ?? 0;
|
||||
}
|
||||
|
||||
private static decimal CalculateContinuePrice(
|
||||
ChannelPriceDetailOutput detail,
|
||||
decimal chargeableWeight,
|
||||
CustomerGroup? customerGroup)
|
||||
{
|
||||
var factors = ParseFactorDictionary(detail.FactorValuesJson);
|
||||
var continuePricing = TryReadContinuePricing(factors, detail);
|
||||
var firstWeight = continuePricing?.FirstWeight ?? 1M;
|
||||
var firstPrice = continuePricing?.FirstPrice ?? detail.CalculatedValue ?? 0;
|
||||
var continueUnit = continuePricing?.ContinueUnit ?? 0.5M;
|
||||
var continuePrice = continuePricing?.ContinuePrice ?? firstPrice;
|
||||
|
||||
if (customerGroup.HasValue)
|
||||
{
|
||||
var groupPrice = ResolveCustomerGroupPrice(detail, customerGroup);
|
||||
if (groupPrice.HasValue)
|
||||
{
|
||||
firstPrice = groupPrice.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (chargeableWeight <= firstWeight)
|
||||
{
|
||||
return Math.Round(firstPrice, 4, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
var overflow = chargeableWeight - firstWeight;
|
||||
var continueUnits = Math.Ceiling(overflow / continueUnit);
|
||||
return Math.Round(firstPrice + continueUnits * continuePrice, 4, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static ContinuePricingConfig? TryReadContinuePricing(
|
||||
Dictionary<string, string> factors,
|
||||
ChannelPriceDetailOutput detail)
|
||||
{
|
||||
if (factors.TryGetValue("continuePricing", out var json) && !string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return JsonSerializer.Deserialize<ContinuePricingConfig>(json, JsonOptions);
|
||||
}
|
||||
|
||||
if (detail.FactorValuesJson is JsonElement element
|
||||
&& element.TryGetProperty("continuePricing", out var continueNode))
|
||||
{
|
||||
return continueNode.Deserialize<ContinuePricingConfig>(JsonOptions);
|
||||
}
|
||||
|
||||
if (factors.TryGetValue("首重", out var firstWeightText)
|
||||
&& decimal.TryParse(firstWeightText, NumberStyles.Any, CultureInfo.InvariantCulture, out var firstWeight))
|
||||
{
|
||||
decimal.TryParse(factors.GetValueOrDefault("首重价"), NumberStyles.Any, CultureInfo.InvariantCulture, out var firstPrice);
|
||||
decimal.TryParse(factors.GetValueOrDefault("续重单位"), NumberStyles.Any, CultureInfo.InvariantCulture, out var continueUnit);
|
||||
decimal.TryParse(factors.GetValueOrDefault("续重价"), NumberStyles.Any, CultureInfo.InvariantCulture, out var continuePrice);
|
||||
return new ContinuePricingConfig
|
||||
{
|
||||
FirstWeight = firstWeight,
|
||||
FirstPrice = firstPrice > 0 ? firstPrice : detail.CalculatedValue ?? 0,
|
||||
ContinueUnit = continueUnit > 0 ? continueUnit : 0.5M,
|
||||
ContinuePrice = continuePrice > 0 ? continuePrice : detail.CalculatedValue ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static decimal? ResolveCustomerGroupPrice(ChannelPriceDetailOutput detail, CustomerGroup? customerGroup)
|
||||
{
|
||||
if (!customerGroup.HasValue || detail.CustomerGroupValues == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(detail.CustomerGroupValues, JsonOptions);
|
||||
var map = JsonSerializer.Deserialize<Dictionary<string, decimal>>(json, JsonOptions);
|
||||
if (map == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = customerGroup.Value switch
|
||||
{
|
||||
CustomerGroup.Vip1 => "客户组1",
|
||||
CustomerGroup.Vip2 => "客户组2",
|
||||
CustomerGroup.Vip3 => "客户组3",
|
||||
CustomerGroup.Vip4 => "客户组4",
|
||||
CustomerGroup.Vip5 => "客户组5",
|
||||
_ => null
|
||||
};
|
||||
if (key != null && map.TryGetValue(key, out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool EvaluateCondition(string condition, decimal gw, decimal vw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(condition))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var expr = Regex.Replace(condition.Trim(), @"\bgw\b", gw.ToString(CultureInfo.InvariantCulture), RegexOptions.IgnoreCase);
|
||||
expr = Regex.Replace(expr, @"\bvw\b", vw.ToString(CultureInfo.InvariantCulture), RegexOptions.IgnoreCase);
|
||||
expr = expr.Replace("==", "=");
|
||||
|
||||
try
|
||||
{
|
||||
var result = new System.Data.DataTable().Compute(expr, string.Empty);
|
||||
return result is bool boolResult && boolResult
|
||||
|| result is int intResult && intResult != 0
|
||||
|| result is decimal decimalResult && decimalResult != 0
|
||||
|| result is double doubleResult && Math.Abs(doubleResult) > double.Epsilon;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPostcodeInRange(string zipCode, string? left, string? right)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(left) && string.IsNullOrWhiteSpace(right))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (long.TryParse(zipCode, out var zip)
|
||||
&& long.TryParse(left, out var leftValue)
|
||||
&& long.TryParse(right, out var rightValue))
|
||||
{
|
||||
return zip >= leftValue && zip <= rightValue;
|
||||
}
|
||||
|
||||
return string.Compare(zipCode, left, StringComparison.OrdinalIgnoreCase) >= 0
|
||||
&& string.Compare(zipCode, right, StringComparison.OrdinalIgnoreCase) <= 0;
|
||||
}
|
||||
|
||||
private sealed class ContinuePricingConfig
|
||||
{
|
||||
public decimal FirstWeight { get; set; }
|
||||
|
||||
public decimal FirstPrice { get; set; }
|
||||
|
||||
public decimal ContinueUnit { get; set; }
|
||||
|
||||
public decimal ContinuePrice { get; set; }
|
||||
}
|
||||
}
|
||||
25
src/Oms.Application/Services/Orders/ParcelQuoteContext.cs
Normal file
25
src/Oms.Application/Services/Orders/ParcelQuoteContext.cs
Normal file
@ -0,0 +1,25 @@
|
||||
namespace Oms.Application.Services.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 查价/试算用的地址与件重尺上下文(不落库)。
|
||||
/// </summary>
|
||||
public sealed class ParcelQuoteContext
|
||||
{
|
||||
public string CountryCode { get; init; } = string.Empty;
|
||||
|
||||
public string? Province { get; init; }
|
||||
|
||||
public string? City { get; init; }
|
||||
|
||||
public string? PostalCode { get; init; }
|
||||
|
||||
public string CargoAttribute { get; init; } = string.Empty;
|
||||
|
||||
public decimal Length { get; init; }
|
||||
|
||||
public decimal Width { get; init; }
|
||||
|
||||
public decimal Height { get; init; }
|
||||
|
||||
public decimal ActualWeight { get; init; }
|
||||
}
|
||||
29
src/Oms.Application/Services/Orders/ParcelQuoteResult.cs
Normal file
29
src/Oms.Application/Services/Orders/ParcelQuoteResult.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
|
||||
namespace Oms.Application.Services.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 单产品销售产品试算结果(与 parcel/create 预报计费同源)。
|
||||
/// </summary>
|
||||
public sealed class ParcelQuoteResult
|
||||
{
|
||||
public int ProductId { get; init; }
|
||||
|
||||
public string ProductCode { get; init; } = string.Empty;
|
||||
|
||||
public string ProductName { get; init; } = string.Empty;
|
||||
|
||||
public string RulesVersion { get; init; } = string.Empty;
|
||||
|
||||
public string PriceVersion { get; init; } = string.Empty;
|
||||
|
||||
public decimal VolumetricWeight { get; init; }
|
||||
|
||||
public decimal ChargeableWeight { get; init; }
|
||||
|
||||
public string? ZoneName { get; init; }
|
||||
|
||||
public bool IsRemote { get; init; }
|
||||
|
||||
public IReadOnlyList<OrderFee> Fees { get; init; } = [];
|
||||
}
|
||||
@ -20,4 +20,6 @@ public interface IKpiAreaRepository : IRepository<KpiArea>
|
||||
Task UpdateInfoAsync(KpiAreaInfo info, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteInfoAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<KpiAreaInfo>> GetAllInfosByAreaIdAsync(int areaId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -51,4 +51,10 @@ public interface ISalesProductRepository : IRepository<SalesProduct>
|
||||
Task<SalesProductPublishRecord> InsertPublishRecordAsync(SalesProductPublishRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SalesProductPublishRecord?> GetPublishRecordByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SalesProductPublishRecord?> GetEffectivePublishRecordAsync(
|
||||
int salesProductId,
|
||||
string operatorType,
|
||||
DateTime bindTime,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -20,4 +20,6 @@ public interface IWeightRoundingRepository : IRepository<WeightRounding>
|
||||
Task UpdateInfoAsync(WeightRoundingInfo info, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteInfoAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<WeightRoundingInfo>> GetAllInfosByRoundingIdAsync(int roundingId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -20,4 +20,6 @@ public interface IWeightRuleRepository : IRepository<WeightRule>
|
||||
Task UpdateInfoAsync(WeightRuleInfo info, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteInfoAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<WeightRuleInfo>> GetAllInfosByRuleIdAsync(int ruleId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
34
src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs
Normal file
34
src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs
Normal file
@ -0,0 +1,34 @@
|
||||
namespace Oms.Domain.OrdersAggregate.Consts;
|
||||
|
||||
public static class OrderFeeTypeCodes
|
||||
{
|
||||
public const string Receivable = "receivable";
|
||||
public const string Payable = "payable";
|
||||
}
|
||||
|
||||
public static class OrderFeeChargingStageCodes
|
||||
{
|
||||
public const string Forecast = "forecast";
|
||||
public const string Inbound = "inbound";
|
||||
public const string Outbound = "outbound";
|
||||
}
|
||||
|
||||
public static class ExportCustomsModeCodes
|
||||
{
|
||||
public const string Mode9610Simple = "9610Simple";
|
||||
public const string Mode9610Summary = "9610Summary";
|
||||
public const string Mode9610Separate = "9610Separate";
|
||||
public const string Mode9710 = "9710";
|
||||
public const string Mode9810 = "9810";
|
||||
public const string Mode0110 = "0110";
|
||||
|
||||
public static readonly HashSet<string> All =
|
||||
[
|
||||
Mode9610Simple,
|
||||
Mode9610Summary,
|
||||
Mode9610Separate,
|
||||
Mode9710,
|
||||
Mode9810,
|
||||
Mode0110
|
||||
];
|
||||
}
|
||||
@ -22,6 +22,14 @@ public class Order : OmsAuditedAggregateRoot
|
||||
|
||||
public string? CargoAttribute { get; set; }
|
||||
|
||||
public string? ExportCustomsMode { get; set; }
|
||||
|
||||
public string? PredeclareFailReason { get; set; }
|
||||
|
||||
public string? SalesProductRulesVersion { get; set; }
|
||||
|
||||
public string? SalesProductPriceVersion { get; set; }
|
||||
|
||||
public string OrderStatus { get; set; } = string.Empty;
|
||||
|
||||
public int? WarehouseId { get; set; }
|
||||
|
||||
@ -21,4 +21,6 @@ public class OrderFee : OmsAuditedEntity
|
||||
public string? PriceNo { get; set; }
|
||||
|
||||
public DateTime? PriceEffectiveTime { get; set; }
|
||||
|
||||
public string? ChargingStage { get; set; }
|
||||
}
|
||||
|
||||
@ -30,4 +30,10 @@ public interface IOrderRepository : IRepository<Order>
|
||||
Task InsertDeclarationsAsync(IEnumerable<OrderDeclaration> declarations, CancellationToken cancellationToken = default);
|
||||
|
||||
Task InsertWeightsAsync(IEnumerable<OrderWeight> weights, CancellationToken cancellationToken = default);
|
||||
|
||||
Task ReplaceWeightsAsync(int orderId, IEnumerable<OrderWeight> weights, CancellationToken cancellationToken = default);
|
||||
|
||||
Task UpsertFeesAsync(int orderId, IEnumerable<OrderFee> fees, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<OrderFee>> GetFeesByOrderIdsAsync(IEnumerable<int> orderIds, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -55,4 +55,13 @@ public class KpiAreaRepository : OmsSqlSugarRepository<KpiArea>, IKpiAreaReposit
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
await db.Deleteable<KpiAreaInfo>().Where(x => x.Id == id).ExecuteCommandAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<KpiAreaInfo>> GetAllInfosByAreaIdAsync(int areaId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
return await db.Queryable<KpiAreaInfo>()
|
||||
.Where(x => x.AreaId == areaId)
|
||||
.OrderBy(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,6 +195,24 @@ public class SalesProductRepository : OmsSqlSugarRepository<SalesProduct>, ISale
|
||||
return await db.Queryable<SalesProductPublishRecord>().FirstAsync(x => x.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SalesProductPublishRecord?> GetEffectivePublishRecordAsync(
|
||||
int salesProductId,
|
||||
string operatorType,
|
||||
DateTime bindTime,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
var records = await db.Queryable<SalesProductPublishRecord>()
|
||||
.Where(x => x.SalesProductId == salesProductId
|
||||
&& x.OperatorType == operatorType
|
||||
&& x.Status == "生效"
|
||||
&& x.PublishTime <= bindTime)
|
||||
.OrderBy(x => x.PublishTime, SqlSugar.OrderByType.Desc)
|
||||
.Take(1)
|
||||
.ToListAsync(cancellationToken);
|
||||
return records.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static async Task InsertPriceChildrenAsync(
|
||||
SqlSugar.ISqlSugarClient db,
|
||||
int priceId,
|
||||
|
||||
@ -55,4 +55,13 @@ public class WeightRoundingRepository : OmsSqlSugarRepository<WeightRounding>, I
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
await db.Deleteable<WeightRoundingInfo>().Where(x => x.Id == id).ExecuteCommandAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<WeightRoundingInfo>> GetAllInfosByRoundingIdAsync(int roundingId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
return await db.Queryable<WeightRoundingInfo>()
|
||||
.Where(x => x.RoundingId == roundingId)
|
||||
.OrderBy(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,4 +55,13 @@ public class WeightRuleRepository : OmsSqlSugarRepository<WeightRule>, IWeightRu
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
await db.Deleteable<WeightRuleInfo>().Where(x => x.Id == id).ExecuteCommandAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<WeightRuleInfo>> GetAllInfosByRuleIdAsync(int ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
return await db.Queryable<WeightRuleInfo>()
|
||||
.Where(x => x.RuleId == ruleId)
|
||||
.OrderBy(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,4 +123,65 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
await db.Insertable(items).ExecuteCommandAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ReplaceWeightsAsync(int orderId, IEnumerable<OrderWeight> weights, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = weights.ToArray();
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
await db.Deleteable<OrderWeight>().Where(x => x.OrderId == orderId).ExecuteCommandAsync(cancellationToken);
|
||||
if (items.Length > 0)
|
||||
{
|
||||
await db.Insertable(items).ExecuteCommandAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpsertFeesAsync(int orderId, IEnumerable<OrderFee> fees, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = fees.ToArray();
|
||||
if (items.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
foreach (var fee in items)
|
||||
{
|
||||
fee.OrderId = orderId;
|
||||
var existingList = await db.Queryable<OrderFee>()
|
||||
.Where(x => x.OrderId == orderId
|
||||
&& x.Type == fee.Type
|
||||
&& x.CostItem == fee.CostItem
|
||||
&& x.ChargingStage == fee.ChargingStage)
|
||||
.Take(1)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existing = existingList.FirstOrDefault();
|
||||
if (existing == null)
|
||||
{
|
||||
await db.Insertable(fee).ExecuteCommandAsync(cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.Amount = fee.Amount;
|
||||
existing.Currency = fee.Currency;
|
||||
existing.PriceNo = fee.PriceNo;
|
||||
existing.PriceEffectiveTime = fee.PriceEffectiveTime;
|
||||
existing.DownstreamProvider = fee.DownstreamProvider;
|
||||
existing.UpdateTime = DateTime.Now;
|
||||
await db.Updateable(existing).ExecuteCommandAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<OrderFee>> GetFeesByOrderIdsAsync(IEnumerable<int> orderIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = orderIds.Distinct().ToArray();
|
||||
if (ids.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
return await db.Queryable<OrderFee>()
|
||||
.Where(x => ids.Contains(x.OrderId))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
258
src/Oms.Tests/ParcelBillingCalculatorTests.cs
Normal file
258
src/Oms.Tests/ParcelBillingCalculatorTests.cs
Normal file
@ -0,0 +1,258 @@
|
||||
using FluentAssertions;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Application.Services.Orders;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
using Oms.Tests.Support;
|
||||
|
||||
namespace Oms.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 小包预报应收/应付计费单元测试(与设计文档 §4.4.3.1 续单价示例对齐)。
|
||||
/// </summary>
|
||||
public class ParcelBillingCalculatorTests
|
||||
{
|
||||
private const string Zone = "美西";
|
||||
private readonly (string PriceVersion, DateTime EffectiveTime) _meta = ParcelBillingTestData.SnapshotMeta();
|
||||
|
||||
#region 计重
|
||||
|
||||
[Fact]
|
||||
public void CalculateRawBillableWeight_ShouldUseGrossWeightWhenConditionHits()
|
||||
{
|
||||
var forecast = new OrderWeight
|
||||
{
|
||||
Length = 30,
|
||||
Width = 20,
|
||||
Height = 10,
|
||||
ActualWeight = 1.5M
|
||||
};
|
||||
var rules = new[] { ParcelBillingTestData.GwRule() };
|
||||
|
||||
var (vw, raw) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, rules);
|
||||
|
||||
vw.Should().Be(1M);
|
||||
raw.Should().Be(1.5M);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyRounding_ShouldCeilToConfiguredUnit()
|
||||
{
|
||||
var ranges = new[] { ParcelBillingTestData.RoundingRange(0, 30, "0.5") };
|
||||
|
||||
var rounded = ParcelBillingCalculator.ApplyRounding(1.23M, ranges);
|
||||
|
||||
rounded.Should().Be(1.5M);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 应收(卖价)
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.8, 18)]
|
||||
[InlineData(1.2, 24)]
|
||||
[InlineData(1.6, 30)]
|
||||
public void CalculateForecastReceivableFees_ContinuePrice_ShouldMatchDesignExamples(
|
||||
double chargeableWeight,
|
||||
decimal expectedFreight)
|
||||
{
|
||||
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
|
||||
ParcelBillingTestData.ReceivablePriceItem(
|
||||
"预报",
|
||||
1,
|
||||
"freight",
|
||||
ParcelBillingTestData.ContinueFreightDetail()));
|
||||
|
||||
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
|
||||
snapshot,
|
||||
_meta.PriceVersion,
|
||||
_meta.EffectiveTime,
|
||||
(decimal)chargeableWeight,
|
||||
Zone,
|
||||
"general",
|
||||
null);
|
||||
|
||||
fees.Should().ContainSingle();
|
||||
fees[0].Type.Should().Be(OrderFeeTypeCodes.Receivable);
|
||||
fees[0].CostItem.Should().Be("freight");
|
||||
fees[0].Amount.Should().Be(expectedFreight);
|
||||
fees[0].ChargingStage.Should().Be(OrderFeeChargingStageCodes.Forecast);
|
||||
fees[0].PriceNo.Should().Be(_meta.PriceVersion);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("入库")]
|
||||
[InlineData("出库")]
|
||||
[InlineData("inbound")]
|
||||
[InlineData("outbound")]
|
||||
public void CalculateForecastReceivableFees_ShouldSkipNonForecastChargingTime(string chargingTime)
|
||||
{
|
||||
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
|
||||
ParcelBillingTestData.ReceivablePriceItem(
|
||||
chargingTime,
|
||||
1,
|
||||
"freight",
|
||||
ParcelBillingTestData.ContinueFreightDetail()));
|
||||
|
||||
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
|
||||
snapshot,
|
||||
_meta.PriceVersion,
|
||||
_meta.EffectiveTime,
|
||||
1.2M,
|
||||
Zone,
|
||||
"general",
|
||||
null);
|
||||
|
||||
fees.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateForecastReceivableFees_ShouldIncludeForecastAndFixedPriceItems()
|
||||
{
|
||||
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
|
||||
ParcelBillingTestData.ReceivablePriceItem(
|
||||
"预报",
|
||||
1,
|
||||
"freight",
|
||||
ParcelBillingTestData.ContinueFreightDetail()),
|
||||
ParcelBillingTestData.ReceivablePriceItem(
|
||||
"预报",
|
||||
1,
|
||||
"操作费",
|
||||
ParcelBillingTestData.FixedPriceDetail(5M)));
|
||||
|
||||
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
|
||||
snapshot,
|
||||
_meta.PriceVersion,
|
||||
_meta.EffectiveTime,
|
||||
0.8M,
|
||||
Zone,
|
||||
"general",
|
||||
null);
|
||||
|
||||
fees.Should().HaveCount(2);
|
||||
fees.Should().Contain(x => x.CostItem == "freight" && x.Amount == 18M);
|
||||
fees.Should().Contain(x => x.CostItem == "操作费" && x.Amount == 5M);
|
||||
|
||||
var summary = ParcelBillingCalculator.SummarizeFees(fees);
|
||||
summary.Freight.Should().Be(18M);
|
||||
summary.OtherFees.Should().Be(5M);
|
||||
summary.TotalCost.Should().Be(23M);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateForecastReceivableFees_ShouldApplyCustomerGroupPrice()
|
||||
{
|
||||
var detail = ParcelBillingTestData.UnitPriceDetail(20M);
|
||||
detail.CustomerGroupValues = new Dictionary<string, decimal> { ["客户组1"] = 15M };
|
||||
|
||||
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
|
||||
ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", detail));
|
||||
|
||||
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
|
||||
snapshot,
|
||||
_meta.PriceVersion,
|
||||
_meta.EffectiveTime,
|
||||
2M,
|
||||
Zone,
|
||||
"general",
|
||||
CustomerGroup.Vip1);
|
||||
|
||||
fees.Should().ContainSingle();
|
||||
fees[0].Amount.Should().Be(15M);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasEnabledForecastPriceItems_ShouldDetectForecastEnabledItems()
|
||||
{
|
||||
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
|
||||
ParcelBillingTestData.ReceivablePriceItem("入库", 1, "freight", ParcelBillingTestData.ContinueFreightDetail()),
|
||||
ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", ParcelBillingTestData.ContinueFreightDetail()));
|
||||
|
||||
ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshot).Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 应付(买价)
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.8, 12)]
|
||||
[InlineData(1.2, 16)]
|
||||
[InlineData(1.6, 20)]
|
||||
public void CalculateForecastPayableFees_ContinuePrice_ShouldUseSameEngine(
|
||||
double chargeableWeight,
|
||||
decimal expectedFreight)
|
||||
{
|
||||
var prices = ParcelBillingTestData.BuildPayablePrices(
|
||||
ParcelBillingTestData.PayablePriceItem(
|
||||
"预报",
|
||||
1,
|
||||
"freight",
|
||||
ParcelBillingTestData.ContinueFreightDetail(
|
||||
firstWeight: 1M,
|
||||
firstPrice: 12M,
|
||||
continueUnit: 0.5M,
|
||||
continuePrice: 4M)));
|
||||
|
||||
var fees = ParcelBillingCalculator.CalculateForecastPayableFees(
|
||||
prices,
|
||||
_meta.PriceVersion,
|
||||
_meta.EffectiveTime,
|
||||
(decimal)chargeableWeight,
|
||||
Zone,
|
||||
"general",
|
||||
null);
|
||||
|
||||
fees.Should().ContainSingle();
|
||||
fees[0].Type.Should().Be(OrderFeeTypeCodes.Payable);
|
||||
fees[0].Amount.Should().Be(expectedFreight);
|
||||
fees[0].ChargingStage.Should().Be(OrderFeeChargingStageCodes.Forecast);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateForecastPayableFees_ShouldSkipDisabledPriceItems()
|
||||
{
|
||||
var prices = ParcelBillingTestData.BuildPayablePrices(
|
||||
ParcelBillingTestData.PayablePriceItem(
|
||||
"预报",
|
||||
0,
|
||||
"freight",
|
||||
ParcelBillingTestData.ContinueFreightDetail()));
|
||||
|
||||
var fees = ParcelBillingCalculator.CalculateForecastPayableFees(
|
||||
prices,
|
||||
_meta.PriceVersion,
|
||||
_meta.EffectiveTime,
|
||||
1.2M,
|
||||
Zone,
|
||||
"general",
|
||||
null);
|
||||
|
||||
fees.Should().BeEmpty();
|
||||
ParcelBillingCalculator.HasEnabledForecastPayablePriceItems(prices).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceivableAndPayable_ShouldDifferByTypeButShareZoneMatching()
|
||||
{
|
||||
var detail = ParcelBillingTestData.ContinueFreightDetail();
|
||||
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
|
||||
ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", detail));
|
||||
var payablePrices = ParcelBillingTestData.BuildPayablePrices(
|
||||
ParcelBillingTestData.PayablePriceItem("预报", 1, "freight", detail));
|
||||
|
||||
var receivable = ParcelBillingCalculator.CalculateForecastReceivableFees(
|
||||
snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null);
|
||||
var payable = ParcelBillingCalculator.CalculateForecastPayableFees(
|
||||
payablePrices, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null);
|
||||
|
||||
receivable[0].Type.Should().Be(OrderFeeTypeCodes.Receivable);
|
||||
payable[0].Type.Should().Be(OrderFeeTypeCodes.Payable);
|
||||
receivable[0].Amount.Should().Be(payable[0].Amount);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
114
src/Oms.Tests/Support/ParcelBillingTestData.cs
Normal file
114
src/Oms.Tests/Support/ParcelBillingTestData.cs
Normal file
@ -0,0 +1,114 @@
|
||||
using System.Globalization;
|
||||
using Oms.Application.Dtos.Channels;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Domain.ChannelsAggregate.Entities;
|
||||
|
||||
namespace Oms.Tests.Support;
|
||||
|
||||
internal static class ParcelBillingTestData
|
||||
{
|
||||
private static readonly DateTime EffectiveTime = new(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
private const string PriceVersion = "P20260401001";
|
||||
private const string Zone = "美西";
|
||||
|
||||
public static SalesProductPricePublishSnapshotDto BuildReceivableSnapshot(
|
||||
params SalesProductPriceListOutput[] prices) =>
|
||||
new() { SalesProductId = 1, Prices = prices.ToList() };
|
||||
|
||||
public static List<ServiceChannelPriceListOutput> BuildPayablePrices(
|
||||
params ServiceChannelPriceListOutput[] prices) => prices.ToList();
|
||||
|
||||
public static SalesProductPriceListOutput ReceivablePriceItem(
|
||||
string chargingTime,
|
||||
byte status,
|
||||
string costItem,
|
||||
params ChannelPriceDetailOutput[] details) =>
|
||||
new()
|
||||
{
|
||||
Status = status,
|
||||
CostItem = costItem,
|
||||
Currency = "CNY",
|
||||
ChargingTime = chargingTime,
|
||||
Details = details.ToList()
|
||||
};
|
||||
|
||||
public static ServiceChannelPriceListOutput PayablePriceItem(
|
||||
string chargingTime,
|
||||
byte status,
|
||||
string costItem,
|
||||
params ChannelPriceDetailOutput[] details) =>
|
||||
new()
|
||||
{
|
||||
Status = status,
|
||||
CostItem = costItem,
|
||||
Currency = "CNY",
|
||||
ChargingTime = chargingTime,
|
||||
Details = details.ToList()
|
||||
};
|
||||
|
||||
public static ChannelPriceDetailOutput ContinueFreightDetail(
|
||||
decimal firstWeight = 1M,
|
||||
decimal firstPrice = 18M,
|
||||
decimal continueUnit = 0.5M,
|
||||
decimal continuePrice = 6M,
|
||||
string zone = Zone,
|
||||
string cargoType = "普货") =>
|
||||
new()
|
||||
{
|
||||
CalculationFormula = "续单价",
|
||||
FactorValuesJson = new Dictionary<string, string>
|
||||
{
|
||||
["分区"] = zone,
|
||||
["货物类型"] = cargoType,
|
||||
["首重"] = firstWeight.ToString(CultureInfo.InvariantCulture),
|
||||
["首重价"] = firstPrice.ToString(CultureInfo.InvariantCulture),
|
||||
["续重单位"] = continueUnit.ToString(CultureInfo.InvariantCulture),
|
||||
["续重价"] = continuePrice.ToString(CultureInfo.InvariantCulture)
|
||||
}
|
||||
};
|
||||
|
||||
public static ChannelPriceDetailOutput FixedPriceDetail(
|
||||
decimal amount,
|
||||
string zone = Zone,
|
||||
string cargoType = "普货") =>
|
||||
new()
|
||||
{
|
||||
CalculationFormula = "固定价",
|
||||
CalculatedValue = amount,
|
||||
FactorValuesJson = new Dictionary<string, string>
|
||||
{
|
||||
["分区"] = zone,
|
||||
["货物类型"] = cargoType
|
||||
}
|
||||
};
|
||||
|
||||
public static ChannelPriceDetailOutput UnitPriceDetail(
|
||||
decimal unitPrice,
|
||||
string zone = Zone) =>
|
||||
new()
|
||||
{
|
||||
CalculationFormula = "单价",
|
||||
CalculatedValue = unitPrice,
|
||||
FactorValuesJson = new Dictionary<string, string> { ["分区"] = zone }
|
||||
};
|
||||
|
||||
public static WeightRuleInfo GwRule(string condition = "gw>=vw", string factor = "6000") =>
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
Condition = condition,
|
||||
BillableWeight = "gw",
|
||||
VolumetricFactor = factor
|
||||
};
|
||||
|
||||
public static WeightRoundingInfo RoundingRange(decimal start, decimal end, string unit) =>
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
StartValue = start,
|
||||
EndValue = end,
|
||||
Public = unit
|
||||
};
|
||||
|
||||
public static (string PriceVersion, DateTime EffectiveTime) SnapshotMeta() => (PriceVersion, EffectiveTime);
|
||||
}
|
||||
@ -25,6 +25,12 @@ public class OmsRefreshTokenInput
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class OmsWxLoginInput
|
||||
{
|
||||
[Required]
|
||||
public string LoginUuid { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class OmsChangePasswordInput
|
||||
{
|
||||
[Required]
|
||||
|
||||
@ -18,6 +18,7 @@ using Oms.Application.Shared;
|
||||
using Oms.Domain.CustomersAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
using SimpleAdmin.Api.WeChat.Services;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
@ -37,7 +38,8 @@ public class AuthService(
|
||||
IWebHostEnvironment webHostEnvironment,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
FakeRbacDbContext rbacDbContext,
|
||||
ICustomerRepository customerRepository)
|
||||
ICustomerRepository customerRepository,
|
||||
IWeChatLoginSessionStore weChatLoginSessionStore)
|
||||
: ApplicationService
|
||||
{
|
||||
private Guid CurrentUserGuid => CurrentUser.FindClaimValueOrNull(ClaimTypes.NameIdentifier)!.To<Guid>();
|
||||
@ -60,6 +62,56 @@ public class AuthService(
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 微信扫码登录(轮询)。
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("/rbac/auth/wx-login")]
|
||||
public async Task<LoginResultDto> WxLoginAsync(
|
||||
[FromBody] OmsWxLoginInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.LoginUuid))
|
||||
{
|
||||
throw new BusinessException("缺少 loginUuid");
|
||||
}
|
||||
|
||||
var loginUuid = input.LoginUuid.Trim();
|
||||
var openId = await weChatLoginSessionStore.GetOpenIdAsync(loginUuid, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(openId))
|
||||
{
|
||||
throw new BusinessException("请扫码登录");
|
||||
}
|
||||
|
||||
var customer = await customerRepository.GetByWechatOpenidAsync(openId, cancellationToken);
|
||||
if (customer == null)
|
||||
{
|
||||
throw new BusinessException("未绑定微信,请先注册或绑定账号");
|
||||
}
|
||||
|
||||
if (customer.ParentCustomerId.HasValue)
|
||||
{
|
||||
throw new BusinessException("子客户不支持微信扫码登录");
|
||||
}
|
||||
|
||||
ValidateMainCustomerStatus(customer);
|
||||
|
||||
customer.LastLoginIp = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||
customer.LastLoginTime = DateTime.Now;
|
||||
await customerRepository.UpdateAsync(customer, cancellationToken);
|
||||
|
||||
var userGuid = await GetRbacUserGuidByMapIdAsync(customer.UserId, cancellationToken);
|
||||
if (!userGuid.HasValue)
|
||||
{
|
||||
throw new BusinessException("客户未关联用户");
|
||||
}
|
||||
|
||||
await weChatLoginSessionStore.RemoveAsync(loginUuid, cancellationToken);
|
||||
|
||||
var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
|
||||
return BuildLoginResult(userGuid.Value, claims);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新登录令牌。
|
||||
/// </summary>
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
<PackageReference Include="Fake.Consul" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Serilog" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.11" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Oms.Application;
|
||||
using Oms.SqlSugarCore;
|
||||
using SimpleAdmin.Api.Auth;
|
||||
using SimpleAdmin.Api.WeChat;
|
||||
|
||||
namespace SimpleAdmin.Api;
|
||||
|
||||
@ -49,12 +50,18 @@ public class SimpleAdminApiModule : FakeModule
|
||||
settings.RootPath = "rbac";
|
||||
settings.TypePredicate = type => type == typeof(AuthService);
|
||||
});
|
||||
options.ApplicationServices2Controller<SimpleAdminApiModule>(settings =>
|
||||
{
|
||||
settings.TypePredicate = type => type.Namespace == "SimpleAdmin.Api.WeChat";
|
||||
});
|
||||
options.ApplicationServices2Controller<FakeRbacApplicationModule>(settings =>
|
||||
{
|
||||
settings.RootPath = "rbac";
|
||||
settings.TypePredicate = type => type != typeof(Fake.Rbac.Application.Services.AuthService);
|
||||
});
|
||||
});
|
||||
|
||||
services.AddOmsWeChat(configuration);
|
||||
|
||||
//Json格式化全局处理
|
||||
context.Services.AddFakeNewtonsoft();
|
||||
|
||||
82
src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs
Normal file
82
src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs
Normal file
@ -0,0 +1,82 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace SimpleAdmin.Api.WeChat.Dtos;
|
||||
|
||||
public class WxLoginQrcodeOutput
|
||||
{
|
||||
public string QrCodeUrl { get; set; } = string.Empty;
|
||||
|
||||
public int ExpiresIn { get; set; } = 600;
|
||||
}
|
||||
|
||||
public class WeChatOAuthCodeInput
|
||||
{
|
||||
[Required]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class WeChatOAuthUserInfoOutput
|
||||
{
|
||||
public string Openid { get; set; } = string.Empty;
|
||||
|
||||
public string? Nickname { get; set; }
|
||||
|
||||
public int Sex { get; set; }
|
||||
|
||||
public string? Province { get; set; }
|
||||
|
||||
public string? City { get; set; }
|
||||
|
||||
public string? Country { get; set; }
|
||||
|
||||
public string? Headimgurl { get; set; }
|
||||
|
||||
public string? Unionid { get; set; }
|
||||
|
||||
public bool AlreadyBound { get; set; }
|
||||
}
|
||||
|
||||
public class WeChatProfileInput
|
||||
{
|
||||
[Required]
|
||||
public string Openid { get; set; } = string.Empty;
|
||||
|
||||
public string? Nickname { get; set; }
|
||||
|
||||
public string? Avatar { get; set; }
|
||||
|
||||
public string? Unionid { get; set; }
|
||||
}
|
||||
|
||||
public class WeChatCustomerRegisterInput
|
||||
{
|
||||
[Required]
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
public string? Email { get; set; }
|
||||
|
||||
[Required]
|
||||
[MinLength(6)]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public WeChatProfileInput Wechat { get; set; } = new();
|
||||
}
|
||||
|
||||
public class WeChatCustomerBindInput
|
||||
{
|
||||
[Required]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public WeChatProfileInput Wechat { get; set; } = new();
|
||||
}
|
||||
22
src/SimpleAdmin.Api/WeChat/Options/WeChatMpOptions.cs
Normal file
22
src/SimpleAdmin.Api/WeChat/Options/WeChatMpOptions.cs
Normal file
@ -0,0 +1,22 @@
|
||||
namespace SimpleAdmin.Api.WeChat.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 微信公众号配置。
|
||||
/// </summary>
|
||||
public class WeChatMpOptions
|
||||
{
|
||||
public const string SectionName = "WeChat:Mp";
|
||||
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
public string Secret { get; set; } = string.Empty;
|
||||
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
public string? EncodingAesKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth 回调基础域名,如 https://oms.nanafa.com
|
||||
/// </summary>
|
||||
public string OAuthRedirectBase { get; set; } = string.Empty;
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
namespace SimpleAdmin.Api.WeChat.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 微信扫码登录会话:loginUuid → openId 桥接。
|
||||
/// </summary>
|
||||
public interface IWeChatLoginSessionStore
|
||||
{
|
||||
Task SetOpenIdAsync(string loginUuid, string openId, TimeSpan ttl, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<string?> GetOpenIdAsync(string loginUuid, CancellationToken cancellationToken = default);
|
||||
|
||||
Task RemoveAsync(string loginUuid, CancellationToken cancellationToken = default);
|
||||
}
|
||||
90
src/SimpleAdmin.Api/WeChat/Services/WeChatEventHandler.cs
Normal file
90
src/SimpleAdmin.Api/WeChat/Services/WeChatEventHandler.cs
Normal file
@ -0,0 +1,90 @@
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
|
||||
namespace SimpleAdmin.Api.WeChat.Services;
|
||||
|
||||
public interface IWeChatEventHandler
|
||||
{
|
||||
Task<string?> HandleAsync(WeChatIncomingMessage message, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class WeChatEventHandler(
|
||||
ICustomerRepository customerRepository,
|
||||
IWeChatLoginSessionStore sessionStore,
|
||||
ILogger<WeChatEventHandler> logger) : IWeChatEventHandler
|
||||
{
|
||||
private const string QrScenePrefix = "qrscene_";
|
||||
|
||||
public async Task<string?> HandleAsync(WeChatIncomingMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.Equals(message.MsgType, "event", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var eventName = message.Event ?? string.Empty;
|
||||
if (string.Equals(eventName, "SCAN", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(eventName, "subscribe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await HandleScanLoginAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
logger.LogDebug("未处理的微信事件: {Event}", eventName);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string?> HandleScanLoginAsync(WeChatIncomingMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
var openId = message.FromUserName;
|
||||
if (string.IsNullOrWhiteSpace(openId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var existing = await customerRepository.GetByWechatOpenidAsync(openId, cancellationToken);
|
||||
if (existing == null)
|
||||
{
|
||||
return BuildTextReply(message, "未绑定账号,请在公众号菜单注册账号或绑定已有账号。");
|
||||
}
|
||||
|
||||
var scene = NormalizeScene(message.EventKey);
|
||||
if (string.IsNullOrWhiteSpace(scene))
|
||||
{
|
||||
if (string.Equals(message.Event, "subscribe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BuildTextReply(message, "感谢关注");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
await sessionStore.SetOpenIdAsync(scene, openId, TimeSpan.FromMinutes(10), cancellationToken);
|
||||
logger.LogInformation("微信扫码登录会话已写入: loginUuid={LoginUuid}, openId={OpenId}", scene, openId);
|
||||
|
||||
if (string.Equals(message.Event, "subscribe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BuildTextReply(message, "感谢关注,请返回电脑端完成登录。");
|
||||
}
|
||||
|
||||
return BuildTextReply(message, "扫码成功,请返回电脑端完成登录。");
|
||||
}
|
||||
|
||||
private static string? NormalizeScene(string? eventKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return eventKey.StartsWith(QrScenePrefix, StringComparison.Ordinal)
|
||||
? eventKey[QrScenePrefix.Length..]
|
||||
: eventKey;
|
||||
}
|
||||
|
||||
private static string BuildTextReply(WeChatIncomingMessage message, string content)
|
||||
{
|
||||
return WeChatXmlMessageBuilder.BuildTextReply(
|
||||
message.FromUserName ?? string.Empty,
|
||||
message.ToUserName ?? string.Empty,
|
||||
content);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
|
||||
namespace SimpleAdmin.Api.WeChat.Services;
|
||||
|
||||
public class WeChatLoginSessionStore(IDistributedCache cache) : IWeChatLoginSessionStore
|
||||
{
|
||||
private const string KeyPrefix = "oms:wechat:login:";
|
||||
|
||||
public Task SetOpenIdAsync(string loginUuid, string openId, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = ttl
|
||||
};
|
||||
return cache.SetStringAsync(BuildKey(loginUuid), openId, options, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<string?> GetOpenIdAsync(string loginUuid, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return cache.GetStringAsync(BuildKey(loginUuid), cancellationToken);
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string loginUuid, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return cache.RemoveAsync(BuildKey(loginUuid), cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildKey(string loginUuid) => $"{KeyPrefix}{loginUuid}";
|
||||
}
|
||||
293
src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs
Normal file
293
src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs
Normal file
@ -0,0 +1,293 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SimpleAdmin.Api.WeChat.Options;
|
||||
|
||||
namespace SimpleAdmin.Api.WeChat.Services;
|
||||
|
||||
public interface IWeChatMpApiClient
|
||||
{
|
||||
Task<string> CreateLoginQrCodeUrlAsync(string loginUuid, int expireSeconds, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WeChatOAuthTokenResult?> GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WeChatOAuthUserResult?> GetOAuthUserInfoAsync(string accessToken, string openId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record WeChatOAuthTokenResult(string AccessToken, string OpenId);
|
||||
|
||||
public sealed record WeChatOAuthUserResult(
|
||||
string Openid,
|
||||
string? Nickname,
|
||||
int Sex,
|
||||
string? Province,
|
||||
string? City,
|
||||
string? Country,
|
||||
string? Headimgurl,
|
||||
string? Unionid);
|
||||
|
||||
internal sealed class WeChatMpApiClient(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache memoryCache,
|
||||
IOptions<WeChatMpOptions> options,
|
||||
ILogger<WeChatMpApiClient> logger) : IWeChatMpApiClient
|
||||
{
|
||||
private const string AccessTokenCacheKey = "oms:wechat:mp:access_token";
|
||||
|
||||
public async Task<string> CreateLoginQrCodeUrlAsync(
|
||||
string loginUuid,
|
||||
int expireSeconds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var accessToken = await GetAccessTokenAsync(cancellationToken);
|
||||
var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient));
|
||||
|
||||
var payload = new
|
||||
{
|
||||
expire_seconds = expireSeconds,
|
||||
action_name = "QR_STR_SCENE",
|
||||
action_info = new
|
||||
{
|
||||
scene = new
|
||||
{
|
||||
scene_str = loginUuid
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
$"https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token={Uri.EscapeDataString(accessToken)}",
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<WeChatQrTicketResponse>(cancellationToken: cancellationToken);
|
||||
if (result == null || string.IsNullOrWhiteSpace(result.Ticket))
|
||||
{
|
||||
throw new InvalidOperationException(result?.Errmsg ?? "创建微信二维码失败");
|
||||
}
|
||||
|
||||
if (result.Errcode is > 0)
|
||||
{
|
||||
throw new InvalidOperationException(result.Errmsg ?? $"创建微信二维码失败({result.Errcode})");
|
||||
}
|
||||
|
||||
return $"https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={Uri.EscapeDataString(result.Ticket)}";
|
||||
}
|
||||
|
||||
public async Task<WeChatOAuthTokenResult?> GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var mp = options.Value;
|
||||
var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient));
|
||||
var url =
|
||||
$"https://api.weixin.qq.com/sns/oauth2/access_token?appid={Uri.EscapeDataString(mp.AppId)}&secret={Uri.EscapeDataString(mp.Secret)}&code={Uri.EscapeDataString(code)}&grant_type=authorization_code";
|
||||
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<WeChatOAuthTokenResponse>(cancellationToken: cancellationToken);
|
||||
if (body == null || string.IsNullOrWhiteSpace(body.AccessToken) || string.IsNullOrWhiteSpace(body.Openid))
|
||||
{
|
||||
logger.LogWarning("微信 OAuth 换取 access_token 失败: {Message}", body?.Errmsg);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new WeChatOAuthTokenResult(body.AccessToken, body.Openid);
|
||||
}
|
||||
|
||||
public async Task<WeChatOAuthUserResult?> GetOAuthUserInfoAsync(
|
||||
string accessToken,
|
||||
string openId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient));
|
||||
var url =
|
||||
$"https://api.weixin.qq.com/sns/userinfo?access_token={Uri.EscapeDataString(accessToken)}&openid={Uri.EscapeDataString(openId)}&lang=zh_CN";
|
||||
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<WeChatOAuthUserResponse>(cancellationToken: cancellationToken);
|
||||
if (body == null || string.IsNullOrWhiteSpace(body.Openid))
|
||||
{
|
||||
logger.LogWarning("微信 OAuth 获取用户信息失败: {Message}", body?.Errmsg);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new WeChatOAuthUserResult(
|
||||
body.Openid,
|
||||
body.Nickname,
|
||||
body.Sex,
|
||||
body.Province,
|
||||
body.City,
|
||||
body.Country,
|
||||
body.Headimgurl,
|
||||
body.Unionid);
|
||||
}
|
||||
|
||||
private async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (memoryCache.TryGetValue(AccessTokenCacheKey, out string? cached) && !string.IsNullOrWhiteSpace(cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var mp = options.Value;
|
||||
var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient));
|
||||
var url =
|
||||
$"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={Uri.EscapeDataString(mp.AppId)}&secret={Uri.EscapeDataString(mp.Secret)}";
|
||||
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<WeChatAccessTokenResponse>(cancellationToken: cancellationToken);
|
||||
if (body == null || string.IsNullOrWhiteSpace(body.AccessToken))
|
||||
{
|
||||
throw new InvalidOperationException(body?.Errmsg ?? "获取微信 access_token 失败");
|
||||
}
|
||||
|
||||
memoryCache.Set(AccessTokenCacheKey, body.AccessToken, TimeSpan.FromSeconds(Math.Max(300, body.ExpiresIn - 300)));
|
||||
return body.AccessToken;
|
||||
}
|
||||
|
||||
private sealed class WeChatAccessTokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string? AccessToken { get; set; }
|
||||
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int ExpiresIn { get; set; }
|
||||
|
||||
[JsonPropertyName("errcode")]
|
||||
public int Errcode { get; set; }
|
||||
|
||||
[JsonPropertyName("errmsg")]
|
||||
public string? Errmsg { get; set; }
|
||||
}
|
||||
|
||||
private sealed class WeChatQrTicketResponse
|
||||
{
|
||||
[JsonPropertyName("ticket")]
|
||||
public string? Ticket { get; set; }
|
||||
|
||||
[JsonPropertyName("expire_seconds")]
|
||||
public int ExpireSeconds { get; set; }
|
||||
|
||||
[JsonPropertyName("errcode")]
|
||||
public int Errcode { get; set; }
|
||||
|
||||
[JsonPropertyName("errmsg")]
|
||||
public string? Errmsg { get; set; }
|
||||
}
|
||||
|
||||
private sealed class WeChatOAuthTokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string? AccessToken { get; set; }
|
||||
|
||||
[JsonPropertyName("openid")]
|
||||
public string? Openid { get; set; }
|
||||
|
||||
[JsonPropertyName("errcode")]
|
||||
public int Errcode { get; set; }
|
||||
|
||||
[JsonPropertyName("errmsg")]
|
||||
public string? Errmsg { get; set; }
|
||||
}
|
||||
|
||||
private sealed class WeChatOAuthUserResponse
|
||||
{
|
||||
[JsonPropertyName("openid")]
|
||||
public string? Openid { get; set; }
|
||||
|
||||
[JsonPropertyName("nickname")]
|
||||
public string? Nickname { get; set; }
|
||||
|
||||
[JsonPropertyName("sex")]
|
||||
public int Sex { get; set; }
|
||||
|
||||
[JsonPropertyName("province")]
|
||||
public string? Province { get; set; }
|
||||
|
||||
[JsonPropertyName("city")]
|
||||
public string? City { get; set; }
|
||||
|
||||
[JsonPropertyName("country")]
|
||||
public string? Country { get; set; }
|
||||
|
||||
[JsonPropertyName("headimgurl")]
|
||||
public string? Headimgurl { get; set; }
|
||||
|
||||
[JsonPropertyName("unionid")]
|
||||
public string? Unionid { get; set; }
|
||||
|
||||
[JsonPropertyName("errcode")]
|
||||
public int Errcode { get; set; }
|
||||
|
||||
[JsonPropertyName("errmsg")]
|
||||
public string? Errmsg { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
internal static class WeChatXmlMessageParser
|
||||
{
|
||||
public static WeChatIncomingMessage? Parse(string xml)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(xml))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var doc = XDocument.Parse(xml);
|
||||
var root = doc.Root;
|
||||
if (root == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? Value(string name) => root.Element(name)?.Value;
|
||||
|
||||
return new WeChatIncomingMessage
|
||||
{
|
||||
ToUserName = Value("ToUserName"),
|
||||
FromUserName = Value("FromUserName"),
|
||||
CreateTime = Value("CreateTime"),
|
||||
MsgType = Value("MsgType"),
|
||||
Event = Value("Event"),
|
||||
EventKey = Value("EventKey"),
|
||||
Content = Value("Content")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WeChatIncomingMessage
|
||||
{
|
||||
public string? ToUserName { get; init; }
|
||||
|
||||
public string? FromUserName { get; init; }
|
||||
|
||||
public string? CreateTime { get; init; }
|
||||
|
||||
public string? MsgType { get; init; }
|
||||
|
||||
public string? Event { get; init; }
|
||||
|
||||
public string? EventKey { get; init; }
|
||||
|
||||
public string? Content { get; init; }
|
||||
}
|
||||
|
||||
internal static class WeChatXmlMessageBuilder
|
||||
{
|
||||
public static string BuildTextReply(string toUser, string fromUser, string content)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
return $"""
|
||||
<xml>
|
||||
<ToUserName><![CDATA[{toUser}]]></ToUserName>
|
||||
<FromUserName><![CDATA[{fromUser}]]></FromUserName>
|
||||
<CreateTime>{now}</CreateTime>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[{content}]]></Content>
|
||||
</xml>
|
||||
""";
|
||||
}
|
||||
}
|
||||
24
src/SimpleAdmin.Api/WeChat/Services/WeChatSignatureHelper.cs
Normal file
24
src/SimpleAdmin.Api/WeChat/Services/WeChatSignatureHelper.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SimpleAdmin.Api.WeChat.Services;
|
||||
|
||||
internal static class WeChatSignatureHelper
|
||||
{
|
||||
public static bool Validate(string token, string timestamp, string nonce, string signature)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)
|
||||
|| string.IsNullOrWhiteSpace(timestamp)
|
||||
|| string.IsNullOrWhiteSpace(nonce)
|
||||
|| string.IsNullOrWhiteSpace(signature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = new[] { token, timestamp, nonce }.OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
var raw = string.Concat(parts);
|
||||
var hash = SHA1.HashData(Encoding.UTF8.GetBytes(raw));
|
||||
var hex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
return string.Equals(hex, signature, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
297
src/SimpleAdmin.Api/WeChat/WeChatCustomerAppService.cs
Normal file
297
src/SimpleAdmin.Api/WeChat/WeChatCustomerAppService.cs
Normal file
@ -0,0 +1,297 @@
|
||||
using Fake.Application;
|
||||
using Fake.Domain.Exceptions;
|
||||
using Fake.Rbac.Application.Dtos.Role;
|
||||
using Fake.Rbac.Application.Dtos.User;
|
||||
using Fake.Rbac.Application.Services;
|
||||
using Fake.Rbac.Infrastructure;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.CustomersAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
using SimpleAdmin.Api.WeChat.Dtos;
|
||||
|
||||
namespace SimpleAdmin.Api.WeChat;
|
||||
|
||||
/// <summary>
|
||||
/// 微信 OAuth 注册 / 绑定客户。
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[ApiExplorerSettings(GroupName = "WeChat")]
|
||||
public class WeChatCustomerAppService(
|
||||
IWeChatMpApiClient weChatMpApiClient,
|
||||
ICustomerRepository customerRepository,
|
||||
UserService userService,
|
||||
RoleService roleService,
|
||||
FakeRbacDbContext rbacDbContext) : ApplicationService
|
||||
{
|
||||
private const string CustomerRoleCode = "Customer";
|
||||
|
||||
/// <summary>
|
||||
/// OAuth code 换取微信用户信息。
|
||||
/// </summary>
|
||||
[HttpPost("/wechat/oauth/user-info")]
|
||||
public async Task<WeChatOAuthUserInfoOutput> GetOAuthUserInfoAsync(
|
||||
[FromBody] WeChatOAuthCodeInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Code))
|
||||
{
|
||||
throw new BusinessException("缺少 code");
|
||||
}
|
||||
|
||||
var token = await weChatMpApiClient.GetOAuthAccessTokenAsync(input.Code.Trim(), cancellationToken);
|
||||
if (token == null)
|
||||
{
|
||||
throw new BusinessException("获取授权信息出现错误");
|
||||
}
|
||||
|
||||
var userInfo = await weChatMpApiClient.GetOAuthUserInfoAsync(token.AccessToken, token.OpenId, cancellationToken);
|
||||
if (userInfo == null)
|
||||
{
|
||||
throw new BusinessException("获取授权信息出现错误");
|
||||
}
|
||||
|
||||
var existing = await customerRepository.GetByWechatOpenidAsync(userInfo.Openid, cancellationToken);
|
||||
return new WeChatOAuthUserInfoOutput
|
||||
{
|
||||
Openid = userInfo.Openid,
|
||||
Nickname = userInfo.Nickname,
|
||||
Sex = userInfo.Sex,
|
||||
Province = userInfo.Province,
|
||||
City = userInfo.City,
|
||||
Country = userInfo.Country,
|
||||
Headimgurl = userInfo.Headimgurl,
|
||||
Unionid = userInfo.Unionid,
|
||||
AlreadyBound = existing != null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 微信授权注册客户。
|
||||
/// </summary>
|
||||
[HttpPost("/wechat/customer/register")]
|
||||
public async Task RegisterAsync(
|
||||
[FromBody] WeChatCustomerRegisterInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateWechatProfile(input.Wechat);
|
||||
|
||||
var customerCode = NormalizeRequired(input.CustomerCode, "客户编号");
|
||||
var customerName = NormalizeRequired(input.CustomerName, "客户名称");
|
||||
var phone = NormalizeRequired(input.Phone, "手机号");
|
||||
var email = NormalizeOptional(input.Email);
|
||||
var password = NormalizeRequired(input.Password, "密码");
|
||||
|
||||
if (password.Length < 6)
|
||||
{
|
||||
throw new BusinessException("密码长度不能少于6位");
|
||||
}
|
||||
|
||||
if (await customerRepository.GetByWechatOpenidAsync(input.Wechat.Openid, cancellationToken) != null)
|
||||
{
|
||||
throw new BusinessException("当前微信已绑定账号");
|
||||
}
|
||||
|
||||
await EnsureCustomerIdentityUniqueAsync(customerCode, phone, email, cancellationToken);
|
||||
|
||||
var customerRoleId = await GetCustomerRoleIdOrThrowAsync(cancellationToken);
|
||||
var user = await userService.CreateAsync(new UserCreateDto
|
||||
{
|
||||
Name = customerName,
|
||||
Account = phone,
|
||||
Password = password,
|
||||
Email = email,
|
||||
Avatar = NormalizeOptional(input.Wechat.Avatar),
|
||||
RoleIds = []
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await userService.AssignRolesAsync(user.Id, [customerRoleId]);
|
||||
var userId = await GetRbacUserMapIdOrThrowAsync(user.Id, cancellationToken);
|
||||
var entity = new Customer
|
||||
{
|
||||
UserId = userId,
|
||||
CustomerCode = customerCode,
|
||||
CustomerName = customerName,
|
||||
Phone = phone,
|
||||
Email = email,
|
||||
Password = OmsPasswordHelper.ToMd5(password),
|
||||
WechatOpenid = input.Wechat.Openid,
|
||||
WechatNickname = NormalizeOptional(input.Wechat.Nickname),
|
||||
WechatAvatar = NormalizeOptional(input.Wechat.Avatar),
|
||||
Status = CustomerStatus.Normal
|
||||
};
|
||||
await customerRepository.InsertAsync(entity);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await TryRollbackRbacUserAsync(user.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 微信授权绑定已有客户。
|
||||
/// </summary>
|
||||
[HttpPost("/wechat/customer/bind")]
|
||||
public async Task BindAsync(
|
||||
[FromBody] WeChatCustomerBindInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateWechatProfile(input.Wechat);
|
||||
|
||||
var account = NormalizeRequired(input.Account, "账号");
|
||||
var password = NormalizeRequired(input.Password, "密码");
|
||||
|
||||
if (await customerRepository.GetByWechatOpenidAsync(input.Wechat.Openid, cancellationToken) != null)
|
||||
{
|
||||
throw new BusinessException("当前微信已绑定账号");
|
||||
}
|
||||
|
||||
var customer = await FindCustomerByAccountAsync(account, cancellationToken);
|
||||
if (customer == null)
|
||||
{
|
||||
throw new BusinessException("未查询到账号信息");
|
||||
}
|
||||
|
||||
if (customer.ParentCustomerId.HasValue)
|
||||
{
|
||||
throw new BusinessException("子客户不支持绑定微信");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(customer.WechatOpenid))
|
||||
{
|
||||
throw new BusinessException("当前账号已绑定微信");
|
||||
}
|
||||
|
||||
ValidateMainCustomerStatus(customer);
|
||||
|
||||
if (!string.Equals(customer.Password, OmsPasswordHelper.ToMd5(password), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new BusinessException("账号或密码错误");
|
||||
}
|
||||
|
||||
customer.WechatOpenid = input.Wechat.Openid;
|
||||
customer.WechatNickname = NormalizeOptional(input.Wechat.Nickname) ?? customer.WechatNickname;
|
||||
customer.WechatAvatar = NormalizeOptional(input.Wechat.Avatar) ?? customer.WechatAvatar;
|
||||
await customerRepository.UpdateAsync(customer, cancellationToken);
|
||||
}
|
||||
|
||||
private static void ValidateWechatProfile(WeChatProfileInput wechat)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(wechat.Openid))
|
||||
{
|
||||
throw new BusinessException("缺少 Openid 授权信息");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureCustomerIdentityUniqueAsync(
|
||||
string customerCode,
|
||||
string phone,
|
||||
string? email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await customerRepository.GetByCustomerCodeAsync(customerCode, cancellationToken) != null)
|
||||
{
|
||||
throw new BusinessException("客户编号已存在");
|
||||
}
|
||||
|
||||
if (await customerRepository.GetByPhoneAsync(phone, cancellationToken) != null)
|
||||
{
|
||||
throw new BusinessException("手机号已存在");
|
||||
}
|
||||
|
||||
if (email != null && await customerRepository.GetByEmailAsync(email, cancellationToken) != null)
|
||||
{
|
||||
throw new BusinessException("邮箱已存在");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Customer?> FindCustomerByAccountAsync(string account, CancellationToken cancellationToken)
|
||||
{
|
||||
var customer = await customerRepository.GetByPhoneAsync(account, cancellationToken);
|
||||
if (customer != null)
|
||||
{
|
||||
return customer;
|
||||
}
|
||||
|
||||
customer = await customerRepository.GetByEmailAsync(account, cancellationToken);
|
||||
if (customer != null)
|
||||
{
|
||||
return customer;
|
||||
}
|
||||
|
||||
return await customerRepository.GetByCustomerCodeAsync(account, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Guid> GetCustomerRoleIdOrThrowAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var roles = await roleService.GetAllRolesAsync();
|
||||
var role = roles.FirstOrDefault(x => string.Equals(x.Code, CustomerRoleCode, StringComparison.OrdinalIgnoreCase));
|
||||
if (role == null)
|
||||
{
|
||||
throw new BusinessException("客户角色未初始化");
|
||||
}
|
||||
|
||||
return role.Id;
|
||||
}
|
||||
|
||||
private async Task<int> GetRbacUserMapIdOrThrowAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = await rbacDbContext.Database.SqlQuery<int?>(
|
||||
$"select `userid` as `Value` from `user` where `Id` = {id}")
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (!userId.HasValue || userId.Value <= 0)
|
||||
{
|
||||
throw new BusinessException("用户编号创建失败");
|
||||
}
|
||||
|
||||
return userId.Value;
|
||||
}
|
||||
|
||||
private async Task TryRollbackRbacUserAsync(Guid userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await userService.DeleteAsync(userId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore rollback failure
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateMainCustomerStatus(Customer customer)
|
||||
{
|
||||
if (customer.Status == CustomerStatus.Disabled)
|
||||
{
|
||||
throw new BusinessException("客户已禁用");
|
||||
}
|
||||
|
||||
if (customer.Status == CustomerStatus.Frozen)
|
||||
{
|
||||
throw new BusinessException("客户已冻结");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string fieldName)
|
||||
{
|
||||
var normalized = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException($"{fieldName}不能为空");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
var normalized = value?.Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
||||
}
|
||||
}
|
||||
130
src/SimpleAdmin.Api/WeChat/WeChatMpAppService.cs
Normal file
130
src/SimpleAdmin.Api/WeChat/WeChatMpAppService.cs
Normal file
@ -0,0 +1,130 @@
|
||||
using Fake.Application;
|
||||
using Fake.Domain.Exceptions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SimpleAdmin.Api.WeChat.Dtos;
|
||||
using SimpleAdmin.Api.WeChat.Options;
|
||||
using SimpleAdmin.Api.WeChat.Services;
|
||||
|
||||
namespace SimpleAdmin.Api.WeChat;
|
||||
|
||||
/// <summary>
|
||||
/// 微信公众号服务器回调与登录二维码。
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[ApiExplorerSettings(GroupName = "WeChat")]
|
||||
public class WeChatMpAppService(
|
||||
IOptions<WeChatMpOptions> options,
|
||||
IWeChatMpApiClient weChatMpApiClient,
|
||||
IWeChatEventHandler eventHandler,
|
||||
ILogger<WeChatMpAppService> logger) : ApplicationService
|
||||
{
|
||||
/// <summary>
|
||||
/// 微信服务器接入验签。
|
||||
/// </summary>
|
||||
[HttpGet("/wechat/mp/{appId}")]
|
||||
[Produces("text/plain")]
|
||||
public ContentResult ValidateAsync(
|
||||
[FromRoute] string appId,
|
||||
[FromQuery] string? signature,
|
||||
[FromQuery] string? timestamp,
|
||||
[FromQuery] string? nonce,
|
||||
[FromQuery] string? echostr)
|
||||
{
|
||||
EnsureAppId(appId);
|
||||
if (string.IsNullOrWhiteSpace(signature)
|
||||
|| string.IsNullOrWhiteSpace(timestamp)
|
||||
|| string.IsNullOrWhiteSpace(nonce)
|
||||
|| string.IsNullOrWhiteSpace(echostr))
|
||||
{
|
||||
return Content("error", "text/plain; charset=utf-8");
|
||||
}
|
||||
|
||||
var valid = WeChatSignatureHelper.Validate(options.Value.Token, timestamp, nonce, signature);
|
||||
return Content(valid ? echostr : "error", "text/plain; charset=utf-8");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 微信消息/事件回调。
|
||||
/// </summary>
|
||||
[HttpPost("/wechat/mp/{appId}")]
|
||||
[Consumes("text/xml", "application/xml", "text/plain")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<ContentResult> MessageAsync(
|
||||
[FromRoute] string appId,
|
||||
[FromQuery] string signature,
|
||||
[FromQuery] string timestamp,
|
||||
[FromQuery] string nonce,
|
||||
[FromQuery] string? openid,
|
||||
[FromQuery(Name = "encrypt_type")] string? encryptType,
|
||||
[FromQuery(Name = "msg_signature")] string? msgSignature,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureAppId(appId);
|
||||
if (!WeChatSignatureHelper.Validate(options.Value.Token, timestamp, nonce, signature))
|
||||
{
|
||||
logger.LogWarning("微信消息验签失败");
|
||||
return Content("error", "text/plain; charset=utf-8");
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(HttpContext.Request.Body);
|
||||
var requestBody = await reader.ReadToEndAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(requestBody))
|
||||
{
|
||||
return Content(string.Empty, "application/xml; charset=utf-8");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(encryptType)
|
||||
&& !string.Equals(encryptType, "raw", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogWarning("收到加密微信消息({EncryptType}),当前仅支持明文模式,请在公众平台切换为明文或后续启用 AES。", encryptType);
|
||||
return Content(string.Empty, "application/xml; charset=utf-8");
|
||||
}
|
||||
|
||||
var message = WeChatXmlMessageParser.Parse(requestBody);
|
||||
if (message == null)
|
||||
{
|
||||
return Content(string.Empty, "application/xml; charset=utf-8");
|
||||
}
|
||||
|
||||
var reply = await eventHandler.HandleAsync(message, cancellationToken);
|
||||
return Content(reply ?? string.Empty, "application/xml; charset=utf-8");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取 PC 扫码登录二维码。
|
||||
/// </summary>
|
||||
[HttpGet("/wechat/mp/login-qrcode")]
|
||||
public async Task<WxLoginQrcodeOutput> GetLoginQrcodeAsync(
|
||||
[FromQuery] string loginUuid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(loginUuid))
|
||||
{
|
||||
throw new BusinessException("缺少 loginUuid");
|
||||
}
|
||||
|
||||
const int expireSeconds = 600;
|
||||
var qrCodeUrl = await weChatMpApiClient.CreateLoginQrCodeUrlAsync(loginUuid.Trim(), expireSeconds, cancellationToken);
|
||||
return new WxLoginQrcodeOutput
|
||||
{
|
||||
QrCodeUrl = qrCodeUrl,
|
||||
ExpiresIn = expireSeconds
|
||||
};
|
||||
}
|
||||
|
||||
private void EnsureAppId(string appId)
|
||||
{
|
||||
var configured = options.Value.AppId;
|
||||
if (string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
throw new BusinessException("未配置微信公众号 AppId");
|
||||
}
|
||||
|
||||
if (!string.Equals(configured, appId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new BusinessException("公众号 AppId 不匹配");
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/SimpleAdmin.Api/WeChat/WeChatServiceExtensions.cs
Normal file
32
src/SimpleAdmin.Api/WeChat/WeChatServiceExtensions.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SimpleAdmin.Api.WeChat.Options;
|
||||
using SimpleAdmin.Api.WeChat.Services;
|
||||
|
||||
namespace SimpleAdmin.Api.WeChat;
|
||||
|
||||
public static class WeChatServiceExtensions
|
||||
{
|
||||
public static IServiceCollection AddOmsWeChat(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<WeChatMpOptions>(configuration.GetSection(WeChatMpOptions.SectionName));
|
||||
services.AddHttpClient(nameof(WeChatMpApiClient));
|
||||
services.AddMemoryCache();
|
||||
|
||||
var redisConnection = configuration.GetConnectionString("Redis") ?? configuration["Redis:ConnectionString"];
|
||||
if (!string.IsNullOrWhiteSpace(redisConnection))
|
||||
{
|
||||
services.AddStackExchangeRedisCache(options => options.Configuration = redisConnection);
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddDistributedMemoryCache();
|
||||
}
|
||||
|
||||
services.AddSingleton<IWeChatMpApiClient, WeChatMpApiClient>();
|
||||
services.AddSingleton<IWeChatLoginSessionStore, WeChatLoginSessionStore>();
|
||||
services.AddTransient<IWeChatEventHandler, WeChatEventHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,22 @@
|
||||
{
|
||||
"UseLocalConfigs": true,
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Information"
|
||||
}
|
||||
},
|
||||
"Redis": {
|
||||
"ConnectionString": ""
|
||||
},
|
||||
"WeChat": {
|
||||
"Mp": {
|
||||
"AppId": "",
|
||||
"Secret": "",
|
||||
"Token": "",
|
||||
"EncodingAesKey": "",
|
||||
"OAuthRedirectBase": "https://oms.nanafa.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user