From 03f11a276fc6205fc593f2851ab1d5343ac627ef Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 28 Jul 2026 16:32:55 +0800 Subject: [PATCH] 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 --- db.sql | 22 ++ sql/20260728_order_file.sql | 50 +++ .../Dtos/Channels/SalesProductDetailOutput.cs | 4 +- .../Dtos/Channels/SalesProductListOutput.cs | 4 +- .../Dtos/Channels/SalesProductSaveInput.cs | 4 +- .../Dtos/Orders/OrderFileDtos.cs | 41 ++ .../Itfx/ItfxCreateOrderOneDataParser.cs | 12 +- .../Labeling/LabelPdfGenerationService.cs | 38 +- .../Services/Operations/LabelPrintService.cs | 1 - .../Orders/Batch/OrderBatchActionExecutor.cs | 18 +- .../Services/Orders/OrderFileManager.cs | 355 ++++++++++++++++++ .../Orders/OrderService.BatchWorker.cs | 1 + .../Orders/OrderService.DownstreamForward.cs | 16 +- .../Orders/OrderService.OrderFiles.cs | 71 ++++ .../Orders/OrderService.ParcelBilling.cs | 11 +- .../Services/Orders/OrderService.cs | 14 +- .../Entities/SalesProduct.cs | 2 +- .../Consts/OrderFileTypeCodes.cs | 46 +++ .../OrdersAggregate/Entities/Order.cs | 20 +- .../OrdersAggregate/Entities/OrderFile.cs | 41 ++ .../Repositories/IOrderFileRepository.cs | 24 ++ .../Orders/OrderFileRepository.cs | 47 +++ .../ItfxCreateOrderOneDataParserTests.cs | 3 +- 23 files changed, 772 insertions(+), 73 deletions(-) create mode 100644 sql/20260728_order_file.sql create mode 100644 src/Oms.Application/Dtos/Orders/OrderFileDtos.cs create mode 100644 src/Oms.Application/Services/Orders/OrderFileManager.cs create mode 100644 src/Oms.Application/Services/Orders/OrderService.OrderFiles.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Entities/OrderFile.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Repositories/IOrderFileRepository.cs create mode 100644 src/Oms.SqlSugarCore/Orders/OrderFileRepository.cs 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]