feat: WPF-приложение для автоматизации оформления деклараций

- Чтение СПРАВКИ из Excel (ClosedXML), поддержка нескольких файлов
- Группировка по ТН ВЭД: схлопывание строк с суммированием кол-ва/веса/суммы
- Автоназначение кодов деклараций по справочнику ТН ВЭД (87 пар)
- Цветовая маркировка: зелёный/жёлтый/красный по уровню уверенности
- Самообучение: ручной выбор кода сохраняется в tnved_codes.json
- Формирование Лист3 с разворачиванием строк по рег. номерам (ключевая функция)
- Экспорт Лист2 + Лист3 в Excel
This commit is contained in:
Dianaka123
2026-04-05 23:19:58 +03:00
commit 059895d1c3
22 changed files with 1257 additions and 0 deletions
@@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Linq;
using DeclarationAutomatization.Models;
namespace DeclarationAutomatization.Services;
public class CodeLookupService
{
private readonly RulesPersistenceService _persistence;
private List<CodeLookupEntry> _entries;
public CodeLookupService(RulesPersistenceService persistence)
{
_persistence = persistence;
_entries = _persistence.Load();
}
public void Reload() => _entries = _persistence.Load();
// Назначает декларационные коды всем позициям в списке.
public void AssignCodes(IEnumerable<DeclarationItem> items)
{
foreach (var item in items)
AssignCode(item);
}
private void AssignCode(DeclarationItem item)
{
var entry = _entries.FirstOrDefault(e => e.TnVed == item.TnVed);
if (entry == null || entry.Codes.Count == 0)
{
item.DeclarationCode = "";
item.Confidence = ConfidenceLevel.Missing;
item.CandidateCodes = new List<string>();
return;
}
if (entry.Codes.Count == 1)
{
item.DeclarationCode = entry.Codes[0];
item.Confidence = ConfidenceLevel.Auto;
item.CandidateCodes = new List<string>(entry.Codes);
return;
}
// Несколько кодов — неоднозначность
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;
}
}
// Фиксирует ручной выбор кода декларантом
public void LearnFromManualEdit(string tnVed, string chosenCode)
{
_persistence.LearnCode(_entries, tnVed, chosenCode);
}
public List<CodeLookupEntry> GetAllEntries() => _entries;
}
@@ -0,0 +1,129 @@
using System.Collections.Generic;
using System.Drawing;
using ClosedXML.Excel;
using DeclarationAutomatization.Models;
namespace DeclarationAutomatization.Services;
public class ExcelExportService
{
public void Export(
string outputPath,
IEnumerable<DeclarationItem> declarationItems,
IEnumerable<Sheet3Row> sheet3Rows)
{
using var workbook = new XLWorkbook();
WriteSheet2(workbook, declarationItems);
WriteSheet3(workbook, sheet3Rows);
workbook.SaveAs(outputPath);
}
private static void WriteSheet2(XLWorkbook workbook, IEnumerable<DeclarationItem> items)
{
var ws = workbook.Worksheets.Add("Лист2");
// Заголовки
var headers = new[]
{
"п/п",
"Наименование, характеристика, сорт, артикул товара",
"ТН ВЭД",
"Страна ID",
"Количество,шт.",
"Сумма с учетом НДС, руб. РФ",
"ВЕС брутто, КГ",
"Масса нетто, КГ",
"Код декларации",
"Регистрационный номер",
"Дата регистрационного номера"
};
for (int c = 0; c < headers.Length; c++)
{
var cell = ws.Cell(1, c + 1);
cell.Value = headers[c];
cell.Style.Font.Bold = true;
cell.Style.Fill.BackgroundColor = XLColor.LightGray;
}
int row = 2;
decimal totalAmount = 0, totalGross = 0, totalNet = 0;
foreach (var item in items)
{
ws.Cell(row, 1).Value = item.SequentialNumber;
ws.Cell(row, 2).Value = item.Description;
ws.Cell(row, 3).Value = item.TnVed;
ws.Cell(row, 4).Value = item.CountryId;
ws.Cell(row, 5).Value = item.Quantity;
ws.Cell(row, 6).Value = item.AmountWithVat;
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
{
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;
totalAmount += item.AmountWithVat;
totalGross += item.GrossWeight;
totalNet += item.NetWeight;
row++;
}
// Итоговая строка
ws.Cell(row, 5).Value = "ИТОГО:";
ws.Cell(row, 5).Style.Font.Bold = true;
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;
ws.Columns().AdjustToContents();
ws.Column(2).Width = 50; // описание — широкая колонка
}
private static void WriteSheet3(XLWorkbook workbook, IEnumerable<Sheet3Row> rows)
{
var ws = workbook.Worksheets.Add("Лист3");
var headers = new[]
{
"п/п",
"Наименование (классификатор)",
"Регистрационный номер",
"Дата регистрационного номера"
};
for (int c = 0; c < headers.Length; c++)
{
var cell = ws.Cell(1, c + 1);
cell.Value = headers[c];
cell.Style.Font.Bold = true;
cell.Style.Fill.BackgroundColor = XLColor.LightGray;
}
int row = 2;
foreach (var r in rows)
{
ws.Cell(row, 1).Value = r.SequentialNumber;
ws.Cell(row, 2).Value = r.ClassifierCode;
ws.Cell(row, 3).Value = r.RegNumber;
ws.Cell(row, 4).Value = r.RegDate;
row++;
}
ws.Columns().AdjustToContents();
}
}
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using ClosedXML.Excel;
using DeclarationAutomatization.Models;
namespace DeclarationAutomatization.Services;
public class ExcelImportService
{
// Читает лист СПРАВКА из файла Excel.
// startingNumber — первый п/п для строк этого файла.
// Возвращает список SpravkaItem с назначенными п/п.
public List<SpravkaItem> ReadSpravka(string filePath, int startingNumber)
{
var result = new List<SpravkaItem>();
using var workbook = new XLWorkbook(filePath);
// Ищем лист "СПРАВКА" (регистронезависимо)
IXLWorksheet? sheet = null;
foreach (var ws in workbook.Worksheets)
{
if (ws.Name.Equals("СПРАВКА", StringComparison.OrdinalIgnoreCase))
{
sheet = ws;
break;
}
}
if (sheet == null)
throw new InvalidOperationException($"Лист 'СПРАВКА' не найден в файле: {filePath}");
// Ищем строку заголовка: первая строка, где ячейка B содержит "Наименование"
int headerRow = FindHeaderRow(sheet);
if (headerRow < 0)
throw new InvalidOperationException("Не найдена строка заголовка в листе СПРАВКА (ожидалась ячейка с 'Наименование')");
int sequentialNumber = startingNumber;
for (int row = headerRow + 1; row <= sheet.LastRowUsed()?.RowNumber(); row++)
{
var cellB = sheet.Cell(row, 2).GetString().Trim();
if (string.IsNullOrWhiteSpace(cellB))
continue; // пропускаем пустые строки
var item = new SpravkaItem
{
SequentialNumber = sequentialNumber++,
Description = cellB,
ProductCode = sheet.Cell(row, 5).GetString().Trim(),
TnVed = sheet.Cell(row, 8).GetString().Trim(),
CountryId = sheet.Cell(row, 11).GetString().Trim(),
Country = sheet.Cell(row, 12).GetString().Trim(),
Unit = sheet.Cell(row, 13).GetString().Trim(),
Quantity = ParseDecimal(sheet.Cell(row, 14)),
AmountWithVat = ParseDecimal(sheet.Cell(row, 15)),
GrossWeight = ParseDecimal(sheet.Cell(row, 16)),
NetWeight = ParseDecimal(sheet.Cell(row, 17)),
RegNumber = sheet.Cell(row, 18).GetString().Trim(),
RegDate = FormatDate(sheet.Cell(row, 19)),
};
result.Add(item);
}
return result;
}
private static int FindHeaderRow(IXLWorksheet sheet)
{
int lastRow = Math.Min(sheet.LastRowUsed()?.RowNumber() ?? 200, 200);
for (int r = 1; r <= lastRow; r++)
{
var val = sheet.Cell(r, 2).GetString();
if (val.Contains("Наименование", StringComparison.OrdinalIgnoreCase))
return r;
}
return -1;
}
private static decimal ParseDecimal(IXLCell cell)
{
try
{
if (cell.IsEmpty()) return 0;
var val = cell.GetString().Trim().Replace(',', '.');
return decimal.TryParse(val, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var d) ? d : 0;
}
catch
{
return 0;
}
}
private static string FormatDate(IXLCell cell)
{
if (cell.IsEmpty()) return "";
try
{
if (cell.DataType == XLDataType.DateTime)
return cell.GetDateTime().ToString("dd.MM.yyyy");
}
catch { }
return cell.GetString().Trim();
}
}
@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using DeclarationAutomatization.Models;
namespace DeclarationAutomatization.Services;
public class RulesPersistenceService
{
private readonly string _rulesPath;
public RulesPersistenceService()
{
// Храним рядом с исполняемым файлом приложения
var appDir = AppContext.BaseDirectory;
_rulesPath = Path.Combine(appDir, "Data", "tnved_codes.json");
}
public List<CodeLookupEntry> Load()
{
if (!File.Exists(_rulesPath))
return new List<CodeLookupEntry>();
try
{
var json = File.ReadAllText(_rulesPath);
return JsonSerializer.Deserialize<List<CodeLookupEntry>>(json)
?? new List<CodeLookupEntry>();
}
catch
{
return new List<CodeLookupEntry>();
}
}
public void Save(IEnumerable<CodeLookupEntry> entries)
{
Directory.CreateDirectory(Path.GetDirectoryName(_rulesPath)!);
var json = JsonSerializer.Serialize(entries.ToList(),
new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_rulesPath, json);
}
// Обновляет (или добавляет) запись: запоминает выбранный декларантом код
public void LearnCode(List<CodeLookupEntry> entries, string tnVed, string chosenCode)
{
var existing = entries.FirstOrDefault(e => e.TnVed == tnVed);
if (existing == null)
{
entries.Add(new CodeLookupEntry
{
TnVed = tnVed,
Codes = new List<string> { chosenCode }
});
}
else
{
// Перемещаем выбранный код на первое место (самообучение)
existing.Codes.Remove(chosenCode);
existing.Codes.Insert(0, chosenCode);
}
Save(entries);
}
}
@@ -0,0 +1,58 @@
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)
{
// Индекс: ТнВэд → уникальные (РегНомер, Дата)
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)
{
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
{
result.Add(new Sheet3Row
{
SequentialNumber = item.SequentialNumber,
ClassifierCode = "09035",
RegNumber = "",
RegDate = "",
});
}
}
return result;
}
}
@@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.Linq;
using DeclarationAutomatization.Models;
namespace DeclarationAutomatization.Services;
public class TransformService
{
// Группирует строки СПРАВКИ по ТН ВЭД → одна позиция Листа2.
// Количество, сумма, вес — суммируются.
// Рег. номер / дата — берётся первый непустой в группе.
// п/п — нумерация по порядку с 1.
public List<DeclarationItem> BuildDeclarationItems(IEnumerable<SpravkaItem> allItems)
{
int sequentialNumber = 1;
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();
}
}