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 { /// /// 获取标准模板字段定义。 /// [HttpGet("/order/template/field-schema")] public Task 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() }); } /// /// 获取模板下拉字典选项。 /// [HttpGet("/order/template/dict-options")] public async Task 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() }; } /// /// 上传自定义模板样例并创建草稿映射。 /// [HttpPost("/order/template/custom/upload")] public async Task 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 }; } /// /// 获取自定义模板详情(含映射)。 /// [HttpGet("/order/template/custom/detail")] public async Task 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() }; } /// /// 保存自定义模板字段映射。 /// [HttpPost("/order/template/custom/save-mapping")] public async Task 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(StringComparer.OrdinalIgnoreCase); var mappings = new List(); 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 }; } /// /// 删除自定义模板。 /// [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); } /// /// 列表查询前确保标准「专线小包」精简版与标准版模板存在。 /// 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 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 DeserializeStringList(string? json) { if (string.IsNullOrWhiteSpace(json)) { return []; } try { return JsonSerializer.Deserialize>(json) ?? []; } catch { return []; } } private static OrderTemplateColumnMappingDocument? DeserializeMapping(string? json) { if (string.IsNullOrWhiteSpace(json)) { return null; } try { return JsonSerializer.Deserialize(json); } catch { return null; } } private async Task> LoadTemplateDictOptionsAsync(IReadOnlyCollection dictTypes) { var result = new List(); 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> BuildTemplateExampleOverridesAsync(int? customerId) { var overrides = new Dictionary(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; } }