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;
///
/// 9610 汇总申报单管理。
///
[ApiExplorerSettings(GroupName = "OMS-Order")]
public class Customs9610OrderService : ApplicationService
{
private ICustoms9610OrderRepository HeaderRepository =>
LazyServiceProvider.GetRequiredService();
private ICustoms9610OrderItemRepository ItemRepository =>
LazyServiceProvider.GetRequiredService();
private IOrderRepository OrderRepository =>
LazyServiceProvider.GetRequiredService();
private ICustomerRepository CustomerRepository =>
LazyServiceProvider.GetRequiredService();
private IItfxForwardClient ItfxForwardClient =>
LazyServiceProvider.GetRequiredService();
private OrderDownstreamCredentialResolver CredentialResolver =>
LazyServiceProvider.GetRequiredService();
private IOptions ItfxOptions =>
LazyServiceProvider.GetRequiredService>();
private ICustomerExportQualificationRepository ExportQualificationRepository =>
LazyServiceProvider.GetRequiredService();
private Customs9610OrderWriter Customs9610Writer =>
LazyServiceProvider.GetRequiredService();
[HttpPost("/order/customs-9610/list")]
public async Task> 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
{
Total = total,
Items = pageItems.Select(x => MapOutput(x, customerNames, includeItems: false)).ToList()
};
}
[HttpGet("/order/customs-9610/status-counts")]
public async Task 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 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 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 DeclareAsync([FromBody] Customs9610IdInput input)
{
return await DeclareOneAsync(input.Id);
}
[HttpPost("/order/customs-9610/declare-batch")]
public async Task DeclareBatchAsync([FromBody] Customs9610IdsInput input)
{
var results = new List();
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,
};
}
///
/// 从已有运单批量创建 9610 汇总申报单:运单改为 9610Summary 并绑定出口资质备案,按运单申报明细生成待申报单。
///
/// 运单 Id 列表、出口资质备案 Id(空则用各客户默认备案)、电商平台编码。
/// 逐单结果;已删除、已有 9610 单、无申报明细、无可用备案的运单记为失败,不影响其它运单。
[HttpPost("/order/customs-9610/create-from-orders")]
public async Task CreateFromOrdersAsync(
[FromBody] Customs9610CreateFromOrdersInput input)
{
var platformCode = input.PlatformCode?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(platformCode))
{
throw new BusinessException("请选择电商平台");
}
if (platformCode.Length > 16)
{
throw new BusinessException("电商平台编码不能超过 16 个字符");
}
var orderIds = input.OrderIds.Where(x => x > 0).Distinct().ToList();
if (orderIds.Count == 0)
{
throw new BusinessException("请选择运单");
}
var sessionCustomerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
null);
var orders = (await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id)))
.ToDictionary(x => x.Id);
var declarations = await OrderRepository.GetDeclarationsByOrderIdsAsync(orderIds);
var ordersWithItems = declarations.Select(x => x.OrderId).ToHashSet();
var results = new List();
foreach (var orderId in orderIds)
{
var result = new Customs9610CreateFromOrderResult { OrderId = orderId };
results.Add(result);
try
{
if (!orders.TryGetValue(orderId, out var order)
|| (sessionCustomerId is > 0 && order.CustomerId != sessionCustomerId.Value))
{
throw new BusinessException("运单不存在或无权访问");
}
result.ServiceNo = order.ServiceNo;
if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("运单已删除");
}
if (order.CustomerId is not > 0)
{
throw new BusinessException("运单未关联客户");
}
if (await HeaderRepository.GetByOrderIdAsync(order.Id) != null)
{
throw new BusinessException("该运单已存在 9610 申报单");
}
if (!ordersWithItems.Contains(order.Id))
{
throw new BusinessException("运单没有申报明细");
}
var qualification = input.ExportQualificationId is > 0
? await ExportQualificationRepository.GetOwnedAsync(
input.ExportQualificationId.Value,
order.CustomerId.Value)
: await ExportQualificationRepository.GetDefaultAsync(order.CustomerId.Value);
if (qualification == null)
{
throw new BusinessException(input.ExportQualificationId is > 0
? "所选出口资质备案不属于该运单客户"
: "运单客户未维护默认出口资质备案");
}
order.ExportCustomsMode = ExportCustomsModeCodes.Mode9610Summary;
order.ExportQualificationId = qualification.Id;
await Customs9610Writer.UpsertFromOrderAsync(order, platformCode);
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
var header = await HeaderRepository.GetByOrderIdAsync(order.Id);
result.Customs9610OrderId = header?.Id;
result.Success = header != null;
result.Message = header == null ? "9610 申报单生成失败" : null;
}
catch (Exception ex)
{
result.Success = false;
result.Message = string.IsNullOrWhiteSpace(ex.Message) ? "创建失败" : ex.Message;
}
}
return new Customs9610CreateFromOrdersOutput
{
SuccessCount = results.Count(x => x.Success),
FailCount = results.Count(x => !x.Success),
Items = results,
};
}
///
/// 批量获取清单数据(调 API 中心上游,标准码暂定),成功后回写本地字段。
///
[HttpPost("/order/customs-9610/fetch-manifest-batch")]
public async Task FetchManifestBatchAsync([FromBody] Customs9610IdsInput input)
{
var results = new List();
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,
};
}
///
/// 预报下游(create9610Order / BH;网关地址取自其他配置 api_center)。
///
[HttpPost("/order/customs-9610/fetch-declaration-form-batch")]
public async Task FetchDeclarationFormBatchAsync([FromBody] Customs9610IdsInput input)
{
var results = new List();
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,
};
}
///
/// 9610 预报下游标准码 / 供应商码(网关根地址由 api_center.BaseUrl 拼装)。
///
private const string Create9610OrderStandardCode = "create9610Order";
private const string Create9610OrderSupplierCode = "BH";
private async Task 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 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 FetchDocumentBatchAsync(
Customs9610IdsInput input,
Func resolveStandardCode,
string actionLabel)
{
var results = new List();
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 FetchDocumentOneAsync(
int id,
Func 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 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 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 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> LoadCustomerNamesAsync(IEnumerable customerIds)
{
var ids = customerIds.Where(x => x > 0).Distinct().ToList();
if (ids.Count == 0)
{
return new Dictionary();
}
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 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];
}
}