admin/src/Oms.Application/Services/Orders/Customs9610OrderService.cs
zzh e931606a75
All checks were successful
Build and Deploy / deploy (push) Successful in 1m2s
feat: 9610 支持从已有运单批量创建汇总申报单
- 新增 POST /order/customs-9610/create-from-orders:运单设为 9610Summary 并绑定出口资质备案(空则客户默认),按申报明细生成待申报单
- 逐单返回结果:已删除、已有 9610 单、无申报明细、无可用备案记为失败,不影响其它运单
2026-10-19 16:24:02 +08:00

1367 lines
52 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>>();
private ICustomerExportQualificationRepository ExportQualificationRepository =>
LazyServiceProvider.GetRequiredService<ICustomerExportQualificationRepository>();
private Customs9610OrderWriter Customs9610Writer =>
LazyServiceProvider.GetRequiredService<Customs9610OrderWriter>();
[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>
/// 从已有运单批量创建 9610 汇总申报单:运单改为 9610Summary 并绑定出口资质备案,按运单申报明细生成待申报单。
/// </summary>
/// <param name="input">运单 Id 列表、出口资质备案 Id(空则用各客户默认备案)、电商平台编码。</param>
/// <returns>逐单结果;已删除、已有 9610 单、无申报明细、无可用备案的运单记为失败,不影响其它运单。</returns>
[HttpPost("/order/customs-9610/create-from-orders")]
public async Task<Customs9610CreateFromOrdersOutput> 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<Customs9610CreateFromOrderResult>();
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,
};
}
/// <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];
}
}