feat: 销售产品贸易条款与查价分组展示,并修复用户资料 SQL 空值写入

查价返回 DDP/DDU 与产品分组;用户更新微信二维码时避免 EF 使用 DBNull。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-30 11:48:36 +08:00
parent f2f4cbf35b
commit e0216e9692
11 changed files with 145 additions and 10 deletions

View File

@ -0,0 +1,3 @@
-- 销售产品:贸易条款 DDP / DDU(查价线路返回并展示于价卡)
ALTER TABLE `t_sales_product`
ADD COLUMN `TradeTerms` VARCHAR(16) NULL COMMENT '贸易条款:DDP/DDU' AFTER `MaxTimeliness`;

View File

@ -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)
{

View File

@ -35,6 +35,11 @@ public class SalesProductDetailOutput
/// </summary>
public int? MaxTimeliness { get; set; }
/// <summary>
/// 贸易条款:DDP / DDU。
/// </summary>
public string? TradeTerms { get; set; }
/// <summary>
/// 系统面单类型。
/// </summary>

View File

@ -37,6 +37,11 @@ public class SalesProductSaveInput
/// </summary>
public int? MaxTimeliness { get; set; }
/// <summary>
/// 贸易条款:DDP / DDU。
/// </summary>
public string? TradeTerms { get; set; }
/// <summary>
/// 系统面单类型(旧枚举字段,兼容展示)。
/// </summary>

View File

@ -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>

View File

@ -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);
}

View File

@ -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,

View File

@ -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,

View File

@ -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>

View File

@ -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");
}
}
}

View File

@ -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);