feat: 销售产品贸易条款与查价分组展示,并修复用户资料 SQL 空值写入
查价返回 DDP/DDU 与产品分组;用户更新微信二维码时避免 EF 使用 DBNull。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f2f4cbf35b
commit
e0216e9692
3
sql/20260730_sales_product_trade_terms.sql
Normal file
3
sql/20260730_sales_product_trade_terms.sql
Normal file
@ -0,0 +1,3 @@
|
||||
-- 销售产品:贸易条款 DDP / DDU(查价线路返回并展示于价卡)
|
||||
ALTER TABLE `t_sales_product`
|
||||
ADD COLUMN `TradeTerms` VARCHAR(16) NULL COMMENT '贸易条款:DDP/DDU' AFTER `MaxTimeliness`;
|
||||
@ -15,7 +15,8 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static object SqlParam(object? value) => value ?? DBNull.Value;
|
||||
// EF Core 原始 SQL 参数不接受 DBNull,空值直接传 null
|
||||
private static object? SqlParam(object? value) => value;
|
||||
|
||||
public async Task<UserDataPermDto> GetUserDataPermAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@ -35,6 +35,11 @@ public class SalesProductDetailOutput
|
||||
/// </summary>
|
||||
public int? MaxTimeliness { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 贸易条款:DDP / DDU。
|
||||
/// </summary>
|
||||
public string? TradeTerms { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 系统面单类型。
|
||||
/// </summary>
|
||||
|
||||
@ -37,6 +37,11 @@ public class SalesProductSaveInput
|
||||
/// </summary>
|
||||
public int? MaxTimeliness { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 贸易条款:DDP / DDU。
|
||||
/// </summary>
|
||||
public string? TradeTerms { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 系统面单类型(旧枚举字段,兼容展示)。
|
||||
/// </summary>
|
||||
|
||||
@ -42,6 +42,21 @@ public class OrderPriceCheckOutput
|
||||
/// </summary>
|
||||
public string DeliveryTime { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 贸易条款:DDP / DDU。
|
||||
/// </summary>
|
||||
public string? TradeTerms { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 销售产品分组编码(字典 sales_product_group)。
|
||||
/// </summary>
|
||||
public string? ProductGroup { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 销售产品分组名称(展示用)。
|
||||
/// </summary>
|
||||
public string? ProductGroupName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 体积重量。
|
||||
/// </summary>
|
||||
|
||||
@ -93,10 +93,11 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende
|
||||
sql,
|
||||
[
|
||||
userId.ToString(),
|
||||
NormalizeOptional(profile.Phone) ?? (object)DBNull.Value,
|
||||
NormalizeOptional(profile.EmployeeNo) ?? (object)DBNull.Value,
|
||||
NormalizeOptional(profile.WechatId) ?? (object)DBNull.Value,
|
||||
NormalizeOptional(profile.WechatQrCode) ?? (object)DBNull.Value,
|
||||
// EF Core 参数不接受 DBNull,空值直接传 null 即可映射为 SQL NULL
|
||||
(object?)NormalizeOptional(profile.Phone),
|
||||
(object?)NormalizeOptional(profile.EmployeeNo),
|
||||
(object?)NormalizeOptional(profile.WechatId),
|
||||
(object?)NormalizeOptional(profile.WechatQrCode),
|
||||
],
|
||||
cancellationToken);
|
||||
}
|
||||
@ -119,9 +120,9 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende
|
||||
sql,
|
||||
[
|
||||
userId.ToString(),
|
||||
NormalizeOptional(profile.Phone) ?? (object)DBNull.Value,
|
||||
NormalizeOptional(profile.WechatId) ?? (object)DBNull.Value,
|
||||
NormalizeOptional(profile.WechatQrCode) ?? (object)DBNull.Value,
|
||||
(object?)NormalizeOptional(profile.Phone),
|
||||
(object?)NormalizeOptional(profile.WechatId),
|
||||
(object?)NormalizeOptional(profile.WechatQrCode),
|
||||
],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
@ -209,6 +209,7 @@ public partial class SalesProductService
|
||||
ProductGroup = product.ProductGroup,
|
||||
MinTimeliness = product.MinTimeliness,
|
||||
MaxTimeliness = product.MaxTimeliness,
|
||||
TradeTerms = product.TradeTerms,
|
||||
CustomerWaybill = product.CustomerWaybill,
|
||||
LabelTemplateId = product.LabelTemplateId,
|
||||
BlacklistAreaId = product.BlacklistAreaId,
|
||||
@ -314,6 +315,7 @@ public partial class SalesProductService
|
||||
entity.ProductGroup = input.ProductGroup;
|
||||
entity.MinTimeliness = input.MinTimeliness;
|
||||
entity.MaxTimeliness = input.MaxTimeliness;
|
||||
entity.TradeTerms = NormalizeTradeTerms(input.TradeTerms);
|
||||
entity.CustomerWaybill = input.CustomerWaybill;
|
||||
entity.LabelTemplateId = input.LabelTemplateId;
|
||||
entity.BlacklistAreaId = input.BlacklistAreaId;
|
||||
@ -328,6 +330,12 @@ public partial class SalesProductService
|
||||
entity.Scheme = string.IsNullOrWhiteSpace(input.Scheme) ? AllocationSchemeCodes.CostLowest : input.Scheme;
|
||||
}
|
||||
|
||||
private static string? NormalizeTradeTerms(string? raw)
|
||||
{
|
||||
var value = (raw ?? string.Empty).Trim().ToUpperInvariant();
|
||||
return value is "DDP" or "DDU" ? value : null;
|
||||
}
|
||||
|
||||
private static SalesProductListOutput MapListOutput(SalesProduct entity)
|
||||
{
|
||||
return new SalesProductListOutput
|
||||
@ -365,6 +373,7 @@ public partial class SalesProductService
|
||||
ProductGroup = entity.ProductGroup,
|
||||
MinTimeliness = entity.MinTimeliness,
|
||||
MaxTimeliness = entity.MaxTimeliness,
|
||||
TradeTerms = entity.TradeTerms,
|
||||
CustomerWaybill = entity.CustomerWaybill,
|
||||
LabelTemplateId = entity.LabelTemplateId,
|
||||
BlacklistAreaId = entity.BlacklistAreaId,
|
||||
|
||||
@ -164,8 +164,22 @@ public partial class OrderService
|
||||
}
|
||||
}
|
||||
|
||||
// 查价页面不展示「国内揽收」分组的产品
|
||||
var domesticPickupGroupCodes = (await DictRepository.GetListAsync(x => x.DictType == SalesProductGroupDictType))
|
||||
// 查价页面不展示「国内揽收」分组的产品;同时解析分组展示名
|
||||
var productGroupDictRows = await DictRepository.GetListAsync(x => x.DictType == SalesProductGroupDictType);
|
||||
var productGroupNameMap = productGroupDictRows
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.DictCode))
|
||||
.GroupBy(x => x.DictCode!.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g =>
|
||||
{
|
||||
var name = g.First().DictName?.Trim();
|
||||
return string.IsNullOrWhiteSpace(name) ? g.Key : name;
|
||||
},
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
productGroupNameMap[UngroupedProductGroupCode] = "未分组";
|
||||
|
||||
var domesticPickupGroupCodes = productGroupDictRows
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.DictName)
|
||||
&& x.DictName.Trim().Contains(DomesticPickupGroupNameKeyword))
|
||||
.Select(x => x.DictCode?.Trim())
|
||||
@ -236,6 +250,12 @@ public partial class OrderService
|
||||
var quote = attempt.Quote!;
|
||||
var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines);
|
||||
var itemHint = PriceCheckHintBuilder.BuildItemHint(profile, quote.UsedZoneFallback);
|
||||
var groupCode = string.IsNullOrWhiteSpace(product.ProductGroup)
|
||||
? UngroupedProductGroupCode
|
||||
: product.ProductGroup.Trim();
|
||||
var groupName = productGroupNameMap.TryGetValue(groupCode, out var mappedName)
|
||||
? mappedName
|
||||
: groupCode;
|
||||
result.Items.Add(new OrderPriceCheckOutput
|
||||
{
|
||||
Id = quote.ProductId,
|
||||
@ -246,6 +266,9 @@ public partial class OrderService
|
||||
WarehouseId = warehouse.Id,
|
||||
Warehouse = warehouse.WarehouseName,
|
||||
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
|
||||
TradeTerms = string.IsNullOrWhiteSpace(product.TradeTerms) ? null : product.TradeTerms.Trim().ToUpperInvariant(),
|
||||
ProductGroup = groupCode,
|
||||
ProductGroupName = groupName,
|
||||
VolumetricWeight = quote.VolumetricWeight,
|
||||
ChargeableWeight = quote.ChargeableWeight,
|
||||
CostFormula = summary.CostFormula,
|
||||
|
||||
@ -17,6 +17,11 @@ public class SalesProduct : OmsAuditedAggregateRoot
|
||||
|
||||
public int? MaxTimeliness { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 贸易条款:DDP / DDU。
|
||||
/// </summary>
|
||||
public string? TradeTerms { get; set; }
|
||||
|
||||
public string? CustomerWaybill { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -185,4 +185,37 @@ public partial class AuthService
|
||||
ExpiresIn = expireSeconds
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同源代理微信 showqrcode 图片,便于微信内长按识别(避免跨域直连 mp.weixin.qq.com)。
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("/wechat/mp/showqrcode")]
|
||||
[ApiExplorerSettings(GroupName = "WeChat")]
|
||||
public async Task<IActionResult> GetWeChatShowQrCodeAsync(
|
||||
[FromQuery] string ticket,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ticket))
|
||||
{
|
||||
return new BadRequestObjectResult("ticket required");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (bytes, contentType) = await WeChatMpApiClient.DownloadShowQrCodeAsync(
|
||||
ticket,
|
||||
cancellationToken);
|
||||
// 短缓存:同一次 ticket 有效期内可复用,减少重复拉取
|
||||
return new FileContentResult(bytes, contentType)
|
||||
{
|
||||
EnableRangeProcessing = false
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WeChatLogger.LogWarning(ex, "代理下载微信登录二维码失败");
|
||||
return new NotFoundObjectResult("qrcode not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,13 @@ public interface IWeChatMpApiClient
|
||||
{
|
||||
Task<string> CreateLoginQrCodeUrlAsync(string loginUuid, int expireSeconds, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 代理下载公众号 showqrcode 图片,供 H5 同源展示以提升微信长按识别成功率。
|
||||
/// </summary>
|
||||
Task<(byte[] Content, string ContentType)> DownloadShowQrCodeAsync(
|
||||
string ticket,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WeChatOAuthTokenResult?> GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WeChatOAuthUserResult?> GetOAuthUserInfoAsync(string accessToken, string openId, CancellationToken cancellationToken = default);
|
||||
@ -104,6 +111,34 @@ internal sealed class WeChatMpApiClient(
|
||||
return $"https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={Uri.EscapeDataString(result.Ticket)}";
|
||||
}
|
||||
|
||||
public async Task<(byte[] Content, string ContentType)> DownloadShowQrCodeAsync(
|
||||
string ticket,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ticket))
|
||||
{
|
||||
throw new ArgumentException("ticket 不能为空", nameof(ticket));
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient));
|
||||
var url = $"https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={Uri.EscapeDataString(ticket.Trim())}";
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
if (bytes.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("微信二维码图片为空");
|
||||
}
|
||||
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType;
|
||||
if (string.IsNullOrWhiteSpace(contentType) || contentType.Contains("text", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentType = "image/jpeg";
|
||||
}
|
||||
|
||||
return (bytes, contentType);
|
||||
}
|
||||
|
||||
public async Task<WeChatOAuthTokenResult?> GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var mp = await configProvider.GetAsync(cancellationToken);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user