feat: price-check by cargo type and warehouse, customer auto-code, track apiCode
Support multi cargo-type and warehouse combinations in price check billing. Auto-generate customer codes (W+yyMM+seq) on create/register; require email. Use order DownstreamSupplierCode as track UpsertSmallBag apiCode. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bc0bde72bc
commit
e875f0c012
@ -9,11 +9,10 @@ namespace Oms.Application.Dtos.Customers;
|
||||
public class CreateCustomerInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户编号。
|
||||
/// 客户编号(留空时系统自动生成,规则 W+年份后2位+月份2位+3位顺序)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
public string? CustomerCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
@ -32,9 +31,10 @@ public class CreateCustomerInput
|
||||
/// <summary>
|
||||
/// 邮箱。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
[StringLength(64)]
|
||||
public string? Email { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 微信头像地址。
|
||||
|
||||
@ -29,4 +29,14 @@ public class OrderPriceCheckFailureOutput
|
||||
/// 未命中原因说明。
|
||||
/// </summary>
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 货物类型名称(展示)。
|
||||
/// </summary>
|
||||
public string? CargoTypeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 仓库名称(展示)。
|
||||
/// </summary>
|
||||
public string? WarehouseName { get; set; }
|
||||
}
|
||||
|
||||
@ -36,10 +36,20 @@ public class OrderPriceCheckInput
|
||||
public decimal? Height { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 货物类型。
|
||||
/// 货物类型(单选,兼容旧版;请优先使用 CargoTypes)。
|
||||
/// </summary>
|
||||
public string? CargoType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 货物类型(多选,字典 cargo_attribute 编码)。
|
||||
/// </summary>
|
||||
public List<string>? CargoTypes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 仓库主键(多选;为空则遍历全部仓库)。
|
||||
/// </summary>
|
||||
public List<int>? WarehouseIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目的省份。
|
||||
/// </summary>
|
||||
|
||||
@ -15,6 +15,21 @@ public class OrderPriceCheckOutput
|
||||
/// </summary>
|
||||
public string ShippingMethod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 货物类型编码(字典 cargo_attribute)。
|
||||
/// </summary>
|
||||
public string? CargoType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 货物类型名称(展示用)。
|
||||
/// </summary>
|
||||
public string? CargoTypeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 仓库主键。
|
||||
/// </summary>
|
||||
public int? WarehouseId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 仓库名称。
|
||||
/// </summary>
|
||||
|
||||
@ -17,6 +17,9 @@ public class SmallBagTrackUpsertRequest
|
||||
|
||||
public string Product { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 轨迹服务商 code(对应轨迹 ServiceProviderCode,取订单 DownstreamSupplierCode)。
|
||||
/// </summary>
|
||||
public string ApiCode { get; set; } = string.Empty;
|
||||
|
||||
public SmallBagTrackConsigneeRequest Consignee { get; set; } = new();
|
||||
|
||||
@ -102,7 +102,7 @@ public class CustomerService : ApplicationService
|
||||
[HttpPost("/customer/create")]
|
||||
public async Task CreateAsync(CreateCustomerInput input)
|
||||
{
|
||||
var normalized = NormalizeCustomerInput(input, "客户编号", "客户名称");
|
||||
var normalized = await NormalizeCreateCustomerInputAsync(input, "客户编号", "客户名称");
|
||||
|
||||
await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email);
|
||||
|
||||
@ -134,7 +134,7 @@ public class CustomerService : ApplicationService
|
||||
var mainCustomer = await GetCurrentMainCustomerOrThrowAsync();
|
||||
ValidateCustomerStatus(mainCustomer);
|
||||
|
||||
var normalized = NormalizeCustomerInput(input, "子客户编号", "子客户名称");
|
||||
var normalized = await NormalizeCreateCustomerInputAsync(input, "子客户编号", "子客户名称");
|
||||
var permissions = await NormalizeAndValidateSubCustomerPermissionsAsync(input.Permissions);
|
||||
|
||||
await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email);
|
||||
@ -314,7 +314,7 @@ public class CustomerService : ApplicationService
|
||||
return customer;
|
||||
}
|
||||
|
||||
private async Task EnsureCustomerIdentityUniqueAsync(string customerCode, string phone, string? email)
|
||||
private async Task EnsureCustomerIdentityUniqueAsync(string customerCode, string phone, string email)
|
||||
{
|
||||
if (await CustomerRepository.GetByCustomerCodeAsync(customerCode) != null)
|
||||
{
|
||||
@ -326,7 +326,7 @@ public class CustomerService : ApplicationService
|
||||
throw new BusinessException("手机号已存在");
|
||||
}
|
||||
|
||||
if (email != null && await CustomerRepository.GetByEmailAsync(email) != null)
|
||||
if (await CustomerRepository.GetByEmailAsync(email) != null)
|
||||
{
|
||||
throw new BusinessException("邮箱已存在");
|
||||
}
|
||||
@ -399,16 +399,20 @@ public class CustomerService : ApplicationService
|
||||
};
|
||||
}
|
||||
|
||||
private static NormalizedCustomerInput NormalizeCustomerInput(
|
||||
private async Task<NormalizedCustomerInput> NormalizeCreateCustomerInputAsync(
|
||||
CreateCustomerInput input,
|
||||
string codeFieldName,
|
||||
string nameFieldName)
|
||||
{
|
||||
var customerCode = string.IsNullOrWhiteSpace(input.CustomerCode)
|
||||
? await CustomerCodeGenerator.GenerateNextAsync(CustomerRepository)
|
||||
: NormalizeRequiredText(input.CustomerCode, codeFieldName);
|
||||
|
||||
return new NormalizedCustomerInput(
|
||||
NormalizeRequiredText(input.CustomerCode, codeFieldName),
|
||||
customerCode,
|
||||
NormalizeRequiredText(input.CustomerName, nameFieldName),
|
||||
NormalizeRequiredText(input.Phone, "手机号"),
|
||||
NormalizeOptionalText(input.Email));
|
||||
NormalizeRequiredText(input.Email, "邮箱"));
|
||||
}
|
||||
|
||||
private async Task<List<string>> NormalizeAndValidateSubCustomerPermissionsAsync(IEnumerable<string>? permissions)
|
||||
@ -499,5 +503,5 @@ public class CustomerService : ApplicationService
|
||||
string CustomerCode,
|
||||
string CustomerName,
|
||||
string Phone,
|
||||
string? Email);
|
||||
string Email);
|
||||
}
|
||||
|
||||
@ -1489,12 +1489,7 @@ public partial class OrderService
|
||||
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
|
||||
var recipient = recipients.FirstOrDefault();
|
||||
|
||||
var apiCode = order.ChannelForecastCode?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(apiCode) && !string.IsNullOrWhiteSpace(order.ChannelCode))
|
||||
{
|
||||
var channel = await ServiceChannelRepository.GetByChannelCodeAsync(order.ChannelCode.Trim());
|
||||
apiCode = channel?.ForecastCode?.Trim() ?? string.Empty;
|
||||
}
|
||||
var apiCode = order.DownstreamSupplierCode?.Trim() ?? string.Empty;
|
||||
|
||||
return new SmallBagTrackUpsertRequest
|
||||
{
|
||||
|
||||
@ -9,18 +9,21 @@ namespace Oms.Application.Services.Orders;
|
||||
|
||||
public partial class OrderService
|
||||
{
|
||||
private sealed record PriceCheckCargoTarget(string Code, string DisplayName, string CargoAttribute);
|
||||
|
||||
private async Task<OrderPriceCheckResultOutput> BuildPriceCheckResultAsync(OrderPriceCheckInput input)
|
||||
{
|
||||
ValidatePriceCheckInput(input);
|
||||
var bindTime = DateTime.Now;
|
||||
var countryCode = await ResolveDestinationCountryCodeAsync(input.DestinationCountry);
|
||||
var cargoAttribute = ParcelBillingCalculator.MapCargoTypeToAttribute(input.CargoType);
|
||||
var quoteContext = new ParcelQuoteContext
|
||||
var cargoTargets = ResolvePriceCheckCargoTargets(input);
|
||||
var warehouses = await ResolvePriceCheckWarehousesAsync(input);
|
||||
|
||||
var baseContext = new ParcelQuoteContext
|
||||
{
|
||||
CountryCode = countryCode,
|
||||
Province = input.DestinationProvince,
|
||||
PostalCode = input.PostalCode,
|
||||
CargoAttribute = cargoAttribute,
|
||||
Length = input.Length!.Value,
|
||||
Width = input.Width!.Value,
|
||||
Height = input.Height!.Value,
|
||||
@ -48,47 +51,120 @@ public partial class OrderService
|
||||
return result;
|
||||
}
|
||||
|
||||
var warehouses = await WarehouseAddressRepository.GetListAsync();
|
||||
var defaultWarehouse = warehouses.OrderBy(x => x.Id).FirstOrDefault();
|
||||
|
||||
foreach (var product in products.OrderBy(x => x.Id))
|
||||
{
|
||||
var attempt = await TryComputePriceCheckQuoteAsync(product, quoteContext, customerGroup, bindTime);
|
||||
if (attempt.Success)
|
||||
foreach (var cargo in cargoTargets)
|
||||
{
|
||||
var quote = attempt.Quote!;
|
||||
var (freight, otherFees, totalCost, costFormula) = ParcelBillingCalculator.SummarizeFees(quote.Fees);
|
||||
result.Items.Add(new OrderPriceCheckOutput
|
||||
foreach (var warehouse in warehouses)
|
||||
{
|
||||
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
|
||||
});
|
||||
continue;
|
||||
}
|
||||
var quoteContext = new ParcelQuoteContext
|
||||
{
|
||||
CountryCode = baseContext.CountryCode,
|
||||
Province = baseContext.Province,
|
||||
PostalCode = baseContext.PostalCode,
|
||||
CargoAttribute = cargo.CargoAttribute,
|
||||
CargoTypeCode = cargo.Code,
|
||||
WarehouseId = warehouse.Id,
|
||||
WarehouseName = warehouse.WarehouseName,
|
||||
Length = baseContext.Length,
|
||||
Width = baseContext.Width,
|
||||
Height = baseContext.Height,
|
||||
ActualWeight = baseContext.ActualWeight
|
||||
};
|
||||
|
||||
if (attempt.Failure != null)
|
||||
{
|
||||
result.Failures.Add(attempt.Failure);
|
||||
var attempt = await TryComputePriceCheckQuoteAsync(
|
||||
product,
|
||||
quoteContext,
|
||||
customerGroup,
|
||||
bindTime,
|
||||
cargo.DisplayName,
|
||||
warehouse.WarehouseName);
|
||||
|
||||
if (attempt.Success)
|
||||
{
|
||||
var quote = attempt.Quote!;
|
||||
var (freight, otherFees, totalCost, costFormula) = ParcelBillingCalculator.SummarizeFees(quote.Fees);
|
||||
result.Items.Add(new OrderPriceCheckOutput
|
||||
{
|
||||
Id = quote.ProductId,
|
||||
ProductCode = quote.ProductCode,
|
||||
ShippingMethod = quote.ProductName,
|
||||
CargoType = cargo.Code,
|
||||
CargoTypeName = cargo.DisplayName,
|
||||
WarehouseId = warehouse.Id,
|
||||
Warehouse = warehouse.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
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attempt.Failure != null)
|
||||
{
|
||||
result.Failures.Add(attempt.Failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<PriceCheckCargoTarget> ResolvePriceCheckCargoTargets(OrderPriceCheckInput input)
|
||||
{
|
||||
var codes = (input.CargoTypes ?? [])
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(x => x.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (codes.Count == 0 && !string.IsNullOrWhiteSpace(input.CargoType))
|
||||
{
|
||||
codes.Add(input.CargoType.Trim());
|
||||
}
|
||||
|
||||
if (codes.Count == 0)
|
||||
{
|
||||
codes.Add(string.Empty);
|
||||
}
|
||||
|
||||
return codes.Select(code =>
|
||||
{
|
||||
var attribute = ParcelBillingCalculator.MapCargoTypeToAttribute(code);
|
||||
var display = string.IsNullOrWhiteSpace(code)
|
||||
? ParcelBillingCalculator.GetCargoAttributeLabel(attribute)
|
||||
: code;
|
||||
return new PriceCheckCargoTarget(code, display, attribute);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<WarehouseAddress>> ResolvePriceCheckWarehousesAsync(OrderPriceCheckInput input)
|
||||
{
|
||||
var ids = (input.WarehouseIds ?? [])
|
||||
.Where(x => x > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
var selected = await WarehouseAddressRepository.GetListAsync(x => ids.Contains(x.Id));
|
||||
return selected.OrderBy(x => x.Id).ToList();
|
||||
}
|
||||
|
||||
var all = await WarehouseAddressRepository.GetListAsync();
|
||||
return all.OrderBy(x => x.Id).ToList();
|
||||
}
|
||||
|
||||
private static void ValidatePriceCheckInput(OrderPriceCheckInput input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.DestinationCountry))
|
||||
@ -177,11 +253,13 @@ public partial class OrderService
|
||||
SalesProduct product,
|
||||
ParcelQuoteContext context,
|
||||
CustomerGroup? customerGroup,
|
||||
DateTime bindTime)
|
||||
DateTime bindTime,
|
||||
string cargoTypeName,
|
||||
string warehouseName)
|
||||
{
|
||||
if (product.IsActive != EnableStatus.Enabled)
|
||||
{
|
||||
return PriceCheckFailure(product, "product", "产品未启用");
|
||||
return PriceCheckFailure(product, "product", "产品未启用", cargoTypeName, warehouseName);
|
||||
}
|
||||
|
||||
ProductSnapshotBinding snapshotBinding;
|
||||
@ -191,7 +269,7 @@ public partial class OrderService
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
return PriceCheckFailure(product, "snapshot", ex.Message);
|
||||
return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName);
|
||||
}
|
||||
|
||||
if (await IsBlacklistHitAsync(
|
||||
@ -205,7 +283,9 @@ public partial class OrderService
|
||||
return PriceCheckFailure(
|
||||
product,
|
||||
"blacklist",
|
||||
$"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 命中产品黑名单");
|
||||
$"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 命中产品黑名单",
|
||||
cargoTypeName,
|
||||
warehouseName);
|
||||
}
|
||||
|
||||
var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
|
||||
@ -215,7 +295,7 @@ public partial class OrderService
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
return PriceCheckFailure(product, "weight", ex.Message);
|
||||
return PriceCheckFailure(product, "weight", ex.Message, cargoTypeName, warehouseName);
|
||||
}
|
||||
|
||||
var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price);
|
||||
@ -228,7 +308,9 @@ public partial class OrderService
|
||||
return PriceCheckFailure(
|
||||
product,
|
||||
"zone",
|
||||
$"{snapshotZoneHint}产品未配置价格分区(PriceAreaId),无法根据国家+邮编匹配卖价明细中的分区键");
|
||||
$"{snapshotZoneHint}产品未配置价格分区(PriceAreaId),无法根据国家+邮编匹配卖价明细中的分区键",
|
||||
cargoTypeName,
|
||||
warehouseName);
|
||||
}
|
||||
|
||||
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
||||
@ -249,7 +331,9 @@ public partial class OrderService
|
||||
product,
|
||||
"zone",
|
||||
$"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格快照内分区规则(PriceAreaId={priceAreaId})," +
|
||||
"请检查发布时的分区国家/邮编配置是否与卖价明细分区键一致");
|
||||
"请检查发布时的分区国家/邮编配置是否与卖价明细分区键一致",
|
||||
cargoTypeName,
|
||||
warehouseName);
|
||||
}
|
||||
|
||||
var chargeableWeight = forecast.ChargeableWeight ?? 0;
|
||||
@ -260,13 +344,15 @@ public partial class OrderService
|
||||
chargeableWeight,
|
||||
zoneKeys,
|
||||
context.CargoAttribute,
|
||||
customerGroup);
|
||||
customerGroup,
|
||||
context.WarehouseId,
|
||||
context.WarehouseName);
|
||||
|
||||
if (fees.Count == 0)
|
||||
{
|
||||
if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price))
|
||||
{
|
||||
return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项");
|
||||
return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项", cargoTypeName, warehouseName);
|
||||
}
|
||||
|
||||
return PriceCheckFailure(
|
||||
@ -277,7 +363,11 @@ public partial class OrderService
|
||||
chargeableWeight,
|
||||
zoneKeys,
|
||||
context.CargoAttribute,
|
||||
customerGroup));
|
||||
customerGroup,
|
||||
context.WarehouseId,
|
||||
context.WarehouseName),
|
||||
cargoTypeName,
|
||||
warehouseName);
|
||||
}
|
||||
|
||||
var isRemote = await IsRemoteHitAsync(
|
||||
@ -306,7 +396,12 @@ public partial class OrderService
|
||||
};
|
||||
}
|
||||
|
||||
private static PriceCheckAttemptResult PriceCheckFailure(SalesProduct product, string step, string reason) =>
|
||||
private static PriceCheckAttemptResult PriceCheckFailure(
|
||||
SalesProduct product,
|
||||
string step,
|
||||
string reason,
|
||||
string? cargoTypeName = null,
|
||||
string? warehouseName = null) =>
|
||||
new()
|
||||
{
|
||||
Failure = new OrderPriceCheckFailureOutput
|
||||
@ -315,10 +410,31 @@ public partial class OrderService
|
||||
ProductCode = product.ProductCode,
|
||||
ProductName = product.ProductName,
|
||||
Step = step,
|
||||
Reason = reason
|
||||
CargoTypeName = cargoTypeName,
|
||||
WarehouseName = warehouseName,
|
||||
Reason = PrefixPriceCheckFailureReason(cargoTypeName, warehouseName, reason)
|
||||
}
|
||||
};
|
||||
|
||||
private static string PrefixPriceCheckFailureReason(
|
||||
string? cargoTypeName,
|
||||
string? warehouseName,
|
||||
string reason)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(cargoTypeName))
|
||||
{
|
||||
parts.Add($"货物类型={cargoTypeName.Trim()}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(warehouseName))
|
||||
{
|
||||
parts.Add($"仓库={warehouseName.Trim()}");
|
||||
}
|
||||
|
||||
return parts.Count == 0 ? reason : $"[{string.Join(",", parts)}] {reason}";
|
||||
}
|
||||
|
||||
private static string FormatPostalCode(string? postalCode) =>
|
||||
string.IsNullOrWhiteSpace(postalCode) ? "-" : postalCode.Trim();
|
||||
}
|
||||
|
||||
@ -353,7 +353,9 @@ internal static class ParcelBillingCalculator
|
||||
decimal chargeableWeight,
|
||||
IReadOnlyList<string> zoneKeys,
|
||||
string cargoAttribute,
|
||||
CustomerGroup? customerGroup) =>
|
||||
CustomerGroup? customerGroup,
|
||||
int? warehouseId = null,
|
||||
string? warehouseName = null) =>
|
||||
CalculateReceivableFees(
|
||||
priceSnapshot,
|
||||
priceVersion,
|
||||
@ -362,7 +364,9 @@ internal static class ParcelBillingCalculator
|
||||
zoneKeys,
|
||||
cargoAttribute,
|
||||
customerGroup,
|
||||
OrderFeeChargingStageCodes.Forecast);
|
||||
OrderFeeChargingStageCodes.Forecast,
|
||||
warehouseId,
|
||||
warehouseName);
|
||||
|
||||
public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
|
||||
HasEnabledReceivablePriceItems(priceSnapshot);
|
||||
@ -399,9 +403,12 @@ internal static class ParcelBillingCalculator
|
||||
decimal chargeableWeight,
|
||||
IReadOnlyList<string> zoneKeys,
|
||||
string cargoAttribute,
|
||||
CustomerGroup? customerGroup)
|
||||
CustomerGroup? customerGroup,
|
||||
int? warehouseId = null,
|
||||
string? warehouseName = null)
|
||||
{
|
||||
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
|
||||
var warehouseLabel = FormatWarehouseLabel(warehouseId, warehouseName);
|
||||
var enabledPrices = priceSnapshot.Prices.Where(x => x.Status == 1).ToList();
|
||||
if (enabledPrices.Count == 0)
|
||||
{
|
||||
@ -462,9 +469,31 @@ internal static class ParcelBillingCalculator
|
||||
continue;
|
||||
}
|
||||
|
||||
var warehouseMatchedDetails = cargoMatchedDetails.Where(detail =>
|
||||
{
|
||||
var factors = ParseFactorDictionary(detail.FactorValuesJson);
|
||||
return MatchWarehouseFactor(factors, warehouseId, warehouseName);
|
||||
}).ToList();
|
||||
|
||||
if (warehouseMatchedDetails.Count == 0)
|
||||
{
|
||||
var requiredWarehouse = cargoMatchedDetails
|
||||
.Select(detail =>
|
||||
{
|
||||
var factors = ParseFactorDictionary(detail.FactorValuesJson);
|
||||
return TryGetFactorValue(factors, out var wh, "仓库", "warehouse") ? wh.Trim() : null;
|
||||
})
|
||||
.Where(wh => !string.IsNullOrWhiteSpace(wh))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
var requiredText = requiredWarehouse.Any() ? string.Join(", ", requiredWarehouse) : "(明细未限定仓库)";
|
||||
reasons.Add(
|
||||
$"【{label}】价格项Id={price.Id}:仓库不匹配,当前={warehouseLabel},明细要求=[{requiredText}]");
|
||||
continue;
|
||||
}
|
||||
|
||||
var weightMatched = false;
|
||||
var intervals = new List<string>();
|
||||
foreach (var detail in cargoMatchedDetails)
|
||||
foreach (var detail in warehouseMatchedDetails)
|
||||
{
|
||||
var factors = ParseFactorDictionary(detail.FactorValuesJson);
|
||||
if (!TryGetWeightFactorText(factors, out var weightText))
|
||||
@ -547,7 +576,9 @@ internal static class ParcelBillingCalculator
|
||||
IReadOnlyList<string> zoneKeys,
|
||||
string cargoAttribute,
|
||||
CustomerGroup? customerGroup,
|
||||
string chargingStage)
|
||||
string chargingStage,
|
||||
int? warehouseId = null,
|
||||
string? warehouseName = null)
|
||||
{
|
||||
var items = priceSnapshot.Prices.Select(p => new ReceivableBillingPriceItem(
|
||||
p.Status, p.CostItem, p.Currency, p.Details));
|
||||
@ -559,7 +590,9 @@ internal static class ParcelBillingCalculator
|
||||
zoneKeys,
|
||||
cargoAttribute,
|
||||
customerGroup,
|
||||
chargingStage);
|
||||
chargingStage,
|
||||
warehouseId,
|
||||
warehouseName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -697,12 +730,20 @@ internal static class ParcelBillingCalculator
|
||||
IReadOnlyList<string> zoneKeys,
|
||||
string cargoAttribute,
|
||||
CustomerGroup? customerGroup,
|
||||
string chargingStage)
|
||||
string chargingStage,
|
||||
int? warehouseId = null,
|
||||
string? warehouseName = null)
|
||||
{
|
||||
var fees = new List<OrderFee>();
|
||||
foreach (var priceItem in priceItems.Where(x => x.Status == 1))
|
||||
{
|
||||
var detail = MatchPriceDetail(priceItem.Details, zoneKeys, cargoAttribute, chargeableWeight);
|
||||
var detail = MatchPriceDetail(
|
||||
priceItem.Details,
|
||||
zoneKeys,
|
||||
cargoAttribute,
|
||||
chargeableWeight,
|
||||
warehouseId,
|
||||
warehouseName);
|
||||
if (detail == null)
|
||||
{
|
||||
continue;
|
||||
@ -714,7 +755,9 @@ internal static class ParcelBillingCalculator
|
||||
zoneKeys,
|
||||
cargoAttribute,
|
||||
chargeableWeight,
|
||||
customerGroup);
|
||||
customerGroup,
|
||||
warehouseId,
|
||||
warehouseName);
|
||||
fees.Add(new OrderFee
|
||||
{
|
||||
Type = OrderFeeTypeCodes.Receivable,
|
||||
@ -738,12 +781,20 @@ internal static class ParcelBillingCalculator
|
||||
IReadOnlyList<string> zoneKeys,
|
||||
string cargoAttribute,
|
||||
CustomerGroup? customerGroup,
|
||||
string chargingStage)
|
||||
string chargingStage,
|
||||
int? warehouseId = null,
|
||||
string? warehouseName = null)
|
||||
{
|
||||
var fees = new List<OrderFee>();
|
||||
foreach (var priceItem in priceItems.Where(x => x.Status == 1))
|
||||
{
|
||||
var detail = MatchPriceDetail(priceItem.Details, zoneKeys, cargoAttribute, chargeableWeight);
|
||||
var detail = MatchPriceDetail(
|
||||
priceItem.Details,
|
||||
zoneKeys,
|
||||
cargoAttribute,
|
||||
chargeableWeight,
|
||||
warehouseId,
|
||||
warehouseName);
|
||||
if (detail == null)
|
||||
{
|
||||
continue;
|
||||
@ -755,7 +806,9 @@ internal static class ParcelBillingCalculator
|
||||
zoneKeys,
|
||||
cargoAttribute,
|
||||
chargeableWeight,
|
||||
customerGroup);
|
||||
customerGroup,
|
||||
warehouseId,
|
||||
warehouseName);
|
||||
fees.Add(new OrderFee
|
||||
{
|
||||
Type = OrderFeeTypeCodes.Payable,
|
||||
@ -787,7 +840,9 @@ internal static class ParcelBillingCalculator
|
||||
IReadOnlyList<ChannelPriceDetailOutput> details,
|
||||
IReadOnlyList<string> zoneKeys,
|
||||
string cargoAttribute,
|
||||
decimal chargeableWeight)
|
||||
decimal chargeableWeight,
|
||||
int? warehouseId = null,
|
||||
string? warehouseName = null)
|
||||
{
|
||||
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
|
||||
ChannelPriceDetailOutput? bestMatch = null;
|
||||
@ -806,6 +861,11 @@ internal static class ParcelBillingCalculator
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!MatchWarehouseFactor(factors, warehouseId, warehouseName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryGetWeightFactorText(factors, out var weightText))
|
||||
{
|
||||
if (bestMatch == null)
|
||||
@ -911,6 +971,41 @@ internal static class ParcelBillingCalculator
|
||||
|| string.Equals(actual.Trim(), cargoLabel, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool MatchWarehouseFactor(
|
||||
Dictionary<string, string> factors,
|
||||
int? warehouseId,
|
||||
string? warehouseName)
|
||||
{
|
||||
if (!TryGetFactorValue(factors, out var actual, "仓库", "warehouse") || string.IsNullOrWhiteSpace(actual))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var expected = actual.Trim();
|
||||
if (warehouseId.HasValue && string.Equals(expected, warehouseId.Value.ToString(), StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(warehouseName)
|
||||
&& string.Equals(expected, warehouseName.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string FormatWarehouseLabel(int? warehouseId, string? warehouseName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(warehouseName))
|
||||
{
|
||||
return warehouseName.Trim();
|
||||
}
|
||||
|
||||
return warehouseId.HasValue ? warehouseId.Value.ToString() : "未指定";
|
||||
}
|
||||
|
||||
private static bool TryGetFactorValue(
|
||||
Dictionary<string, string> factors,
|
||||
out string value,
|
||||
@ -963,7 +1058,9 @@ internal static class ParcelBillingCalculator
|
||||
IReadOnlyList<string> zoneKeys,
|
||||
string cargoAttribute,
|
||||
decimal chargeableWeight,
|
||||
CustomerGroup? customerGroup)
|
||||
CustomerGroup? customerGroup,
|
||||
int? warehouseId = null,
|
||||
string? warehouseName = null)
|
||||
{
|
||||
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
|
||||
return NormalizeCalculationFormula(detail.CalculationFormula) switch
|
||||
@ -976,7 +1073,9 @@ internal static class ParcelBillingCalculator
|
||||
zoneKeys,
|
||||
cargoLabel,
|
||||
chargeableWeight,
|
||||
customerGroup),
|
||||
customerGroup,
|
||||
warehouseId,
|
||||
warehouseName),
|
||||
_ => detail.CalculatedValue ?? 0
|
||||
};
|
||||
}
|
||||
@ -1002,7 +1101,9 @@ internal static class ParcelBillingCalculator
|
||||
IReadOnlyList<string> zoneKeys,
|
||||
string cargoLabel,
|
||||
decimal chargeableWeight,
|
||||
CustomerGroup? customerGroup)
|
||||
CustomerGroup? customerGroup,
|
||||
int? warehouseId = null,
|
||||
string? warehouseName = null)
|
||||
{
|
||||
var factors = ParseFactorDictionary(detail.FactorValuesJson);
|
||||
if (TryGetWeightFactorText(factors, out var weightText)
|
||||
@ -1013,6 +1114,8 @@ internal static class ParcelBillingCalculator
|
||||
cargoLabel,
|
||||
rangeStart,
|
||||
customerGroup,
|
||||
warehouseId,
|
||||
warehouseName,
|
||||
out var basePrice))
|
||||
{
|
||||
var continueUnitPrice = ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0;
|
||||
@ -1039,13 +1142,17 @@ internal static class ParcelBillingCalculator
|
||||
string cargoLabel,
|
||||
decimal continueRangeStart,
|
||||
CustomerGroup? customerGroup,
|
||||
int? warehouseId,
|
||||
string? warehouseName,
|
||||
out decimal basePrice)
|
||||
{
|
||||
basePrice = 0;
|
||||
foreach (var candidate in allDetails)
|
||||
{
|
||||
var factors = ParseFactorDictionary(candidate.FactorValuesJson);
|
||||
if (!ZoneMatches(factors, zoneKeys) || !MatchCargoTypeFactor(factors, cargoLabel))
|
||||
if (!ZoneMatches(factors, zoneKeys)
|
||||
|| !MatchCargoTypeFactor(factors, cargoLabel)
|
||||
|| !MatchWarehouseFactor(factors, warehouseId, warehouseName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -15,6 +15,13 @@ internal sealed class ParcelQuoteContext
|
||||
|
||||
public string CargoAttribute { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>查价入参原始货物类型编码(字典 cargo_attribute)。</summary>
|
||||
public string? CargoTypeCode { get; init; }
|
||||
|
||||
public int? WarehouseId { get; init; }
|
||||
|
||||
public string? WarehouseName { get; init; }
|
||||
|
||||
public decimal Length { get; init; }
|
||||
|
||||
public decimal Width { get; init; }
|
||||
|
||||
44
src/Oms.Application/Shared/CustomerCodeGenerator.cs
Normal file
44
src/Oms.Application/Shared/CustomerCodeGenerator.cs
Normal file
@ -0,0 +1,44 @@
|
||||
using Fake.Application;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 客户编号生成:W + 年份后2位 + 月份2位 + 3位顺序号,如 W2606001。
|
||||
/// </summary>
|
||||
public static class CustomerCodeGenerator
|
||||
{
|
||||
private const int MaxSequence = 999;
|
||||
private const int MaxRetries = 5;
|
||||
|
||||
public static async Task<string> GenerateNextAsync(
|
||||
ICustomerRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
for (var attempt = 0; attempt < MaxRetries; attempt++)
|
||||
{
|
||||
var code = await BuildNextCodeAsync(repository, cancellationToken);
|
||||
if (await repository.GetByCustomerCodeAsync(code, cancellationToken) == null)
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
throw new BusinessException("客户编号生成失败,请重试");
|
||||
}
|
||||
|
||||
private static async Task<string> BuildNextCodeAsync(
|
||||
ICustomerRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var prefix = $"W{DateTime.Now:yyMM}";
|
||||
var maxSeq = await repository.GetMaxCustomerCodeSequenceForPrefixAsync(prefix, cancellationToken);
|
||||
var next = maxSeq + 1;
|
||||
if (next > MaxSequence)
|
||||
{
|
||||
throw new BusinessException($"当月客户编号已达上限({prefix}xxx)");
|
||||
}
|
||||
|
||||
return $"{prefix}{next:D3}";
|
||||
}
|
||||
}
|
||||
@ -14,4 +14,9 @@ public interface ICustomerRepository : IRepository<Customer>
|
||||
Task<Customer?> GetByWechatOpenidAsync(string wechatOpenid, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ExistsPhoneAsync(string phone, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询指定前缀(WyyMM)下已用顺序号的最大值。
|
||||
/// </summary>
|
||||
Task<int> GetMaxCustomerCodeSequenceForPrefixAsync(string prefix, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -31,4 +31,33 @@ public class CustomerRepository : OmsSqlSugarRepository<Customer>, ICustomerRepo
|
||||
{
|
||||
return AnyAsync(x => x.Phone == phone, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> GetMaxCustomerCodeSequenceForPrefixAsync(
|
||||
string prefix,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expectedLength = prefix.Length + 3;
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
var codes = await db.Queryable<Customer>()
|
||||
.Where(x => x.CustomerCode.StartsWith(prefix) && x.CustomerCode.Length == expectedLength)
|
||||
.Select(x => x.CustomerCode)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var max = 0;
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code.Length <= prefix.Length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var suffix = code[prefix.Length..];
|
||||
if (int.TryParse(suffix, out var seq) && seq > max)
|
||||
{
|
||||
max = seq;
|
||||
}
|
||||
}
|
||||
|
||||
return max;
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,8 +50,7 @@ public class WeChatProfileInput
|
||||
|
||||
public class WeChatCustomerRegisterInput
|
||||
{
|
||||
[Required]
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
public string? CustomerCode { get; set; }
|
||||
|
||||
[Required]
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
@ -59,7 +58,9 @@ public class WeChatCustomerRegisterInput
|
||||
[Required]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
public string? Email { get; set; }
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MinLength(6)]
|
||||
|
||||
@ -80,10 +80,9 @@ public class WeChatCustomerAppService(
|
||||
{
|
||||
ValidateWechatProfile(input.Wechat);
|
||||
|
||||
var customerCode = NormalizeRequired(input.CustomerCode, "客户编号");
|
||||
var customerName = NormalizeRequired(input.CustomerName, "客户名称");
|
||||
var phone = NormalizeRequired(input.Phone, "手机号");
|
||||
var email = NormalizeOptional(input.Email);
|
||||
var email = NormalizeRequired(input.Email, "邮箱");
|
||||
var password = NormalizeRequired(input.Password, "密码");
|
||||
|
||||
if (password.Length < 6)
|
||||
@ -96,6 +95,7 @@ public class WeChatCustomerAppService(
|
||||
throw new BusinessException("当前微信已绑定账号");
|
||||
}
|
||||
|
||||
var customerCode = await CustomerCodeGenerator.GenerateNextAsync(customerRepository, cancellationToken);
|
||||
await EnsureCustomerIdentityUniqueAsync(customerCode, phone, email, cancellationToken);
|
||||
|
||||
var customerRoleId = await GetCustomerRoleIdOrThrowAsync(cancellationToken);
|
||||
@ -193,7 +193,7 @@ public class WeChatCustomerAppService(
|
||||
private async Task EnsureCustomerIdentityUniqueAsync(
|
||||
string customerCode,
|
||||
string phone,
|
||||
string? email,
|
||||
string email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await customerRepository.GetByCustomerCodeAsync(customerCode, cancellationToken) != null)
|
||||
@ -206,7 +206,7 @@ public class WeChatCustomerAppService(
|
||||
throw new BusinessException("手机号已存在");
|
||||
}
|
||||
|
||||
if (email != null && await customerRepository.GetByEmailAsync(email, cancellationToken) != null)
|
||||
if (await customerRepository.GetByEmailAsync(email, cancellationToken) != null)
|
||||
{
|
||||
throw new BusinessException("邮箱已存在");
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user