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]; } }