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:
zzh 2026-07-28 16:32:55 +08:00
parent e2443ed6f8
commit 03f11a276f
23 changed files with 772 additions and 73 deletions

22
db.sql
View File

@ -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',

View 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`;

View File

@ -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; }

View File

@ -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; }

View File

@ -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; }

View 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; }
}

View File

@ -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;
} }

View File

@ -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
}; };
} }

View File

@ -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);
} }

View File

@ -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(

View 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;
}
}
}

View File

@ -39,6 +39,7 @@ public partial class OrderService
mapped, mapped,
templateIdOverride: null, templateIdOverride: null,
operatorName: null, operatorName: null,
operatorUserId: 0,
cancellationToken); cancellationToken);
} }

View File

@ -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

View File

@ -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));
}
}

View File

@ -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;
} }

View File

@ -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))
{ {

View File

@ -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; }

View 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";
}

View File

@ -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; }
} }

View 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; }
}

View File

@ -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);
}

View 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);
}
}
}

View File

@ -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]