diff --git a/db.sql b/db.sql
index c12be5c..c8afb68 100644
--- a/db.sql
+++ b/db.sql
@@ -156,6 +156,7 @@ CREATE TABLE `t_order` (
`Quantity` INT NULL COMMENT '包裹数量',
`IsIntercept` 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-是',
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
@@ -177,6 +178,27 @@ CREATE TABLE `t_order` (
KEY `idx_ord_source_ctime` (`OrderSource`, `CreateTime`)
) 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` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`CustomerId` INT NOT NULL COMMENT '归属客户 t_customer.Id',
diff --git a/sql/20260728_order_file.sql b/sql/20260728_order_file.sql
new file mode 100644
index 0000000..79d7970
--- /dev/null
+++ b/sql/20260728_order_file.sql
@@ -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`;
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs
index e91886b..50d519f 100644
--- a/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs
@@ -36,12 +36,12 @@ public class SalesProductDetailOutput
public int? MaxTimeliness { get; set; }
///
- /// 客户面单类型。
+ /// 系统面单类型。
///
public string? CustomerWaybill { get; set; }
///
- /// 客户面单绑定的标签模板 Id。
+ /// 系统面单绑定的标签模板 Id。
///
public int? LabelTemplateId { get; set; }
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs
index 8ac24f0..5a230e4 100644
--- a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs
@@ -36,12 +36,12 @@ public class SalesProductListOutput
public int? MaxTimeliness { get; set; }
///
- /// 客户面单类型。
+ /// 系统面单类型。
///
public string? CustomerWaybill { get; set; }
///
- /// 客户面单绑定的标签模板 Id。
+ /// 系统面单绑定的标签模板 Id。
///
public int? LabelTemplateId { get; set; }
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs b/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs
index 65b1aa6..d7d71ec 100644
--- a/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs
+++ b/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs
@@ -38,12 +38,12 @@ public class SalesProductSaveInput
public int? MaxTimeliness { get; set; }
///
- /// 客户面单类型(旧枚举,兼容展示)。
+ /// 系统面单类型(旧枚举字段,兼容展示)。
///
public string? CustomerWaybill { get; set; }
///
- /// 客户面单绑定的标签模板 Id。
+ /// 系统面单绑定的标签模板 Id。
///
public int? LabelTemplateId { get; set; }
diff --git a/src/Oms.Application/Dtos/Orders/OrderFileDtos.cs b/src/Oms.Application/Dtos/Orders/OrderFileDtos.cs
new file mode 100644
index 0000000..b39a4fa
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderFileDtos.cs
@@ -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; }
+
+ ///
+ /// 客户登录时仅含 systemLabel;内部含全部类型。
+ ///
+ public Dictionary> Groups { get; set; } = new();
+}
+
+public class OrderFileDeleteInput
+{
+ public int Id { get; set; }
+}
diff --git a/src/Oms.Application/Integrations/Itfx/ItfxCreateOrderOneDataParser.cs b/src/Oms.Application/Integrations/Itfx/ItfxCreateOrderOneDataParser.cs
index c0eb1a2..53dc3a0 100644
--- a/src/Oms.Application/Integrations/Itfx/ItfxCreateOrderOneDataParser.cs
+++ b/src/Oms.Application/Integrations/Itfx/ItfxCreateOrderOneDataParser.cs
@@ -62,7 +62,7 @@ internal static class ItfxCreateOrderOneDataParser
///
/// 将下游返回写入订单:服务商单号 ← Data.serviceNo,跟踪号 ← Data.trackNo。
- /// 空值不覆盖已有字段。
+ /// 空值不覆盖已有字段。面单 URL 不写订单表,由调用方写入 t_order_file.downstreamLabel。
///
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;
}
diff --git a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs
index b1056ee..749dedb 100644
--- a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs
+++ b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs
@@ -2,6 +2,7 @@ using Fake.Application;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
using Oms.Application.Dtos.Orders;
+using Oms.Application.Services.Orders;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
@@ -12,7 +13,7 @@ using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Labeling;
///
-/// 面单 PDF 生成:解析模板 → 渲染 → 落盘 → 回写订单。
+/// 面单 PDF 生成:解析模板 → 渲染 → 落盘 → 写入 t_order_file(systemLabel)。
///
public sealed class LabelPdfGenerationService(
ILabelPdfRenderer renderer,
@@ -20,6 +21,7 @@ public sealed class LabelPdfGenerationService(
ISalesProductRepository salesProductRepository,
ISystemOtherConfigRepository systemOtherConfigRepository,
IOrderRepository orderRepository,
+ OrderFileManager orderFileManager,
IWebHostEnvironment webHostEnvironment,
ILogger logger)
{
@@ -28,6 +30,7 @@ public sealed class LabelPdfGenerationService(
OrderOutput orderOutput,
int? templateIdOverride = null,
string? operatorName = null,
+ int operatorUserId = 0,
CancellationToken cancellationToken = default)
{
try
@@ -35,13 +38,13 @@ public sealed class LabelPdfGenerationService(
var templateId = await ResolveTemplateIdAsync(order, templateIdOverride, cancellationToken);
if (!templateId.HasValue)
{
- return await MarkFailedAsync(order, "未配置标签模板(产品未绑且无全局默认)", cancellationToken);
+ return MarkFailed("未配置标签模板(产品未绑且无全局默认)", order.LabelTemplateId);
}
var template = await labelTemplateRepository.FirstOrDefaultAsync(x => x.Id == templateId.Value);
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);
@@ -49,12 +52,19 @@ public sealed class LabelPdfGenerationService(
var relativeUrl = await SavePdfAsync(order.ServiceNo, pdfBytes, cancellationToken);
order.LabelTemplateId = templateId;
- order.LabelUrl = relativeUrl;
- order.LabelGenerateStatus = LabelGenerateStatusCodes.Ready;
- order.LabelGenerateError = null;
order.UpdateTime = DateTime.Now;
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
{
Success = true,
@@ -66,7 +76,7 @@ public sealed class LabelPdfGenerationService(
catch (Exception ex)
{
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;
}
- private async Task MarkFailedAsync(
- Order order,
- string error,
- CancellationToken cancellationToken)
+ private static LabelPdfGenerateResult MarkFailed(string error, int? labelTemplateId)
{
- order.LabelGenerateStatus = LabelGenerateStatusCodes.Failed;
- order.LabelGenerateError = error.Length > 500 ? error[..500] : error;
- order.UpdateTime = DateTime.Now;
- await orderRepository.UpdateAsync(order);
+ var msg = error.Length > 500 ? error[..500] : error;
return new LabelPdfGenerateResult
{
Success = false,
Status = LabelGenerateStatusCodes.Failed,
- Error = order.LabelGenerateError,
- LabelTemplateId = order.LabelTemplateId
+ Error = msg,
+ LabelTemplateId = labelTemplateId
};
}
diff --git a/src/Oms.Application/Services/Operations/LabelPrintService.cs b/src/Oms.Application/Services/Operations/LabelPrintService.cs
index d711a56..def1f94 100644
--- a/src/Oms.Application/Services/Operations/LabelPrintService.cs
+++ b/src/Oms.Application/Services/Operations/LabelPrintService.cs
@@ -130,7 +130,6 @@ public class LabelPrintService : ApplicationService
if (labelTemplate != null)
{
order.LabelTemplateId = labelTemplate.Id;
- order.LabelGenerateStatus = LabelGenerateStatusCodes.Pending;
await OrderRepository.UpdateAsync(order);
}
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
index 840fcf9..3d95a99 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
@@ -20,6 +20,7 @@ public sealed class OrderBatchActionExecutor(
ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder,
OrderDownstreamCredentialResolver downstreamCredentialResolver,
IItfxForwardClient itfxForwardClient,
+ OrderFileManager orderFileManager,
OrderBatchCreateActionHandler createActionHandler,
OrderBatchOrderActionHandler orderActionHandler) : IOrderBatchActionExecutor, IScopedDependency
{
@@ -215,10 +216,21 @@ public sealed class OrderBatchActionExecutor(
}
if (ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
- && data != null
- && ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
+ && data != null)
{
- 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(
diff --git a/src/Oms.Application/Services/Orders/OrderFileManager.cs b/src/Oms.Application/Services/Orders/OrderFileManager.cs
new file mode 100644
index 0000000..d93f20a
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/OrderFileManager.cs
@@ -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;
+
+///
+/// 订单附件读写(系统面单仅 1 份,其它类型最多 20)。
+///
+public sealed class OrderFileManager(
+ IOrderFileRepository orderFileRepository,
+ IWebHostEnvironment webHostEnvironment) : IScopedDependency
+{
+ private static readonly HashSet ImageExt =
+ new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" };
+
+ private static readonly HashSet 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 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 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 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 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> 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();
+
+ 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;
+ }
+ }
+}
diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs
index 92b12eb..97b5db7 100644
--- a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs
@@ -39,6 +39,7 @@ public partial class OrderService
mapped,
templateIdOverride: null,
operatorName: null,
+ operatorUserId: 0,
cancellationToken);
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs b/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs
index 1f0f3a4..3ac11e3 100644
--- a/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs
@@ -53,10 +53,20 @@ public partial class OrderService
if (forward.Success
&& ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
- && data != null
- && ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
+ && data != null)
{
- 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() : 0);
+ }
}
return new OrderDownstreamForwardResult
diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderFiles.cs b/src/Oms.Application/Services/Orders/OrderService.OrderFiles.cs
new file mode 100644
index 0000000..610de53
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/OrderService.OrderFiles.cs
@@ -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();
+
+ ///
+ /// 查询订单附件(按类型分组)。客户登录仅返回系统面单。
+ ///
+ [HttpGet("/order/files")]
+ public async Task GetOrderFilesAsync([FromQuery] int orderId)
+ {
+ await GetOrderOrThrowAsync(orderId);
+ var customerView = OmsSessionHelper.IsCustomerLogin(CurrentUser);
+ return await OrderFileManager.GetGroupedAsync(orderId, customerView);
+ }
+
+ ///
+ /// 上传订单附件。客户账号禁止上传。
+ ///
+ [HttpPost("/order/files/upload")]
+ [Consumes("multipart/form-data")]
+ public async Task> UploadOrderFilesAsync(
+ [FromForm] int orderId,
+ [FromForm] string fileType,
+ [FromForm] List 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(),
+ OmsSessionHelper.IsCustomerLogin(CurrentUser));
+ }
+
+ ///
+ /// 删除订单附件。客户账号禁止删除。
+ ///
+ [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()
+ .FirstOrDefaultAsync(x => x.Id == input.Id)
+ ?? throw new BusinessException("附件不存在");
+
+ await GetOrderOrThrowAsync(entity.OrderId);
+ await OrderFileManager.DeleteAsync(
+ input.Id,
+ OmsSessionHelper.IsCustomerLogin(CurrentUser));
+ }
+}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index c12024a..d4e9afe 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -1416,7 +1416,6 @@ public partial class OrderService
SalesProductRulesVersion = snapshotBinding.RulesVersion,
SalesProductPriceVersion = snapshotBinding.PriceVersion,
LabelTemplateId = product.LabelTemplateId,
- LabelGenerateStatus = LabelGenerateStatusCodes.Pending,
OrderStatus = OrderStatusCodes.Draft
};
}
@@ -1720,6 +1719,7 @@ public partial class OrderService
private async Task BuildParcelCreateOutputAsync(Order order)
{
var mapped = (await MapOrderOutputsAsync([order])).First();
+ var systemLabelUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id);
return new OrderParcelCreateOutput
{
OrderId = order.Id,
@@ -1728,8 +1728,8 @@ public partial class OrderService
TrackNo = order.TrackNo,
ConsigneeCountry = order.ConsigneeCountry,
ConsigneeCountryName = order.ConsigneeCountryName,
- LabelUrl = !string.IsNullOrWhiteSpace(order.LabelUrl)
- ? order.LabelUrl!
+ LabelUrl = !string.IsNullOrWhiteSpace(systemLabelUrl)
+ ? systemLabelUrl!
: order.OrderStatus == OrderStatusCodes.Predeclared
? await CreateOrderPrintFileAsync(mapped)
: string.Empty
@@ -1748,7 +1748,10 @@ public partial class OrderService
{
// 本地未开队列:同步生成,避免 Pending 悬挂
var mapped = (await MapOrderOutputsAsync([order])).First();
- await LabelPdfGenerationService.GenerateAndPersistAsync(order, mapped);
+ await LabelPdfGenerationService.GenerateAndPersistAsync(
+ order,
+ mapped,
+ operatorUserId: CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0);
return;
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index db135ff..b8c8424 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -253,23 +253,23 @@ public partial class OrderService : ApplicationService
var order = await GetOrderOrThrowAsync(input.Id);
var mapped = (await MapOrderOutputsAsync([order])).First();
- if (string.Equals(order.LabelGenerateStatus, LabelGenerateStatusCodes.Ready, StringComparison.OrdinalIgnoreCase)
- && !string.IsNullOrWhiteSpace(order.LabelUrl))
+ var existingUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id);
+ if (!string.IsNullOrWhiteSpace(existingUrl))
{
return new OrderPrintOutput
{
- PrintUrl = order.LabelUrl!,
- LabelGenerateStatus = order.LabelGenerateStatus,
- LabelGenerateError = order.LabelGenerateError
+ PrintUrl = existingUrl!,
+ LabelGenerateStatus = LabelGenerateStatusCodes.Ready
};
}
- // Pending / Failed / 无状态:即时补生成(打印场景优先出 PDF)
+ // 无系统面单:即时补生成(打印场景优先出 PDF)
var result = await LabelPdfGenerationService.GenerateAndPersistAsync(
order,
mapped,
templateIdOverride: null,
- operatorName: CurrentUser.UserName);
+ operatorName: CurrentUser.UserName,
+ operatorUserId: CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0);
if (!result.Success || string.IsNullOrWhiteSpace(result.LabelUrl))
{
diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs
index d60c922..5b44626 100644
--- a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs
+++ b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs
@@ -20,7 +20,7 @@ public class SalesProduct : OmsAuditedAggregateRoot
public string? CustomerWaybill { get; set; }
///
- /// 客户面单绑定的标签模板 Id。
+ /// 系统面单绑定的标签模板 Id。
///
public int? LabelTemplateId { get; set; }
diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs
new file mode 100644
index 0000000..f82a107
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs
@@ -0,0 +1,46 @@
+namespace Oms.Domain.OrdersAggregate.Consts;
+
+///
+/// 订单附件类型。
+///
+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 All = new(StringComparer.OrdinalIgnoreCase)
+ {
+ TradeProof,
+ SystemLabel,
+ DownstreamLabel,
+ LastMileLabel,
+ InfraredImage
+ };
+
+ ///
+ /// 系统面单仅允许 1 份。
+ ///
+ 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();
+}
+
+///
+/// 订单附件来源。
+///
+public static class OrderFileSourceCodes
+{
+ public const string Upload = "upload";
+ public const string SystemGenerate = "systemGenerate";
+ public const string Downstream = "downstream";
+ public const string Migrate = "migrate";
+}
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
index aa2d386..f096c77 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
@@ -168,25 +168,7 @@ public class Order : OmsAuditedAggregateRoot
public byte IsRemote { get; set; }
///
- /// 面单 PDF 相对路径(如 /uploads/prints/orders/xxx.pdf)。
- ///
- [SugarColumn(Length = 500, IsNullable = true)]
- public string? LabelUrl { get; set; }
-
- ///
- /// 出单时实际使用的标签模板 Id。
+ /// 出单时实际使用的标签模板 Id(面单文件见 t_order_file.systemLabel)。
///
public int? LabelTemplateId { get; set; }
-
- ///
- /// 面单生成状态:Pending / Ready / Failed。
- ///
- [SugarColumn(Length = 32, IsNullable = true)]
- public string? LabelGenerateStatus { get; set; }
-
- ///
- /// 面单生成失败原因。
- ///
- [SugarColumn(Length = 500, IsNullable = true)]
- public string? LabelGenerateError { get; set; }
}
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderFile.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderFile.cs
new file mode 100644
index 0000000..3e6ecdd
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderFile.cs
@@ -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; }
+
+ ///
+ /// 附件类型:tradeProof / systemLabel / downstreamLabel / lastMileLabel / infraredImage。
+ ///
+ [SugarColumn(Length = 32)]
+ public string FileType { get; set; } = string.Empty;
+
+ [SugarColumn(Length = 255, IsNullable = true)]
+ public string? FileName { get; set; }
+
+ ///
+ /// 相对路径或外链 URL。
+ ///
+ [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; }
+
+ ///
+ /// upload / systemGenerate / downstream / migrate。
+ ///
+ [SugarColumn(Length = 32)]
+ public string Source { get; set; } = "upload";
+
+ [SugarColumn(Length = 512, IsNullable = true)]
+ public string? Remark { get; set; }
+}
diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderFileRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderFileRepository.cs
new file mode 100644
index 0000000..6fe8fe5
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderFileRepository.cs
@@ -0,0 +1,24 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.OrdersAggregate.Entities;
+
+namespace Oms.Domain.OrdersAggregate.Repositories;
+
+public interface IOrderFileRepository : IRepository
+{
+ Task> GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default);
+
+ Task GetByOrderAndTypeAsync(
+ int orderId,
+ string fileType,
+ CancellationToken cancellationToken = default);
+
+ Task> GetByOrderAndTypeListAsync(
+ int orderId,
+ string fileType,
+ CancellationToken cancellationToken = default);
+
+ Task SoftDeleteByOrderAndTypeAsync(
+ int orderId,
+ string fileType,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Oms.SqlSugarCore/Orders/OrderFileRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderFileRepository.cs
new file mode 100644
index 0000000..1f44077
--- /dev/null
+++ b/src/Oms.SqlSugarCore/Orders/OrderFileRepository.cs
@@ -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,
+ IOrderFileRepository,
+ IScopedDependency
+{
+ public Task> GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default) =>
+ GetListAsync(
+ x => x.OrderId == orderId,
+ cancellationToken: cancellationToken);
+
+ public Task GetByOrderAndTypeAsync(
+ int orderId,
+ string fileType,
+ CancellationToken cancellationToken = default) =>
+ FirstOrDefaultAsync(
+ x => x.OrderId == orderId && x.FileType == fileType,
+ cancellationToken);
+
+ public Task> 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);
+ }
+ }
+}
diff --git a/src/Oms.Tests/ItfxCreateOrderOneDataParserTests.cs b/src/Oms.Tests/ItfxCreateOrderOneDataParserTests.cs
index 3811028..2864f23 100644
--- a/src/Oms.Tests/ItfxCreateOrderOneDataParserTests.cs
+++ b/src/Oms.Tests/ItfxCreateOrderOneDataParserTests.cs
@@ -49,7 +49,8 @@ public class ItfxCreateOrderOneDataParserTests
Assert.Equal("OMS001", order.ServiceNo);
Assert.Equal("1ZK6B4420326915547", order.DownstreamServiceNo);
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]