Add order attachment files table and move system labels off Order.
Persist generated/printed/downstream labels in t_order_file, drop Order.LabelUrl generate-status fields, and expose file list/upload/delete APIs with customer read-only systemLabel access. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e2443ed6f8
commit
03f11a276f
22
db.sql
22
db.sql
@ -156,6 +156,7 @@ CREATE TABLE `t_order` (
|
|||||||
`Quantity` INT NULL COMMENT '包裹数量',
|
`Quantity` INT NULL COMMENT '包裹数量',
|
||||||
`IsIntercept` TINYINT NOT NULL DEFAULT 0 COMMENT '是否拦截:0-否,1-是',
|
`IsIntercept` TINYINT NOT NULL DEFAULT 0 COMMENT '是否拦截:0-否,1-是',
|
||||||
`IsRemote` TINYINT NOT NULL DEFAULT 0 COMMENT '是否偏远:0-否,1-是',
|
`IsRemote` TINYINT NOT NULL DEFAULT 0 COMMENT '是否偏远:0-否,1-是',
|
||||||
|
`LabelTemplateId` INT NULL COMMENT '出单标签模板Id',
|
||||||
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
|
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
|
||||||
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
|
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
|
||||||
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
|
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||||
@ -177,6 +178,27 @@ CREATE TABLE `t_order` (
|
|||||||
KEY `idx_ord_source_ctime` (`OrderSource`, `CreateTime`)
|
KEY `idx_ord_source_ctime` (`OrderSource`, `CreateTime`)
|
||||||
) COMMENT='订单表';
|
) COMMENT='订单表';
|
||||||
|
|
||||||
|
CREATE TABLE `t_order_file` (
|
||||||
|
`Id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`OrderId` INT NOT NULL COMMENT '订单Id',
|
||||||
|
`FileType` VARCHAR(32) NOT NULL COMMENT 'tradeProof/systemLabel/downstreamLabel/lastMileLabel/infraredImage',
|
||||||
|
`FileName` VARCHAR(255) NULL COMMENT '原始文件名',
|
||||||
|
`FileUrl` VARCHAR(512) NOT NULL COMMENT '相对路径或外链',
|
||||||
|
`ContentType` VARCHAR(128) NULL,
|
||||||
|
`FileSize` BIGINT NULL,
|
||||||
|
`Sort` INT NOT NULL DEFAULT 0,
|
||||||
|
`Source` VARCHAR(32) NOT NULL DEFAULT 'upload' COMMENT 'upload/systemGenerate/downstream/migrate',
|
||||||
|
`Remark` VARCHAR(512) NULL,
|
||||||
|
`IsDeleted` TINYINT NOT NULL DEFAULT 0,
|
||||||
|
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()),
|
||||||
|
`CreateUserId` INT NOT NULL DEFAULT 0,
|
||||||
|
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`UpdateUserId` INT NULL,
|
||||||
|
`UpdateTime` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`Id`),
|
||||||
|
KEY `idx_ord_file_order_type` (`OrderId`, `FileType`, `IsDeleted`)
|
||||||
|
) COMMENT='订单附件表';
|
||||||
|
|
||||||
CREATE TABLE `t_customer_sender_address` (
|
CREATE TABLE `t_customer_sender_address` (
|
||||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
`CustomerId` INT NOT NULL COMMENT '归属客户 t_customer.Id',
|
`CustomerId` INT NOT NULL COMMENT '归属客户 t_customer.Id',
|
||||||
|
|||||||
50
sql/20260728_order_file.sql
Normal file
50
sql/20260728_order_file.sql
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
-- Order files table + migrate LabelUrl into systemLabel, then drop legacy columns.
|
||||||
|
-- Run on MySQL. Backup first.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `t_order_file` (
|
||||||
|
`Id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`OrderId` INT NOT NULL COMMENT 'Order Id',
|
||||||
|
`FileType` VARCHAR(32) NOT NULL COMMENT 'tradeProof/systemLabel/downstreamLabel/lastMileLabel/infraredImage',
|
||||||
|
`FileName` VARCHAR(255) NULL COMMENT 'Original file name',
|
||||||
|
`FileUrl` VARCHAR(512) NOT NULL COMMENT 'Relative or absolute URL',
|
||||||
|
`ContentType` VARCHAR(128) NULL,
|
||||||
|
`FileSize` BIGINT NULL,
|
||||||
|
`Sort` INT NOT NULL DEFAULT 0,
|
||||||
|
`Source` VARCHAR(32) NOT NULL DEFAULT 'upload' COMMENT 'upload/systemGenerate/downstream/migrate',
|
||||||
|
`Remark` VARCHAR(512) NULL,
|
||||||
|
`IsDeleted` TINYINT NOT NULL DEFAULT 0,
|
||||||
|
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()),
|
||||||
|
`CreateUserId` INT NOT NULL DEFAULT 0,
|
||||||
|
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`UpdateUserId` INT NULL,
|
||||||
|
`UpdateTime` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`Id`),
|
||||||
|
KEY `idx_ord_file_order_type` (`OrderId`, `FileType`, `IsDeleted`)
|
||||||
|
) COMMENT='Order attachment files';
|
||||||
|
|
||||||
|
-- Migrate existing system label URL (ignore if column already dropped)
|
||||||
|
INSERT INTO `t_order_file` (`OrderId`, `FileType`, `FileName`, `FileUrl`, `ContentType`, `Sort`, `Source`, `CreateUserId`, `CreateTime`)
|
||||||
|
SELECT
|
||||||
|
`Id`,
|
||||||
|
'systemLabel',
|
||||||
|
SUBSTRING_INDEX(`LabelUrl`, '/', -1),
|
||||||
|
`LabelUrl`,
|
||||||
|
'application/pdf',
|
||||||
|
0,
|
||||||
|
'migrate',
|
||||||
|
0,
|
||||||
|
NOW()
|
||||||
|
FROM `t_order`
|
||||||
|
WHERE `LabelUrl` IS NOT NULL
|
||||||
|
AND TRIM(`LabelUrl`) <> ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM `t_order_file` f
|
||||||
|
WHERE f.`OrderId` = `t_order`.`Id`
|
||||||
|
AND f.`FileType` = 'systemLabel'
|
||||||
|
AND f.`IsDeleted` = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE `t_order`
|
||||||
|
DROP COLUMN `LabelUrl`,
|
||||||
|
DROP COLUMN `LabelGenerateStatus`,
|
||||||
|
DROP COLUMN `LabelGenerateError`;
|
||||||
@ -36,12 +36,12 @@ public class SalesProductDetailOutput
|
|||||||
public int? MaxTimeliness { get; set; }
|
public int? MaxTimeliness { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 客户面单类型。
|
/// 系统面单类型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? CustomerWaybill { get; set; }
|
public string? CustomerWaybill { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 客户面单绑定的标签模板 Id。
|
/// 系统面单绑定的标签模板 Id。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? LabelTemplateId { get; set; }
|
public int? LabelTemplateId { get; set; }
|
||||||
|
|
||||||
|
|||||||
@ -36,12 +36,12 @@ public class SalesProductListOutput
|
|||||||
public int? MaxTimeliness { get; set; }
|
public int? MaxTimeliness { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 客户面单类型。
|
/// 系统面单类型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? CustomerWaybill { get; set; }
|
public string? CustomerWaybill { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 客户面单绑定的标签模板 Id。
|
/// 系统面单绑定的标签模板 Id。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? LabelTemplateId { get; set; }
|
public int? LabelTemplateId { get; set; }
|
||||||
|
|
||||||
|
|||||||
@ -38,12 +38,12 @@ public class SalesProductSaveInput
|
|||||||
public int? MaxTimeliness { get; set; }
|
public int? MaxTimeliness { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 客户面单类型(旧枚举,兼容展示)。
|
/// 系统面单类型(旧枚举字段,兼容展示)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? CustomerWaybill { get; set; }
|
public string? CustomerWaybill { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 客户面单绑定的标签模板 Id。
|
/// 系统面单绑定的标签模板 Id。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? LabelTemplateId { get; set; }
|
public int? LabelTemplateId { get; set; }
|
||||||
|
|
||||||
|
|||||||
41
src/Oms.Application/Dtos/Orders/OrderFileDtos.cs
Normal file
41
src/Oms.Application/Dtos/Orders/OrderFileDtos.cs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
namespace Oms.Application.Dtos.Orders;
|
||||||
|
|
||||||
|
public class OrderFileItemOutput
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int OrderId { get; set; }
|
||||||
|
|
||||||
|
public string FileType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? FileName { get; set; }
|
||||||
|
|
||||||
|
public string FileUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? ContentType { get; set; }
|
||||||
|
|
||||||
|
public long? FileSize { get; set; }
|
||||||
|
|
||||||
|
public int Sort { get; set; }
|
||||||
|
|
||||||
|
public string Source { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Remark { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreateTime { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderFileGroupedOutput
|
||||||
|
{
|
||||||
|
public int OrderId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户登录时仅含 systemLabel;内部含全部类型。
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, List<OrderFileItemOutput>> Groups { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderFileDeleteInput
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
}
|
||||||
@ -62,7 +62,7 @@ internal static class ItfxCreateOrderOneDataParser
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 将下游返回写入订单:服务商单号 ← Data.serviceNo,跟踪号 ← Data.trackNo。
|
/// 将下游返回写入订单:服务商单号 ← Data.serviceNo,跟踪号 ← Data.trackNo。
|
||||||
/// 空值不覆盖已有字段。
|
/// 空值不覆盖已有字段。面单 URL 不写订单表,由调用方写入 t_order_file.downstreamLabel。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool ApplyToOrder(Order order, CreateOrderOneData data)
|
public static bool ApplyToOrder(Order order, CreateOrderOneData data)
|
||||||
{
|
{
|
||||||
@ -88,16 +88,6 @@ internal static class ItfxCreateOrderOneDataParser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(data.LabelUrl))
|
|
||||||
{
|
|
||||||
var value = data.LabelUrl.Trim();
|
|
||||||
if (!string.Equals(order.LabelUrl, value, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
order.LabelUrl = value;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ using Fake.Application;
|
|||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
|
using Oms.Application.Services.Orders;
|
||||||
using Oms.Domain.ChannelsAggregate.Repositories;
|
using Oms.Domain.ChannelsAggregate.Repositories;
|
||||||
using Oms.Domain.OrdersAggregate.Consts;
|
using Oms.Domain.OrdersAggregate.Consts;
|
||||||
using Oms.Domain.OrdersAggregate.Entities;
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
@ -12,7 +13,7 @@ using Oms.Domain.SystemAggregate.Repositories;
|
|||||||
namespace Oms.Application.Labeling;
|
namespace Oms.Application.Labeling;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 面单 PDF 生成:解析模板 → 渲染 → 落盘 → 回写订单。
|
/// 面单 PDF 生成:解析模板 → 渲染 → 落盘 → 写入 t_order_file(systemLabel)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LabelPdfGenerationService(
|
public sealed class LabelPdfGenerationService(
|
||||||
ILabelPdfRenderer renderer,
|
ILabelPdfRenderer renderer,
|
||||||
@ -20,6 +21,7 @@ public sealed class LabelPdfGenerationService(
|
|||||||
ISalesProductRepository salesProductRepository,
|
ISalesProductRepository salesProductRepository,
|
||||||
ISystemOtherConfigRepository systemOtherConfigRepository,
|
ISystemOtherConfigRepository systemOtherConfigRepository,
|
||||||
IOrderRepository orderRepository,
|
IOrderRepository orderRepository,
|
||||||
|
OrderFileManager orderFileManager,
|
||||||
IWebHostEnvironment webHostEnvironment,
|
IWebHostEnvironment webHostEnvironment,
|
||||||
ILogger<LabelPdfGenerationService> logger)
|
ILogger<LabelPdfGenerationService> logger)
|
||||||
{
|
{
|
||||||
@ -28,6 +30,7 @@ public sealed class LabelPdfGenerationService(
|
|||||||
OrderOutput orderOutput,
|
OrderOutput orderOutput,
|
||||||
int? templateIdOverride = null,
|
int? templateIdOverride = null,
|
||||||
string? operatorName = null,
|
string? operatorName = null,
|
||||||
|
int operatorUserId = 0,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@ -35,13 +38,13 @@ public sealed class LabelPdfGenerationService(
|
|||||||
var templateId = await ResolveTemplateIdAsync(order, templateIdOverride, cancellationToken);
|
var templateId = await ResolveTemplateIdAsync(order, templateIdOverride, cancellationToken);
|
||||||
if (!templateId.HasValue)
|
if (!templateId.HasValue)
|
||||||
{
|
{
|
||||||
return await MarkFailedAsync(order, "未配置标签模板(产品未绑且无全局默认)", cancellationToken);
|
return MarkFailed("未配置标签模板(产品未绑且无全局默认)", order.LabelTemplateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var template = await labelTemplateRepository.FirstOrDefaultAsync(x => x.Id == templateId.Value);
|
var template = await labelTemplateRepository.FirstOrDefaultAsync(x => x.Id == templateId.Value);
|
||||||
if (template == null || string.IsNullOrWhiteSpace(template.TemplateJson))
|
if (template == null || string.IsNullOrWhiteSpace(template.TemplateJson))
|
||||||
{
|
{
|
||||||
return await MarkFailedAsync(order, $"标签模板不存在或 JSON 为空: {templateId}", cancellationToken);
|
return MarkFailed($"标签模板不存在或 JSON 为空: {templateId}", order.LabelTemplateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var printData = LabelPrintDataBuilder.Build(orderOutput, operatorName);
|
var printData = LabelPrintDataBuilder.Build(orderOutput, operatorName);
|
||||||
@ -49,12 +52,19 @@ public sealed class LabelPdfGenerationService(
|
|||||||
|
|
||||||
var relativeUrl = await SavePdfAsync(order.ServiceNo, pdfBytes, cancellationToken);
|
var relativeUrl = await SavePdfAsync(order.ServiceNo, pdfBytes, cancellationToken);
|
||||||
order.LabelTemplateId = templateId;
|
order.LabelTemplateId = templateId;
|
||||||
order.LabelUrl = relativeUrl;
|
|
||||||
order.LabelGenerateStatus = LabelGenerateStatusCodes.Ready;
|
|
||||||
order.LabelGenerateError = null;
|
|
||||||
order.UpdateTime = DateTime.Now;
|
order.UpdateTime = DateTime.Now;
|
||||||
await orderRepository.UpdateAsync(order);
|
await orderRepository.UpdateAsync(order);
|
||||||
|
|
||||||
|
await orderFileManager.ReplaceSystemLabelAsync(
|
||||||
|
order.Id,
|
||||||
|
relativeUrl,
|
||||||
|
fileName: Path.GetFileName(relativeUrl),
|
||||||
|
contentType: "application/pdf",
|
||||||
|
fileSize: pdfBytes.LongLength,
|
||||||
|
source: OrderFileSourceCodes.SystemGenerate,
|
||||||
|
operatorUserId: operatorUserId,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
return new LabelPdfGenerateResult
|
return new LabelPdfGenerateResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
@ -66,7 +76,7 @@ public sealed class LabelPdfGenerationService(
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Generate label PDF failed for order {OrderId}", order.Id);
|
logger.LogError(ex, "Generate label PDF failed for order {OrderId}", order.Id);
|
||||||
return await MarkFailedAsync(order, ex.Message, cancellationToken);
|
return MarkFailed(ex.Message, order.LabelTemplateId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,21 +144,15 @@ public sealed class LabelPdfGenerationService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<LabelPdfGenerateResult> MarkFailedAsync(
|
private static LabelPdfGenerateResult MarkFailed(string error, int? labelTemplateId)
|
||||||
Order order,
|
|
||||||
string error,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
order.LabelGenerateStatus = LabelGenerateStatusCodes.Failed;
|
var msg = error.Length > 500 ? error[..500] : error;
|
||||||
order.LabelGenerateError = error.Length > 500 ? error[..500] : error;
|
|
||||||
order.UpdateTime = DateTime.Now;
|
|
||||||
await orderRepository.UpdateAsync(order);
|
|
||||||
return new LabelPdfGenerateResult
|
return new LabelPdfGenerateResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Status = LabelGenerateStatusCodes.Failed,
|
Status = LabelGenerateStatusCodes.Failed,
|
||||||
Error = order.LabelGenerateError,
|
Error = msg,
|
||||||
LabelTemplateId = order.LabelTemplateId
|
LabelTemplateId = labelTemplateId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -130,7 +130,6 @@ public class LabelPrintService : ApplicationService
|
|||||||
if (labelTemplate != null)
|
if (labelTemplate != null)
|
||||||
{
|
{
|
||||||
order.LabelTemplateId = labelTemplate.Id;
|
order.LabelTemplateId = labelTemplate.Id;
|
||||||
order.LabelGenerateStatus = LabelGenerateStatusCodes.Pending;
|
|
||||||
await OrderRepository.UpdateAsync(order);
|
await OrderRepository.UpdateAsync(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ public sealed class OrderBatchActionExecutor(
|
|||||||
ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder,
|
ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder,
|
||||||
OrderDownstreamCredentialResolver downstreamCredentialResolver,
|
OrderDownstreamCredentialResolver downstreamCredentialResolver,
|
||||||
IItfxForwardClient itfxForwardClient,
|
IItfxForwardClient itfxForwardClient,
|
||||||
|
OrderFileManager orderFileManager,
|
||||||
OrderBatchCreateActionHandler createActionHandler,
|
OrderBatchCreateActionHandler createActionHandler,
|
||||||
OrderBatchOrderActionHandler orderActionHandler) : IOrderBatchActionExecutor, IScopedDependency
|
OrderBatchOrderActionHandler orderActionHandler) : IOrderBatchActionExecutor, IScopedDependency
|
||||||
{
|
{
|
||||||
@ -215,10 +216,21 @@ public sealed class OrderBatchActionExecutor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
|
if (ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
|
||||||
&& data != null
|
&& data != null)
|
||||||
&& ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
|
|
||||||
{
|
{
|
||||||
await orderRepository.UpdateAsync(order, cancellationToken);
|
if (ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
|
||||||
|
{
|
||||||
|
await orderRepository.UpdateAsync(order, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(data.LabelUrl))
|
||||||
|
{
|
||||||
|
await orderFileManager.AddOrReplaceDownstreamLabelAsync(
|
||||||
|
order.Id,
|
||||||
|
data.LabelUrl!,
|
||||||
|
message.OperatorUserId,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return OrderBatchActionExecuteResult.Ok(
|
return OrderBatchActionExecuteResult.Ok(
|
||||||
|
|||||||
355
src/Oms.Application/Services/Orders/OrderFileManager.cs
Normal file
355
src/Oms.Application/Services/Orders/OrderFileManager.cs
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
using Fake.Application;
|
||||||
|
using Fake.DependencyInjection;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Oms.Application.Dtos.Orders;
|
||||||
|
using Oms.Application.Shared;
|
||||||
|
using Oms.Domain.OrdersAggregate.Consts;
|
||||||
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
|
using Oms.Domain.OrdersAggregate.Repositories;
|
||||||
|
|
||||||
|
namespace Oms.Application.Services.Orders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单附件读写(系统面单仅 1 份,其它类型最多 20)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrderFileManager(
|
||||||
|
IOrderFileRepository orderFileRepository,
|
||||||
|
IWebHostEnvironment webHostEnvironment) : IScopedDependency
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> ImageExt =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" };
|
||||||
|
|
||||||
|
private static readonly HashSet<string> LabelExt =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase) { ".pdf", ".jpg", ".jpeg", ".png" };
|
||||||
|
|
||||||
|
private const long MaxImageBytes = 5 * 1024 * 1024;
|
||||||
|
private const long MaxPdfBytes = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
public async Task<string?> GetSystemLabelUrlAsync(int orderId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var file = await orderFileRepository.GetByOrderAndTypeAsync(
|
||||||
|
orderId,
|
||||||
|
OrderFileTypeCodes.SystemLabel,
|
||||||
|
cancellationToken);
|
||||||
|
return string.IsNullOrWhiteSpace(file?.FileUrl) ? null : file.FileUrl.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OrderFileGroupedOutput> GetGroupedAsync(
|
||||||
|
int orderId,
|
||||||
|
bool customerView,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var list = await orderFileRepository.GetByOrderIdAsync(orderId, cancellationToken);
|
||||||
|
if (customerView)
|
||||||
|
{
|
||||||
|
list = list
|
||||||
|
.Where(x => string.Equals(x.FileType, OrderFileTypeCodes.SystemLabel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var groups = list
|
||||||
|
.OrderBy(x => x.Sort)
|
||||||
|
.ThenBy(x => x.Id)
|
||||||
|
.GroupBy(x => x.FileType, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => g.Select(MapItem).ToList(),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Ensure known keys present for UI
|
||||||
|
foreach (var type in OrderFileTypeCodes.All)
|
||||||
|
{
|
||||||
|
if (customerView && !string.Equals(type, OrderFileTypeCodes.SystemLabel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groups.ContainsKey(type))
|
||||||
|
{
|
||||||
|
groups[type] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OrderFileGroupedOutput { OrderId = orderId, Groups = groups };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OrderFileItemOutput> ReplaceSystemLabelAsync(
|
||||||
|
int orderId,
|
||||||
|
string fileUrl,
|
||||||
|
string? fileName,
|
||||||
|
string? contentType,
|
||||||
|
long? fileSize,
|
||||||
|
string source,
|
||||||
|
int operatorUserId,
|
||||||
|
string? remark = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await orderFileRepository.SoftDeleteByOrderAndTypeAsync(
|
||||||
|
orderId,
|
||||||
|
OrderFileTypeCodes.SystemLabel,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var entity = new OrderFile
|
||||||
|
{
|
||||||
|
OrderId = orderId,
|
||||||
|
FileType = OrderFileTypeCodes.SystemLabel,
|
||||||
|
FileName = fileName,
|
||||||
|
FileUrl = fileUrl.Trim(),
|
||||||
|
ContentType = contentType,
|
||||||
|
FileSize = fileSize,
|
||||||
|
Sort = 0,
|
||||||
|
Source = source,
|
||||||
|
Remark = remark,
|
||||||
|
CreateUserId = operatorUserId,
|
||||||
|
CreateTime = DateTime.Now
|
||||||
|
};
|
||||||
|
var saved = await orderFileRepository.InsertAsync(entity, cancellationToken);
|
||||||
|
return MapItem(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OrderFileItemOutput> AddOrReplaceDownstreamLabelAsync(
|
||||||
|
int orderId,
|
||||||
|
string fileUrl,
|
||||||
|
int operatorUserId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fileUrl))
|
||||||
|
{
|
||||||
|
throw new BusinessException("下游面单地址为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await orderFileRepository.GetByOrderAndTypeListAsync(
|
||||||
|
orderId,
|
||||||
|
OrderFileTypeCodes.DownstreamLabel,
|
||||||
|
cancellationToken);
|
||||||
|
var dup = existing.FirstOrDefault(x =>
|
||||||
|
string.Equals(x.FileUrl, fileUrl.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (dup != null)
|
||||||
|
{
|
||||||
|
return MapItem(dup);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.Count >= OrderFileTypeCodes.GetMaxCount(OrderFileTypeCodes.DownstreamLabel))
|
||||||
|
{
|
||||||
|
// 超额时软删最早一条再追加
|
||||||
|
var oldest = existing.OrderBy(x => x.Id).First();
|
||||||
|
oldest.IsDeleted = true;
|
||||||
|
oldest.UpdateTime = DateTime.Now;
|
||||||
|
await orderFileRepository.UpdateAsync(oldest, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = new OrderFile
|
||||||
|
{
|
||||||
|
OrderId = orderId,
|
||||||
|
FileType = OrderFileTypeCodes.DownstreamLabel,
|
||||||
|
FileName = TryFileNameFromUrl(fileUrl),
|
||||||
|
FileUrl = fileUrl.Trim(),
|
||||||
|
ContentType = null,
|
||||||
|
Sort = existing.Count,
|
||||||
|
Source = OrderFileSourceCodes.Downstream,
|
||||||
|
CreateUserId = operatorUserId,
|
||||||
|
CreateTime = DateTime.Now
|
||||||
|
};
|
||||||
|
var saved = await orderFileRepository.InsertAsync(entity, cancellationToken);
|
||||||
|
return MapItem(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<OrderFileItemOutput>> UploadAsync(
|
||||||
|
int orderId,
|
||||||
|
string fileType,
|
||||||
|
IFormFileCollection files,
|
||||||
|
int operatorUserId,
|
||||||
|
bool isCustomer,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (isCustomer)
|
||||||
|
{
|
||||||
|
throw new BusinessException("客户账号无权上传订单附件");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!OrderFileTypeCodes.IsValid(fileType))
|
||||||
|
{
|
||||||
|
throw new BusinessException("不支持的附件类型");
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = OrderFileTypeCodes.Normalize(fileType);
|
||||||
|
if (files == null || files.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException("请选择文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken);
|
||||||
|
var max = OrderFileTypeCodes.GetMaxCount(type);
|
||||||
|
var results = new List<OrderFileItemOutput>();
|
||||||
|
|
||||||
|
if (string.Equals(type, OrderFileTypeCodes.SystemLabel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (files.Count != 1)
|
||||||
|
{
|
||||||
|
throw new BusinessException("系统面单仅允许上传 1 个文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
var savedPath = await SaveUploadFileAsync(files[0], orderId, type);
|
||||||
|
results.Add(await ReplaceSystemLabelAsync(
|
||||||
|
orderId,
|
||||||
|
savedPath.Url,
|
||||||
|
savedPath.FileName,
|
||||||
|
savedPath.ContentType,
|
||||||
|
savedPath.Size,
|
||||||
|
OrderFileSourceCodes.Upload,
|
||||||
|
operatorUserId,
|
||||||
|
cancellationToken: cancellationToken));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.Count + files.Count > max)
|
||||||
|
{
|
||||||
|
throw new BusinessException($"类型 {type} 最多 {max} 个文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortBase = existing.Count == 0 ? 0 : existing.Max(x => x.Sort) + 1;
|
||||||
|
var index = 0;
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
var savedPath = await SaveUploadFileAsync(file, orderId, type);
|
||||||
|
var entity = new OrderFile
|
||||||
|
{
|
||||||
|
OrderId = orderId,
|
||||||
|
FileType = type,
|
||||||
|
FileName = savedPath.FileName,
|
||||||
|
FileUrl = savedPath.Url,
|
||||||
|
ContentType = savedPath.ContentType,
|
||||||
|
FileSize = savedPath.Size,
|
||||||
|
Sort = sortBase + index,
|
||||||
|
Source = OrderFileSourceCodes.Upload,
|
||||||
|
CreateUserId = operatorUserId,
|
||||||
|
CreateTime = DateTime.Now
|
||||||
|
};
|
||||||
|
var saved = await orderFileRepository.InsertAsync(entity, cancellationToken);
|
||||||
|
results.Add(MapItem(saved));
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(
|
||||||
|
int fileId,
|
||||||
|
bool isCustomer,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (isCustomer)
|
||||||
|
{
|
||||||
|
throw new BusinessException("客户账号无权删除订单附件");
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = await orderFileRepository.FirstOrDefaultAsync(x => x.Id == fileId, cancellationToken)
|
||||||
|
?? throw new BusinessException("附件不存在");
|
||||||
|
entity.IsDeleted = true;
|
||||||
|
entity.UpdateTime = DateTime.Now;
|
||||||
|
await orderFileRepository.UpdateAsync(entity, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string Url, string FileName, string? ContentType, long Size)> SaveUploadFileAsync(
|
||||||
|
IFormFile file,
|
||||||
|
int orderId,
|
||||||
|
string fileType)
|
||||||
|
{
|
||||||
|
if (file == null || file.Length <= 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException("上传文件不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(file.FileName);
|
||||||
|
if (string.IsNullOrWhiteSpace(ext))
|
||||||
|
{
|
||||||
|
ext = ".bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
var isLabelType = fileType is OrderFileTypeCodes.SystemLabel
|
||||||
|
or OrderFileTypeCodes.DownstreamLabel
|
||||||
|
or OrderFileTypeCodes.LastMileLabel;
|
||||||
|
var isInfraredOrProof = fileType is OrderFileTypeCodes.InfraredImage
|
||||||
|
or OrderFileTypeCodes.TradeProof;
|
||||||
|
|
||||||
|
if (isLabelType)
|
||||||
|
{
|
||||||
|
if (!LabelExt.Contains(ext))
|
||||||
|
{
|
||||||
|
throw new BusinessException("面单仅支持 pdf / jpg / png");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && file.Length > MaxPdfBytes)
|
||||||
|
{
|
||||||
|
throw new BusinessException("PDF 不能超过 10MB");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && file.Length > MaxImageBytes)
|
||||||
|
{
|
||||||
|
throw new BusinessException("图片不能超过 5MB");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (isInfraredOrProof)
|
||||||
|
{
|
||||||
|
if (!ImageExt.Contains(ext) && !string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new BusinessException("交易证明/红外图片仅支持图片或 pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.Length > MaxPdfBytes)
|
||||||
|
{
|
||||||
|
throw new BusinessException("文件不能超过 10MB");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var webRoot = string.IsNullOrWhiteSpace(webHostEnvironment.WebRootPath)
|
||||||
|
? Path.Combine(webHostEnvironment.ContentRootPath, "wwwroot")
|
||||||
|
: webHostEnvironment.WebRootPath;
|
||||||
|
var relativeDir = Path.Combine("uploads", "order-files", orderId.ToString(), fileType);
|
||||||
|
var absoluteDir = Path.Combine(webRoot, relativeDir);
|
||||||
|
Directory.CreateDirectory(absoluteDir);
|
||||||
|
|
||||||
|
var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
|
||||||
|
var absolutePath = Path.Combine(absoluteDir, storedName);
|
||||||
|
await using (var stream = File.Create(absolutePath))
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = $"/uploads/order-files/{orderId}/{fileType}/{storedName}".Replace('\\', '/');
|
||||||
|
return (url, Path.GetFileName(file.FileName), file.ContentType, file.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OrderFileItemOutput MapItem(OrderFile x) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = x.Id,
|
||||||
|
OrderId = x.OrderId,
|
||||||
|
FileType = x.FileType,
|
||||||
|
FileName = x.FileName,
|
||||||
|
FileUrl = x.FileUrl,
|
||||||
|
ContentType = x.ContentType,
|
||||||
|
FileSize = x.FileSize,
|
||||||
|
Sort = x.Sort,
|
||||||
|
Source = x.Source,
|
||||||
|
Remark = x.Remark,
|
||||||
|
CreateTime = x.CreateTime
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string? TryFileNameFromUrl(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = url.Contains('?', StringComparison.Ordinal)
|
||||||
|
? url[..url.IndexOf('?', StringComparison.Ordinal)]
|
||||||
|
: url;
|
||||||
|
var name = Path.GetFileName(path);
|
||||||
|
return string.IsNullOrWhiteSpace(name) ? null : name;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,6 +39,7 @@ public partial class OrderService
|
|||||||
mapped,
|
mapped,
|
||||||
templateIdOverride: null,
|
templateIdOverride: null,
|
||||||
operatorName: null,
|
operatorName: null,
|
||||||
|
operatorUserId: 0,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -53,10 +53,20 @@ public partial class OrderService
|
|||||||
|
|
||||||
if (forward.Success
|
if (forward.Success
|
||||||
&& ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
|
&& ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
|
||||||
&& data != null
|
&& data != null)
|
||||||
&& ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
|
|
||||||
{
|
{
|
||||||
await OrderRepository.UpdateAsync(order);
|
if (ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
|
||||||
|
{
|
||||||
|
await OrderRepository.UpdateAsync(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(data.LabelUrl))
|
||||||
|
{
|
||||||
|
await OrderFileManager.AddOrReplaceDownstreamLabelAsync(
|
||||||
|
order.Id,
|
||||||
|
data.LabelUrl!,
|
||||||
|
CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new OrderDownstreamForwardResult
|
return new OrderDownstreamForwardResult
|
||||||
|
|||||||
@ -0,0 +1,71 @@
|
|||||||
|
using Fake.Application;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Oms.Application.Dtos.Orders;
|
||||||
|
using Oms.Application.Shared;
|
||||||
|
|
||||||
|
namespace Oms.Application.Services.Orders;
|
||||||
|
|
||||||
|
public partial class OrderService
|
||||||
|
{
|
||||||
|
private OrderFileManager OrderFileManager =>
|
||||||
|
LazyServiceProvider.GetRequiredService<OrderFileManager>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询订单附件(按类型分组)。客户登录仅返回系统面单。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("/order/files")]
|
||||||
|
public async Task<OrderFileGroupedOutput> GetOrderFilesAsync([FromQuery] int orderId)
|
||||||
|
{
|
||||||
|
await GetOrderOrThrowAsync(orderId);
|
||||||
|
var customerView = OmsSessionHelper.IsCustomerLogin(CurrentUser);
|
||||||
|
return await OrderFileManager.GetGroupedAsync(orderId, customerView);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 上传订单附件。客户账号禁止上传。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("/order/files/upload")]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
|
public async Task<List<OrderFileItemOutput>> UploadOrderFilesAsync(
|
||||||
|
[FromForm] int orderId,
|
||||||
|
[FromForm] string fileType,
|
||||||
|
[FromForm] List<IFormFile> files)
|
||||||
|
{
|
||||||
|
await GetOrderOrThrowAsync(orderId);
|
||||||
|
var collection = new FormFileCollection();
|
||||||
|
foreach (var file in files ?? [])
|
||||||
|
{
|
||||||
|
collection.Add(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await OrderFileManager.UploadAsync(
|
||||||
|
orderId,
|
||||||
|
fileType,
|
||||||
|
collection,
|
||||||
|
CurrentUser.GetId<int>(),
|
||||||
|
OmsSessionHelper.IsCustomerLogin(CurrentUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除订单附件。客户账号禁止删除。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("/order/files/delete")]
|
||||||
|
public async Task DeleteOrderFileAsync([FromBody] OrderFileDeleteInput input)
|
||||||
|
{
|
||||||
|
if (input == null || input.Id <= 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException("附件 Id 无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = await LazyServiceProvider
|
||||||
|
.GetRequiredService<Domain.OrdersAggregate.Repositories.IOrderFileRepository>()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == input.Id)
|
||||||
|
?? throw new BusinessException("附件不存在");
|
||||||
|
|
||||||
|
await GetOrderOrThrowAsync(entity.OrderId);
|
||||||
|
await OrderFileManager.DeleteAsync(
|
||||||
|
input.Id,
|
||||||
|
OmsSessionHelper.IsCustomerLogin(CurrentUser));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1416,7 +1416,6 @@ public partial class OrderService
|
|||||||
SalesProductRulesVersion = snapshotBinding.RulesVersion,
|
SalesProductRulesVersion = snapshotBinding.RulesVersion,
|
||||||
SalesProductPriceVersion = snapshotBinding.PriceVersion,
|
SalesProductPriceVersion = snapshotBinding.PriceVersion,
|
||||||
LabelTemplateId = product.LabelTemplateId,
|
LabelTemplateId = product.LabelTemplateId,
|
||||||
LabelGenerateStatus = LabelGenerateStatusCodes.Pending,
|
|
||||||
OrderStatus = OrderStatusCodes.Draft
|
OrderStatus = OrderStatusCodes.Draft
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1720,6 +1719,7 @@ public partial class OrderService
|
|||||||
private async Task<OrderParcelCreateOutput> BuildParcelCreateOutputAsync(Order order)
|
private async Task<OrderParcelCreateOutput> BuildParcelCreateOutputAsync(Order order)
|
||||||
{
|
{
|
||||||
var mapped = (await MapOrderOutputsAsync([order])).First();
|
var mapped = (await MapOrderOutputsAsync([order])).First();
|
||||||
|
var systemLabelUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id);
|
||||||
return new OrderParcelCreateOutput
|
return new OrderParcelCreateOutput
|
||||||
{
|
{
|
||||||
OrderId = order.Id,
|
OrderId = order.Id,
|
||||||
@ -1728,8 +1728,8 @@ public partial class OrderService
|
|||||||
TrackNo = order.TrackNo,
|
TrackNo = order.TrackNo,
|
||||||
ConsigneeCountry = order.ConsigneeCountry,
|
ConsigneeCountry = order.ConsigneeCountry,
|
||||||
ConsigneeCountryName = order.ConsigneeCountryName,
|
ConsigneeCountryName = order.ConsigneeCountryName,
|
||||||
LabelUrl = !string.IsNullOrWhiteSpace(order.LabelUrl)
|
LabelUrl = !string.IsNullOrWhiteSpace(systemLabelUrl)
|
||||||
? order.LabelUrl!
|
? systemLabelUrl!
|
||||||
: order.OrderStatus == OrderStatusCodes.Predeclared
|
: order.OrderStatus == OrderStatusCodes.Predeclared
|
||||||
? await CreateOrderPrintFileAsync(mapped)
|
? await CreateOrderPrintFileAsync(mapped)
|
||||||
: string.Empty
|
: string.Empty
|
||||||
@ -1748,7 +1748,10 @@ public partial class OrderService
|
|||||||
{
|
{
|
||||||
// 本地未开队列:同步生成,避免 Pending 悬挂
|
// 本地未开队列:同步生成,避免 Pending 悬挂
|
||||||
var mapped = (await MapOrderOutputsAsync([order])).First();
|
var mapped = (await MapOrderOutputsAsync([order])).First();
|
||||||
await LabelPdfGenerationService.GenerateAndPersistAsync(order, mapped);
|
await LabelPdfGenerationService.GenerateAndPersistAsync(
|
||||||
|
order,
|
||||||
|
mapped,
|
||||||
|
operatorUserId: CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -253,23 +253,23 @@ public partial class OrderService : ApplicationService
|
|||||||
var order = await GetOrderOrThrowAsync(input.Id);
|
var order = await GetOrderOrThrowAsync(input.Id);
|
||||||
var mapped = (await MapOrderOutputsAsync([order])).First();
|
var mapped = (await MapOrderOutputsAsync([order])).First();
|
||||||
|
|
||||||
if (string.Equals(order.LabelGenerateStatus, LabelGenerateStatusCodes.Ready, StringComparison.OrdinalIgnoreCase)
|
var existingUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id);
|
||||||
&& !string.IsNullOrWhiteSpace(order.LabelUrl))
|
if (!string.IsNullOrWhiteSpace(existingUrl))
|
||||||
{
|
{
|
||||||
return new OrderPrintOutput
|
return new OrderPrintOutput
|
||||||
{
|
{
|
||||||
PrintUrl = order.LabelUrl!,
|
PrintUrl = existingUrl!,
|
||||||
LabelGenerateStatus = order.LabelGenerateStatus,
|
LabelGenerateStatus = LabelGenerateStatusCodes.Ready
|
||||||
LabelGenerateError = order.LabelGenerateError
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pending / Failed / 无状态:即时补生成(打印场景优先出 PDF)
|
// 无系统面单:即时补生成(打印场景优先出 PDF)
|
||||||
var result = await LabelPdfGenerationService.GenerateAndPersistAsync(
|
var result = await LabelPdfGenerationService.GenerateAndPersistAsync(
|
||||||
order,
|
order,
|
||||||
mapped,
|
mapped,
|
||||||
templateIdOverride: null,
|
templateIdOverride: null,
|
||||||
operatorName: CurrentUser.UserName);
|
operatorName: CurrentUser.UserName,
|
||||||
|
operatorUserId: CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
|
||||||
|
|
||||||
if (!result.Success || string.IsNullOrWhiteSpace(result.LabelUrl))
|
if (!result.Success || string.IsNullOrWhiteSpace(result.LabelUrl))
|
||||||
{
|
{
|
||||||
|
|||||||
@ -20,7 +20,7 @@ public class SalesProduct : OmsAuditedAggregateRoot
|
|||||||
public string? CustomerWaybill { get; set; }
|
public string? CustomerWaybill { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 客户面单绑定的标签模板 Id。
|
/// 系统面单绑定的标签模板 Id。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? LabelTemplateId { get; set; }
|
public int? LabelTemplateId { get; set; }
|
||||||
|
|
||||||
|
|||||||
46
src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs
Normal file
46
src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
namespace Oms.Domain.OrdersAggregate.Consts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单附件类型。
|
||||||
|
/// </summary>
|
||||||
|
public static class OrderFileTypeCodes
|
||||||
|
{
|
||||||
|
public const string TradeProof = "tradeProof";
|
||||||
|
public const string SystemLabel = "systemLabel";
|
||||||
|
public const string DownstreamLabel = "downstreamLabel";
|
||||||
|
public const string LastMileLabel = "lastMileLabel";
|
||||||
|
public const string InfraredImage = "infraredImage";
|
||||||
|
|
||||||
|
public const int MaxFilesPerType = 20;
|
||||||
|
|
||||||
|
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
TradeProof,
|
||||||
|
SystemLabel,
|
||||||
|
DownstreamLabel,
|
||||||
|
LastMileLabel,
|
||||||
|
InfraredImage
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 系统面单仅允许 1 份。
|
||||||
|
/// </summary>
|
||||||
|
public static int GetMaxCount(string fileType) =>
|
||||||
|
string.Equals(fileType, SystemLabel, StringComparison.OrdinalIgnoreCase) ? 1 : MaxFilesPerType;
|
||||||
|
|
||||||
|
public static bool IsValid(string? fileType) =>
|
||||||
|
!string.IsNullOrWhiteSpace(fileType) && All.Contains(fileType.Trim());
|
||||||
|
|
||||||
|
public static string Normalize(string fileType) => fileType.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单附件来源。
|
||||||
|
/// </summary>
|
||||||
|
public static class OrderFileSourceCodes
|
||||||
|
{
|
||||||
|
public const string Upload = "upload";
|
||||||
|
public const string SystemGenerate = "systemGenerate";
|
||||||
|
public const string Downstream = "downstream";
|
||||||
|
public const string Migrate = "migrate";
|
||||||
|
}
|
||||||
@ -168,25 +168,7 @@ public class Order : OmsAuditedAggregateRoot
|
|||||||
public byte IsRemote { get; set; }
|
public byte IsRemote { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 面单 PDF 相对路径(如 /uploads/prints/orders/xxx.pdf)。
|
/// 出单时实际使用的标签模板 Id(面单文件见 t_order_file.systemLabel)。
|
||||||
/// </summary>
|
|
||||||
[SugarColumn(Length = 500, IsNullable = true)]
|
|
||||||
public string? LabelUrl { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 出单时实际使用的标签模板 Id。
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? LabelTemplateId { get; set; }
|
public int? LabelTemplateId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 面单生成状态:Pending / Ready / Failed。
|
|
||||||
/// </summary>
|
|
||||||
[SugarColumn(Length = 32, IsNullable = true)]
|
|
||||||
public string? LabelGenerateStatus { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 面单生成失败原因。
|
|
||||||
/// </summary>
|
|
||||||
[SugarColumn(Length = 500, IsNullable = true)]
|
|
||||||
public string? LabelGenerateError { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/Oms.Domain/OrdersAggregate/Entities/OrderFile.cs
Normal file
41
src/Oms.Domain/OrdersAggregate/Entities/OrderFile.cs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
using Oms.Domain.Shared;
|
||||||
|
using SqlSugar;
|
||||||
|
|
||||||
|
namespace Oms.Domain.OrdersAggregate.Entities;
|
||||||
|
|
||||||
|
[SugarTable("t_order_file")]
|
||||||
|
public class OrderFile : OmsAuditedAggregateRoot
|
||||||
|
{
|
||||||
|
public int OrderId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 附件类型:tradeProof / systemLabel / downstreamLabel / lastMileLabel / infraredImage。
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(Length = 32)]
|
||||||
|
public string FileType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[SugarColumn(Length = 255, IsNullable = true)]
|
||||||
|
public string? FileName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 相对路径或外链 URL。
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(Length = 512)]
|
||||||
|
public string FileUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[SugarColumn(Length = 128, IsNullable = true)]
|
||||||
|
public string? ContentType { get; set; }
|
||||||
|
|
||||||
|
public long? FileSize { get; set; }
|
||||||
|
|
||||||
|
public int Sort { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// upload / systemGenerate / downstream / migrate。
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(Length = 32)]
|
||||||
|
public string Source { get; set; } = "upload";
|
||||||
|
|
||||||
|
[SugarColumn(Length = 512, IsNullable = true)]
|
||||||
|
public string? Remark { get; set; }
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
using Fake.Domain.Repositories;
|
||||||
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
|
|
||||||
|
namespace Oms.Domain.OrdersAggregate.Repositories;
|
||||||
|
|
||||||
|
public interface IOrderFileRepository : IRepository<OrderFile>
|
||||||
|
{
|
||||||
|
Task<List<OrderFile>> GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<OrderFile?> GetByOrderAndTypeAsync(
|
||||||
|
int orderId,
|
||||||
|
string fileType,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<List<OrderFile>> GetByOrderAndTypeListAsync(
|
||||||
|
int orderId,
|
||||||
|
string fileType,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task SoftDeleteByOrderAndTypeAsync(
|
||||||
|
int orderId,
|
||||||
|
string fileType,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
47
src/Oms.SqlSugarCore/Orders/OrderFileRepository.cs
Normal file
47
src/Oms.SqlSugarCore/Orders/OrderFileRepository.cs
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
using Fake.DependencyInjection;
|
||||||
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
|
using Oms.Domain.OrdersAggregate.Repositories;
|
||||||
|
using Oms.SqlSugarCore.Repositories;
|
||||||
|
|
||||||
|
namespace Oms.SqlSugarCore.Orders;
|
||||||
|
|
||||||
|
public class OrderFileRepository
|
||||||
|
: OmsSqlSugarRepository<OrderFile>,
|
||||||
|
IOrderFileRepository,
|
||||||
|
IScopedDependency
|
||||||
|
{
|
||||||
|
public Task<List<OrderFile>> GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default) =>
|
||||||
|
GetListAsync(
|
||||||
|
x => x.OrderId == orderId,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
public Task<OrderFile?> GetByOrderAndTypeAsync(
|
||||||
|
int orderId,
|
||||||
|
string fileType,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
FirstOrDefaultAsync(
|
||||||
|
x => x.OrderId == orderId && x.FileType == fileType,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
public Task<List<OrderFile>> GetByOrderAndTypeListAsync(
|
||||||
|
int orderId,
|
||||||
|
string fileType,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
GetListAsync(
|
||||||
|
x => x.OrderId == orderId && x.FileType == fileType,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
public async Task SoftDeleteByOrderAndTypeAsync(
|
||||||
|
int orderId,
|
||||||
|
string fileType,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var list = await GetByOrderAndTypeListAsync(orderId, fileType, cancellationToken);
|
||||||
|
foreach (var item in list)
|
||||||
|
{
|
||||||
|
item.IsDeleted = true;
|
||||||
|
item.UpdateTime = DateTime.Now;
|
||||||
|
await UpdateAsync(item, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,7 +49,8 @@ public class ItfxCreateOrderOneDataParserTests
|
|||||||
Assert.Equal("OMS001", order.ServiceNo);
|
Assert.Equal("OMS001", order.ServiceNo);
|
||||||
Assert.Equal("1ZK6B4420326915547", order.DownstreamServiceNo);
|
Assert.Equal("1ZK6B4420326915547", order.DownstreamServiceNo);
|
||||||
Assert.Equal("TRACK123", order.TrackNo);
|
Assert.Equal("TRACK123", order.TrackNo);
|
||||||
Assert.Equal("https://label.example/a.pdf", order.LabelUrl);
|
// LabelUrl 由调用方写入 t_order_file,不再回写订单表
|
||||||
|
Assert.Equal("https://label.example/a.pdf", data.LabelUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user