admin/src/Oms.Application/Services/Orders/Customs9610OrderService.cs
zzh 24b8c29c9b
All checks were successful
Build and Deploy / deploy (push) Successful in 58s
fix: 9610预报下游回写下游单号兼容 serviceNo/orderNo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 14:50:27 +08:00

1254 lines
46 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 Fake.Application;
using Fake.Domain.Exceptions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Integrations.Itfx;
using Oms.Application.Shared;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 9610 汇总申报单管理。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Order")]
public class Customs9610OrderService : ApplicationService
{
private ICustoms9610OrderRepository HeaderRepository =>
LazyServiceProvider.GetRequiredService<ICustoms9610OrderRepository>();
private ICustoms9610OrderItemRepository ItemRepository =>
LazyServiceProvider.GetRequiredService<ICustoms9610OrderItemRepository>();
private IOrderRepository OrderRepository =>
LazyServiceProvider.GetRequiredService<IOrderRepository>();
private ICustomerRepository CustomerRepository =>
LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private IItfxForwardClient ItfxForwardClient =>
LazyServiceProvider.GetRequiredService<IItfxForwardClient>();
private OrderDownstreamCredentialResolver CredentialResolver =>
LazyServiceProvider.GetRequiredService<OrderDownstreamCredentialResolver>();
private IOptions<ItfxGatewayOptions> ItfxOptions =>
LazyServiceProvider.GetRequiredService<IOptions<ItfxGatewayOptions>>();
[HttpPost("/order/customs-9610/list")]
public async Task<OmsPageResult<Customs9610OrderOutput>> ListAsync([FromBody] Customs9610ListInput input)
{
var page = input.Page <= 0 ? 1 : input.Page;
var pageSize = input.PageSize <= 0 ? 30 : Math.Min(input.PageSize, 200);
var customerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
var keyword = Normalize(input.Keyword);
var status = Normalize(input.Status);
var all = await HeaderRepository.GetListAsync(x =>
(customerId == null || x.CustomerId == customerId.Value) &&
(status == null || x.Status == status) &&
(input.CreateTimeStart == null || x.CreateTime >= input.CreateTimeStart) &&
(input.CreateTimeEnd == null || x.CreateTime <= input.CreateTimeEnd) &&
(keyword == null ||
(x.ServiceNo != null && x.ServiceNo.Contains(keyword)) ||
(x.ClientNo != null && x.ClientNo.Contains(keyword)) ||
(x.CustomsOrderNo != null && x.CustomsOrderNo.Contains(keyword))));
var ordered = all.OrderByDescending(x => x.CreateTime).ThenByDescending(x => x.Id).ToList();
var total = ordered.Count;
var pageItems = ordered.Skip((page - 1) * pageSize).Take(pageSize).ToList();
var customerNames = await LoadCustomerNamesAsync(pageItems.Select(x => x.CustomerId));
return new OmsPageResult<Customs9610OrderOutput>
{
Total = total,
Items = pageItems.Select(x => MapOutput(x, customerNames, includeItems: false)).ToList()
};
}
[HttpGet("/order/customs-9610/status-counts")]
public async Task<Customs9610StatusCountsOutput> GetStatusCountsAsync([FromQuery] int? customerId)
{
var resolvedCustomerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
customerId);
var all = await HeaderRepository.GetListAsync(x =>
resolvedCustomerId == null || x.CustomerId == resolvedCustomerId.Value);
return new Customs9610StatusCountsOutput
{
Total = all.Count,
Pending = all.Count(x =>
string.Equals(x.Status, Customs9610StatusCodes.Pending, StringComparison.OrdinalIgnoreCase)),
Declared = all.Count(x =>
string.Equals(x.Status, Customs9610StatusCodes.Declared, StringComparison.OrdinalIgnoreCase)),
};
}
[HttpGet("/order/customs-9610/detail")]
public async Task<Customs9610OrderOutput> GetDetailAsync([FromQuery] int id)
{
var header = await GetOwnedOrThrowAsync(id);
var items = await ItemRepository.GetByHeaderIdAsync(header.Id);
var customerNames = await LoadCustomerNamesAsync([header.CustomerId]);
var output = MapOutput(header, customerNames, includeItems: true);
output.Items = items
.OrderBy(x => x.SortNo)
.ThenBy(x => x.Id)
.Select(MapItem)
.ToList();
return output;
}
[HttpPost("/order/customs-9610/update")]
public async Task<Customs9610OrderOutput> UpdateAsync([FromBody] Customs9610UpdateInput input)
{
var header = await GetOwnedOrThrowAsync(input.Id);
if (!string.Equals(header.Status, Customs9610StatusCodes.Pending, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("仅待申报单据可编辑");
}
ValidateFormFields(input);
header.PlatformCode = input.PlatformCode.Trim();
header.EcomCorpCode = Normalize(input.EcomCorpCode);
header.EcomCorpName = Normalize(input.EcomCorpName);
header.ProducerCode = Normalize(input.ProducerCode);
header.ProducerName = Normalize(input.ProducerName);
header.ClientNo = input.ClientNo.Trim();
header.ServiceNo = input.ServiceNo.Trim();
header.BusinessType = input.BusinessType.Trim().ToUpperInvariant();
header.Region = Normalize(input.Region);
header.AgentDeclare = Normalize(input.AgentDeclare);
header.ActualSellerCode = Normalize(input.ActualSellerCode);
header.PreRecordFlag = Normalize(input.PreRecordFlag);
header.IsSummarySimple = input.IsSummarySimple ? (byte)1 : (byte)0;
header.GrossWeight = input.GrossWeight;
header.Currency = input.Currency.Trim().ToUpperInvariant();
header.DestCountry = input.DestCountry.Trim().ToUpperInvariant();
header.Freight = input.Freight;
header.ShipmentNo = Normalize(input.ShipmentNo);
header.FailReason = null;
header.UpdateTime = DateTime.Now;
await HeaderRepository.UpdateAsync(header);
await HeaderRepository.HardDeleteItemsByHeaderIdAsync(header.Id);
var sort = 0;
foreach (var row in input.Items)
{
if (string.IsNullOrWhiteSpace(row.Sku) || string.IsNullOrWhiteSpace(row.CnName))
{
throw new BusinessException("商品货号与品名不能为空");
}
await ItemRepository.InsertAsync(new Customs9610OrderItem
{
Customs9610OrderId = header.Id,
OrderDeclarationId = row.OrderDeclarationId,
Sku = row.Sku.Trim(),
CnName = row.CnName.Trim(),
ExHsCode = Normalize(row.ExHsCode),
DeclareElement = Normalize(row.DeclareElement),
UnitPrice = row.UnitPrice,
Quantity = row.Quantity,
Freight = row.Freight,
SortNo = row.SortNo > 0 ? row.SortNo : sort,
CreateTime = DateTime.Now,
});
sort++;
}
return await GetDetailAsync(header.Id);
}
[HttpPost("/order/customs-9610/declare")]
public async Task<Customs9610DeclareResult> DeclareAsync([FromBody] Customs9610IdInput input)
{
return await DeclareOneAsync(input.Id);
}
[HttpPost("/order/customs-9610/declare-batch")]
public async Task<Customs9610DeclareBatchOutput> DeclareBatchAsync([FromBody] Customs9610IdsInput input)
{
var results = new List<Customs9610DeclareResult>();
foreach (var id in input.Ids.Where(x => x > 0).Distinct())
{
try
{
results.Add(await DeclareOneAsync(id));
}
catch (Exception ex)
{
results.Add(new Customs9610DeclareResult
{
Id = id,
Success = false,
Message = string.IsNullOrWhiteSpace(ex.Message) ? "申报失败" : ex.Message,
});
}
}
return new Customs9610DeclareBatchOutput
{
SuccessCount = results.Count(x => x.Success),
FailCount = results.Count(x => !x.Success),
Items = results,
};
}
/// <summary>
/// 批量获取清单数据(调 API 中心上游,标准码暂定),成功后回写本地字段。
/// </summary>
[HttpPost("/order/customs-9610/fetch-manifest-batch")]
public async Task<Customs9610DeclareBatchOutput> FetchManifestBatchAsync([FromBody] Customs9610IdsInput input)
{
var results = new List<Customs9610DeclareResult>();
foreach (var id in input.Ids.Where(x => x > 0).Distinct())
{
try
{
results.Add(await FetchManifestOneAsync(id));
}
catch (Exception ex)
{
results.Add(new Customs9610DeclareResult
{
Id = id,
Success = false,
Message = string.IsNullOrWhiteSpace(ex.Message) ? "获取清单数据失败" : ex.Message,
});
}
}
return new Customs9610DeclareBatchOutput
{
SuccessCount = results.Count(x => x.Success),
FailCount = results.Count(x => !x.Success),
Items = results,
};
}
/// <summary>
/// 预报下游(create9610Order / BH;网关地址取自其他配置 api_center)。
/// </summary>
[HttpPost("/order/customs-9610/fetch-declaration-form-batch")]
public async Task<Customs9610DeclareBatchOutput> FetchDeclarationFormBatchAsync([FromBody] Customs9610IdsInput input)
{
var results = new List<Customs9610DeclareResult>();
foreach (var id in input.Ids.Where(x => x > 0).Distinct())
{
try
{
results.Add(await DownstreamForwardOneAsync(id));
}
catch (Exception ex)
{
results.Add(new Customs9610DeclareResult
{
Id = id,
Success = false,
Message = string.IsNullOrWhiteSpace(ex.Message) ? "预报下游失败" : ex.Message,
});
}
}
return new Customs9610DeclareBatchOutput
{
SuccessCount = results.Count(x => x.Success),
FailCount = results.Count(x => !x.Success),
Items = results,
};
}
/// <summary>
/// 9610 预报下游标准码 / 供应商码(网关根地址由 api_center.BaseUrl 拼装)。
/// </summary>
private const string Create9610OrderStandardCode = "create9610Order";
private const string Create9610OrderSupplierCode = "BH";
private async Task<Customs9610DeclareResult> DownstreamForwardOneAsync(int id)
{
var header = await GetOwnedOrThrowAsync(id);
if (!string.Equals(header.Status, Customs9610StatusCodes.Pending, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("仅待申报单据可预报下游");
}
var items = await ItemRepository.GetByHeaderIdAsync(header.Id);
if (items.Count == 0)
{
throw new BusinessException("申报商品不能为空");
}
ValidateHeaderForDeclare(header);
var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == header.OrderId)
?? throw new BusinessException("关联运单不存在");
var creds = await CredentialResolver.ResolveAsync(order);
var body = BuildDeclareBody(header, items);
ItfxForwardResult forward;
try
{
// BaseUrl 来自其他配置 api_center(ItfxForwardClient 内部解析)
forward = await ItfxForwardClient.ForwardWithSubscribeAsync(
Create9610OrderStandardCode,
Create9610OrderSupplierCode,
creds.AppKey,
creds.AppSecret,
body);
}
catch (Exception ex)
{
header.FailReason = Truncate(ex.Message, 512);
header.UpdateTime = DateTime.Now;
await HeaderRepository.UpdateAsync(header);
return new Customs9610DeclareResult
{
Id = header.Id,
Success = false,
Message = header.FailReason ?? "调用 API 中心失败",
Status = header.Status,
ServiceNo = header.ServiceNo,
ClientNo = header.ClientNo,
};
}
if (!forward.Success)
{
var reason = ItfxForwardResultEvaluator.BuildFailReason(
forward.StatusCode,
forward.ResponseBody,
null,
null);
header.FailReason = Truncate(reason, 512);
header.UpdateTime = DateTime.Now;
await HeaderRepository.UpdateAsync(header);
return new Customs9610DeclareResult
{
Id = header.Id,
Success = false,
Message = header.FailReason ?? "预报下游失败",
Status = header.Status,
ServiceNo = header.ServiceNo,
ClientNo = header.ClientNo,
HttpStatusCode = forward.StatusCode,
ResponseBody = forward.ResponseBody,
Url = forward.Url,
RequestJson = forward.RequestJson,
};
}
TryParseDeclareResponse(forward.ResponseBody, out var customsOrderNo, out var downstreamRef);
header.Status = Customs9610StatusCodes.Declared;
header.CustomsOrderNo = Truncate(customsOrderNo, 32) ?? header.CustomsOrderNo;
header.DownstreamRefNo = Truncate(downstreamRef, 32) ?? header.DownstreamRefNo;
header.DeclareTime = DateTime.Now;
header.FailReason = null;
header.UpdateTime = DateTime.Now;
await HeaderRepository.UpdateAsync(header);
return new Customs9610DeclareResult
{
Id = header.Id,
Success = true,
Message = "预报下游成功",
CustomsOrderNo = header.CustomsOrderNo,
Status = header.Status,
ServiceNo = header.ServiceNo,
ClientNo = header.ClientNo,
HttpStatusCode = forward.StatusCode,
ResponseBody = forward.ResponseBody,
Url = forward.Url,
RequestJson = forward.RequestJson,
};
}
private async Task<Customs9610DeclareResult> FetchManifestOneAsync(int id)
{
var forwardResult = await FetchDocumentOneAsync(
id,
settings => string.IsNullOrWhiteSpace(settings.Customs9610ManifestStandardCode)
? "customs9610Manifest"
: settings.Customs9610ManifestStandardCode.Trim(),
"获取清单数据");
if (!forwardResult.Success)
{
return forwardResult;
}
try
{
await ApplyManifestFromResponseAsync(id, forwardResult.ResponseBody);
}
catch (Exception ex)
{
return new Customs9610DeclareResult
{
Id = id,
Success = false,
Message = Truncate(ex.Message, 512) ?? "清单数据解析/回写失败",
Status = forwardResult.Status,
CustomsOrderNo = forwardResult.CustomsOrderNo,
HttpStatusCode = forwardResult.HttpStatusCode,
ResponseBody = forwardResult.ResponseBody,
};
}
return forwardResult;
}
private async Task ApplyManifestFromResponseAsync(int headerId, string? responseBody)
{
if (string.IsNullOrWhiteSpace(responseBody))
{
throw new BusinessException("清单响应为空");
}
using var doc = JsonDocument.Parse(responseBody);
var payload = ResolveManifestPayload(doc.RootElement);
if (payload.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
{
throw new BusinessException("清单响应缺少有效数据节点");
}
var header = await GetOwnedOrThrowAsync(headerId);
ApplyManifestHeader(header, payload);
header.ManifestSyncTime = DateTime.Now;
header.UpdateTime = DateTime.Now;
await HeaderRepository.UpdateAsync(header);
var itemArray = ResolveManifestItems(payload);
if (itemArray is null || itemArray.Value.ValueKind != JsonValueKind.Array)
{
return;
}
var existing = await ItemRepository.GetByHeaderIdAsync(header.Id);
foreach (var el in itemArray.Value.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object)
{
continue;
}
var sortNo = ReadInt(el, "gNo", "GNo", "gno") ?? 0;
var itemNo = Truncate(ReadString(el, "itemNo", "ItemNo", "sku", "Sku") ?? string.Empty, 32) ?? string.Empty;
var matched = existing.FirstOrDefault(x => sortNo > 0 && x.SortNo == sortNo)
?? existing.FirstOrDefault(x =>
!string.IsNullOrWhiteSpace(itemNo)
&& string.Equals(x.Sku, itemNo, StringComparison.OrdinalIgnoreCase));
if (matched is null)
{
matched = new Customs9610OrderItem
{
Customs9610OrderId = header.Id,
SortNo = sortNo,
Sku = itemNo,
CreateTime = DateTime.Now,
};
ApplyManifestItem(matched, el);
await ItemRepository.InsertAsync(matched);
existing.Add(matched);
}
else
{
ApplyManifestItem(matched, el);
matched.UpdateTime = DateTime.Now;
await ItemRepository.UpdateAsync(matched);
}
}
}
private static JsonElement ResolveManifestPayload(JsonElement root)
{
if (TryGetObject(root, out var data, "data", "Data"))
{
if (TryGetObject(data, out var nested, "cebItems", "CebItems"))
{
// 部分上游把头字段放 data,商品放 cebItems;头字段仍用 data
return data;
}
return data;
}
if (TryGetObject(root, out var ceb, "cebItems", "CebItems"))
{
return ceb;
}
return root;
}
private static JsonElement? ResolveManifestItems(JsonElement payload)
{
if (TryGetPropertyIgnoreCase(payload, out var items, "items", "Items")
&& items.ValueKind == JsonValueKind.Array)
{
return items;
}
if (TryGetObject(payload, out var ceb, "cebItems", "CebItems")
&& TryGetPropertyIgnoreCase(ceb, out var nested, "items", "Items")
&& nested.ValueKind == JsonValueKind.Array)
{
return nested;
}
return null;
}
private static void ApplyManifestHeader(Customs9610Order header, JsonElement payload)
{
var customsOrderNo = Truncate(ReadString(payload, "customsOrderNo", "CustomsOrderNo"), 32);
if (!string.IsNullOrWhiteSpace(customsOrderNo))
{
header.CustomsOrderNo = customsOrderNo;
}
var logisticsNo = Truncate(ReadString(payload, "logisticsNo", "LogisticsNo", "serviceNo", "ServiceNo"), 48);
if (!string.IsNullOrWhiteSpace(logisticsNo))
{
header.ServiceNo = logisticsNo!;
}
var clientNo = Truncate(ReadString(payload, "referenceNo", "ReferenceNo", "clientNo", "ClientNo"), 48);
if (!string.IsNullOrWhiteSpace(clientNo))
{
header.ClientNo = clientNo!;
}
header.EnterpriseBizNo = Truncate(ReadString(payload, "copNo", "CopNo"), 48) ?? header.EnterpriseBizNo;
header.ManifestNo = Truncate(ReadString(payload, "invtNo", "InvtNo"), 48) ?? header.ManifestNo;
header.ClearanceTime = Truncate(ReadString(payload, "clearanceTime", "ClearanceTime"), 48) ?? header.ClearanceTime;
header.CustomsInboundTime =
Truncate(ReadString(payload, "warehouseTime", "WarehouseTime"), 48) ?? header.CustomsInboundTime;
header.CustomsState = Truncate(ReadString(payload, "customsStatus", "CustomsStatus"), 4) ?? header.CustomsState;
header.DeclareCustomsCode =
Truncate(ReadString(payload, "customsCode", "CustomsCode"), 4) ?? header.DeclareCustomsCode;
header.PlatformCorpCode = Truncate(ReadString(payload, "ebpCode", "EbpCode", "platformCorpCode", "PlatformCorpCode"), 18) ?? header.PlatformCorpCode;
header.PlatformName = Truncate(ReadString(payload, "ebpName", "EbpName", "platformName", "PlatformName"), 100) ?? header.PlatformName;
header.LogisticsCorpCode =
Truncate(ReadString(payload, "logisticsCode", "LogisticsCode"), 18) ?? header.LogisticsCorpCode;
header.LogisticsCorpName =
Truncate(ReadString(payload, "logisticsName", "LogisticsName"), 100) ?? header.LogisticsCorpName;
header.IeMark = Truncate(ReadString(payload, "ieFlag", "IeFlag"), 4) ?? header.IeMark;
header.ExportPortCode = Truncate(ReadString(payload, "portCode", "PortCode"), 4) ?? header.ExportPortCode;
header.ExportDate = Truncate(ReadString(payload, "ieDate", "IeDate"), 32) ?? header.ExportDate;
var businessType = Truncate(ReadString(payload, "statisticsFlag", "StatisticsFlag", "formType", "FormType", "businessType", "BusinessType"), 1);
if (businessType is "A" or "B" or "a" or "b")
{
header.BusinessType = businessType.ToUpperInvariant();
}
header.DeclareAgentCode = Truncate(ReadString(payload, "agentCode", "AgentCode"), 18) ?? header.DeclareAgentCode;
header.DeclareAgentName = Truncate(ReadString(payload, "agentName", "AgentName"), 100) ?? header.DeclareAgentName;
header.EcomCorpCode = Truncate(ReadString(payload, "ebcCode", "EbcCode", "ecomCorpCode", "EcomCorpCode"), 18) ?? header.EcomCorpCode;
header.EcomCorpName = Truncate(ReadString(payload, "ebcName", "EbcName", "ecomCorpName", "EcomCorpName"), 100) ?? header.EcomCorpName;
header.ProducerCode = Truncate(ReadString(payload, "ownerCode", "OwnerCode", "producerCode", "ProducerCode"), 18) ?? header.ProducerCode;
header.ProducerName = Truncate(ReadString(payload, "ownerName", "OwnerName", "producerName", "ProducerName"), 100) ?? header.ProducerName;
header.TradeModeCode = Truncate(ReadString(payload, "tradeMode", "TradeMode"), 4) ?? header.TradeModeCode;
header.TransportMode = Truncate(ReadString(payload, "trafMode", "TrafMode"), 4) ?? header.TransportMode;
var destCountry = Truncate(ReadString(payload, "countryCode", "CountryCode", "destCountry", "DestCountry"), 4);
if (!string.IsNullOrWhiteSpace(destCountry))
{
header.DestCountry = destCountry!;
}
var freight = ReadDecimal(payload, "freight", "Freight");
if (freight.HasValue)
{
header.Freight = freight;
}
header.FreightCurrency = Truncate(ReadString(payload, "fCurrency", "FCurrency"), 4) ?? header.FreightCurrency;
header.FreightMark = ReadInt(payload, "fFlag", "FFlag") ?? header.FreightMark;
header.PackageType = ReadInt(payload, "wrapType", "WrapType") ?? header.PackageType;
header.PackageQty = ReadInt(payload, "packNo", "PackNo") ?? header.PackageQty;
// 兼容:若平台短码仍空,用 platformCorpCode 回填 PlatformCode
if (string.IsNullOrWhiteSpace(header.PlatformCode) && !string.IsNullOrWhiteSpace(header.PlatformCorpCode))
{
header.PlatformCode = Truncate(header.PlatformCorpCode, 16)!;
}
}
private static void ApplyManifestItem(Customs9610OrderItem item, JsonElement el)
{
var sortNo = ReadInt(el, "gNo", "GNo", "gno");
if (sortNo.HasValue)
{
item.SortNo = sortNo.Value;
}
var itemNo = Truncate(ReadString(el, "itemNo", "ItemNo", "sku", "Sku"), 32);
if (!string.IsNullOrWhiteSpace(itemNo))
{
item.Sku = itemNo!;
}
var itemName = Truncate(ReadString(el, "itemName", "ItemName", "cnName", "CnName"), 250);
if (!string.IsNullOrWhiteSpace(itemName))
{
item.CnName = itemName!;
}
item.ExHsCode = Truncate(ReadString(el, "gcode", "Gcode", "gCode", "exHsCode", "ExHsCode"), 10) ?? item.ExHsCode;
item.DeclareElement = Truncate(ReadString(el, "gmodel", "Gmodel", "gModel", "declareElement", "DeclareElement"), 255) ?? item.DeclareElement;
item.Barcode = Truncate(ReadString(el, "barCode", "BarCode", "barcode"), 255) ?? item.Barcode;
item.DestCountry = Truncate(ReadString(el, "country", "Country", "destCountry", "DestCountry"), 255) ?? item.DestCountry;
item.ItemCurrency = Truncate(ReadString(el, "currency", "Currency"), 3) ?? item.ItemCurrency;
var qty = ReadDecimal(el, "qty", "Qty");
if (qty.HasValue)
{
item.Quantity = qty.Value;
}
item.LegalQty = ReadDecimal(el, "qty1", "Qty1") ?? item.LegalQty;
item.Unit = Truncate(ReadString(el, "unit", "Unit"), 3) ?? item.Unit;
item.LegalUnit = Truncate(ReadString(el, "unit1", "Unit1"), 3) ?? item.LegalUnit;
var price = ReadDecimal(el, "price", "Price");
if (price.HasValue)
{
item.UnitPrice = price.Value;
}
item.Amount = ReadDecimal(el, "totalPrice", "TotalPrice", "amount", "Amount") ?? item.Amount;
}
private static bool TryGetObject(JsonElement parent, out JsonElement child, params string[] names)
{
if (TryGetPropertyIgnoreCase(parent, out child, names) && child.ValueKind == JsonValueKind.Object)
{
return true;
}
child = default;
return false;
}
private static bool TryGetPropertyIgnoreCase(JsonElement parent, out JsonElement child, params string[] names)
{
foreach (var name in names)
{
if (parent.TryGetProperty(name, out child))
{
return true;
}
}
child = default;
return false;
}
private static int? ReadInt(JsonElement el, params string[] names)
{
foreach (var name in names)
{
if (!el.TryGetProperty(name, out var p))
{
continue;
}
if (p.ValueKind == JsonValueKind.Number && p.TryGetInt32(out var n))
{
return n;
}
if (p.ValueKind == JsonValueKind.String
&& int.TryParse(p.GetString(), out var parsed))
{
return parsed;
}
}
return null;
}
private static decimal? ReadDecimal(JsonElement el, params string[] names)
{
foreach (var name in names)
{
if (!el.TryGetProperty(name, out var p))
{
continue;
}
if (p.ValueKind == JsonValueKind.Number && p.TryGetDecimal(out var d))
{
return d;
}
if (p.ValueKind == JsonValueKind.String
&& decimal.TryParse(p.GetString(), out var parsed))
{
return parsed;
}
}
return null;
}
private async Task<Customs9610DeclareBatchOutput> FetchDocumentBatchAsync(
Customs9610IdsInput input,
Func<ItfxGatewayOptions, string> resolveStandardCode,
string actionLabel)
{
var results = new List<Customs9610DeclareResult>();
foreach (var id in input.Ids.Where(x => x > 0).Distinct())
{
try
{
results.Add(await FetchDocumentOneAsync(id, resolveStandardCode, actionLabel));
}
catch (Exception ex)
{
results.Add(new Customs9610DeclareResult
{
Id = id,
Success = false,
Message = string.IsNullOrWhiteSpace(ex.Message) ? $"{actionLabel}失败" : ex.Message,
});
}
}
return new Customs9610DeclareBatchOutput
{
SuccessCount = results.Count(x => x.Success),
FailCount = results.Count(x => !x.Success),
Items = results,
};
}
private async Task<Customs9610DeclareResult> FetchDocumentOneAsync(
int id,
Func<ItfxGatewayOptions, string> resolveStandardCode,
string actionLabel)
{
var header = await GetOwnedOrThrowAsync(id);
if (!string.Equals(header.Status, Customs9610StatusCodes.Declared, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException($"仅已申报单据可{actionLabel}");
}
if (string.IsNullOrWhiteSpace(header.CustomsOrderNo)
&& string.IsNullOrWhiteSpace(header.ClientNo)
&& string.IsNullOrWhiteSpace(header.ServiceNo))
{
throw new BusinessException("缺少海关订单号/客户单号/运单号,无法获取");
}
var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == header.OrderId)
?? throw new BusinessException("关联运单不存在");
var settings = ItfxOptions.Value;
var standardCode = resolveStandardCode(settings);
var creds = await CredentialResolver.ResolveAsync(order);
var supplierCode = !string.IsNullOrWhiteSpace(settings.Customs9610SupplierCode)
? settings.Customs9610SupplierCode.Trim()
: creds.SupplierCode;
var body = new
{
customsOrderNo = header.CustomsOrderNo,
clientNo = header.ClientNo,
serviceNo = header.ServiceNo,
businessType = header.BusinessType,
platformCode = header.PlatformCode,
};
ItfxForwardResult forward;
try
{
forward = await ItfxForwardClient.ForwardWithSubscribeAsync(
standardCode,
supplierCode,
creds.AppKey,
creds.AppSecret,
body);
}
catch (Exception ex)
{
return new Customs9610DeclareResult
{
Id = header.Id,
Success = false,
Message = Truncate(ex.Message, 512) ?? $"调用 API 中心失败({actionLabel})",
Status = header.Status,
CustomsOrderNo = header.CustomsOrderNo,
};
}
if (!forward.Success)
{
var reason = ItfxForwardResultEvaluator.BuildFailReason(
forward.StatusCode,
forward.ResponseBody,
null,
null);
return new Customs9610DeclareResult
{
Id = header.Id,
Success = false,
Message = Truncate(reason, 512) ?? $"{actionLabel}失败",
Status = header.Status,
CustomsOrderNo = header.CustomsOrderNo,
HttpStatusCode = forward.StatusCode,
ResponseBody = forward.ResponseBody,
};
}
return new Customs9610DeclareResult
{
Id = header.Id,
Success = true,
Message = $"{actionLabel}成功",
Status = header.Status,
CustomsOrderNo = header.CustomsOrderNo,
HttpStatusCode = forward.StatusCode,
ResponseBody = forward.ResponseBody,
};
}
private async Task<Customs9610DeclareResult> DeclareOneAsync(int id)
{
var header = await GetOwnedOrThrowAsync(id);
if (!string.Equals(header.Status, Customs9610StatusCodes.Pending, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("仅待申报单据可申报");
}
var items = await ItemRepository.GetByHeaderIdAsync(header.Id);
if (items.Count == 0)
{
throw new BusinessException("申报商品不能为空");
}
ValidateHeaderForDeclare(header);
var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == header.OrderId)
?? throw new BusinessException("关联运单不存在");
var settings = ItfxOptions.Value;
var standardCode = string.IsNullOrWhiteSpace(settings.Customs9610StandardCode)
? "customs9610Declare"
: settings.Customs9610StandardCode.Trim();
var creds = await CredentialResolver.ResolveAsync(order);
var supplierCode = !string.IsNullOrWhiteSpace(settings.Customs9610SupplierCode)
? settings.Customs9610SupplierCode.Trim()
: creds.SupplierCode;
var body = BuildDeclareBody(header, items);
ItfxForwardResult forward;
try
{
forward = await ItfxForwardClient.ForwardWithSubscribeAsync(
standardCode,
supplierCode,
creds.AppKey,
creds.AppSecret,
body);
}
catch (Exception ex)
{
header.FailReason = Truncate(ex.Message, 512);
header.UpdateTime = DateTime.Now;
await HeaderRepository.UpdateAsync(header);
return new Customs9610DeclareResult
{
Id = header.Id,
Success = false,
Message = header.FailReason ?? "调用 API 中心失败",
Status = header.Status,
};
}
if (!forward.Success)
{
var reason = ItfxForwardResultEvaluator.BuildFailReason(
forward.StatusCode,
forward.ResponseBody,
null,
null);
header.FailReason = Truncate(reason, 512);
header.UpdateTime = DateTime.Now;
await HeaderRepository.UpdateAsync(header);
return new Customs9610DeclareResult
{
Id = header.Id,
Success = false,
Message = header.FailReason ?? "申报失败",
Status = header.Status,
HttpStatusCode = forward.StatusCode,
ResponseBody = forward.ResponseBody,
};
}
TryParseDeclareResponse(forward.ResponseBody, out var customsOrderNo, out var downstreamRef);
header.Status = Customs9610StatusCodes.Declared;
header.CustomsOrderNo = Truncate(customsOrderNo, 32) ?? header.CustomsOrderNo;
header.DownstreamRefNo = Truncate(downstreamRef, 32) ?? header.DownstreamRefNo;
header.DeclareTime = DateTime.Now;
header.FailReason = null;
header.UpdateTime = DateTime.Now;
await HeaderRepository.UpdateAsync(header);
return new Customs9610DeclareResult
{
Id = header.Id,
Success = true,
Message = "申报成功",
CustomsOrderNo = header.CustomsOrderNo,
Status = header.Status,
HttpStatusCode = forward.StatusCode,
ResponseBody = forward.ResponseBody,
};
}
private static object BuildDeclareBody(Customs9610Order header, List<Customs9610OrderItem> items)
{
return new
{
platformCode = header.PlatformCode,
ecomCorpCode = header.EcomCorpCode,
ecomCorpName = header.EcomCorpName,
producerCode = header.ProducerCode,
producerName = header.ProducerName,
clientNo = header.ClientNo,
serviceNo = header.ServiceNo,
businessType = header.BusinessType,
region = header.Region,
agentDeclare = header.AgentDeclare,
actualSellerCode = header.ActualSellerCode,
preRecordFlag = header.PreRecordFlag,
isSummarySimple = header.IsSummarySimple == 1,
grossWeight = header.GrossWeight,
currency = header.Currency,
destCountry = header.DestCountry,
freight = header.Freight,
shipmentNo = header.ShipmentNo,
items = items
.OrderBy(x => x.SortNo)
.ThenBy(x => x.Id)
.Select(x => new
{
sku = x.Sku,
cnName = x.CnName,
exHsCode = x.ExHsCode,
declareElement = x.DeclareElement,
unitPrice = x.UnitPrice,
quantity = x.Quantity,
freight = x.Freight,
sortNo = x.SortNo,
})
.ToList()
};
}
private static void TryParseDeclareResponse(string? responseBody, out string? customsOrderNo, out string? referenceNo)
{
customsOrderNo = null;
referenceNo = null;
if (string.IsNullOrWhiteSpace(responseBody))
{
return;
}
try
{
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
if (root.TryGetProperty("data", out var data) || root.TryGetProperty("Data", out data))
{
customsOrderNo = ReadString(data, "orderNo", "OrderNo", "customsOrderNo", "CustomsOrderNo");
// 下游单号:优先 serviceNo;兼容 referenceNo;BH create9610Order 常回 orderNo
referenceNo = ReadString(
data,
"serviceNo",
"ServiceNo",
"referenceNo",
"ReferenceNo",
"orderNo",
"OrderNo");
}
else
{
customsOrderNo = ReadString(root, "orderNo", "OrderNo", "customsOrderNo", "CustomsOrderNo");
referenceNo = ReadString(
root,
"serviceNo",
"ServiceNo",
"referenceNo",
"ReferenceNo",
"orderNo",
"OrderNo");
}
}
catch
{
// ignore parse errors
}
}
private static string? ReadString(JsonElement el, params string[] names)
{
foreach (var name in names)
{
if (!el.TryGetProperty(name, out var p))
{
continue;
}
if (p.ValueKind == JsonValueKind.String)
{
var s = p.GetString();
if (!string.IsNullOrWhiteSpace(s))
{
return s.Trim();
}
}
else if (p.ValueKind == JsonValueKind.Number)
{
return p.ToString();
}
}
return null;
}
private static void ValidateFormFields(Customs9610UpdateInput input)
{
if (string.IsNullOrWhiteSpace(input.PlatformCode))
{
throw new BusinessException("电商平台 platformCode 不能为空");
}
var businessType = (input.BusinessType ?? string.Empty).Trim().ToUpperInvariant();
if (businessType is not (Customs9610FormTypeCodes.SimpleA or Customs9610FormTypeCodes.SummaryB))
{
throw new BusinessException("businessType 仅支持 A 或 B");
}
if (input.GrossWeight <= 0)
{
throw new BusinessException("毛重必须大于 0");
}
if (string.IsNullOrWhiteSpace(input.Currency))
{
throw new BusinessException("币制不能为空");
}
if (string.IsNullOrWhiteSpace(input.DestCountry))
{
throw new BusinessException("目的国不能为空");
}
if (businessType == Customs9610FormTypeCodes.SimpleA)
{
if (string.IsNullOrWhiteSpace(input.Region))
{
throw new BusinessException("A 简化申报必须填写 region");
}
if (string.IsNullOrWhiteSpace(input.AgentDeclare))
{
throw new BusinessException("A 简化申报必须填写 agentDeclare");
}
}
if (businessType == Customs9610FormTypeCodes.SummaryB
&& string.IsNullOrWhiteSpace(input.PreRecordFlag))
{
throw new BusinessException("B 汇总申报必须填写 preRecordFlag");
}
}
private static void ValidateHeaderForDeclare(Customs9610Order header)
{
if (string.IsNullOrWhiteSpace(header.PlatformCode))
{
throw new BusinessException("请先填写电商平台 platformCode");
}
if (string.IsNullOrWhiteSpace(header.ClientNo))
{
throw new BusinessException("客户单号不能为空");
}
if (string.IsNullOrWhiteSpace(header.ServiceNo))
{
throw new BusinessException("运单号不能为空");
}
if (header.GrossWeight <= 0)
{
throw new BusinessException("毛重必须大于 0");
}
if (string.Equals(header.BusinessType, Customs9610FormTypeCodes.SummaryB, StringComparison.OrdinalIgnoreCase)
&& string.IsNullOrWhiteSpace(header.PreRecordFlag))
{
throw new BusinessException("B 汇总申报必须填写 preRecordFlag");
}
if (string.Equals(header.BusinessType, Customs9610FormTypeCodes.SimpleA, StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(header.Region) || string.IsNullOrWhiteSpace(header.AgentDeclare))
{
throw new BusinessException("A 简化申报必须填写 region / agentDeclare");
}
}
}
private async Task<Customs9610Order> GetOwnedOrThrowAsync(int id)
{
var header = await HeaderRepository.FirstOrDefaultAsync(x => x.Id == id)
?? throw new BusinessException("9610 订单不存在");
var customerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
null);
if (customerId is > 0 && header.CustomerId != customerId.Value)
{
throw new BusinessException("无权访问该 9610 订单");
}
return header;
}
private async Task<Dictionary<int, string>> LoadCustomerNamesAsync(IEnumerable<int> customerIds)
{
var ids = customerIds.Where(x => x > 0).Distinct().ToList();
if (ids.Count == 0)
{
return new Dictionary<int, string>();
}
var customers = await CustomerRepository.GetListAsync(x => ids.Contains(x.Id));
return customers.ToDictionary(x => x.Id, x => x.CustomerName);
}
private static Customs9610OrderOutput MapOutput(
Customs9610Order header,
IReadOnlyDictionary<int, string> customerNames,
bool includeItems)
{
customerNames.TryGetValue(header.CustomerId, out var customerName);
return new Customs9610OrderOutput
{
Id = header.Id,
OrderId = header.OrderId,
ServiceNo = header.ServiceNo,
CustomerId = header.CustomerId,
CustomerName = customerName,
Status = header.Status,
PlatformCode = header.PlatformCode,
EcomCorpCode = header.EcomCorpCode,
EcomCorpName = header.EcomCorpName,
ProducerCode = header.ProducerCode,
ProducerName = header.ProducerName,
ClientNo = header.ClientNo,
BusinessType = header.BusinessType,
Region = header.Region,
AgentDeclare = header.AgentDeclare,
ActualSellerCode = header.ActualSellerCode,
PreRecordFlag = header.PreRecordFlag,
IsSummarySimple = header.IsSummarySimple == 1,
GrossWeight = header.GrossWeight,
Currency = header.Currency,
DestCountry = header.DestCountry,
Freight = header.Freight,
FreightCurrency = header.FreightCurrency,
FreightMark = header.FreightMark,
PackageType = header.PackageType,
PackageQty = header.PackageQty,
ShipmentNo = header.ShipmentNo,
CustomsOrderNo = header.CustomsOrderNo,
DownstreamRefNo = header.DownstreamRefNo,
EnterpriseBizNo = header.EnterpriseBizNo,
ManifestNo = header.ManifestNo,
ClearanceTime = header.ClearanceTime,
CustomsInboundTime = header.CustomsInboundTime,
CustomsState = header.CustomsState,
DeclareCustomsCode = header.DeclareCustomsCode,
PlatformCorpCode = header.PlatformCorpCode,
PlatformName = header.PlatformName,
LogisticsCorpCode = header.LogisticsCorpCode,
LogisticsCorpName = header.LogisticsCorpName,
IeMark = header.IeMark,
ExportPortCode = header.ExportPortCode,
ExportDate = header.ExportDate,
DeclareAgentCode = header.DeclareAgentCode,
DeclareAgentName = header.DeclareAgentName,
TradeModeCode = header.TradeModeCode,
TransportMode = header.TransportMode,
DeclareTime = header.DeclareTime,
ManifestSyncTime = header.ManifestSyncTime,
FailReason = header.FailReason,
CreateTime = header.CreateTime,
UpdateTime = header.UpdateTime == default ? null : header.UpdateTime,
Items = includeItems ? [] : null,
};
}
private static Customs9610ItemOutput MapItem(Customs9610OrderItem item) => new()
{
Id = item.Id,
OrderDeclarationId = item.OrderDeclarationId,
Sku = item.Sku,
CnName = item.CnName,
ExHsCode = item.ExHsCode,
DeclareElement = item.DeclareElement,
Barcode = item.Barcode,
DestCountry = item.DestCountry,
ItemCurrency = item.ItemCurrency,
UnitPrice = item.UnitPrice,
Amount = item.Amount,
Quantity = item.Quantity,
LegalQty = item.LegalQty,
Unit = item.Unit,
LegalUnit = item.LegalUnit,
Freight = item.Freight,
SortNo = item.SortNo,
};
private static string? Normalize(string? value)
{
var t = value?.Trim();
return string.IsNullOrEmpty(t) ? null : t;
}
private static string? Truncate(string? value, int max)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
return value.Length <= max ? value : value[..max];
}
}