diff --git a/db.sql b/db.sql index 2f18829..72e747b 100644 --- a/db.sql +++ b/db.sql @@ -261,6 +261,30 @@ CREATE TABLE `t_customer_sender_address` ( KEY `idx_csa_customer_default` (`CustomerId`, `AddressType`, `IsDefault`) ) COMMENT='客户地址簿(发件/退件/收件)'; +CREATE TABLE `t_customer_export_qualification` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '归属客户 t_customer.Id', + `CompanyName` VARCHAR(200) NOT NULL COMMENT '企业名称', + `SocialCreditCode` VARCHAR(64) NOT NULL COMMENT '企业社会信用代码', + `DomesticConsignor` VARCHAR(200) NOT NULL COMMENT '境内发货人', + `DomesticConsignorCustomsCode` VARCHAR(64) NULL COMMENT '境内发货人海关代码', + `ProducerName` VARCHAR(200) NOT NULL COMMENT '生产销售单位', + `ProducerCustomsCode` VARCHAR(64) NULL COMMENT '生产销售单位海关代码', + `CustomsPort` VARCHAR(100) NULL COMMENT '备案海关港口地', + `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认:0-否,1-是(同客户仅一条)', + `Remark` VARCHAR(512) NULL COMMENT '备注', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + KEY `idx_ceq_customer` (`CustomerId`, `IsDeleted`, `Id`), + KEY `idx_ceq_credit` (`SocialCreditCode`, `IsDeleted`), + KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`) +) COMMENT='客户出口资质备案'; + CREATE TABLE `t_order_template` ( `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `Name` VARCHAR(64) NOT NULL COMMENT '模板名称', @@ -403,6 +427,7 @@ CREATE TABLE `t_order_declaration` ( `HsCode` VARCHAR(50) NULL COMMENT 'HsCode', `ImHsCode` VARCHAR(50) NULL COMMENT '进口HsCode', `ExHsCode` VARCHAR(50) NULL COMMENT '出口HsCode', + `DeclareElement` VARCHAR(255) NULL COMMENT '出口申报要素', `SkuImage` VARCHAR(512) NULL COMMENT '图片(存 storage key 或短 URL)', `Category` VARCHAR(20) NULL COMMENT '类别', `Brand` VARCHAR(50) NULL COMMENT '品牌', @@ -1351,6 +1376,7 @@ CREATE TABLE `c_sku` ( `UnitPrice` DECIMAL(12,4) NULL COMMENT '单价', `ExHsCode` VARCHAR(64) NULL COMMENT '出口HSCode', `ImHsCode` VARCHAR(64) NULL COMMENT '进口HSCode', + `DeclareElement` VARCHAR(255) NULL COMMENT '出口申报要素', `WeightG` DECIMAL(10,2) NULL COMMENT '重量(g)', `LengthCm` DECIMAL(10,2) NULL COMMENT '长(cm)', `WidthCm` DECIMAL(10,2) NULL COMMENT '宽(cm)', @@ -1998,7 +2024,7 @@ CREATE TABLE `t_9610_order_item` ( `Sku` VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'SKU(对齐 t_order_declaration.Sku)', `CnName` VARCHAR(250) NOT NULL DEFAULT '' COMMENT '中文品名(对齐 t_order_declaration.CnName)', `ExHsCode` VARCHAR(10) NULL COMMENT '出口海关编码(对齐 t_order_declaration.ExHsCode)', - `DeclareElement` VARCHAR(255) NULL COMMENT '申报要素', + `DeclareElement` VARCHAR(255) NULL COMMENT '出口申报要素', `Barcode` VARCHAR(255) NULL COMMENT '条形码', `DestCountry` VARCHAR(255) NULL COMMENT '目的国(地区)代码', `ItemCurrency` VARCHAR(3) NULL COMMENT '币制', diff --git a/sql/migrate_customer_export_qualification.sql b/sql/migrate_customer_export_qualification.sql new file mode 100644 index 0000000..efd88fe --- /dev/null +++ b/sql/migrate_customer_export_qualification.sql @@ -0,0 +1,98 @@ +-- 存量库:客户出口资质备案表(含 IsDefault / Remark) +-- 可重复执行。 +-- 目标表:t_customer_export_qualification + +SET @db := DATABASE(); + +-- 1) 表不存在则整表创建 +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_customer_export_qualification' +); + +SET @sql := IF( + @exists = 0, + 'CREATE TABLE `t_customer_export_qualification` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', + `CustomerId` INT NOT NULL COMMENT ''归属客户 t_customer.Id'', + `CompanyName` VARCHAR(200) NOT NULL COMMENT ''企业名称'', + `SocialCreditCode` VARCHAR(64) NOT NULL COMMENT ''企业社会信用代码'', + `DomesticConsignor` VARCHAR(200) NOT NULL COMMENT ''境内发货人'', + `DomesticConsignorCustomsCode` VARCHAR(64) NULL COMMENT ''境内发货人海关代码'', + `ProducerName` VARCHAR(200) NOT NULL COMMENT ''生产销售单位'', + `ProducerCustomsCode` VARCHAR(64) NULL COMMENT ''生产销售单位海关代码'', + `CustomsPort` VARCHAR(100) NULL COMMENT ''备案海关港口地'', + `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'', + `Remark` VARCHAR(512) NULL COMMENT ''备注'', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'', + `UpdateUserId` INT NULL COMMENT ''更新人'', + `UpdateTime` DATETIME NULL COMMENT ''更新时间'', + PRIMARY KEY (`Id`), + KEY `idx_ceq_customer` (`CustomerId`, `IsDeleted`, `Id`), + KEY `idx_ceq_credit` (`SocialCreditCode`, `IsDeleted`), + KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`) +) COMMENT=''客户出口资质备案''', + 'SELECT ''t_customer_export_qualification already exists'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2) 已建表但缺 IsDefault 时补列 +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_customer_export_qualification' + AND COLUMN_NAME = 'IsDefault' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_customer_export_qualification` + ADD COLUMN `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'' AFTER `CustomsPort`', + 'SELECT ''IsDefault already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 3) 已建表但缺 Remark 时补列 +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_customer_export_qualification' + AND COLUMN_NAME = 'Remark' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_customer_export_qualification` + ADD COLUMN `Remark` VARCHAR(512) NULL COMMENT ''备注'' AFTER `IsDefault`', + 'SELECT ''Remark already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 4) 默认索引 +SET @idx := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_customer_export_qualification' + AND INDEX_NAME = 'idx_ceq_customer_default' +); +SET @sql := IF( + @idx = 0, + 'ALTER TABLE `t_customer_export_qualification` ADD KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)', + 'SELECT ''idx_ceq_customer_default already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/sql/migrate_declare_element.sql b/sql/migrate_declare_element.sql new file mode 100644 index 0000000..62f59fd --- /dev/null +++ b/sql/migrate_declare_element.sql @@ -0,0 +1,38 @@ +-- 存量库:SKU / 订单申报增加「出口申报要素」DeclareElement +-- 可重复执行;仅在列不存在时 ADD。 + +SET @db := DATABASE(); + +-- c_sku.DeclareElement +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 'c_sku' + AND COLUMN_NAME = 'DeclareElement' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `c_sku` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ImHsCode`', + 'SELECT ''c_sku.DeclareElement already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- t_order_declaration.DeclareElement +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order_declaration' + AND COLUMN_NAME = 'DeclareElement' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_order_declaration` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ExHsCode`', + 'SELECT ''t_order_declaration.DeclareElement already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs b/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs new file mode 100644 index 0000000..d068309 --- /dev/null +++ b/src/Oms.Application/Dtos/Customers/CustomerExportQualificationDtos.cs @@ -0,0 +1,109 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Customers; + +/// +/// 出口资质备案分页查询。 +/// +public class CustomerExportQualificationPaginatedInput +{ + [Newtonsoft.Json.JsonProperty("page")] + public int Page { get; set; } = 1; + + [Newtonsoft.Json.JsonProperty("pageSize")] + public int PageSize { get; set; } = 50; + + /// + /// 客户 ID(管理端可选;不传则查全部)。 + /// + [Newtonsoft.Json.JsonProperty("customerId")] + public int? CustomerId { get; set; } + + [Newtonsoft.Json.JsonProperty("keyword")] + public string? Keyword { get; set; } + + [Newtonsoft.Json.JsonProperty("onlyDefault")] + public bool OnlyDefault { get; set; } +} + +/// +/// 新增出口资质备案。 +/// +public class CreateCustomerExportQualificationInput +{ + /// + /// 客户 ID(管理端代操作必传;客户门户可省略)。 + /// + public int? CustomerId { get; set; } + + [Required] + [StringLength(200)] + public string CompanyName { get; set; } = string.Empty; + + [Required] + [StringLength(64)] + public string SocialCreditCode { get; set; } = string.Empty; + + [Required] + [StringLength(200)] + public string DomesticConsignor { get; set; } = string.Empty; + + [StringLength(64)] + public string? DomesticConsignorCustomsCode { get; set; } + + [Required] + [StringLength(200)] + public string ProducerName { get; set; } = string.Empty; + + [StringLength(64)] + public string? ProducerCustomsCode { get; set; } + + [StringLength(100)] + public string? CustomsPort { get; set; } + + public bool IsDefault { get; set; } + + [StringLength(512)] + public string? Remark { get; set; } +} + +/// +/// 更新出口资质备案。 +/// +public class UpdateCustomerExportQualificationInput : CreateCustomerExportQualificationInput +{ + [Required] + public int Id { get; set; } +} + +/// +/// 出口资质备案出参。 +/// +public class CustomerExportQualificationOutput +{ + public int Id { get; set; } + + public int CustomerId { get; set; } + + public string CompanyName { get; set; } = string.Empty; + + public string SocialCreditCode { get; set; } = string.Empty; + + public string DomesticConsignor { get; set; } = string.Empty; + + public string? DomesticConsignorCustomsCode { get; set; } + + public string ProducerName { get; set; } = string.Empty; + + public string? ProducerCustomsCode { get; set; } + + public string? CustomsPort { get; set; } + + public bool IsDefault { get; set; } + + public string? Remark { get; set; } + + public DateTime? CreateTime { get; set; } + + public DateTime? UpdateTime { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/Customs9610Dtos.cs b/src/Oms.Application/Dtos/Orders/Customs9610Dtos.cs index c8bce36..5d7f66b 100644 --- a/src/Oms.Application/Dtos/Orders/Customs9610Dtos.cs +++ b/src/Oms.Application/Dtos/Orders/Customs9610Dtos.cs @@ -301,6 +301,14 @@ public class Customs9610DeclareResult public int? HttpStatusCode { get; set; } public string? ResponseBody { get; set; } + + public string? Url { get; set; } + + public string? RequestJson { get; set; } + + public string? ServiceNo { get; set; } + + public string? ClientNo { get; set; } } public class Customs9610DeclareBatchOutput diff --git a/src/Oms.Application/Dtos/Orders/OrderItemOutput.cs b/src/Oms.Application/Dtos/Orders/OrderItemOutput.cs index c7dba00..6797c7f 100644 --- a/src/Oms.Application/Dtos/Orders/OrderItemOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderItemOutput.cs @@ -65,6 +65,11 @@ public class OrderItemOutput /// public string? ExHsCode { get; set; } + /// + /// 出口申报要素。 + /// + public string? DeclareElement { get; set; } + /// /// SKU 图片。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs index e108084..7f29d6f 100644 --- a/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs @@ -60,6 +60,11 @@ public class OrderParcelItemInput /// public string? ExHsCode { get; set; } + /// + /// 出口申报要素。 + /// + public string? DeclareElement { get; set; } + /// /// SKU 图片。 /// diff --git a/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs b/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs index ebdd332..ed1c63d 100644 --- a/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs +++ b/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs @@ -42,6 +42,11 @@ public class WarehouseSkuOutput public string? ImHsCode { get; set; } + /// + /// 出口申报要素。 + /// + public string? DeclareElement { get; set; } + public decimal? WeightG { get; set; } public decimal? LengthCm { get; set; } @@ -81,6 +86,11 @@ public class WarehouseSkuSaveInput public string? ImHsCode { get; set; } + /// + /// 出口申报要素。 + /// + public string? DeclareElement { get; set; } + public decimal? WeightG { get; set; } public decimal? LengthCm { get; set; } diff --git a/src/Oms.Application/Integrations/Itfx/IItfxForwardClient.cs b/src/Oms.Application/Integrations/Itfx/IItfxForwardClient.cs index cdeee86..c64c164 100644 --- a/src/Oms.Application/Integrations/Itfx/IItfxForwardClient.cs +++ b/src/Oms.Application/Integrations/Itfx/IItfxForwardClient.cs @@ -32,4 +32,14 @@ public interface IItfxForwardClient string appSecret, object body, CancellationToken cancellationToken = default); + + /// + /// 向指定绝对 URL 发起 ForwardWithSubscribe(用于写死网关地址的场景)。 + /// + Task ForwardToAbsoluteUrlAsync( + string absoluteUrl, + string appKey, + string appSecret, + object body, + CancellationToken cancellationToken = default); } diff --git a/src/Oms.Application/Integrations/Itfx/ItfxForwardClient.cs b/src/Oms.Application/Integrations/Itfx/ItfxForwardClient.cs index c61ad95..32f8943 100644 --- a/src/Oms.Application/Integrations/Itfx/ItfxForwardClient.cs +++ b/src/Oms.Application/Integrations/Itfx/ItfxForwardClient.cs @@ -64,6 +64,23 @@ internal sealed class ItfxForwardClient( var root = $"{settings.BaseUrl.Trim().TrimEnd('/')}/InterfaceForward/ForwardWithSubscribe"; var url = $"{root}?standardCode={Uri.EscapeDataString(standardCode.Trim())}&supplierCode={Uri.EscapeDataString(supplierCode.Trim())}"; + return await ForwardToAbsoluteUrlAsync(url, appKey, appSecret, body, cancellationToken); + } + + public async Task ForwardToAbsoluteUrlAsync( + string absoluteUrl, + string appKey, + string appSecret, + object body, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(absoluteUrl)) + { + throw new InvalidOperationException("Itfx absoluteUrl is empty"); + } + + var settings = await configProvider.GetAsync(cancellationToken); + var url = absoluteUrl.Trim(); var requestJson = JsonSerializer.Serialize(body, JsonOptions); using var request = new HttpRequestMessage(HttpMethod.Post, url); diff --git a/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs b/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs new file mode 100644 index 0000000..4818a7b --- /dev/null +++ b/src/Oms.Application/Services/Customers/CustomerExportQualificationService.cs @@ -0,0 +1,239 @@ +using System.Linq.Expressions; +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Customers; +using Oms.Application.Dtos.Shared; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Entities; +using Oms.Domain.CustomersAggregate.Repositories; + +namespace Oms.Application.Services.Customers; + +/// +/// 客户出口资质备案 API。 +/// +[ApiExplorerSettings(GroupName = "OMS-Customer")] +public class CustomerExportQualificationService : ApplicationService +{ + private ICustomerRepository CustomerRepository => + LazyServiceProvider.GetRequiredService(); + + private ICustomerExportQualificationRepository ExportQualificationRepository => + LazyServiceProvider.GetRequiredService(); + + /// + /// 出口资质备案分页列表。 + /// 管理端未传 customerId 时查询全部;客户门户仅查自身。 + /// + [HttpPost("/customer/export-qualification/list")] + public async Task> GetListAsync( + [FromBody] CustomerExportQualificationPaginatedInput input) + { + var customerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var predicate = BuildPredicate(customerId, input); + + var (entities, total) = await RepositoryPageHelper.GetPageAsync( + ExportQualificationRepository, + predicate, + pageIndex, + pageSize, + q => q.OrderByDescending(x => x.IsDefault).ThenByDescending(x => x.Id)); + + return new OmsPageResult + { + Items = entities.Select(MapOutput).ToList(), + Total = total + }; + } + + /// + /// 出口资质备案详情。 + /// + [HttpGet("/customer/export-qualification/detail")] + public async Task GetDetailAsync( + [FromQuery] int id, + [FromQuery] int? customerId = null) + { + var entity = await GetOwnedOrThrowAsync(id, customerId); + return MapOutput(entity); + } + + /// + /// 新增出口资质备案。 + /// + [HttpPost("/customer/export-qualification/create")] + public async Task CreateAsync([FromBody] CreateCustomerExportQualificationInput input) + { + var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + ValidateInput(input); + + if (input.IsDefault) + { + await ExportQualificationRepository.ClearDefaultAsync(customerId); + } + + var entity = new CustomerExportQualification + { + CustomerId = customerId, + CompanyName = input.CompanyName.Trim(), + SocialCreditCode = input.SocialCreditCode.Trim(), + DomesticConsignor = input.DomesticConsignor.Trim(), + DomesticConsignorCustomsCode = TrimOrNull(input.DomesticConsignorCustomsCode), + ProducerName = input.ProducerName.Trim(), + ProducerCustomsCode = TrimOrNull(input.ProducerCustomsCode), + CustomsPort = TrimOrNull(input.CustomsPort), + IsDefault = input.IsDefault ? (byte)1 : (byte)0, + Remark = TrimOrNull(input.Remark) + }; + + entity = await ExportQualificationRepository.InsertAsync(entity); + return new { id = entity.Id }; + } + + /// + /// 更新出口资质备案。 + /// + [HttpPost("/customer/export-qualification/update")] + public async Task UpdateAsync([FromBody] UpdateCustomerExportQualificationInput input) + { + var entity = await GetOwnedOrThrowAsync(input.Id, input.CustomerId); + ValidateInput(input); + + if (input.IsDefault) + { + await ExportQualificationRepository.ClearDefaultAsync(entity.CustomerId, entity.Id); + } + + entity.CompanyName = input.CompanyName.Trim(); + entity.SocialCreditCode = input.SocialCreditCode.Trim(); + entity.DomesticConsignor = input.DomesticConsignor.Trim(); + entity.DomesticConsignorCustomsCode = TrimOrNull(input.DomesticConsignorCustomsCode); + entity.ProducerName = input.ProducerName.Trim(); + entity.ProducerCustomsCode = TrimOrNull(input.ProducerCustomsCode); + entity.CustomsPort = TrimOrNull(input.CustomsPort); + entity.IsDefault = input.IsDefault ? (byte)1 : (byte)0; + entity.Remark = TrimOrNull(input.Remark); + + await ExportQualificationRepository.UpdateAsync(entity); + } + + /// + /// 删除出口资质备案(软删)。 + /// + [HttpPost("/customer/export-qualification/delete")] + public async Task DeleteAsync([FromBody] IdInput input) + { + var entity = await GetOwnedOrThrowAsync(input.Id, null); + await ExportQualificationRepository.DeleteAsync(entity); + } + + /// + /// 设为默认出口资质备案(同客户仅一条)。 + /// + [HttpPost("/customer/export-qualification/set-default")] + public async Task SetDefaultAsync([FromBody] IdInput input) + { + var entity = await GetOwnedOrThrowAsync(input.Id, null); + await ExportQualificationRepository.ClearDefaultAsync(entity.CustomerId, entity.Id); + entity.IsDefault = 1; + await ExportQualificationRepository.UpdateAsync(entity); + } + + private async Task GetOwnedOrThrowAsync(int id, int? requestedCustomerId) + { + var customerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync( + CustomerRepository, + CurrentUser, + requestedCustomerId); + var (hasCustomerFilter, customerIdValue) = WarehouseCustomerHelper.ToCustomerFilter(customerId); + + var entity = hasCustomerFilter + ? await ExportQualificationRepository.GetOwnedAsync(id, customerIdValue) + : await ExportQualificationRepository.FirstOrDefaultAsync(x => x.Id == id); + if (entity == null) + { + throw new BusinessException("出口资质备案不存在"); + } + + return entity; + } + + private static void ValidateInput(CreateCustomerExportQualificationInput input) + { + if (string.IsNullOrWhiteSpace(input.CompanyName)) + { + throw new BusinessException("企业名称不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.SocialCreditCode)) + { + throw new BusinessException("企业社会信用代码不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.DomesticConsignor)) + { + throw new BusinessException("境内发货人不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.ProducerName)) + { + throw new BusinessException("生产销售单位不能为空"); + } + } + + private static Expression> BuildPredicate( + int? customerId, + CustomerExportQualificationPaginatedInput input) + { + var (hasCustomerFilter, customerIdValue) = WarehouseCustomerHelper.ToCustomerFilter(customerId); + var keyword = input.Keyword?.Trim(); + var hasKeyword = !string.IsNullOrWhiteSpace(keyword); + var onlyDefault = input.OnlyDefault; + + return x => + (!hasCustomerFilter || x.CustomerId == customerIdValue) + && (!onlyDefault || x.IsDefault == 1) + && (!hasKeyword + || x.CompanyName.Contains(keyword!) + || x.SocialCreditCode.Contains(keyword!) + || x.DomesticConsignor.Contains(keyword!) + || (x.DomesticConsignorCustomsCode != null && x.DomesticConsignorCustomsCode.Contains(keyword!)) + || x.ProducerName.Contains(keyword!) + || (x.ProducerCustomsCode != null && x.ProducerCustomsCode.Contains(keyword!)) + || (x.CustomsPort != null && x.CustomsPort.Contains(keyword!)) + || (x.Remark != null && x.Remark.Contains(keyword!))); + } + + private static CustomerExportQualificationOutput MapOutput(CustomerExportQualification entity) + { + return new CustomerExportQualificationOutput + { + Id = entity.Id, + CustomerId = entity.CustomerId, + CompanyName = entity.CompanyName, + SocialCreditCode = entity.SocialCreditCode, + DomesticConsignor = entity.DomesticConsignor, + DomesticConsignorCustomsCode = entity.DomesticConsignorCustomsCode, + ProducerName = entity.ProducerName, + ProducerCustomsCode = entity.ProducerCustomsCode, + CustomsPort = entity.CustomsPort, + IsDefault = entity.IsDefault > 0, + Remark = entity.Remark, + CreateTime = entity.CreateTime, + UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) + }; + } + + private static string? TrimOrNull(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs index 9feb3bc..57b3d84 100644 --- a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs +++ b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs @@ -242,17 +242,138 @@ public class Customs9610OrderService : ApplicationService } /// - /// 批量获取报关单(调 API 中心上游,标准码暂定)。 + /// 预报下游(写死 create9610Order / BH 网关地址)。 /// [HttpPost("/order/customs-9610/fetch-declaration-form-batch")] public async Task FetchDeclarationFormBatchAsync([FromBody] Customs9610IdsInput input) { - return await FetchDocumentBatchAsync( - input, - settings => string.IsNullOrWhiteSpace(settings.Customs9610DeclarationFormStandardCode) - ? "customs9610DeclarationForm" - : settings.Customs9610DeclarationFormStandardCode.Trim(), - "获取报关单"); + 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 预报下游地址:create9610Order + supplierCode=BH。 + /// + private const string Create9610OrderForwardUrl = + "https://api.ideship.com/itfx/InterfaceForward/ForwardWithSubscribe?standardCode=create9610Order&supplierCode=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 + { + forward = await ItfxForwardClient.ForwardToAbsoluteUrlAsync( + Create9610OrderForwardUrl, + 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, + Url = Create9610OrderForwardUrl, + }; + } + + 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) diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs b/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs index 0fc0a5b..f0a4f1d 100644 --- a/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs +++ b/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs @@ -119,7 +119,9 @@ public class Customs9610OrderWriter : IScopedDependency { var itemName = FirstNonEmpty(d.CnName, d.EnName) ?? string.Empty; var sku = FirstNonEmpty(d.Sku, $"ITEM{sort + 1}")!; - var declareElement = JoinNonEmpty(" ", d.Model, d.Specifications, d.Attributes, d.Material); + var declareElement = FirstNonEmpty( + d.DeclareElement, + JoinNonEmpty(" ", d.Model, d.Specifications, d.Attributes, d.Material)); await _itemRepository.InsertAsync(new Customs9610OrderItem { Customs9610OrderId = header.Id, diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs index 0e9e8ce..e403be4 100644 --- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs @@ -198,6 +198,7 @@ public static class AutoTriggerFormulaMeta yield return ("HS编码", "t_order_declaration", "HsCode", FormulaValueType.String, false); yield return ("进口HS编码", "t_order_declaration", "ImHsCode", FormulaValueType.String, false); yield return ("出口HS编码", "t_order_declaration", "ExHsCode", FormulaValueType.String, false); + yield return ("出口申报要素", "t_order_declaration", "DeclareElement", FormulaValueType.String, false); yield return ("申报类别", "t_order_declaration", "Category", FormulaValueType.String, false); yield return ("品牌", "t_order_declaration", "Brand", FormulaValueType.String, false); yield return ("品牌类型", "t_order_declaration", "BrandType", FormulaValueType.String, false); diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index 5dbd124..ab15879 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -759,6 +759,7 @@ public partial class OrderService HsCode = x.ExHsCode ?? x.ImHsCode ?? x.HsCode, ImHsCode = x.ImHsCode ?? x.HsCode, ExHsCode = x.ExHsCode ?? x.HsCode, + DeclareElement = x.DeclareElement, SkuImage = FileStorage.GetPublicUrl(x.SkuImage), Category = x.Category, Brand = x.Brand, diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index 88c1430..57aec08 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -1128,6 +1128,7 @@ public partial class OrderService HsCode = x.ExHsCode ?? x.ImHsCode, ImHsCode = x.ImHsCode, ExHsCode = x.ExHsCode ?? x.ImHsCode, + DeclareElement = x.DeclareElement, SkuImage = FileStorage.ToStorageKey(x.SkuImage), Category = x.Category, Brand = x.Brand, diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 08b78c0..0ef8833 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -2012,6 +2012,7 @@ public partial class OrderService HsCode = x.ExHsCode ?? x.ImHsCode, ImHsCode = x.ImHsCode, ExHsCode = x.ExHsCode, + DeclareElement = x.DeclareElement, SkuImage = FileStorage.ToStorageKey(x.SkuImage), Category = x.Category, Brand = x.Brand, diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs index 2139a4f..21cd3d8 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs @@ -121,6 +121,7 @@ public partial class WarehouseService entity.UnitPrice = input.UnitPrice; entity.ExHsCode = string.IsNullOrWhiteSpace(input.ExHsCode) ? null : input.ExHsCode.Trim(); entity.ImHsCode = string.IsNullOrWhiteSpace(input.ImHsCode) ? null : input.ImHsCode.Trim(); + entity.DeclareElement = string.IsNullOrWhiteSpace(input.DeclareElement) ? null : input.DeclareElement.Trim(); entity.WeightG = input.WeightG; entity.LengthCm = input.LengthCm; entity.WidthCm = input.WidthCm; @@ -230,6 +231,7 @@ public partial class WarehouseService UnitPrice = entity.UnitPrice, ExHsCode = entity.ExHsCode, ImHsCode = entity.ImHsCode, + DeclareElement = entity.DeclareElement, WeightG = entity.WeightG, LengthCm = entity.LengthCm, WidthCm = entity.WidthCm, @@ -251,6 +253,7 @@ public partial class WarehouseService UnitPrice = entity.UnitPrice, ExHsCode = entity.ExHsCode, ImHsCode = entity.ImHsCode, + DeclareElement = entity.DeclareElement, WeightG = entity.WeightG, LengthCm = entity.LengthCm, WidthCm = entity.WidthCm, diff --git a/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs b/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs index aa8c17a..22bdf36 100644 --- a/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs +++ b/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs @@ -199,6 +199,7 @@ public static class OrderDownstreamCreateOneBodyBuilder Weight = x.Weight, ImHsCode = x.ImHsCode, ExHsCode = x.ExHsCode ?? x.HsCode, + DeclareElement = x.DeclareElement, SkuImage = fileStorage?.GetPublicUrl(x.SkuImage) ?? x.SkuImage, Category = x.Category, Brand = x.Brand, diff --git a/src/Oms.Domain/CustomersAggregate/Entities/CustomerExportQualification.cs b/src/Oms.Domain/CustomersAggregate/Entities/CustomerExportQualification.cs new file mode 100644 index 0000000..9040a19 --- /dev/null +++ b/src/Oms.Domain/CustomersAggregate/Entities/CustomerExportQualification.cs @@ -0,0 +1,58 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.CustomersAggregate.Entities; + +/// +/// 客户出口资质备案。 +/// +[SugarTable("t_customer_export_qualification")] +public class CustomerExportQualification : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + /// + /// 企业名称。 + /// + public string CompanyName { get; set; } = string.Empty; + + /// + /// 企业社会信用代码。 + /// + public string SocialCreditCode { get; set; } = string.Empty; + + /// + /// 境内发货人。 + /// + public string DomesticConsignor { get; set; } = string.Empty; + + /// + /// 境内发货人海关代码。 + /// + public string? DomesticConsignorCustomsCode { get; set; } + + /// + /// 生产销售单位。 + /// + public string ProducerName { get; set; } = string.Empty; + + /// + /// 生产销售单位海关代码。 + /// + public string? ProducerCustomsCode { get; set; } + + /// + /// 备案海关港口地。 + /// + public string? CustomsPort { get; set; } + + /// + /// 是否默认:同客户仅一条为默认。 + /// + public byte IsDefault { get; set; } + + /// + /// 备注。 + /// + public string? Remark { get; set; } +} diff --git a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerExportQualificationRepository.cs b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerExportQualificationRepository.cs new file mode 100644 index 0000000..2745c10 --- /dev/null +++ b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerExportQualificationRepository.cs @@ -0,0 +1,17 @@ +using Fake.Domain.Repositories; +using Oms.Domain.CustomersAggregate.Entities; + +namespace Oms.Domain.CustomersAggregate.Repositories; + +public interface ICustomerExportQualificationRepository : IRepository +{ + Task GetOwnedAsync( + int id, + int customerId, + CancellationToken cancellationToken = default); + + Task ClearDefaultAsync( + int customerId, + int? excludeId = null, + CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderDeclaration.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderDeclaration.cs index 5d45160..ab158c3 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/OrderDeclaration.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderDeclaration.cs @@ -32,6 +32,11 @@ public class OrderDeclaration : OmsAuditedEntity public string? ExHsCode { get; set; } + /// + /// 出口申报要素。 + /// + public string? DeclareElement { get; set; } + public string? SkuImage { get; set; } public string? Category { get; set; } diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CSku.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CSku.cs index cbb6855..7c7559b 100644 --- a/src/Oms.Domain/WarehouseAggregate/Entities/CSku.cs +++ b/src/Oms.Domain/WarehouseAggregate/Entities/CSku.cs @@ -22,6 +22,11 @@ public class CSku : OmsAuditedAggregateRoot public string? ImHsCode { get; set; } + /// + /// 出口申报要素。 + /// + public string? DeclareElement { get; set; } + public decimal? WeightG { get; set; } public decimal? LengthCm { get; set; } diff --git a/src/Oms.SqlSugarCore/Customers/CustomerExportQualificationRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerExportQualificationRepository.cs new file mode 100644 index 0000000..6852a27 --- /dev/null +++ b/src/Oms.SqlSugarCore/Customers/CustomerExportQualificationRepository.cs @@ -0,0 +1,44 @@ +using Fake.DependencyInjection; +using Oms.Domain.CustomersAggregate.Entities; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Customers; + +public class CustomerExportQualificationRepository + : OmsSqlSugarRepository, + ICustomerExportQualificationRepository, + IScopedDependency +{ + public Task GetOwnedAsync( + int id, + int customerId, + CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId, cancellationToken); + } + + public async Task ClearDefaultAsync( + int customerId, + int? excludeId = null, + CancellationToken cancellationToken = default) + { + var hasExcludeId = excludeId.HasValue; + var excludeIdValue = excludeId.GetValueOrDefault(); + var list = await GetListAsync( + x => x.CustomerId == customerId + && x.IsDefault == 1 + && (!hasExcludeId || x.Id != excludeIdValue), + cancellationToken: cancellationToken); + if (list.Count == 0) + { + return; + } + + foreach (var item in list) + { + item.IsDefault = 0; + await UpdateAsync(item); + } + } +}