feat: единый файл Лист1+СПРАВКА, фильтры, сортировка, живой п/п
- Загрузка одного .xlsx (СПРАВКА + Лист1 в одном файле) - Рег. номера берутся из СПРАВКА автоматически, группируются по (ТН ВЭД, Страна) - Отдельный опциональный справочник кодов (CodesImportService) - Фильтры: Все / Авто / Проверить / Нет кода / Без нетто - Таблица сразу отсортирована по ТН ВЭД с живым обновлением п/п - Начальный п/п: поле в UI, автоинкремент после экспорта - Лист2: рамки, жёлтая строка при >1 рег. номере, итого по кол-ву - Лист3: ТН ВЭД в колонке B вместо константы 09035 - Красный ErrorMessage под кнопкой загрузки, удалён SpravkaFileEntry
This commit is contained in:
@@ -15,9 +15,15 @@ public class CodeLookupService
|
||||
_entries = _persistence.Load();
|
||||
}
|
||||
|
||||
public void Reload() => _entries = _persistence.Load();
|
||||
// Загружает коды из внешнего xlsx-справочника (заменяет текущие записи)
|
||||
public void LoadFromEntries(List<CodeLookupEntry> entries)
|
||||
{
|
||||
_entries = entries;
|
||||
}
|
||||
|
||||
// Назначает декларационные коды всем позициям в списке.
|
||||
public bool HasEntries => _entries.Count > 0;
|
||||
|
||||
// Назначает декларационные коды всем позициям в списке
|
||||
public void AssignCodes(IEnumerable<DeclarationItem> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
@@ -46,18 +52,8 @@ public class CodeLookupService
|
||||
|
||||
// Несколько кодов — неоднозначность
|
||||
item.CandidateCodes = new List<string>(entry.Codes);
|
||||
|
||||
// Эвристика: если у позиции есть рег. номер — предпочесть 9000
|
||||
if (!string.IsNullOrEmpty(item.RegNumber) && entry.Codes.Contains("9000"))
|
||||
{
|
||||
item.DeclarationCode = "9000";
|
||||
item.Confidence = ConfidenceLevel.Auto;
|
||||
}
|
||||
else
|
||||
{
|
||||
item.DeclarationCode = entry.Codes[0]; // первый = наиболее вероятный
|
||||
item.Confidence = ConfidenceLevel.Review;
|
||||
}
|
||||
item.DeclarationCode = entry.Codes[0];
|
||||
item.Confidence = ConfidenceLevel.Review;
|
||||
}
|
||||
|
||||
// Фиксирует ручной выбор кода декларантом
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ClosedXML.Excel;
|
||||
using DeclarationAutomatization.Models;
|
||||
|
||||
namespace DeclarationAutomatization.Services;
|
||||
|
||||
// Читает справочник ТН ВЭД → код декларации из внешнего xlsx-файла.
|
||||
// Ожидаемый формат: лист "новые", колонка B = ТН ВЭД, колонка C = код.
|
||||
public class CodesImportService
|
||||
{
|
||||
private const string SheetName = "новые";
|
||||
|
||||
public List<CodeLookupEntry> ReadEntries(string filePath)
|
||||
{
|
||||
using var workbook = new XLWorkbook(filePath);
|
||||
|
||||
IXLWorksheet? sheet = null;
|
||||
foreach (var ws in workbook.Worksheets)
|
||||
{
|
||||
if (ws.Name.Equals(SheetName, StringComparison.OrdinalIgnoreCase))
|
||||
{ sheet = ws; break; }
|
||||
}
|
||||
|
||||
if (sheet == null)
|
||||
throw new InvalidOperationException($"Лист '{SheetName}' не найден в файле: {filePath}");
|
||||
|
||||
var result = new List<CodeLookupEntry>();
|
||||
int lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1;
|
||||
|
||||
for (int r = 1; r <= lastRow; r++)
|
||||
{
|
||||
var tnVed = sheet.Cell(r, 2).GetString().Trim();
|
||||
var code = sheet.Cell(r, 3).GetString().Trim();
|
||||
|
||||
if (tnVed.Length != 10 || string.IsNullOrWhiteSpace(code))
|
||||
continue;
|
||||
|
||||
result.Add(new CodeLookupEntry
|
||||
{
|
||||
TnVed = tnVed,
|
||||
Codes = new List<string> { code },
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using ClosedXML.Excel;
|
||||
using DeclarationAutomatization.Models;
|
||||
|
||||
@@ -24,7 +23,6 @@ public class ExcelExportService
|
||||
{
|
||||
var ws = workbook.Worksheets.Add("Лист2");
|
||||
|
||||
// Заголовки
|
||||
var headers = new[]
|
||||
{
|
||||
"п/п",
|
||||
@@ -37,7 +35,7 @@ public class ExcelExportService
|
||||
"Масса нетто, КГ",
|
||||
"Код декларации",
|
||||
"Регистрационный номер",
|
||||
"Дата регистрационного номера"
|
||||
"Дата регистрационного номера",
|
||||
};
|
||||
|
||||
for (int c = 0; c < headers.Length; c++)
|
||||
@@ -49,7 +47,7 @@ public class ExcelExportService
|
||||
}
|
||||
|
||||
int row = 2;
|
||||
decimal totalAmount = 0, totalGross = 0, totalNet = 0;
|
||||
decimal totalAmount = 0, totalGross = 0, totalNet = 0, totalQty = 0;
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
@@ -62,36 +60,37 @@ public class ExcelExportService
|
||||
ws.Cell(row, 7).Value = item.GrossWeight;
|
||||
ws.Cell(row, 8).Value = item.NetWeight;
|
||||
ws.Cell(row, 9).Value = item.DeclarationCode;
|
||||
ws.Cell(row, 10).Value = item.RegNumber;
|
||||
ws.Cell(row, 11).Value = item.RegDate;
|
||||
|
||||
// Цветовая маркировка строк по уровню уверенности
|
||||
var rowStyle = ws.Row(row).Style;
|
||||
var fillColor = item.Confidence switch
|
||||
if (item.RegEntries.Count == 1)
|
||||
{
|
||||
ConfidenceLevel.Auto => XLColor.FromArgb(198, 239, 206), // светло-зелёный
|
||||
ConfidenceLevel.Review => XLColor.FromArgb(255, 235, 156), // светло-жёлтый
|
||||
ConfidenceLevel.Missing => XLColor.FromArgb(255, 199, 206), // светло-красный
|
||||
_ => XLColor.White
|
||||
};
|
||||
ws.Row(row).Style.Fill.BackgroundColor = fillColor;
|
||||
ws.Cell(row, 10).Value = item.RegEntries[0].Number;
|
||||
ws.Cell(row, 11).Value = item.RegEntries[0].Date;
|
||||
}
|
||||
else if (item.RegEntries.Count > 1)
|
||||
{
|
||||
// Несколько партий — жёлтая строка целиком, подробности в Лист3
|
||||
ws.Row(row).Style.Fill.BackgroundColor = XLColor.Yellow;
|
||||
}
|
||||
|
||||
totalAmount += item.AmountWithVat;
|
||||
totalGross += item.GrossWeight;
|
||||
totalNet += item.NetWeight;
|
||||
totalGross += item.GrossWeight;
|
||||
totalNet += item.NetWeight;
|
||||
totalQty += item.Quantity;
|
||||
row++;
|
||||
}
|
||||
|
||||
// Итоговая строка
|
||||
ws.Cell(row, 5).Value = "ИТОГО:";
|
||||
ws.Cell(row, 5).Style.Font.Bold = true;
|
||||
ws.Cell(row, 2).Value = "ИТОГО:";
|
||||
ws.Cell(row, 5).Value = totalQty;
|
||||
ws.Cell(row, 6).Value = totalAmount;
|
||||
ws.Cell(row, 7).Value = totalGross;
|
||||
ws.Cell(row, 8).Value = totalNet;
|
||||
ws.Row(row).Style.Font.Bold = true;
|
||||
|
||||
ApplyBorders(ws, 1, row, headers.Length);
|
||||
|
||||
ws.Columns().AdjustToContents();
|
||||
ws.Column(2).Width = 50; // описание — широкая колонка
|
||||
ws.Column(2).Width = 50;
|
||||
}
|
||||
|
||||
private static void WriteSheet3(XLWorkbook workbook, IEnumerable<Sheet3Row> rows)
|
||||
@@ -101,9 +100,9 @@ public class ExcelExportService
|
||||
var headers = new[]
|
||||
{
|
||||
"п/п",
|
||||
"Наименование (классификатор)",
|
||||
"ТН ВЭД",
|
||||
"Регистрационный номер",
|
||||
"Дата регистрационного номера"
|
||||
"Дата регистрационного номера",
|
||||
};
|
||||
|
||||
for (int c = 0; c < headers.Length; c++)
|
||||
@@ -118,12 +117,21 @@ public class ExcelExportService
|
||||
foreach (var r in rows)
|
||||
{
|
||||
ws.Cell(row, 1).Value = r.SequentialNumber;
|
||||
ws.Cell(row, 2).Value = r.ClassifierCode;
|
||||
ws.Cell(row, 2).Value = r.TnVed;
|
||||
ws.Cell(row, 3).Value = r.RegNumber;
|
||||
ws.Cell(row, 4).Value = r.RegDate;
|
||||
row++;
|
||||
}
|
||||
|
||||
ApplyBorders(ws, 1, row - 1, headers.Length);
|
||||
|
||||
ws.Columns().AdjustToContents();
|
||||
}
|
||||
|
||||
private static void ApplyBorders(IXLWorksheet ws, int fromRow, int toRow, int colCount)
|
||||
{
|
||||
var range = ws.Range(fromRow, 1, toRow, colCount);
|
||||
range.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
|
||||
range.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using ClosedXML.Excel;
|
||||
using DeclarationAutomatization.Models;
|
||||
@@ -9,9 +8,8 @@ namespace DeclarationAutomatization.Services;
|
||||
|
||||
public class Sheet1ImportService
|
||||
{
|
||||
// Паттерн регистрационного номера: 8цифр/6цифр/буква?7+цифр
|
||||
private static readonly Regex RegPattern =
|
||||
new(@"\d{8}/\d{6}/[A-ZА-Я]?\d+", RegexOptions.Compiled);
|
||||
private const string SheetName = "Лист1";
|
||||
private const string ItogoMarker = "ИТОГО";
|
||||
|
||||
public List<Sheet1Group> ReadSheet1(string filePath)
|
||||
{
|
||||
@@ -20,34 +18,27 @@ public class Sheet1ImportService
|
||||
IXLWorksheet? sheet = null;
|
||||
foreach (var ws in workbook.Worksheets)
|
||||
{
|
||||
if (ws.Name.Equals("Лист1", StringComparison.OrdinalIgnoreCase))
|
||||
if (ws.Name.Equals(SheetName, StringComparison.OrdinalIgnoreCase))
|
||||
{ sheet = ws; break; }
|
||||
}
|
||||
|
||||
if (sheet == null)
|
||||
throw new InvalidOperationException($"Лист 'Лист1' не найден в файле: {filePath}");
|
||||
throw new InvalidOperationException($"Лист '{SheetName}' не найден в файле: {filePath}");
|
||||
|
||||
var groups = new List<Sheet1Group>();
|
||||
var groupRows = new List<IXLRow>(); // строки текущей группы (детальные)
|
||||
int sequentialNumber = 1;
|
||||
|
||||
int lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1;
|
||||
|
||||
for (int r = 1; r <= lastRow; r++)
|
||||
{
|
||||
var row = sheet.Row(r);
|
||||
if (!IsItogoRow(row)) continue;
|
||||
|
||||
if (IsItogoRow(row))
|
||||
var group = BuildGroup(row, sequentialNumber);
|
||||
if (group != null)
|
||||
{
|
||||
var group = BuildGroup(row, groupRows, sequentialNumber++);
|
||||
if (group != null)
|
||||
groups.Add(group);
|
||||
|
||||
groupRows = new List<IXLRow>();
|
||||
}
|
||||
else
|
||||
{
|
||||
groupRows.Add(row);
|
||||
groups.Add(group);
|
||||
sequentialNumber++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,40 +47,29 @@ public class Sheet1ImportService
|
||||
|
||||
private static bool IsItogoRow(IXLRow row)
|
||||
{
|
||||
// ИТОГО-строка: ячейка C содержит "ИТОГО" (или B)
|
||||
foreach (var cell in row.CellsUsed())
|
||||
{
|
||||
var val = cell.GetString();
|
||||
if (val.Contains("ИТОГО", StringComparison.OrdinalIgnoreCase))
|
||||
if (cell.GetString().Contains(ItogoMarker, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Sheet1Group? BuildGroup(IXLRow itogoRow, List<IXLRow> detailRows, int sequentialNumber)
|
||||
private static Sheet1Group? BuildGroup(IXLRow row, int sequentialNumber)
|
||||
{
|
||||
// Берём ТН ВЭД из строки ИТОГО — ищем первую ячейку с 10-значным кодом
|
||||
string tnVed = FindTnVed(itogoRow);
|
||||
if (string.IsNullOrWhiteSpace(tnVed))
|
||||
return null;
|
||||
string tnVed = FindTnVed(row);
|
||||
if (string.IsNullOrWhiteSpace(tnVed)) return null;
|
||||
|
||||
// Описание — столбец B строки ИТОГО
|
||||
string description = itogoRow.Cell(2).GetString().Trim();
|
||||
string description = row.Cell(2).GetString().Trim();
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
description = itogoRow.Cell(3).GetString().Trim(); // иногда в C
|
||||
description = row.Cell(3).GetString().Trim();
|
||||
|
||||
// Страна — первый двухбуквенный код в строке
|
||||
string countryId = FindCountryId(itogoRow);
|
||||
string countryId = FindCountryId(row);
|
||||
|
||||
// Числа: Количество(I=9), Сумма(J=10), Брутто(K=11), Нетто(L=12)
|
||||
decimal qty = ParseDecimal(itogoRow.Cell(9));
|
||||
decimal amount = ParseDecimal(itogoRow.Cell(10));
|
||||
decimal gross = ParseDecimal(itogoRow.Cell(11));
|
||||
decimal net = ParseDecimal(itogoRow.Cell(12));
|
||||
|
||||
// Собираем все уникальные рег. номера из ВСЕХ строк группы + строки ИТОГО
|
||||
var allRows = new List<IXLRow>(detailRows) { itogoRow };
|
||||
var regNumbers = CollectRegNumbers(allRows);
|
||||
decimal qty = ParseDecimal(row.Cell(9));
|
||||
decimal amount = ParseDecimal(row.Cell(10));
|
||||
decimal gross = ParseDecimal(row.Cell(11));
|
||||
decimal net = ParseDecimal(row.Cell(12));
|
||||
|
||||
return new Sheet1Group
|
||||
{
|
||||
@@ -101,7 +81,6 @@ public class Sheet1ImportService
|
||||
AmountWithVat = amount,
|
||||
GrossWeight = gross,
|
||||
NetWeight = net,
|
||||
RegNumbers = regNumbers,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,7 +89,6 @@ public class Sheet1ImportService
|
||||
foreach (var cell in row.CellsUsed())
|
||||
{
|
||||
var val = cell.GetString().Trim();
|
||||
// ТН ВЭД = 10 цифр подряд
|
||||
if (Regex.IsMatch(val, @"^\d{10}$"))
|
||||
return val;
|
||||
}
|
||||
@@ -122,36 +100,12 @@ public class Sheet1ImportService
|
||||
foreach (var cell in row.CellsUsed())
|
||||
{
|
||||
var val = cell.GetString().Trim();
|
||||
// Двухбуквенный код страны (AM, BD, CN, ...)
|
||||
if (Regex.IsMatch(val, @"^[A-Z]{2}$"))
|
||||
return val;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private List<string> CollectRegNumbers(IEnumerable<IXLRow> rows)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var result = new List<string>();
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
foreach (var cell in row.CellsUsed())
|
||||
{
|
||||
var val = cell.GetString().Trim();
|
||||
var match = RegPattern.Match(val);
|
||||
if (match.Success)
|
||||
{
|
||||
var regNum = match.Value;
|
||||
if (seen.Add(regNum))
|
||||
result.Add(regNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static decimal ParseDecimal(IXLCell cell)
|
||||
{
|
||||
if (cell.IsEmpty()) return 0;
|
||||
|
||||
@@ -1,56 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using DeclarationAutomatization.Models;
|
||||
|
||||
namespace DeclarationAutomatization.Services;
|
||||
|
||||
public class Sheet3ExpandService
|
||||
{
|
||||
// Строит строки Листа3, разворачивая позиции по регистрационным номерам.
|
||||
// Группировка по ТН ВЭД: все рег. номера с таким же ТН ВЭД из СПРАВКИ.
|
||||
public List<Sheet3Row> Expand(
|
||||
IEnumerable<DeclarationItem> declarationItems,
|
||||
IEnumerable<SpravkaItem> allSpravkaItems)
|
||||
// Разворачивает позиции Листа2 по рег. номерам.
|
||||
// Один товар с N рег. номерами → N строк в Листе3 (с тем же п/п).
|
||||
public List<Sheet3Row> Expand(IEnumerable<DeclarationItem> items)
|
||||
{
|
||||
// Индекс: ТнВэд → уникальные (РегНомер, Дата)
|
||||
var regByTnVed = allSpravkaItems
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.RegNumber))
|
||||
.GroupBy(s => s.TnVed)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g
|
||||
.Select(s => (s.RegNumber, s.RegDate))
|
||||
.Distinct()
|
||||
.ToList()
|
||||
);
|
||||
|
||||
var result = new List<Sheet3Row>();
|
||||
|
||||
foreach (var item in declarationItems)
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (regByTnVed.TryGetValue(item.TnVed, out var regPairs) && regPairs.Count > 0)
|
||||
{
|
||||
foreach (var (regNum, regDate) in regPairs)
|
||||
{
|
||||
result.Add(new Sheet3Row
|
||||
{
|
||||
SequentialNumber = item.SequentialNumber,
|
||||
ClassifierCode = "09035",
|
||||
RegNumber = regNum,
|
||||
RegDate = regDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
if (item.RegEntries.Count == 0)
|
||||
{
|
||||
result.Add(new Sheet3Row
|
||||
{
|
||||
SequentialNumber = item.SequentialNumber,
|
||||
ClassifierCode = "09035",
|
||||
TnVed = item.TnVed,
|
||||
RegNumber = "",
|
||||
RegDate = "",
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var entry in item.RegEntries)
|
||||
{
|
||||
result.Add(new Sheet3Row
|
||||
{
|
||||
SequentialNumber = item.SequentialNumber,
|
||||
TnVed = item.TnVed,
|
||||
RegNumber = entry.Number,
|
||||
RegDate = entry.Date,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -6,35 +6,36 @@ namespace DeclarationAutomatization.Services;
|
||||
|
||||
public class TransformService
|
||||
{
|
||||
// Группирует строки СПРАВКИ по ТН ВЭД → одна позиция Листа2.
|
||||
// Количество, сумма, вес — суммируются.
|
||||
// Рег. номер / дата — берётся первый непустой в группе.
|
||||
// п/п — нумерация по порядку с 1.
|
||||
public List<DeclarationItem> BuildDeclarationItems(IEnumerable<SpravkaItem> allItems)
|
||||
// Строит позиции Листа2 из ИТОГО-строк Листа1.
|
||||
// Рег. номера берёт из СПРАВКИ по ТН ВЭД.
|
||||
public List<DeclarationItem> BuildDeclarationItems(
|
||||
IEnumerable<Sheet1Group> groups,
|
||||
IEnumerable<SpravkaItem> spravkaItems)
|
||||
{
|
||||
int sequentialNumber = 1;
|
||||
// Индекс: (ТН ВЭД, Страна) → уникальные (рег. номер, дата) из СПРАВКИ
|
||||
var regByKey = spravkaItems
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.RegNumber))
|
||||
.GroupBy(s => (s.TnVed, s.CountryId))
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(s => new RegEntry(s.RegNumber, s.RegDate))
|
||||
.Distinct()
|
||||
.ToList()
|
||||
);
|
||||
|
||||
return allItems
|
||||
.GroupBy(s => s.TnVed)
|
||||
.Select(g =>
|
||||
{
|
||||
var first = g.First();
|
||||
var regEntry = g.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s.RegNumber));
|
||||
|
||||
return new DeclarationItem
|
||||
{
|
||||
SequentialNumber = sequentialNumber++,
|
||||
Description = first.Description,
|
||||
TnVed = first.TnVed,
|
||||
CountryId = first.CountryId,
|
||||
Quantity = g.Sum(s => s.Quantity),
|
||||
AmountWithVat = g.Sum(s => s.AmountWithVat),
|
||||
GrossWeight = g.Sum(s => s.GrossWeight),
|
||||
NetWeight = g.Sum(s => s.NetWeight),
|
||||
RegNumber = regEntry?.RegNumber ?? "",
|
||||
RegDate = regEntry?.RegDate ?? "",
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
return groups.Select(group => new DeclarationItem
|
||||
{
|
||||
SequentialNumber = group.SequentialNumber,
|
||||
Description = group.Description,
|
||||
TnVed = group.TnVed,
|
||||
CountryId = group.CountryId,
|
||||
Quantity = group.Quantity,
|
||||
AmountWithVat = group.AmountWithVat,
|
||||
GrossWeight = group.GrossWeight,
|
||||
NetWeight = group.NetWeight,
|
||||
RegEntries = regByKey.TryGetValue((group.TnVed, group.CountryId), out var regs)
|
||||
? regs
|
||||
: new List<RegEntry>(),
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user