feat: 9610汇总下单关联出口资质,并补齐批量模板字段
All checks were successful
Build and Deploy / deploy (push) Successful in 58s

下单/导入在 9610Summary 时校验并落库 ExportQualificationId(可回落默认备案),写入 9610 申报企业信息;专线小包模板长宽高改必填并增加资质备案列。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-24 16:12:39 +08:00
parent dd4644e914
commit ddb8efba15
15 changed files with 204 additions and 19 deletions

2
db.sql
View File

@ -124,6 +124,7 @@ CREATE TABLE `t_order` (
`ConsigneeCountryName` VARCHAR(64) NULL COMMENT '国家中文名称',
`CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive',
`ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式',
`ExportQualificationId` INT NULL COMMENT '出口资质备案 Id(t_customer_export_qualification.Id)',
`PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因',
`AllocationPendingReason` VARCHAR(512) NULL COMMENT '待排货原因(排货或渠道买价未匹配)',
`AllocationScheme` VARCHAR(32) NULL COMMENT '最近一次排货方案 costLowest/timeFastest/overallBest/designated',
@ -204,6 +205,7 @@ CREATE TABLE `t_order` (
KEY `idx_ord_source_ctime` (`OrderSource`, `CreateTime`),
KEY `idx_order_pickup` (`PickupOrderId`),
KEY `idx_order_inbound_method` (`InboundMethod`),
KEY `idx_order_export_qualification` (`ExportQualificationId`),
KEY `idx_ord_fulfillment_customer` (`FulfillmentType`, `CustomerId`, `OrderStatus`)
) COMMENT='订单表';

View File

@ -0,0 +1,24 @@
-- 存量库:t_order 增加出口资质备案 Id(9610Summary 选用)
-- 可重复执行。
SET @db := DATABASE();
SET @exists := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order'
AND COLUMN_NAME = 'ExportQualificationId'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `t_order`
ADD COLUMN `ExportQualificationId` INT NULL COMMENT ''出口资质备案 Id(t_customer_export_qualification.Id)'' AFTER `ExportCustomsMode`,
ADD KEY `idx_order_export_qualification` (`ExportQualificationId`)',
'SELECT ''t_order.ExportQualificationId already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -48,18 +48,21 @@ public class CreateCustomerExportQualificationInput
[StringLength(200)]
public string DomesticConsignor { get; set; } = string.Empty;
[Required]
[StringLength(64)]
public string? DomesticConsignorCustomsCode { get; set; }
public string DomesticConsignorCustomsCode { get; set; } = string.Empty;
[Required]
[StringLength(200)]
public string ProducerName { get; set; } = string.Empty;
[Required]
[StringLength(64)]
public string? ProducerCustomsCode { get; set; }
public string ProducerCustomsCode { get; set; } = string.Empty;
[Required]
[StringLength(100)]
public string? CustomsPort { get; set; }
public string CustomsPort { get; set; } = string.Empty;
public bool IsDefault { get; set; }

View File

@ -205,6 +205,11 @@ public class OrderOutput
/// </summary>
public string? ExportCustomsMode { get; set; }
/// <summary>
/// 出口资质备案 Id。
/// </summary>
public int? ExportQualificationId { get; set; }
/// <summary>
/// 预报失败原因(草稿订单)。
/// </summary>

View File

@ -45,6 +45,11 @@ public class OrderParcelCreateInput
/// </summary>
public string? ExportCustomsMode { get; set; }
/// <summary>
/// 出口资质备案 Id(ExportCustomsMode=9610Summary 时必填)。
/// </summary>
public int? ExportQualificationId { get; set; }
/// <summary>
/// 仓库主键。
/// </summary>

View File

@ -86,10 +86,10 @@ public class CustomerExportQualificationService : ApplicationService
CompanyName = input.CompanyName.Trim(),
SocialCreditCode = input.SocialCreditCode.Trim(),
DomesticConsignor = input.DomesticConsignor.Trim(),
DomesticConsignorCustomsCode = TrimOrNull(input.DomesticConsignorCustomsCode),
DomesticConsignorCustomsCode = input.DomesticConsignorCustomsCode.Trim(),
ProducerName = input.ProducerName.Trim(),
ProducerCustomsCode = TrimOrNull(input.ProducerCustomsCode),
CustomsPort = TrimOrNull(input.CustomsPort),
ProducerCustomsCode = input.ProducerCustomsCode.Trim(),
CustomsPort = input.CustomsPort.Trim(),
IsDefault = input.IsDefault ? (byte)1 : (byte)0,
Remark = TrimOrNull(input.Remark)
};
@ -115,10 +115,10 @@ public class CustomerExportQualificationService : ApplicationService
entity.CompanyName = input.CompanyName.Trim();
entity.SocialCreditCode = input.SocialCreditCode.Trim();
entity.DomesticConsignor = input.DomesticConsignor.Trim();
entity.DomesticConsignorCustomsCode = TrimOrNull(input.DomesticConsignorCustomsCode);
entity.DomesticConsignorCustomsCode = input.DomesticConsignorCustomsCode.Trim();
entity.ProducerName = input.ProducerName.Trim();
entity.ProducerCustomsCode = TrimOrNull(input.ProducerCustomsCode);
entity.CustomsPort = TrimOrNull(input.CustomsPort);
entity.ProducerCustomsCode = input.ProducerCustomsCode.Trim();
entity.CustomsPort = input.CustomsPort.Trim();
entity.IsDefault = input.IsDefault ? (byte)1 : (byte)0;
entity.Remark = TrimOrNull(input.Remark);
@ -183,10 +183,25 @@ public class CustomerExportQualificationService : ApplicationService
throw new BusinessException("境内发货人不能为空");
}
if (string.IsNullOrWhiteSpace(input.DomesticConsignorCustomsCode))
{
throw new BusinessException("境内发货人海关代码不能为空");
}
if (string.IsNullOrWhiteSpace(input.ProducerName))
{
throw new BusinessException("生产销售单位不能为空");
}
if (string.IsNullOrWhiteSpace(input.ProducerCustomsCode))
{
throw new BusinessException("生产销售单位海关代码不能为空");
}
if (string.IsNullOrWhiteSpace(input.CustomsPort))
{
throw new BusinessException("备案海关港口地不能为空");
}
}
private static Expression<Func<CustomerExportQualification, bool>> BuildPredicate(

View File

@ -1,4 +1,6 @@
using Fake.DependencyInjection;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
@ -13,15 +15,18 @@ public class Customs9610OrderWriter : IScopedDependency
private readonly ICustoms9610OrderRepository _headerRepository;
private readonly ICustoms9610OrderItemRepository _itemRepository;
private readonly IOrderRepository _orderRepository;
private readonly ICustomerExportQualificationRepository _exportQualificationRepository;
public Customs9610OrderWriter(
ICustoms9610OrderRepository headerRepository,
ICustoms9610OrderItemRepository itemRepository,
IOrderRepository orderRepository)
IOrderRepository orderRepository,
ICustomerExportQualificationRepository exportQualificationRepository)
{
_headerRepository = headerRepository;
_itemRepository = itemRepository;
_orderRepository = orderRepository;
_exportQualificationRepository = exportQualificationRepository;
}
public async Task UpsertFromOrderAsync(Order order, CancellationToken cancellationToken = default)
@ -55,6 +60,7 @@ public class Customs9610OrderWriter : IScopedDependency
var grossWeight = ResolveGrossWeight(weights);
var serviceNo = order.ServiceNo?.Trim() ?? string.Empty;
var clientNo = FirstNonEmpty(order.ClientNo, order.ServiceNo) ?? string.Empty;
var qualification = await ResolveQualificationAsync(order, cancellationToken);
if (existing != null
&& string.Equals(existing.Status, Customs9610StatusCodes.Declared, StringComparison.OrdinalIgnoreCase))
@ -98,6 +104,8 @@ public class Customs9610OrderWriter : IScopedDependency
header.PreRecordFlag = "1";
}
ApplyQualification(header, qualification);
header.GrossWeight = grossWeight;
header.Currency = FirstNonEmpty(order.DeclaredCurrency, header.Currency, "CNY")!;
header.DestCountry = FirstNonEmpty(order.ConsigneeCountry, header.DestCountry) ?? string.Empty;
@ -141,6 +149,50 @@ public class Customs9610OrderWriter : IScopedDependency
}
}
private async Task<CustomerExportQualification?> ResolveQualificationAsync(
Order order,
CancellationToken cancellationToken)
{
if (order.CustomerId is not > 0)
{
return null;
}
if (order.ExportQualificationId is > 0)
{
var owned = await _exportQualificationRepository.GetOwnedAsync(
order.ExportQualificationId.Value,
order.CustomerId.Value,
cancellationToken);
if (owned != null)
{
return owned;
}
}
return await _exportQualificationRepository.GetDefaultAsync(
order.CustomerId.Value,
cancellationToken);
}
private static void ApplyQualification(Customs9610Order header, CustomerExportQualification? qualification)
{
if (qualification == null)
{
return;
}
header.EcomCorpCode = Truncate(
FirstNonEmpty(qualification.DomesticConsignorCustomsCode, qualification.SocialCreditCode),
18);
header.EcomCorpName = Truncate(
FirstNonEmpty(qualification.CompanyName, qualification.DomesticConsignor),
100);
header.ProducerCode = Truncate(qualification.ProducerCustomsCode, 18);
header.ProducerName = Truncate(qualification.ProducerName, 100);
header.ActualSellerCode = Truncate(qualification.SocialCreditCode, 18);
}
private static decimal ResolveGrossWeight(IReadOnlyList<OrderWeight> weights)
{
var forecast = weights.FirstOrDefault(x =>

View File

@ -200,6 +200,7 @@ public partial class OrderService
: null,
RequestId = order.RequestId,
ExportCustomsMode = order.ExportCustomsMode,
ExportQualificationId = order.ExportQualificationId,
PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason)
? null
: order.PredeclareFailReason.Trim(),

View File

@ -40,6 +40,8 @@ public partial class OrderService
private static readonly ConcurrentDictionary<int, byte> InboundBillingInFlight = new();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private ICustomerExportQualificationRepository ExportQualificationRepository =>
LazyServiceProvider.GetRequiredService<ICustomerExportQualificationRepository>();
private IMemberLevelRepository MemberLevelRepository => LazyServiceProvider.GetRequiredService<IMemberLevelRepository>();
@ -409,6 +411,39 @@ public partial class OrderService
throw new BusinessException("出口报关方式不合法");
}
var is9610Summary = string.Equals(
input.ExportCustomsMode,
ExportCustomsModeCodes.Mode9610Summary,
StringComparison.OrdinalIgnoreCase);
if (is9610Summary)
{
CustomerExportQualification? qualification = null;
if (input.ExportQualificationId is > 0)
{
qualification = await ExportQualificationRepository.GetOwnedAsync(
input.ExportQualificationId.Value,
input.CustomerId!.Value);
if (qualification == null)
{
throw new BusinessException("出口资质备案不存在或不属于当前客户");
}
}
else
{
qualification = await ExportQualificationRepository.GetDefaultAsync(input.CustomerId!.Value);
if (qualification == null)
{
throw new BusinessException("9610汇总申报必须选择出口资质备案(或先维护默认备案)");
}
input.ExportQualificationId = qualification.Id;
}
}
else
{
input.ExportQualificationId = null;
}
if (string.IsNullOrWhiteSpace(input.DeclaredCurrency))
{
throw new BusinessException("申报币种不能为空");
@ -1906,6 +1941,7 @@ public partial class OrderService
ConsigneeCountryName = consigneeCountryName,
CargoAttribute = input.CargoAttribute ?? CargoAttributeCodes.General,
ExportCustomsMode = input.ExportCustomsMode,
ExportQualificationId = input.ExportQualificationId,
WarehouseId = input.WarehouseId,
CustomerId = input.CustomerId,
OrderSource = orderSource,

View File

@ -110,10 +110,11 @@ public static class OrderTemplateFieldRegistry
Add("warehouseId", "发货仓库", true, "select", "warehouse", "basic", "1", hint: "从下拉选择发货仓库 ID");
Add("actualWeight", "实重(kg)", true, "number", example: "1.2", hint: "包裹实际重量,单位千克");
Add("quantity", "包裹件数", true, "number", example: "1", hint: "一般为 1");
Add("length", "长(cm)", false, "number", example: "10", hint: "选填,用于材积计费");
Add("width", "宽(cm)", false, "number", example: "10", hint: "选填,用于材积计费");
Add("height", "高(cm)", false, "number", example: "10", hint: "选填,用于材积计费");
Add("exportCustomsMode", "出口报关", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则按简易申报");
Add("length", "长(cm)", true, "number", example: "10", hint: "必填,用于材积计费");
Add("width", "宽(cm)", true, "number", example: "10", hint: "必填,用于材积计费");
Add("height", "高(cm)", true, "number", example: "10", hint: "必填,用于材积计费");
Add("exportCustomsMode", "出口报关", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则按简易申报;填 9610Summary 时需有默认出口资质或填备案Id");
Add("exportQualificationId", "出口资质备案Id", false, "number", example: "1", hint: "9610汇总申报时填写;留空则用客户默认备案");
Add("receiver.recipientsName", "收件人姓名", true, group: "receiver", example: "John Smith", hint: "必填");
Add("receiver.phone", "收件人手机", true, group: "receiver", example: "13900139000", hint: "必填");
@ -167,11 +168,12 @@ public static class OrderTemplateFieldRegistry
Add("clientNo", "客户单号", true, example: "C202604230001", hint: "您自己的订单编号,同一客户不可重复");
Add("productCode", "运输方式", true, "select", "sales_product", "basic", "PROD001", hint: "从下拉选择产品编码");
Add("cargoAttribute", "货物属性", true, "select", "cargo_attribute", "basic", "general", hint: "普货选 general");
Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则简易申报");
Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则简易申报;填 9610Summary 时需有默认出口资质或填备案Id");
Add("exportQualificationId", "出口资质备案Id", false, "number", example: "1", hint: "9610汇总申报时填写;留空则用客户默认备案");
Add("warehouseId", "发货仓库", true, "select", "warehouse", "basic", "1", hint: "从下拉选择仓库 ID");
Add("length", "长(cm)", false, "number", example: "10", hint: "材积计费用");
Add("width", "宽(cm)", false, "number", example: "10", hint: "材积计费用");
Add("height", "高(cm)", false, "number", example: "10", hint: "材积计费用");
Add("length", "长(cm)", true, "number", example: "10", hint: "必填,材积计费用");
Add("width", "宽(cm)", true, "number", example: "10", hint: "必填,材积计费用");
Add("height", "高(cm)", true, "number", example: "10", hint: "必填,材积计费用");
Add("actualWeight", "实重(kg)", true, "number", example: "1.2", hint: "必填");
Add("isCod", "是否COD", false, "boolean", example: "false", hint: "货到付款填 true");
Add("codCurrency", "COD币种", false, "select", "currency", example: "USD", hint: "COD 订单填写");

View File

@ -20,6 +20,10 @@ public static class OrderTemplateHeaderMatcher
["产品编码"] = "productCode",
["货物属性"] = "cargoAttribute",
["出口报关方式"] = "exportCustomsMode",
["出口报关"] = "exportCustomsMode",
["出口资质备案Id"] = "exportQualificationId",
["出口资质备案"] = "exportQualificationId",
["资质备案Id"] = "exportQualificationId",
["仓库"] = "warehouseId",
["仓库ID"] = "warehouseId",
["长cm"] = "length",

View File

@ -27,7 +27,7 @@ public static class OrderTemplateRowMapper
private static readonly HashSet<string> ForceNumberFields = new(StringComparer.OrdinalIgnoreCase)
{
"warehouseId", "length", "width", "height", "actualWeight", "codSum", "declaredSum", "quantity",
"warehouseId", "exportQualificationId", "length", "width", "height", "actualWeight", "codSum", "declaredSum", "quantity",
"receiver.taxType", "receiver.certificateType", "items[0].unitPrice", "items[0].unitWeight",
"items[0].quantity", "items[0].amount", "items[0].weight"
};
@ -75,9 +75,28 @@ public static class OrderTemplateRowMapper
}
ApplyDerivedDeclarationFields(input);
ApplyParcelDimensionDefaults(input);
return input;
}
private static void ApplyParcelDimensionDefaults(OrderParcelCreateInput input)
{
if (!input.Length.HasValue || input.Length <= 0)
{
input.Length = 10;
}
if (!input.Width.HasValue || input.Width <= 0)
{
input.Width = 10;
}
if (!input.Height.HasValue || input.Height <= 0)
{
input.Height = 10;
}
}
private static void ApplyDerivedDeclarationFields(OrderParcelCreateInput input)
{
if (string.IsNullOrWhiteSpace(input.DeclaredCurrency))

View File

@ -10,6 +10,10 @@ public interface ICustomerExportQualificationRepository : IRepository<CustomerEx
int customerId,
CancellationToken cancellationToken = default);
Task<CustomerExportQualification?> GetDefaultAsync(
int customerId,
CancellationToken cancellationToken = default);
Task ClearDefaultAsync(
int customerId,
int? excludeId = null,

View File

@ -52,6 +52,12 @@ public class Order : OmsAuditedAggregateRoot
public string? ExportCustomsMode { get; set; }
/// <summary>
/// 出口资质备案 Id(9610 汇总申报选用)。
/// </summary>
[SugarColumn(IsNullable = true)]
public int? ExportQualificationId { get; set; }
public string? PredeclareFailReason { get; set; }
/// <summary>

View File

@ -18,6 +18,13 @@ public class CustomerExportQualificationRepository
return FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId, cancellationToken);
}
public Task<CustomerExportQualification?> GetDefaultAsync(
int customerId,
CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.CustomerId == customerId && x.IsDefault == 1, cancellationToken);
}
public async Task ClearDefaultAsync(
int customerId,
int? excludeId = null,