admin/src/Oms.Application/Services/Orders/OrderService.ReceiverAddressParse.cs
zzh 654a65aba4 feat: workplace claimed stats and receiver address parse improvements
Add claimed count to workplace stats; refine receiver address parse DTOs/helpers and AI skill seed wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:06:49 +08:00

318 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.Json;
using System.Text.RegularExpressions;
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Ai;
using Oms.Application.Shared;
using Oms.Domain.AiAggregate.Entities;
using Oms.Domain.AiAggregate.Repositories;
namespace Oms.Application.Services.Orders;
public partial class OrderService
{
private static readonly string[] ReceiverRequiredFieldLabels =
[
"收件人名称",
"收件人手机",
"国家",
"邮编",
"地址1",
];
private const string DefaultReceiverAddressParseSkill =
"""
你是国际物流 OMS 的收件地址解析助手(跨境打单场景)。
目标:把客户粘贴的整段中/英收件信息拆成结构化字段,并做「粗校验」提示——只关心下单/预报大概能不能用,不做地图精校、不验证门牌是否真实、不臆造邮编与城市匹配。
只输出 JSON,不要 Markdown,不要解释。字段:
{
"recipientsName": "",
"companyName": "",
"phone": "",
"telePhone": "",
"email": "",
"countryCode": "",
"province": "",
"city": "",
"streetNumber": "",
"streetAddress1": "",
"streetAddress2": "",
"streetAddress3": "",
"zipCode": "",
"warnings": []
}
规则:
1) countryCode 必须是 ISO 二字码大写(中国→CN,美国→US,英国→GB 不要用 UK,香港→HK,台湾→TW,澳门→MO)。无法判断则留空并在 warnings 说明。
2) phone 优先填手机;固定电话放 telePhone;保留 + 国家码。
3) 街道门牌放入 streetAddress1;过长拆到 streetAddress2/3;单独门牌号可放 streetNumber。
4) 中国地址:省→province,市→city,区县街道合并进 streetAddress1(不要把省市区塞进姓名)。
5) 英文地址:State→province,City→city,Street→streetAddress*,ZIP/Postcode→zipCode。
6) 粗风险才写 warnings(短中文):缺国家/邮编/电话不像号码/街道与姓名粘连拆不清等。禁止写「地图不存在」「邮编与城市不匹配」这类无依据结论。
7) 不确定的字段留空;不要编造原文没有的省市区邮编。
8) 税号/EORI/VAT 不是本任务重点,原文没有就忽略。
""";
private IAiProviderRepository AiProviderRepository =>
LazyServiceProvider.GetRequiredService<IAiProviderRepository>();
private AiLlmClient LlmClient => LazyServiceProvider.GetRequiredService<AiLlmClient>();
/// <summary>
/// AI 解析粘贴的收件地址文本,并校验是否完整。
/// </summary>
[HttpPost("/order/receiver/parse-address")]
public async Task<OrderReceiverParseAddressOutput> ParseReceiverAddressAsync(
[FromBody] OrderReceiverParseAddressInput input,
CancellationToken cancellationToken = default)
{
var raw = (input.RawText ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(raw))
{
throw new BusinessException("请先粘贴收件地址文本");
}
if (raw.Length > 4000)
{
throw new BusinessException("地址文本过长,请精简后再解析");
}
var customerId = await OmsSessionHelper.ResolveXhsCustomerIdAsync(CustomerRepository, CurrentUser);
var provider = await ResolveAiProviderAsync(customerId, input.ProviderKey);
var skillService = LazyServiceProvider.GetRequiredService<AiSkillService>();
var skillKey = string.IsNullOrWhiteSpace(input.SkillKey)
? AiSkillService.KeyReceiverAddressParse
: input.SkillKey.Trim();
var systemPrompt = await skillService.TryGetContentByKeyAsync(skillKey);
if (string.IsNullOrWhiteSpace(systemPrompt))
{
if (skillKey == AiSkillService.KeyReceiverAddressParse)
{
systemPrompt = DefaultReceiverAddressParseSkill;
}
else
{
throw new BusinessException($"Skill「{skillKey}」未配置或未启用");
}
}
var userPrompt = "请解析以下收件信息:\n" + raw;
string modelText;
try
{
modelText = await LlmClient.ChatCompletionAsync(
provider,
[("system", systemPrompt), ("user", userPrompt)],
string.IsNullOrWhiteSpace(input.Model) ? null : input.Model.Trim(),
cancellationToken);
}
catch (BusinessException)
{
throw;
}
catch (Exception ex)
{
throw new BusinessException($"AI 地址解析失败:{ex.Message}");
}
var receiver = ParseReceiverJson(modelText, out var modelWarnings);
NormalizeReceiver(receiver);
var (isComplete, missingRequired, missingRecommended) = EvaluateCompleteness(receiver);
var warnings = new List<string>();
warnings.AddRange(modelWarnings);
if (!string.IsNullOrWhiteSpace(receiver.CountryCode) &&
receiver.CountryCode!.Length != 2)
{
warnings.Add("国家代码格式异常,请手动确认");
}
return new OrderReceiverParseAddressOutput
{
Receiver = receiver,
IsComplete = isComplete,
MissingRequired = missingRequired,
MissingRecommended = missingRecommended,
Warnings = warnings.Distinct(StringComparer.Ordinal).ToList(),
};
}
private async Task<AiProvider> ResolveAiProviderAsync(int customerId, string? providerKey)
{
var key = (providerKey ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(key))
{
var byKey = await AiProviderRepository.GetByProviderKeyAsync(customerId, key);
if (byKey is not { Enabled: true })
{
byKey = await AiProviderRepository.GetByProviderKeyAsync(0, key);
}
if (byKey is not { Enabled: true })
{
throw new BusinessException($"AI 提供商 {key} 未配置或未启用");
}
return byKey;
}
var list = await AiProviderRepository.GetListAsync(x =>
(x.CustomerId == customerId || x.CustomerId == 0) && x.Enabled);
var preferred = list
.OrderByDescending(x => x.CustomerId == customerId)
.ThenByDescending(x => x.IsDefault)
.ThenBy(x => x.Id)
.FirstOrDefault();
if (preferred == null)
{
throw new BusinessException("未配置可用的 AI 提供商,请先在系统中启用 AI 服务");
}
return preferred;
}
private static OrderParcelReceiverInput ParseReceiverJson(string modelText, out List<string> warnings)
{
warnings = [];
var json = ExtractJsonObject(modelText);
if (string.IsNullOrWhiteSpace(json))
{
throw new BusinessException("AI 未返回可识别的地址结构,请调整粘贴内容后重试");
}
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
throw new BusinessException("AI 返回格式异常,请重试");
}
if (root.TryGetProperty("warnings", out var warnNode) && warnNode.ValueKind == JsonValueKind.Array)
{
foreach (var item in warnNode.EnumerateArray())
{
var w = item.GetString()?.Trim();
if (!string.IsNullOrWhiteSpace(w))
{
warnings.Add(w);
}
}
}
return new OrderParcelReceiverInput
{
RecipientsName = ReadStr(root, "recipientsName"),
CompanyName = ReadStr(root, "companyName"),
Phone = ReadStr(root, "phone"),
TelePhone = ReadStr(root, "telePhone"),
Email = ReadStr(root, "email"),
CountryCode = ReadStr(root, "countryCode"),
Province = ReadStr(root, "province"),
City = ReadStr(root, "city"),
StreetNumber = ReadStr(root, "streetNumber"),
StreetAddress1 = ReadStr(root, "streetAddress1"),
StreetAddress2 = ReadStr(root, "streetAddress2"),
StreetAddress3 = ReadStr(root, "streetAddress3"),
ZipCode = ReadStr(root, "zipCode"),
};
}
private static void NormalizeReceiver(OrderParcelReceiverInput receiver)
{
receiver.RecipientsName = CleanText(receiver.RecipientsName);
receiver.CompanyName = CleanText(receiver.CompanyName);
receiver.Phone = CleanText(receiver.Phone);
receiver.TelePhone = CleanText(receiver.TelePhone);
receiver.Email = CleanText(receiver.Email);
receiver.Province = CleanText(receiver.Province);
receiver.City = CleanText(receiver.City);
receiver.StreetNumber = CleanText(receiver.StreetNumber);
receiver.StreetAddress1 = CleanText(receiver.StreetAddress1);
receiver.StreetAddress2 = CleanText(receiver.StreetAddress2);
receiver.StreetAddress3 = CleanText(receiver.StreetAddress3);
receiver.ZipCode = CleanText(receiver.ZipCode);
var cc = CleanText(receiver.CountryCode);
if (!string.IsNullOrWhiteSpace(cc))
{
receiver.CountryCode = cc.ToUpperInvariant();
}
else
{
receiver.CountryCode = null;
}
}
private static (bool IsComplete, List<string> MissingRequired, List<string> MissingRecommended)
EvaluateCompleteness(OrderParcelReceiverInput receiver)
{
var missingRequired = new List<string>();
if (string.IsNullOrWhiteSpace(receiver.RecipientsName)) missingRequired.Add(ReceiverRequiredFieldLabels[0]);
if (string.IsNullOrWhiteSpace(receiver.Phone)) missingRequired.Add(ReceiverRequiredFieldLabels[1]);
if (string.IsNullOrWhiteSpace(receiver.CountryCode)) missingRequired.Add(ReceiverRequiredFieldLabels[2]);
if (string.IsNullOrWhiteSpace(receiver.ZipCode)) missingRequired.Add(ReceiverRequiredFieldLabels[3]);
if (string.IsNullOrWhiteSpace(receiver.StreetAddress1)) missingRequired.Add(ReceiverRequiredFieldLabels[4]);
var missingRecommended = new List<string>();
if (string.IsNullOrWhiteSpace(receiver.Province)) missingRecommended.Add("省/州");
if (string.IsNullOrWhiteSpace(receiver.City)) missingRecommended.Add("城市");
return (missingRequired.Count == 0, missingRequired, missingRecommended);
}
private static string? ReadStr(JsonElement root, string name)
{
if (!root.TryGetProperty(name, out var node))
{
// 兼容 PascalCase
var pascal = char.ToUpperInvariant(name[0]) + name[1..];
if (!root.TryGetProperty(pascal, out node))
{
return null;
}
}
return node.ValueKind switch
{
JsonValueKind.String => node.GetString(),
JsonValueKind.Number => node.ToString(),
JsonValueKind.Null => null,
_ => node.ToString(),
};
}
private static string? CleanText(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? ExtractJsonObject(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
var trimmed = text.Trim();
var fence = Regex.Match(
trimmed,
@"```(?:json)?\s*([\s\S]*?)```",
RegexOptions.IgnoreCase);
if (fence.Success)
{
trimmed = fence.Groups[1].Value.Trim();
}
var start = trimmed.IndexOf('{');
var end = trimmed.LastIndexOf('}');
if (start < 0 || end <= start)
{
return null;
}
return trimmed[start..(end + 1)];
}
}