using System.Globalization;
using ClosedXML.Excel;
using FoodLabeling.Application.Contracts.Dtos.Location;
namespace FoodLabeling.Application.Helpers;
///
/// Location 批量导入/导出 Excel(列名与 Web「Location Manager」表头对齐,兼容中英文表头别名)
///
public static class LocationBatchExcelHelper
{
/// 导出表头顺序(与模板一致)
public static readonly string[] ExportHeaders =
{
"Company", "Region", "Location ID", "Location Name", "Street", "City", "State", "Country",
"Zip Code", "Phone", "Email", "Latitude", "Longitude", "Active"
};
///
/// 将门店列表写入 xlsx 内存流。
///
public static MemoryStream BuildExportWorkbook(IReadOnlyList rows)
{
var ms = new MemoryStream();
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Locations");
for (var i = 0; i < ExportHeaders.Length; i++)
{
ws.Cell(1, i + 1).Value = ExportHeaders[i];
ws.Cell(1, i + 1).Style.Font.Bold = true;
}
var r = 2;
foreach (var x in rows)
{
ws.Cell(r, 1).Value = x.Partner ?? string.Empty;
ws.Cell(r, 2).Value = x.GroupName ?? string.Empty;
ws.Cell(r, 3).Value = x.LocationCode ?? string.Empty;
ws.Cell(r, 4).Value = x.LocationName ?? string.Empty;
ws.Cell(r, 5).Value = x.Street ?? string.Empty;
ws.Cell(r, 6).Value = x.City ?? string.Empty;
ws.Cell(r, 7).Value = x.StateCode ?? string.Empty;
ws.Cell(r, 8).Value = x.Country ?? string.Empty;
ws.Cell(r, 9).Value = x.ZipCode ?? string.Empty;
ws.Cell(r, 10).Value = x.Phone ?? string.Empty;
ws.Cell(r, 11).Value = x.Email ?? string.Empty;
ws.Cell(r, 12).Value = x.Latitude?.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
ws.Cell(r, 13).Value = x.Longitude?.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
ws.Cell(r, 14).Value = x.State ? "TRUE" : "FALSE";
r++;
}
ws.Columns().AdjustToContents();
wb.SaveAs(ms);
ms.Position = 0;
return ms;
}
///
/// 从上传的 Excel 解析为创建入参列表(行号从 2 起为数据行)。
///
public static List<(int RowNumber, LocationCreateInputVo Input)> ParseImportWorkbook(Stream stream,
int maxRows,
out List parseErrors)
{
parseErrors = new List();
var result = new List<(int, LocationCreateInputVo)>();
using var wb = new XLWorkbook(stream);
var ws = wb.Worksheets.FirstOrDefault();
if (ws is null)
{
parseErrors.Add(new LocationBatchImportErrorDto
{
RowNumber = 0,
Message = "Excel 中无工作表"
});
return result;
}
var headerRow = ws.Row(1);
if (!headerRow.CellsUsed().Any())
{
parseErrors.Add(new LocationBatchImportErrorDto { RowNumber = 1, Message = "表头为空" });
return result;
}
var colMap = BuildHeaderColumnMap(headerRow);
if (!colMap.ContainsKey("locationcode"))
{
parseErrors.Add(new LocationBatchImportErrorDto
{
RowNumber = 1,
Message = "未找到「Location ID」列(或同义表头),请使用官方模板"
});
return result;
}
var lastRow = ws.LastRowUsed()?.RowNumber() ?? 1;
var dataRowCount = 0;
for (var rowNum = 2; rowNum <= lastRow; rowNum++)
{
if (dataRowCount >= maxRows)
{
parseErrors.Add(new LocationBatchImportErrorDto
{
RowNumber = rowNum,
Message = $"已超过单次导入上限 {maxRows} 行,后续行已忽略"
});
break;
}
var locCode = GetCellByField(colMap, ws, rowNum, "locationcode");
if (string.IsNullOrWhiteSpace(locCode) && IsRowEmpty(colMap, ws, rowNum))
{
continue;
}
dataRowCount++;
var errPrefix = $"第 {rowNum} 行";
try
{
var input = BuildCreateInputFromRow(colMap, ws, rowNum, out var rowErrs);
if (rowErrs.Count > 0)
{
foreach (var e in rowErrs)
{
parseErrors.Add(new LocationBatchImportErrorDto
{
RowNumber = rowNum,
LocationCode = locCode,
Message = $"{errPrefix}:{e}"
});
}
continue;
}
result.Add((rowNum, input!));
}
catch (Exception ex)
{
parseErrors.Add(new LocationBatchImportErrorDto
{
RowNumber = rowNum,
LocationCode = locCode,
Message = $"{errPrefix}:{ex.Message}"
});
}
}
return result;
}
private static Dictionary BuildHeaderColumnMap(IXLRow headerRow)
{
var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var cell in headerRow.CellsUsed())
{
var key = NormalizeHeaderKey(cell.GetString());
if (string.IsNullOrEmpty(key))
{
continue;
}
var field = MapHeaderToField(key);
if (field is null)
{
continue;
}
if (!map.ContainsKey(field))
{
map[field] = cell.Address.ColumnNumber;
}
}
return map;
}
private static string? MapHeaderToField(string normalizedHeader)
{
return normalizedHeader switch
{
"company" or "partner" or "合作伙伴" or "公司" => "partner",
"region" or "groupname" or "group" or "区域" or "组织" => "groupname",
"locationid" or "locationcode" or "门店编码" or "门店id" => "locationcode",
"locationname" or "门店名称" or "name" => "locationname",
"street" or "地址" => "street",
"city" or "城市" => "city",
"state" or "statecode" or "省州" => "statecode",
"country" or "国家" => "country",
"zipcode" or "zip" or "邮编" or "邮政编码" => "zipcode",
"phone" or "电话" or "手机" => "phone",
"email" or "邮箱" => "email",
"latitude" or "lat" or "纬度" => "latitude",
"longitude" or "lng" or "lon" or "经度" => "longitude",
"active" or "启用" or "状态" or "isactive" => "active",
_ => null
};
}
private static string NormalizeHeaderKey(string raw)
{
var s = raw.Trim();
if (s.Length > 0 && s[0] == '\uFEFF')
{
s = s.TrimStart('\uFEFF');
}
s = s.Trim();
return string.Concat(s.Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
}
private static bool IsRowEmpty(Dictionary colMap, IXLWorksheet ws, int rowNum)
{
foreach (var col in colMap.Values)
{
var t = ws.Cell(rowNum, col).GetString().Trim();
if (!string.IsNullOrEmpty(t))
{
return false;
}
}
return true;
}
private static string GetCellByField(Dictionary colMap, IXLWorksheet ws, int row, string field)
{
if (!colMap.TryGetValue(field, out var col))
{
return string.Empty;
}
return ws.Cell(row, col).GetString().Trim();
}
private static LocationCreateInputVo? BuildCreateInputFromRow(Dictionary colMap, IXLWorksheet ws,
int rowNum, out List errors)
{
errors = new List();
var partner = GetCellByField(colMap, ws, rowNum, "partner");
var groupName = GetCellByField(colMap, ws, rowNum, "groupname");
var locationCode = GetCellByField(colMap, ws, rowNum, "locationcode");
var locationName = GetCellByField(colMap, ws, rowNum, "locationname");
var street = GetCellByField(colMap, ws, rowNum, "street");
var city = GetCellByField(colMap, ws, rowNum, "city");
var stateCode = GetCellByField(colMap, ws, rowNum, "statecode");
var country = GetCellByField(colMap, ws, rowNum, "country");
var zipCode = GetCellByField(colMap, ws, rowNum, "zipcode");
var phone = GetCellByField(colMap, ws, rowNum, "phone");
var email = GetCellByField(colMap, ws, rowNum, "email");
var latStr = GetCellByField(colMap, ws, rowNum, "latitude");
var lngStr = GetCellByField(colMap, ws, rowNum, "longitude");
var activeStr = GetCellByField(colMap, ws, rowNum, "active");
if (string.IsNullOrWhiteSpace(locationCode))
{
errors.Add("Location ID 不能为空");
}
if (string.IsNullOrWhiteSpace(locationName))
{
errors.Add("Location Name 不能为空");
}
decimal? lat = null;
if (!string.IsNullOrWhiteSpace(latStr))
{
if (!decimal.TryParse(latStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var latVal))
{
errors.Add("Latitude 格式不正确");
}
else
{
lat = latVal;
}
}
decimal? lng = null;
if (!string.IsNullOrWhiteSpace(lngStr))
{
if (!decimal.TryParse(lngStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var lngVal))
{
errors.Add("Longitude 格式不正确");
}
else
{
lng = lngVal;
}
}
if (errors.Count > 0)
{
return null;
}
var state = ParseBool(activeStr, defaultValue: true);
return new LocationCreateInputVo
{
Partner = NullIfEmpty(partner),
GroupName = NullIfEmpty(groupName),
LocationCode = locationCode,
LocationName = locationName,
Street = NullIfEmpty(street),
City = NullIfEmpty(city),
StateCode = NullIfEmpty(stateCode),
Country = NullIfEmpty(country),
ZipCode = NullIfEmpty(zipCode),
Phone = NullIfEmpty(phone),
Email = NullIfEmpty(email),
Latitude = lat,
Longitude = lng,
State = state
};
}
private static string? NullIfEmpty(string s)
{
var t = s.Trim();
return string.IsNullOrEmpty(t) ? null : t;
}
private static bool ParseBool(string? raw, bool defaultValue)
{
if (string.IsNullOrWhiteSpace(raw))
{
return defaultValue;
}
var s = raw.Trim();
if (bool.TryParse(s, out var b))
{
return b;
}
if (int.TryParse(s, out var n))
{
return n != 0;
}
if (string.Equals(s, "是", StringComparison.Ordinal) ||
string.Equals(s, "Y", StringComparison.OrdinalIgnoreCase) ||
string.Equals(s, "Yes", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (string.Equals(s, "否", StringComparison.Ordinal) ||
string.Equals(s, "N", StringComparison.OrdinalIgnoreCase) ||
string.Equals(s, "No", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return defaultValue;
}
}