commit 059895d1c33633aeb89448ac39fc1aee38217204 Author: Dianaka123 Date: Sun Apr 5 23:19:58 2026 +0300 feat: WPF-приложение для автоматизации оформления деклараций - Чтение СПРАВКИ из Excel (ClosedXML), поддержка нескольких файлов - Группировка по ТН ВЭД: схлопывание строк с суммированием кол-ва/веса/суммы - Автоназначение кодов деклараций по справочнику ТН ВЭД (87 пар) - Цветовая маркировка: зелёный/жёлтый/красный по уровню уверенности - Самообучение: ручной выбор кода сохраняется в tnved_codes.json - Формирование Лист3 с разворачиванием строк по рег. номерам (ключевая функция) - Экспорт Лист2 + Лист3 в Excel diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb76c40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +*.user +.vs/ +*.suo diff --git a/DeclarationAutomatization/App.xaml b/DeclarationAutomatization/App.xaml new file mode 100644 index 0000000..deb32b8 --- /dev/null +++ b/DeclarationAutomatization/App.xaml @@ -0,0 +1,9 @@ + + + + + diff --git a/DeclarationAutomatization/App.xaml.cs b/DeclarationAutomatization/App.xaml.cs new file mode 100644 index 0000000..2abc30c --- /dev/null +++ b/DeclarationAutomatization/App.xaml.cs @@ -0,0 +1,40 @@ +using System.Windows; +using DeclarationAutomatization.Services; +using DeclarationAutomatization.ViewModels; +using DeclarationAutomatization.Views; +using Microsoft.Extensions.DependencyInjection; + +namespace DeclarationAutomatization; + +public partial class App : Application +{ + private ServiceProvider? _serviceProvider; + + private void OnStartup(object sender, StartupEventArgs e) + { + var services = new ServiceCollection(); + ConfigureServices(services); + _serviceProvider = services.BuildServiceProvider(); + + var mainWindow = _serviceProvider.GetRequiredService(); + mainWindow.Show(); + } + + private static void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + protected override void OnExit(ExitEventArgs e) + { + _serviceProvider?.Dispose(); + base.OnExit(e); + } +} diff --git a/DeclarationAutomatization/AssemblyInfo.cs b/DeclarationAutomatization/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/DeclarationAutomatization/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/DeclarationAutomatization/Data/tnved_codes.json b/DeclarationAutomatization/Data/tnved_codes.json new file mode 100644 index 0000000..02bebb8 --- /dev/null +++ b/DeclarationAutomatization/Data/tnved_codes.json @@ -0,0 +1,89 @@ +[ + { "TnVed": "3406000000", "Codes": ["0"] }, + { "TnVed": "3506100000", "Codes": ["0"] }, + { "TnVed": "3926100000", "Codes": ["0"] }, + { "TnVed": "4202390000", "Codes": ["0"] }, + { "TnVed": "4810998000", "Codes": ["0"] }, + { "TnVed": "4811419000", "Codes": ["0"] }, + { "TnVed": "4901100000", "Codes": ["0"] }, + { "TnVed": "4903000000", "Codes": ["0"] }, + { "TnVed": "4911990000", "Codes": ["0"] }, + { "TnVed": "7117900000", "Codes": ["0"] }, + { "TnVed": "9011800000", "Codes": ["0"] }, + { "TnVed": "9503004100", "Codes": ["0"] }, + { "TnVed": "9503004900", "Codes": ["0"] }, + { "TnVed": "9503005500", "Codes": ["0"] }, + { "TnVed": "9503006100", "Codes": ["0"] }, + { "TnVed": "9503007000", "Codes": ["0"] }, + { "TnVed": "9503009500", "Codes": ["0"] }, + { "TnVed": "9503009909", "Codes": ["0"] }, + { "TnVed": "9504908009", "Codes": ["0"] }, + { "TnVed": "9505900000", "Codes": ["0"] }, + { "TnVed": "9603301000", "Codes": ["0"] }, + { "TnVed": "9608109200", "Codes": ["0"] }, + { "TnVed": "9608200000", "Codes": ["0"] }, + { "TnVed": "9609901000", "Codes": ["0"] }, + { "TnVed": "9610000000", "Codes": ["0"] }, + { "TnVed": "9503002100", "Codes": ["1000"] }, + { "TnVed": "9503003000", "Codes": ["1000"] }, + { "TnVed": "9503007500", "Codes": ["1000"] }, + { "TnVed": "9503007900", "Codes": ["1000"] }, + { "TnVed": "9503008500", "Codes": ["1000"] }, + { "TnVed": "9609909000", "Codes": ["1000"] }, + { "TnVed": "9619008109", "Codes": ["1000"] }, + { "TnVed": "3924900001", "Codes": ["1100"] }, + { "TnVed": "3924900009", "Codes": ["1100"] }, + { "TnVed": "9603210000", "Codes": ["1100"] }, + { "TnVed": "8509800000", "Codes": ["1900"] }, + { "TnVed": "8516797000", "Codes": ["1900"] }, + { "TnVed": "3304200000", "Codes": ["3200"] }, + { "TnVed": "3824999608", "Codes": ["5000"] }, + { "TnVed": "1901100000", "Codes": ["9000"] }, + { "TnVed": "3919900000", "Codes": ["9000"] }, + { "TnVed": "3926909200", "Codes": ["9000"] }, + { "TnVed": "4016920000", "Codes": ["9000"] }, + { "TnVed": "4202929800", "Codes": ["9000"] }, + { "TnVed": "4820103000", "Codes": ["9000"] }, + { "TnVed": "4820900000", "Codes": ["9000"] }, + { "TnVed": "6102309000", "Codes": ["9000"] }, + { "TnVed": "6103220000", "Codes": ["9000"] }, + { "TnVed": "6103420001", "Codes": ["9000"] }, + { "TnVed": "6104220000", "Codes": ["9000", "9900", "1100"] }, + { "TnVed": "6104420000", "Codes": ["9000"] }, + { "TnVed": "6104620000", "Codes": ["9000"] }, + { "TnVed": "6107110000", "Codes": ["9000"] }, + { "TnVed": "6107210000", "Codes": ["9000"] }, + { "TnVed": "6107910000", "Codes": ["9000"] }, + { "TnVed": "6108310000", "Codes": ["9000"] }, + { "TnVed": "6108910000", "Codes": ["9000"] }, + { "TnVed": "6110201000", "Codes": ["9000"] }, + { "TnVed": "6110209100", "Codes": ["9000"] }, + { "TnVed": "6110209900", "Codes": ["9000", "9900"] }, + { "TnVed": "6110309900", "Codes": ["9000"] }, + { "TnVed": "6112311000", "Codes": ["9000"] }, + { "TnVed": "6115290000", "Codes": ["9000"] }, + { "TnVed": "6115950000", "Codes": ["9000"] }, + { "TnVed": "6117808009", "Codes": ["9000"] }, + { "TnVed": "6203431900", "Codes": ["9000"] }, + { "TnVed": "6204623100", "Codes": ["9000"] }, + { "TnVed": "6204623900", "Codes": ["9000"] }, + { "TnVed": "6205200000", "Codes": ["9000"] }, + { "TnVed": "6210300000", "Codes": ["9000"] }, + { "TnVed": "6210500000", "Codes": ["9000"] }, + { "TnVed": "9503001009", "Codes": ["9000"] }, + { "TnVed": "9503003500", "Codes": ["9000"] }, + { "TnVed": "9503006900", "Codes": ["9000"] }, + { "TnVed": "9506701000", "Codes": ["9000"] }, + { "TnVed": "9608101000", "Codes": ["9000"] }, + { "TnVed": "3213100000", "Codes": ["9100"] }, + { "TnVed": "6402993900", "Codes": ["9100"] }, + { "TnVed": "3926909709", "Codes": ["9900"] }, + { "TnVed": "6109100000", "Codes": ["9900"] }, + { "TnVed": "6111209000", "Codes": ["9900"] }, + { "TnVed": "6209300000", "Codes": ["9900"] }, + { "TnVed": "9506999000", "Codes": ["9900"] }, + { "TnVed": "9615900000", "Codes": ["9900"] }, + { "TnVed": "9609101000", "Codes": ["9921"] }, + { "TnVed": "9609109000", "Codes": ["9929"] }, + { "TnVed": "6404199000", "Codes": ["9939"] } +] diff --git a/DeclarationAutomatization/DeclarationAutomatization.csproj b/DeclarationAutomatization/DeclarationAutomatization.csproj new file mode 100644 index 0000000..d2eb1cd --- /dev/null +++ b/DeclarationAutomatization/DeclarationAutomatization.csproj @@ -0,0 +1,24 @@ + + + + WinExe + net9.0-windows + enable + enable + true + + + + + + + + + + + + PreserveNewest + + + + diff --git a/DeclarationAutomatization/Models/CodeLookupEntry.cs b/DeclarationAutomatization/Models/CodeLookupEntry.cs new file mode 100644 index 0000000..8d1e6f7 --- /dev/null +++ b/DeclarationAutomatization/Models/CodeLookupEntry.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace DeclarationAutomatization.Models; + +public class CodeLookupEntry +{ + public string TnVed { get; set; } = ""; + // Список кодов (обычно 1, реже 2–3 при неоднозначности) + public List Codes { get; set; } = new(); +} diff --git a/DeclarationAutomatization/Models/ConfidenceLevel.cs b/DeclarationAutomatization/Models/ConfidenceLevel.cs new file mode 100644 index 0000000..5ef07ca --- /dev/null +++ b/DeclarationAutomatization/Models/ConfidenceLevel.cs @@ -0,0 +1,8 @@ +namespace DeclarationAutomatization.Models; + +public enum ConfidenceLevel +{ + Auto, // код назначен однозначно — зелёный + Review, // несколько вариантов — жёлтый + Missing // ТН ВЭД не найден в справочнике — красный +} diff --git a/DeclarationAutomatization/Models/DeclarationItem.cs b/DeclarationAutomatization/Models/DeclarationItem.cs new file mode 100644 index 0000000..50e4874 --- /dev/null +++ b/DeclarationAutomatization/Models/DeclarationItem.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace DeclarationAutomatization.Models; + +public partial class DeclarationItem : ObservableObject +{ + public int SequentialNumber { get; init; } + public string Description { get; init; } = ""; + public string TnVed { get; init; } = ""; + public string CountryId { get; init; } = ""; + public decimal Quantity { get; init; } + public decimal AmountWithVat { get; init; } + public decimal GrossWeight { get; init; } + public decimal NetWeight { get; init; } + public string RegNumber { get; init; } = ""; + public string RegDate { get; init; } = ""; + + [ObservableProperty] + private string _declarationCode = ""; + + [ObservableProperty] + private ConfidenceLevel _confidence = ConfidenceLevel.Missing; + + // Все возможные коды при неоднозначном ТН ВЭД (для выпадающего списка) + public List CandidateCodes { get; set; } = new(); +} diff --git a/DeclarationAutomatization/Models/Sheet3Row.cs b/DeclarationAutomatization/Models/Sheet3Row.cs new file mode 100644 index 0000000..b347ef6 --- /dev/null +++ b/DeclarationAutomatization/Models/Sheet3Row.cs @@ -0,0 +1,9 @@ +namespace DeclarationAutomatization.Models; + +public class Sheet3Row +{ + public int SequentialNumber { get; init; } + public string ClassifierCode { get; init; } = "09035"; + public string RegNumber { get; init; } = ""; + public string RegDate { get; init; } = ""; +} diff --git a/DeclarationAutomatization/Models/SpravkaFileEntry.cs b/DeclarationAutomatization/Models/SpravkaFileEntry.cs new file mode 100644 index 0000000..70b251b --- /dev/null +++ b/DeclarationAutomatization/Models/SpravkaFileEntry.cs @@ -0,0 +1,15 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace DeclarationAutomatization.Models; + +// Запись о загруженном файле СПРАВКИ с настройкой начального п/п +public partial class SpravkaFileEntry : ObservableObject +{ + [ObservableProperty] + private string _filePath = ""; + + [ObservableProperty] + private int _startingNumber = 1; + + public string FileName => System.IO.Path.GetFileName(FilePath); +} diff --git a/DeclarationAutomatization/Models/SpravkaItem.cs b/DeclarationAutomatization/Models/SpravkaItem.cs new file mode 100644 index 0000000..0621fbd --- /dev/null +++ b/DeclarationAutomatization/Models/SpravkaItem.cs @@ -0,0 +1,18 @@ +namespace DeclarationAutomatization.Models; + +public class SpravkaItem +{ + public int SequentialNumber { get; init; } // п/п (назначается приложением) + public string Description { get; init; } = ""; // Наименование, характеристика... + public string ProductCode { get; init; } = ""; // Код товара + public string TnVed { get; init; } = ""; // ТН ВЭД + public string CountryId { get; init; } = ""; // Страна ID + public string Country { get; init; } = ""; // Страна + public string Unit { get; init; } = ""; // Ед. Изм. + public decimal Quantity { get; init; } // Количество, шт. + public decimal AmountWithVat { get; init; } // Сумма с учётом НДС + public decimal GrossWeight { get; init; } // ВЕС брутто, КГ + public decimal NetWeight { get; init; } // Масса нетто, КГ + public string RegNumber { get; init; } = ""; // Регистрационный номер + public string RegDate { get; init; } = ""; // Дата регистрационного номера +} diff --git a/DeclarationAutomatization/Services/CodeLookupService.cs b/DeclarationAutomatization/Services/CodeLookupService.cs new file mode 100644 index 0000000..0ec2d4c --- /dev/null +++ b/DeclarationAutomatization/Services/CodeLookupService.cs @@ -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 _entries; + + public CodeLookupService(RulesPersistenceService persistence) + { + _persistence = persistence; + _entries = _persistence.Load(); + } + + public void Reload() => _entries = _persistence.Load(); + + // Назначает декларационные коды всем позициям в списке. + public void AssignCodes(IEnumerable 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(); + return; + } + + if (entry.Codes.Count == 1) + { + item.DeclarationCode = entry.Codes[0]; + item.Confidence = ConfidenceLevel.Auto; + item.CandidateCodes = new List(entry.Codes); + return; + } + + // Несколько кодов — неоднозначность + item.CandidateCodes = new List(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 GetAllEntries() => _entries; +} diff --git a/DeclarationAutomatization/Services/ExcelExportService.cs b/DeclarationAutomatization/Services/ExcelExportService.cs new file mode 100644 index 0000000..9323474 --- /dev/null +++ b/DeclarationAutomatization/Services/ExcelExportService.cs @@ -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 declarationItems, + IEnumerable sheet3Rows) + { + using var workbook = new XLWorkbook(); + + WriteSheet2(workbook, declarationItems); + WriteSheet3(workbook, sheet3Rows); + + workbook.SaveAs(outputPath); + } + + private static void WriteSheet2(XLWorkbook workbook, IEnumerable 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 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(); + } +} diff --git a/DeclarationAutomatization/Services/ExcelImportService.cs b/DeclarationAutomatization/Services/ExcelImportService.cs new file mode 100644 index 0000000..58f6e28 --- /dev/null +++ b/DeclarationAutomatization/Services/ExcelImportService.cs @@ -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 ReadSpravka(string filePath, int startingNumber) + { + var result = new List(); + + 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(); + } +} diff --git a/DeclarationAutomatization/Services/RulesPersistenceService.cs b/DeclarationAutomatization/Services/RulesPersistenceService.cs new file mode 100644 index 0000000..0d8bf07 --- /dev/null +++ b/DeclarationAutomatization/Services/RulesPersistenceService.cs @@ -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 Load() + { + if (!File.Exists(_rulesPath)) + return new List(); + + try + { + var json = File.ReadAllText(_rulesPath); + return JsonSerializer.Deserialize>(json) + ?? new List(); + } + catch + { + return new List(); + } + } + + public void Save(IEnumerable entries) + { + Directory.CreateDirectory(Path.GetDirectoryName(_rulesPath)!); + var json = JsonSerializer.Serialize(entries.ToList(), + new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(_rulesPath, json); + } + + // Обновляет (или добавляет) запись: запоминает выбранный декларантом код + public void LearnCode(List 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 { chosenCode } + }); + } + else + { + // Перемещаем выбранный код на первое место (самообучение) + existing.Codes.Remove(chosenCode); + existing.Codes.Insert(0, chosenCode); + } + + Save(entries); + } +} diff --git a/DeclarationAutomatization/Services/Sheet3ExpandService.cs b/DeclarationAutomatization/Services/Sheet3ExpandService.cs new file mode 100644 index 0000000..a53622e --- /dev/null +++ b/DeclarationAutomatization/Services/Sheet3ExpandService.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Linq; +using DeclarationAutomatization.Models; + +namespace DeclarationAutomatization.Services; + +public class Sheet3ExpandService +{ + // Строит строки Листа3, разворачивая позиции по регистрационным номерам. + // Группировка по ТН ВЭД: все рег. номера с таким же ТН ВЭД из СПРАВКИ. + public List Expand( + IEnumerable declarationItems, + IEnumerable 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(); + + 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; + } +} diff --git a/DeclarationAutomatization/Services/TransformService.cs b/DeclarationAutomatization/Services/TransformService.cs new file mode 100644 index 0000000..d345785 --- /dev/null +++ b/DeclarationAutomatization/Services/TransformService.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using DeclarationAutomatization.Models; + +namespace DeclarationAutomatization.Services; + +public class TransformService +{ + // Группирует строки СПРАВКИ по ТН ВЭД → одна позиция Листа2. + // Количество, сумма, вес — суммируются. + // Рег. номер / дата — берётся первый непустой в группе. + // п/п — нумерация по порядку с 1. + public List BuildDeclarationItems(IEnumerable 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(); + } +} diff --git a/DeclarationAutomatization/ViewModels/MainViewModel.cs b/DeclarationAutomatization/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..71afc66 --- /dev/null +++ b/DeclarationAutomatization/ViewModels/MainViewModel.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DeclarationAutomatization.Models; +using DeclarationAutomatization.Services; + +namespace DeclarationAutomatization.ViewModels; + +public partial class MainViewModel : ObservableObject +{ + private readonly ExcelImportService _importService; + private readonly TransformService _transformService; + private readonly CodeLookupService _codeLookupService; + private readonly Sheet3ExpandService _expandService; + private readonly ExcelExportService _exportService; + + // Загруженные файлы СПРАВОК + public ObservableCollection SpravkaFiles { get; } = new(); + + // Все строки из всех загруженных СПРАВОК + private List _allSpravkaItems = new(); + + // Позиции для Листа2 после обработки + public ObservableCollection DeclarationItems { get; } = new(); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(AutoCount))] + [NotifyPropertyChangedFor(nameof(ReviewCount))] + [NotifyPropertyChangedFor(nameof(MissingCount))] + private int _totalItems; + + public int AutoCount => DeclarationItems.Count(i => i.Confidence == ConfidenceLevel.Auto); + public int ReviewCount => DeclarationItems.Count(i => i.Confidence == ConfidenceLevel.Review); + public int MissingCount => DeclarationItems.Count(i => i.Confidence == ConfidenceLevel.Missing); + + [ObservableProperty] private string _statusMessage = "Загрузите файл СПРАВКИ для начала работы"; + [ObservableProperty] private bool _isProcessing; + [ObservableProperty] private bool _hasResults; + + public MainViewModel( + ExcelImportService importService, + TransformService transformService, + CodeLookupService codeLookupService, + Sheet3ExpandService expandService, + ExcelExportService exportService) + { + _importService = importService; + _transformService = transformService; + _codeLookupService = codeLookupService; + _expandService = expandService; + _exportService = exportService; + } + + [RelayCommand] + private void AddSpravkaFile() + { + var dialog = new Microsoft.Win32.OpenFileDialog + { + Title = "Выберите файл СПРАВКИ", + Filter = "Excel файлы (*.xlsx)|*.xlsx|Все файлы (*.*)|*.*", + Multiselect = true + }; + + if (dialog.ShowDialog() != true) return; + + foreach (var path in dialog.FileNames) + { + // Определяем начальный п/п: продолжаем с последнего + int nextStart = SpravkaFiles.Count == 0 ? 1 + : SpravkaFiles.Max(f => f.StartingNumber) + EstimateRowCount(f => f.FilePath == path ? 0 : 1); + + // Простая эвристика: берём последний стартовый номер + 1000 + int startingNumber = SpravkaFiles.Count == 0 ? 1 + : SpravkaFiles.Last().StartingNumber + 1000; + + SpravkaFiles.Add(new SpravkaFileEntry + { + FilePath = path, + StartingNumber = startingNumber + }); + } + } + + private int EstimateRowCount(Func _) => 1000; + + [RelayCommand] + private void RemoveSpravkaFile(SpravkaFileEntry? entry) + { + if (entry != null) + SpravkaFiles.Remove(entry); + } + + [RelayCommand] + private async Task ProcessAsync() + { + if (SpravkaFiles.Count == 0) + { + StatusMessage = "Добавьте хотя бы один файл СПРАВКИ"; + return; + } + + IsProcessing = true; + HasResults = false; + StatusMessage = "Обработка..."; + + try + { + await Task.Run(() => + { + _allSpravkaItems = new List(); + + foreach (var entry in SpravkaFiles) + { + var items = _importService.ReadSpravka(entry.FilePath, entry.StartingNumber); + _allSpravkaItems.AddRange(items); + } + + var declItems = _transformService.BuildDeclarationItems(_allSpravkaItems); + + _codeLookupService.AssignCodes(declItems); + + Application.Current.Dispatcher.Invoke(() => + { + DeclarationItems.Clear(); + foreach (var item in declItems) + DeclarationItems.Add(item); + + TotalItems = DeclarationItems.Count; + OnPropertyChanged(nameof(AutoCount)); + OnPropertyChanged(nameof(ReviewCount)); + OnPropertyChanged(nameof(MissingCount)); + HasResults = DeclarationItems.Count > 0; + StatusMessage = $"Обработано {DeclarationItems.Count} позиций: " + + $"✅ {AutoCount} авто, ⚠️ {ReviewCount} проверить, 🔴 {MissingCount} не найдено"; + }); + }); + } + catch (Exception ex) + { + StatusMessage = $"Ошибка: {ex.Message}"; + } + finally + { + IsProcessing = false; + } + } + + [RelayCommand] + private void ApproveAllAuto() + { + // Кнопка «Принять все авто» — уже зелёные, действий не требуется. + // Можно добавить визуальное подтверждение. + StatusMessage = $"Подтверждено {AutoCount} авто-позиций. Проверьте жёлтые и красные."; + } + + [RelayCommand] + private async Task ExportAsync() + { + if (!HasResults) + { + StatusMessage = "Нет данных для экспорта. Сначала выполните обработку."; + return; + } + + var dialog = new Microsoft.Win32.SaveFileDialog + { + Title = "Сохранить результат", + Filter = "Excel файлы (*.xlsx)|*.xlsx", + FileName = $"Декларация_{DateTime.Now:yyyyMMdd_HHmm}.xlsx" + }; + + if (dialog.ShowDialog() != true) return; + + IsProcessing = true; + StatusMessage = "Экспорт..."; + + try + { + var sheet3Rows = await Task.Run(() => + _expandService.Expand(DeclarationItems, _allSpravkaItems)); + + await Task.Run(() => + _exportService.Export(dialog.FileName, DeclarationItems, sheet3Rows)); + + StatusMessage = $"Файл сохранён: {Path.GetFileName(dialog.FileName)} " + + $"(Лист2: {DeclarationItems.Count} строк, Лист3: {sheet3Rows.Count} строк)"; + } + catch (Exception ex) + { + StatusMessage = $"Ошибка экспорта: {ex.Message}"; + } + finally + { + IsProcessing = false; + } + } + + // Вызывается из UI при ручном изменении кода в таблице + public void OnCodeManuallyChanged(DeclarationItem item, string newCode) + { + if (string.IsNullOrWhiteSpace(item.TnVed) || string.IsNullOrWhiteSpace(newCode)) + return; + + item.DeclarationCode = newCode; + item.Confidence = ConfidenceLevel.Auto; + _codeLookupService.LearnFromManualEdit(item.TnVed, newCode); + + OnPropertyChanged(nameof(AutoCount)); + OnPropertyChanged(nameof(ReviewCount)); + OnPropertyChanged(nameof(MissingCount)); + } +} diff --git a/DeclarationAutomatization/Views/ConfidenceToColorConverter.cs b/DeclarationAutomatization/Views/ConfidenceToColorConverter.cs new file mode 100644 index 0000000..5320335 --- /dev/null +++ b/DeclarationAutomatization/Views/ConfidenceToColorConverter.cs @@ -0,0 +1,28 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; +using DeclarationAutomatization.Models; + +namespace DeclarationAutomatization.Views; + +public class ConfidenceToColorConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is ConfidenceLevel level) + { + return level switch + { + ConfidenceLevel.Auto => new SolidColorBrush(Color.FromRgb(198, 239, 206)), // зелёный + ConfidenceLevel.Review => new SolidColorBrush(Color.FromRgb(255, 235, 156)), // жёлтый + ConfidenceLevel.Missing => new SolidColorBrush(Color.FromRgb(255, 199, 206)), // красный + _ => Brushes.White + }; + } + return Brushes.White; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/DeclarationAutomatization/Views/MainWindow.xaml b/DeclarationAutomatization/Views/MainWindow.xaml new file mode 100644 index 0000000..32f3ff6 --- /dev/null +++ b/DeclarationAutomatization/Views/MainWindow.xaml @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +