微信登录和创建应收和应付费用
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 '渠道编码',
|
`ChannelCode` VARCHAR(128) NULL COMMENT '渠道编码',
|
||||||
`ConsigneeCountry` VARCHAR(8) NULL COMMENT '国家二字码',
|
`ConsigneeCountry` VARCHAR(8) NULL COMMENT '国家二字码',
|
||||||
`CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive',
|
`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 '订单状态',
|
`OrderStatus` VARCHAR(32) NOT NULL COMMENT '订单状态',
|
||||||
`WarehouseId` INT NULL COMMENT '仓库ID',
|
`WarehouseId` INT NULL COMMENT '仓库ID',
|
||||||
`CustomerId` INT NULL COMMENT '客户ID',
|
`CustomerId` INT NULL COMMENT '客户ID',
|
||||||
@ -280,6 +284,7 @@ CREATE TABLE `t_order_fee` (
|
|||||||
`DownstreamProvider` VARCHAR(128) NULL COMMENT '下游服务商',
|
`DownstreamProvider` VARCHAR(128) NULL COMMENT '下游服务商',
|
||||||
`PriceNo` VARCHAR(64) NULL COMMENT '价格编号',
|
`PriceNo` VARCHAR(64) NULL COMMENT '价格编号',
|
||||||
`PriceEffectiveTime` DATETIME NULL COMMENT '价格编号生效时间',
|
`PriceEffectiveTime` DATETIME NULL COMMENT '价格编号生效时间',
|
||||||
|
`ChargingStage` VARCHAR(16) NULL COMMENT '计费阶段:forecast/inbound/outbound',
|
||||||
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
|
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
|
||||||
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
|
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||||
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 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>
|
/// </summary>
|
||||||
public string? CargoAttribute { get; set; }
|
public string? CargoAttribute { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 出口报关方式。
|
||||||
|
/// </summary>
|
||||||
|
public string? ExportCustomsMode { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 仓库主键。
|
/// 仓库主键。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -64,4 +64,24 @@ public class OrderPriceCheckOutput
|
|||||||
/// 总成本数值。
|
/// 总成本数值。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal TotalCostNum { get; set; }
|
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" />
|
<PackageReference Include="MiniExcel" Version="1.43.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Oms.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -276,6 +276,19 @@ public partial class ServiceChannelService
|
|||||||
entity.IsActive = input.IsActive;
|
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)
|
private static ServiceChannelListOutput MapListOutput(ServiceChannel entity, IReadOnlyDictionary<int, string> groupMap)
|
||||||
{
|
{
|
||||||
return new ServiceChannelListOutput
|
return new ServiceChannelListOutput
|
||||||
|
|||||||
@ -125,6 +125,27 @@ public partial class ServiceChannelService : ApplicationService
|
|||||||
await ServiceChannelRepository.DeleteAsync(entity);
|
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>
|
||||||
/// 新增服务渠道分组。
|
/// 新增服务渠道分组。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -465,4 +486,17 @@ public partial class ServiceChannelService : ApplicationService
|
|||||||
(string.IsNullOrWhiteSpace(keyword) || entity.ChannelCode.Contains(keyword) || entity.ChannelName.Contains(keyword)) &&
|
(string.IsNullOrWhiteSpace(keyword) || entity.ChannelCode.Contains(keyword) || entity.ChannelName.Contains(keyword)) &&
|
||||||
(!hasIsActiveFilter || entity.IsActive == isActiveValue);
|
(!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();
|
}).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)
|
private async Task<OmsPageResult<OrderTemplateOutput>> GetTemplateOutputsAsync(OrderTemplateListInput input)
|
||||||
{
|
{
|
||||||
var page = OmsDocHelper.NormalizePage(input.Page);
|
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)
|
private async Task<string> CreateOrderPrintFileAsync(OrderOutput order)
|
||||||
{
|
{
|
||||||
var directory = Path.Combine(GetWebRootDirectory(), "uploads", "prints", "orders");
|
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("批量创建请求不能为空");
|
throw new BusinessException("批量创建请求不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.Count > ParcelBatchMaxCount)
|
||||||
|
{
|
||||||
|
throw new BusinessException($"单次批量创建不能超过{ParcelBatchMaxCount}条");
|
||||||
|
}
|
||||||
|
|
||||||
var batchNo = $"PCB{DateTime.Now:yyyyMMddHHmmssfff}";
|
var batchNo = $"PCB{DateTime.Now:yyyyMMddHHmmssfff}";
|
||||||
var items = new List<OrderParcelBatchCreateItemOutput>(input.Count);
|
var items = new List<OrderParcelBatchCreateItemOutput>(input.Count);
|
||||||
var successCount = 0;
|
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 UpdateInfoAsync(KpiAreaInfo info, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task DeleteInfoAsync(int id, 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> InsertPublishRecordAsync(SalesProductPublishRecord record, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<SalesProductPublishRecord?> GetPublishRecordByIdAsync(int id, 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 UpdateInfoAsync(WeightRoundingInfo info, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task DeleteInfoAsync(int id, 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 UpdateInfoAsync(WeightRuleInfo info, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task DeleteInfoAsync(int id, 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? 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 string OrderStatus { get; set; } = string.Empty;
|
||||||
|
|
||||||
public int? WarehouseId { get; set; }
|
public int? WarehouseId { get; set; }
|
||||||
|
|||||||
@ -21,4 +21,6 @@ public class OrderFee : OmsAuditedEntity
|
|||||||
public string? PriceNo { get; set; }
|
public string? PriceNo { get; set; }
|
||||||
|
|
||||||
public DateTime? PriceEffectiveTime { 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 InsertDeclarationsAsync(IEnumerable<OrderDeclaration> declarations, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task InsertWeightsAsync(IEnumerable<OrderWeight> weights, 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);
|
var db = await GetDbContextAsync(cancellationToken);
|
||||||
await db.Deleteable<KpiAreaInfo>().Where(x => x.Id == id).ExecuteCommandAsync(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);
|
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(
|
private static async Task InsertPriceChildrenAsync(
|
||||||
SqlSugar.ISqlSugarClient db,
|
SqlSugar.ISqlSugarClient db,
|
||||||
int priceId,
|
int priceId,
|
||||||
|
|||||||
@ -55,4 +55,13 @@ public class WeightRoundingRepository : OmsSqlSugarRepository<WeightRounding>, I
|
|||||||
var db = await GetDbContextAsync(cancellationToken);
|
var db = await GetDbContextAsync(cancellationToken);
|
||||||
await db.Deleteable<WeightRoundingInfo>().Where(x => x.Id == id).ExecuteCommandAsync(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);
|
var db = await GetDbContextAsync(cancellationToken);
|
||||||
await db.Deleteable<WeightRuleInfo>().Where(x => x.Id == id).ExecuteCommandAsync(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);
|
var db = await GetDbContextAsync(cancellationToken);
|
||||||
await db.Insertable(items).ExecuteCommandAsync(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 string RefreshToken { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class OmsWxLoginInput
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string LoginUuid { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public class OmsChangePasswordInput
|
public class OmsChangePasswordInput
|
||||||
{
|
{
|
||||||
[Required]
|
[Required]
|
||||||
|
|||||||
@ -18,6 +18,7 @@ using Oms.Application.Shared;
|
|||||||
using Oms.Domain.CustomersAggregate.Entities;
|
using Oms.Domain.CustomersAggregate.Entities;
|
||||||
using Oms.Domain.CustomersAggregate.Enums;
|
using Oms.Domain.CustomersAggregate.Enums;
|
||||||
using Oms.Domain.CustomersAggregate.Repositories;
|
using Oms.Domain.CustomersAggregate.Repositories;
|
||||||
|
using SimpleAdmin.Api.WeChat.Services;
|
||||||
using SixLabors.ImageSharp;
|
using SixLabors.ImageSharp;
|
||||||
using SixLabors.ImageSharp.Processing;
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
|
||||||
@ -37,7 +38,8 @@ public class AuthService(
|
|||||||
IWebHostEnvironment webHostEnvironment,
|
IWebHostEnvironment webHostEnvironment,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
FakeRbacDbContext rbacDbContext,
|
FakeRbacDbContext rbacDbContext,
|
||||||
ICustomerRepository customerRepository)
|
ICustomerRepository customerRepository,
|
||||||
|
IWeChatLoginSessionStore weChatLoginSessionStore)
|
||||||
: ApplicationService
|
: ApplicationService
|
||||||
{
|
{
|
||||||
private Guid CurrentUserGuid => CurrentUser.FindClaimValueOrNull(ClaimTypes.NameIdentifier)!.To<Guid>();
|
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>
|
||||||
/// 刷新登录令牌。
|
/// 刷新登录令牌。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
<PackageReference Include="Fake.Consul" Version="$(FakePackageVersion)" />
|
<PackageReference Include="Fake.Consul" Version="$(FakePackageVersion)" />
|
||||||
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
||||||
<PackageReference Include="Fake.Serilog" 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"/>
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ using Microsoft.AspNetCore.HttpOverrides;
|
|||||||
using Oms.Application;
|
using Oms.Application;
|
||||||
using Oms.SqlSugarCore;
|
using Oms.SqlSugarCore;
|
||||||
using SimpleAdmin.Api.Auth;
|
using SimpleAdmin.Api.Auth;
|
||||||
|
using SimpleAdmin.Api.WeChat;
|
||||||
|
|
||||||
namespace SimpleAdmin.Api;
|
namespace SimpleAdmin.Api;
|
||||||
|
|
||||||
@ -49,6 +50,10 @@ public class SimpleAdminApiModule : FakeModule
|
|||||||
settings.RootPath = "rbac";
|
settings.RootPath = "rbac";
|
||||||
settings.TypePredicate = type => type == typeof(AuthService);
|
settings.TypePredicate = type => type == typeof(AuthService);
|
||||||
});
|
});
|
||||||
|
options.ApplicationServices2Controller<SimpleAdminApiModule>(settings =>
|
||||||
|
{
|
||||||
|
settings.TypePredicate = type => type.Namespace == "SimpleAdmin.Api.WeChat";
|
||||||
|
});
|
||||||
options.ApplicationServices2Controller<FakeRbacApplicationModule>(settings =>
|
options.ApplicationServices2Controller<FakeRbacApplicationModule>(settings =>
|
||||||
{
|
{
|
||||||
settings.RootPath = "rbac";
|
settings.RootPath = "rbac";
|
||||||
@ -56,6 +61,8 @@ public class SimpleAdminApiModule : FakeModule
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
services.AddOmsWeChat(configuration);
|
||||||
|
|
||||||
//Json格式化全局处理
|
//Json格式化全局处理
|
||||||
context.Services.AddFakeNewtonsoft();
|
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": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning",
|
"Microsoft.AspNetCore": "Warning",
|
||||||
"Microsoft.EntityFrameworkCore": "Information"
|
"Microsoft.EntityFrameworkCore": "Information"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Redis": {
|
||||||
|
"ConnectionString": ""
|
||||||
|
},
|
||||||
|
"WeChat": {
|
||||||
|
"Mp": {
|
||||||
|
"AppId": "",
|
||||||
|
"Secret": "",
|
||||||
|
"Token": "",
|
||||||
|
"EncodingAesKey": "",
|
||||||
|
"OAuthRedirectBase": "https://oms.nanafa.com"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user