All checks were successful
Build and Deploy / deploy (push) Successful in 1m43s
444 lines
17 KiB
C#
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,
|
|
FileStorage);
|
|
|
|
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;
|
|
}
|
|
}
|