feat: единый файл Лист1+СПРАВКА, фильтры, сортировка, живой п/п

- Загрузка одного .xlsx (СПРАВКА + Лист1 в одном файле)
- Рег. номера берутся из СПРАВКА автоматически, группируются по (ТН ВЭД, Страна)
- Отдельный опциональный справочник кодов (CodesImportService)
- Фильтры: Все / Авто / Проверить / Нет кода / Без нетто
- Таблица сразу отсортирована по ТН ВЭД с живым обновлением п/п
- Начальный п/п: поле в UI, автоинкремент после экспорта
- Лист2: рамки, жёлтая строка при >1 рег. номере, итого по кол-ву
- Лист3: ТН ВЭД в колонке B вместо константы 09035
- Красный ErrorMessage под кнопкой загрузки, удалён SpravkaFileEntry
This commit is contained in:
Dianaka123
2026-04-07 11:47:31 +03:00
parent addf55e3b2
commit 697ae44519
13 changed files with 602 additions and 429 deletions
@@ -14,136 +14,187 @@ namespace DeclarationAutomatization.ViewModels;
public partial class MainViewModel : ObservableObject
{
private readonly ExcelImportService _importService;
private readonly TransformService _transformService;
private readonly CodeLookupService _codeLookupService;
private readonly Sheet1ImportService _sheet1ImportService;
private readonly ExcelImportService _spravkaImportService;
private readonly CodesImportService _codesImportService;
private readonly TransformService _transformService;
private readonly CodeLookupService _codeLookupService;
private readonly Sheet3ExpandService _expandService;
private readonly ExcelExportService _exportService;
private readonly ExcelExportService _exportService;
// Загруженные файлы СПРАВОК
public ObservableCollection<SpravkaFileEntry> SpravkaFiles { get; } = new();
// Все позиции после обработки (полный список для экспорта)
private readonly List<DeclarationItem> _allItems = new();
// Все строки из всех загруженных СПРАВОК
private List<SpravkaItem> _allSpravkaItems = new();
// Отфильтрованный список для отображения в таблице
public ObservableCollection<DeclarationItem> FilteredItems { get; } = new();
// Позиции для Листа2 после обработки
public ObservableCollection<DeclarationItem> DeclarationItems { get; } = new();
[ObservableProperty] private int _startingNumber = 1;
[ObservableProperty] private string _declarationFileName = "";
[ObservableProperty] private string _codesFileName = "";
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private string _errorMessage = "";
[ObservableProperty] private bool _isProcessing;
[ObservableProperty] private bool _hasResults;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AutoCount))]
[NotifyPropertyChangedFor(nameof(ReviewCount))]
[NotifyPropertyChangedFor(nameof(MissingCount))]
private int _totalItems;
[NotifyPropertyChangedFor(nameof(IsFilterAll))]
[NotifyPropertyChangedFor(nameof(IsFilterAuto))]
[NotifyPropertyChangedFor(nameof(IsFilterReview))]
[NotifyPropertyChangedFor(nameof(IsFilterMissing))]
[NotifyPropertyChangedFor(nameof(IsFilterNoNetWeight))]
private string _activeFilter = "All";
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);
public bool IsFilterAll => ActiveFilter == "All";
public bool IsFilterAuto => ActiveFilter == "Auto";
public bool IsFilterReview => ActiveFilter == "Review";
public bool IsFilterMissing => ActiveFilter == "Missing";
public bool IsFilterNoNetWeight => ActiveFilter == "NoNetWeight";
[ObservableProperty] private string _statusMessage = "Загрузите файл СПРАВКИ для начала работы";
[ObservableProperty] private bool _isProcessing;
[ObservableProperty] private bool _hasResults;
public int AutoCount => _allItems.Count(i => i.Confidence == ConfidenceLevel.Auto);
public int ReviewCount => _allItems.Count(i => i.Confidence == ConfidenceLevel.Review);
public int MissingCount => _allItems.Count(i => i.Confidence == ConfidenceLevel.Missing);
public int MissingNetWeightCount => _allItems.Count(i => i.NetWeight == 0);
private string _filePath = "";
public MainViewModel(
ExcelImportService importService,
TransformService transformService,
CodeLookupService codeLookupService,
Sheet1ImportService sheet1ImportService,
ExcelImportService spravkaImportService,
CodesImportService codesImportService,
TransformService transformService,
CodeLookupService codeLookupService,
Sheet3ExpandService expandService,
ExcelExportService exportService)
ExcelExportService exportService)
{
_importService = importService;
_transformService = transformService;
_codeLookupService = codeLookupService;
_expandService = expandService;
_exportService = exportService;
_sheet1ImportService = sheet1ImportService;
_spravkaImportService = spravkaImportService;
_codesImportService = codesImportService;
_transformService = transformService;
_codeLookupService = codeLookupService;
_expandService = expandService;
_exportService = exportService;
}
[RelayCommand]
private void AddSpravkaFile()
private async Task OpenDeclarationFileAsync()
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "Выберите файл СПРАВКИ",
Filter = "Excel файлы (*.xlsx)|*.xlsx|Все файлы (*.*)|*.*",
Multiselect = true
Title = "Выберите файл декларации",
Filter = "Excel файлы (*.xlsx)|*.xlsx",
};
if (dialog.ShowDialog() != true) return;
foreach (var path in dialog.FileNames)
_filePath = dialog.FileName;
DeclarationFileName = Path.GetFileName(_filePath);
await ProcessAsync();
}
[RelayCommand]
private void ClearDeclarationFile()
{
_filePath = "";
DeclarationFileName = "";
_allItems.Clear();
FilteredItems.Clear();
HasResults = false;
ActiveFilter = "All";
NotifyStats();
StatusMessage = "Загрузите файл декларации для начала работы";
}
[RelayCommand]
private void ClearCodesFile()
{
_codeLookupService.LoadFromEntries(new System.Collections.Generic.List<CodeLookupEntry>());
CodesFileName = "";
StatusMessage = "Справочник кодов очищен";
if (_allItems.Count > 0)
{
// Определяем начальный п/п: продолжаем с последнего
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
});
_codeLookupService.AssignCodes(_allItems);
RefreshFilter();
NotifyStats();
}
}
private int EstimateRowCount(Func<SpravkaFileEntry, int> _) => 1000;
[RelayCommand]
private void RemoveSpravkaFile(SpravkaFileEntry? entry)
private void LoadCodesFile()
{
if (entry != null)
SpravkaFiles.Remove(entry);
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "Выберите справочник кодов",
Filter = "Excel файлы (*.xlsx)|*.xlsx",
};
if (dialog.ShowDialog() != true) return;
try
{
var entries = _codesImportService.ReadEntries(dialog.FileName);
_codeLookupService.LoadFromEntries(entries);
CodesFileName = Path.GetFileName(dialog.FileName);
StatusMessage = $"Справочник загружен: {entries.Count} кодов";
// Переназначить коды если декларация уже загружена
if (_allItems.Count > 0)
{
_codeLookupService.AssignCodes(_allItems);
RefreshFilter();
NotifyStats();
}
}
catch (Exception ex)
{
StatusMessage = $"Ошибка загрузки справочника: {ex.Message}";
}
}
[RelayCommand]
private void SetFilter(string filter)
{
ActiveFilter = filter;
RefreshFilter();
}
private async Task ProcessAsync()
{
if (SpravkaFiles.Count == 0)
{
StatusMessage = "Добавьте хотя бы один файл СПРАВКИ";
return;
}
if (string.IsNullOrEmpty(_filePath)) return;
IsProcessing = true;
HasResults = false;
ErrorMessage = "";
StatusMessage = "Обработка...";
try
{
await Task.Run(() =>
{
_allSpravkaItems = new List<SpravkaItem>();
var groups = _sheet1ImportService.ReadSheet1(_filePath);
var spravkaItems = _spravkaImportService.ReadSpravka(_filePath, 1);
var items = _transformService.BuildDeclarationItems(groups, spravkaItems);
foreach (var entry in SpravkaFiles)
{
var items = _importService.ReadSpravka(entry.FilePath, entry.StartingNumber);
_allSpravkaItems.AddRange(items);
}
var declItems = _transformService.BuildDeclarationItems(_allSpravkaItems);
_codeLookupService.AssignCodes(declItems);
_codeLookupService.AssignCodes(items);
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} не найдено";
_allItems.Clear();
_allItems.AddRange(items.OrderBy(i => i.TnVed));
RenumberItems();
RefreshFilter();
NotifyStats();
HasResults = _allItems.Count > 0;
StatusMessage = $"Загружено {_allItems.Count} позиций: " +
$"✅ {AutoCount} ⚠️ {ReviewCount} 🔴 {MissingCount}";
});
});
}
catch (Exception ex)
{
StatusMessage = $"Ошибка: {ex.Message}";
_filePath = "";
DeclarationFileName = "";
ErrorMessage = FriendlyError(ex);
StatusMessage = "";
}
finally
{
@@ -151,20 +202,12 @@ public partial class MainViewModel : ObservableObject
}
}
[RelayCommand]
private void ApproveAllAuto()
{
// Кнопка «Принять все авто» — уже зелёные, действий не требуется.
// Можно добавить визуальное подтверждение.
StatusMessage = $"Подтверждено {AutoCount} авто-позиций. Проверьте жёлтые и красные.";
}
[RelayCommand]
private async Task ExportAsync()
{
if (!HasResults)
{
StatusMessage = "Нет данных для экспорта. Сначала выполните обработку.";
StatusMessage = "Нет данных для экспорта. Сначала загрузите файл.";
return;
}
@@ -172,9 +215,8 @@ public partial class MainViewModel : ObservableObject
{
Title = "Сохранить результат",
Filter = "Excel файлы (*.xlsx)|*.xlsx",
FileName = $"Декларация_{DateTime.Now:yyyyMMdd_HHmm}.xlsx"
FileName = $"Декларация_{DateTime.Now:yyyyMMdd_HHmm}.xlsx",
};
if (dialog.ShowDialog() != true) return;
IsProcessing = true;
@@ -182,14 +224,15 @@ public partial class MainViewModel : ObservableObject
try
{
var sheet3Rows = await Task.Run(() =>
_expandService.Expand(DeclarationItems, _allSpravkaItems));
// _allItems уже отсортированы и перенумерованы — просто экспортируем
var sheet3Rows = await Task.Run(() => _expandService.Expand(_allItems));
await Task.Run(() => _exportService.Export(dialog.FileName, _allItems, sheet3Rows));
await Task.Run(() =>
_exportService.Export(dialog.FileName, DeclarationItems, sheet3Rows));
// Сдвигаем стартовый номер для следующей справки
StartingNumber += _allItems.Count;
StatusMessage = $"Файл сохранён: {Path.GetFileName(dialog.FileName)} " +
$"(Лист2: {DeclarationItems.Count} строк, Лист3: {sheet3Rows.Count} строк)";
$"(Лист2: {_allItems.Count} строк, Лист3: {sheet3Rows.Count} строк)";
}
catch (Exception ex)
{
@@ -204,15 +247,54 @@ public partial class MainViewModel : ObservableObject
// Вызывается из UI при ручном изменении кода в таблице
public void OnCodeManuallyChanged(DeclarationItem item, string newCode)
{
if (string.IsNullOrWhiteSpace(item.TnVed) || string.IsNullOrWhiteSpace(newCode))
return;
if (string.IsNullOrWhiteSpace(item.TnVed) || string.IsNullOrWhiteSpace(newCode)) return;
item.DeclarationCode = newCode;
item.Confidence = ConfidenceLevel.Auto;
_codeLookupService.LearnFromManualEdit(item.TnVed, newCode);
NotifyStats();
}
private void RefreshFilter()
{
FilteredItems.Clear();
var items = ActiveFilter switch
{
"Auto" => _allItems.Where(i => i.Confidence == ConfidenceLevel.Auto),
"Review" => _allItems.Where(i => i.Confidence == ConfidenceLevel.Review),
"Missing" => _allItems.Where(i => i.Confidence == ConfidenceLevel.Missing),
"NoNetWeight" => _allItems.Where(i => i.NetWeight == 0),
_ => _allItems.AsEnumerable(),
};
foreach (var item in items)
FilteredItems.Add(item);
}
partial void OnStartingNumberChanged(int value)
{
if (_allItems.Count == 0) return;
RenumberItems();
RefreshFilter();
}
private void RenumberItems()
{
for (int i = 0; i < _allItems.Count; i++)
_allItems[i].SequentialNumber = StartingNumber + i;
}
private static string FriendlyError(Exception ex)
{
if (ex is IOException || ex.InnerException is IOException)
return "Файл открыт в другой программе. Закройте его и попробуйте снова.";
return ex.Message;
}
private void NotifyStats()
{
OnPropertyChanged(nameof(AutoCount));
OnPropertyChanged(nameof(ReviewCount));
OnPropertyChanged(nameof(MissingCount));
OnPropertyChanged(nameof(MissingNetWeightCount));
}
}