admin/src/Oms.Application/Services/Orders/OrderService.Template.cs
zzh a1daea6b35 refactor: 仓储表 C_* 改为 c_*,优化专线小包批量模板
- 仓储实体与 db.sql 统一为小写 c_ 前缀,新增 migrate_rename_warehouse_C_to_c.sql
- 专线小包批量模板精简版/标准版双模板,Excel 必填标红与发件地址上传参数

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 16:18:59 +08:00

444 lines
17 KiB
C#

using System.Text.Json;
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders.Templates;
using Oms.Application.Shared;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.Shared.Enums;
namespace Oms.Application.Services.Orders;
public partial class OrderService
{
/// <summary>
/// 获取标准模板字段定义。
/// </summary>
[HttpGet("/order/template/field-schema")]
public Task<OrderTemplateFieldSchemaOutput> GetTemplateFieldSchemaAsync([FromQuery] string? templateKey)
{
var key = string.IsNullOrWhiteSpace(templateKey) ? OrderTemplateKeys.DedicatedSmallParcelStandard : templateKey.Trim();
var fields = OrderTemplateFieldRegistry.GetFields(key);
return Task.FromResult(new OrderTemplateFieldSchemaOutput
{
TemplateKey = key,
TemplateName = OrderTemplateFieldRegistry.GetTemplateDisplayName(key),
Fields = fields.Select(MapFieldSchemaItem).ToList()
});
}
/// <summary>
/// 获取模板下拉字典选项。
/// </summary>
[HttpGet("/order/template/dict-options")]
public async Task<OrderTemplateDictOptionsOutput> GetTemplateDictOptionsAsync([FromQuery] string? dictTypes)
{
var types = (dictTypes ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var items = await LoadTemplateDictOptionsAsync(types);
return new OrderTemplateDictOptionsOutput { Items = items.Select(x => new OrderTemplateDictOptionOutput
{
DictType = x.DictType,
Code = x.Code,
Label = x.Label
}).ToList() };
}
/// <summary>
/// 上传自定义模板样例并创建草稿映射。
/// </summary>
[HttpPost("/order/template/custom/upload")]
public async Task<OrderTemplateCustomUploadOutput> UploadCustomTemplateAsync([FromForm] OrderTemplateCustomUploadInput input)
{
if (input.File == null || input.File.Length == 0)
{
throw new BusinessException("上传文件不能为空");
}
var name = input.Name?.Trim();
if (string.IsNullOrWhiteSpace(name))
{
throw new BusinessException("模板名称不能为空");
}
var customer = await OmsSessionHelper.ResolveSessionCustomerAsync(CustomerRepository, CurrentUser);
var customerId = customer.Id;
await using var headerStream = input.File.OpenReadStream();
var headers = OrderTemplateHeaderMatcher.ReadHeaderRow(headerStream);
if (headers.Count == 0)
{
throw new BusinessException("未能识别 Excel 表头,请确认第一行为列名");
}
var (relativePath, originalName) = await OmsFileUploadHelper.SaveOrderTemplateSampleAsync(
input.File,
customerId,
WebHostEnvironment);
var suggested = OrderTemplateHeaderMatcher.BuildSuggestedMapping(OrderTemplateKeys.DedicatedSmallParcelStandard, headers);
var entity = new OrderTemplate
{
Name = name,
Type = "custom",
TemplateKey = $"custom_{Guid.NewGuid():N}".Substring(0, 24),
StandardTemplateKey = OrderTemplateKeys.DedicatedSmallParcelStandard,
MappingStatus = OrderTemplateMappingStatus.Draft,
CustomerId = customerId,
SourceFileName = originalName,
SourceFilePath = relativePath,
SourceHeadersJson = JsonSerializer.Serialize(headers),
ColumnMappingJson = JsonSerializer.Serialize(suggested)
};
await OrderTemplateRepository.InsertAsync(entity);
return new OrderTemplateCustomUploadOutput
{
TemplateId = entity.Id,
Name = entity.Name,
MappingStatus = entity.MappingStatus ?? OrderTemplateMappingStatus.Draft,
SourceHeaders = headers
};
}
/// <summary>
/// 获取自定义模板详情(含映射)。
/// </summary>
[HttpGet("/order/template/custom/detail")]
public async Task<OrderTemplateCustomDetailOutput> GetCustomTemplateDetailAsync([FromQuery] int templateId)
{
var template = await GetAccessibleTemplateOrThrowAsync(templateId);
if (!string.Equals(template.Type, "custom", StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("仅自定义模板支持映射配置");
}
var standardKey = template.StandardTemplateKey ?? OrderTemplateKeys.DedicatedSmallParcel;
return new OrderTemplateCustomDetailOutput
{
Id = template.Id,
Name = template.Name,
Type = template.Type,
StandardTemplateKey = standardKey,
MappingStatus = template.MappingStatus ?? OrderTemplateMappingStatus.Draft,
SourceFileName = template.SourceFileName,
SourceHeaders = DeserializeStringList(template.SourceHeadersJson),
Mapping = DeserializeMapping(template.ColumnMappingJson),
StandardFields = OrderTemplateFieldRegistry.GetFields(standardKey).Select(MapFieldSchemaItem).ToList()
};
}
/// <summary>
/// 保存自定义模板字段映射。
/// </summary>
[HttpPost("/order/template/custom/save-mapping")]
public async Task<OrderTemplateSaveMappingOutput> SaveCustomTemplateMappingAsync([FromBody] OrderTemplateSaveMappingInput input)
{
var template = await GetAccessibleTemplateOrThrowAsync(input.TemplateId);
if (!string.Equals(template.Type, "custom", StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("仅自定义模板支持映射配置");
}
var sourceHeaders = DeserializeStringList(template.SourceHeadersJson);
if (sourceHeaders.Count == 0)
{
throw new BusinessException("模板表头为空,请重新上传样例文件");
}
var standardKey = !string.IsNullOrWhiteSpace(input.StandardTemplateKey)
? input.StandardTemplateKey.Trim()
: template.StandardTemplateKey ?? OrderTemplateKeys.DedicatedSmallParcel;
template.StandardTemplateKey = standardKey;
var headerSet = sourceHeaders.ToHashSet(StringComparer.OrdinalIgnoreCase);
var usedColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var mappings = new List<OrderTemplateColumnMappingItem>();
foreach (var item in input.Mappings ?? [])
{
var fieldKey = item.StandardFieldKey?.Trim();
var sourceColumn = item.SourceColumn?.Trim();
if (string.IsNullOrWhiteSpace(fieldKey) || string.IsNullOrWhiteSpace(sourceColumn))
{
continue;
}
if (!headerSet.Contains(sourceColumn))
{
throw new BusinessException($"客户列「{sourceColumn}」不存在于上传模板表头中");
}
if (!usedColumns.Add(sourceColumn))
{
throw new BusinessException($"客户列「{sourceColumn}」不能重复映射");
}
var field = OrderTemplateFieldRegistry.TryGetField(standardKey, fieldKey);
if (field == null)
{
throw new BusinessException($"未知标准字段:{fieldKey}");
}
mappings.Add(new OrderTemplateColumnMappingItem
{
StandardFieldKey = field.FieldKey,
SourceColumn = sourceColumn,
Required = field.Required
});
}
var mappedColumns = mappings.Select(x => x.SourceColumn).ToHashSet(StringComparer.OrdinalIgnoreCase);
var missingRequired = OrderTemplateFieldRegistry.GetFields(standardKey)
.Where(f => f.Required)
.Where(f => mappings.All(m => !string.Equals(m.StandardFieldKey, f.FieldKey, StringComparison.OrdinalIgnoreCase)))
.Select(f => f.Label)
.ToList();
var mappingDoc = new OrderTemplateColumnMappingDocument
{
StandardTemplateKey = standardKey,
HeaderRowIndex = input.HeaderRowIndex <= 0 ? 1 : input.HeaderRowIndex,
DataStartRowIndex = input.DataStartRowIndex <= 0 ? 2 : input.DataStartRowIndex,
Mappings = mappings,
UnmappedSourceColumns = sourceHeaders.Where(h => !mappedColumns.Contains(h)).ToList()
};
template.ColumnMappingJson = JsonSerializer.Serialize(mappingDoc);
template.MappingStatus = missingRequired.Count == 0
? OrderTemplateMappingStatus.Ready
: OrderTemplateMappingStatus.Draft;
await OrderTemplateRepository.UpdateAsync(template);
return new OrderTemplateSaveMappingOutput
{
TemplateId = template.Id,
MappingStatus = template.MappingStatus ?? OrderTemplateMappingStatus.Draft,
MissingRequiredFields = missingRequired
};
}
/// <summary>
/// 删除自定义模板。
/// </summary>
[HttpDelete("/order/template/custom/{templateId:int}")]
public async Task DeleteCustomTemplateAsync([FromRoute] int templateId)
{
var template = await GetAccessibleTemplateOrThrowAsync(templateId);
if (!string.Equals(template.Type, "custom", StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("标准模板不可删除");
}
await OrderTemplateRepository.DeleteAsync(template);
}
/// <summary>
/// 列表查询前确保标准「专线小包」精简版与标准版模板存在。
/// </summary>
private async Task EnsureDedicatedSmallParcelStandardTemplateAsync()
{
await EnsureBuiltinStandardTemplateAsync(
OrderTemplateKeys.DedicatedSmallParcelStandard,
"专线小包模板(标准版)");
await EnsureBuiltinStandardTemplateAsync(
OrderTemplateKeys.DedicatedSmallParcelSimple,
"专线小包模板(精简版)");
}
private async Task EnsureBuiltinStandardTemplateAsync(string templateKey, string name)
{
var existing = await OrderTemplateRepository.FirstOrDefaultAsync(x =>
x.Type == "standard" && x.TemplateKey == templateKey);
if (existing != null)
{
if (!string.Equals(existing.Name, name, StringComparison.Ordinal))
{
existing.Name = name;
await OrderTemplateRepository.UpdateAsync(existing);
}
return;
}
await OrderTemplateRepository.InsertAsync(new OrderTemplate
{
Name = name,
Type = "standard",
TemplateKey = templateKey,
StandardTemplateKey = templateKey,
MappingStatus = OrderTemplateMappingStatus.Ready,
CustomerId = null,
});
}
private async Task<OrderTemplate> GetAccessibleTemplateOrThrowAsync(int templateId)
{
var template = await OrderTemplateRepository.FirstOrDefaultAsync(x => x.Id == templateId);
if (template == null)
{
throw new BusinessException("模板不存在");
}
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null);
if (customerId.HasValue)
{
EnsureTemplateAccessible(template, customerId.Value);
}
return template;
}
private static OrderTemplateFieldSchemaItemOutput MapFieldSchemaItem(OrderTemplateFieldDefinition field) => new()
{
FieldKey = field.FieldKey,
Label = field.Label,
Required = field.Required,
DataType = field.DataType,
DictType = field.DictType,
Group = field.Group,
Example = field.Example,
Hint = field.Hint,
};
private static List<string> DeserializeStringList(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
catch
{
return [];
}
}
private static OrderTemplateColumnMappingDocument? DeserializeMapping(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
return JsonSerializer.Deserialize<OrderTemplateColumnMappingDocument>(json);
}
catch
{
return null;
}
}
private async Task<List<OrderTemplateDictOption>> LoadTemplateDictOptionsAsync(IReadOnlyCollection<string> dictTypes)
{
var result = new List<OrderTemplateDictOption>();
if (dictTypes.Count == 0)
{
dictTypes = ["cargo_attribute", "export_customs_mode", "currency"];
}
foreach (var dictType in dictTypes)
{
if (string.Equals(dictType, "sales_product", StringComparison.OrdinalIgnoreCase))
{
var products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled);
result.AddRange(products
.OrderBy(x => x.ProductCode)
.Select(x => new OrderTemplateDictOption
{
DictType = "sales_product",
Code = x.ProductCode,
Label = string.IsNullOrWhiteSpace(x.ProductName) ? x.ProductCode : $"{x.ProductName} {x.ProductCode}"
}));
continue;
}
if (string.Equals(dictType, "warehouse", StringComparison.OrdinalIgnoreCase))
{
var warehouses = await WarehouseAddressRepository.GetListAsync(_ => true);
result.AddRange(warehouses
.OrderBy(x => x.Id)
.Select(x => new OrderTemplateDictOption
{
DictType = "warehouse",
Code = x.Id.ToString(),
Label = x.WarehouseName
}));
continue;
}
var dictRows = await DictRepository.GetListAsync(x => x.DictType == dictType);
result.AddRange(dictRows
.OrderBy(x => x.DictSort)
.ThenBy(x => x.DictCode)
.Select(x => new OrderTemplateDictOption
{
DictType = dictType,
Code = x.DictCode,
Label = string.IsNullOrWhiteSpace(x.DictName) ? x.DictCode : x.DictName
}));
}
return result;
}
private async Task<Dictionary<string, string>> BuildTemplateExampleOverridesAsync(int? customerId)
{
var overrides = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled);
var firstProduct = products.OrderBy(x => x.ProductCode).FirstOrDefault();
if (firstProduct != null)
{
overrides["productCode"] = firstProduct.ProductCode;
}
var warehouses = await WarehouseAddressRepository.GetListAsync(_ => true);
var firstWarehouse = warehouses.OrderBy(x => x.Id).FirstOrDefault();
if (firstWarehouse != null)
{
overrides["warehouseId"] = firstWarehouse.Id.ToString();
}
var cargoDict = await DictRepository.GetListAsync(x => x.DictType == "cargo_attribute");
var firstCargo = cargoDict.OrderBy(x => x.DictSort).FirstOrDefault();
if (firstCargo != null)
{
overrides["cargoAttribute"] = firstCargo.DictCode;
}
var currencyDict = await DictRepository.GetListAsync(x => x.DictType == "currency");
var defaultCurrency = currencyDict.FirstOrDefault(x =>
string.Equals(x.DictCode, "USD", StringComparison.OrdinalIgnoreCase))
?? currencyDict.OrderBy(x => x.DictSort).FirstOrDefault();
if (defaultCurrency != null)
{
overrides["declaredCurrency"] = defaultCurrency.DictCode;
overrides["codCurrency"] = defaultCurrency.DictCode;
}
var exportDict = await DictRepository.GetListAsync(x => x.DictType == "export_customs_mode");
var export9610 = exportDict.FirstOrDefault(x =>
string.Equals(x.DictCode, "9610Simple", StringComparison.OrdinalIgnoreCase) ||
x.DictName.Contains("9610", StringComparison.OrdinalIgnoreCase));
if (export9610 != null)
{
overrides["exportCustomsMode"] = export9610.DictCode;
}
if (customerId.HasValue)
{
overrides["clientNo"] = $"C{DateTime.Now:yyyyMMdd}{Random.Shared.Next(100000, 999999)}";
}
return overrides;
}
}