feat: WPF-приложение для автоматизации оформления деклараций
- Чтение СПРАВКИ из Excel (ClosedXML), поддержка нескольких файлов - Группировка по ТН ВЭД: схлопывание строк с суммированием кол-ва/веса/суммы - Автоназначение кодов деклараций по справочнику ТН ВЭД (87 пар) - Цветовая маркировка: зелёный/жёлтый/красный по уровню уверенности - Самообучение: ручной выбор кода сохраняется в tnved_codes.json - Формирование Лист3 с разворачиванием строк по рег. номерам (ключевая функция) - Экспорт Лист2 + Лист3 в Excel
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user