From 52483177f0f7b0c252849136e507a12b591582a3 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Wed, 16 Sep 2026 16:25:52 +0200 Subject: [PATCH] Add wizard UI, general-assembly resolution, and test suite Restructure Form1 into a sequential wizard (database -> import -> raw -> recursive component resolution -> completeness/STEP report), fix case-sensitivity bugs in codice/tipo_file lookups and a word-boundary gap in the assembly scanner that missed _D100-style sub-assembly references, and treat the distinta's general assembly as an ordinary CSV row (derived from the DBP__ filename) instead of special-casing it throughout. Also adds CadSearchGui.Tests (xUnit) covering the scanner, filename parsing, and the case-insensitive DB lookup behavior. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 4 + CLAUDE.md | 51 +- .../AssemblyComponentScannerTests.cs | 60 ++ CadSearchGui.Tests/CadSearchGui.Tests.csproj | 26 + .../CaseInsensitiveLookupIntegrationTests.cs | 124 +++ .../DistintaBaseImporterTests.cs | 69 ++ .../IndexerParseFilenameTests.cs | 31 + CadSearchGui/AssemblyComponentScanner.cs | 14 +- CadSearchGui/CadSearchGui.csproj | 6 + CadSearchGui/DistintaBaseImporter.cs | 14 + CadSearchGui/Form1.cs | 803 +++++++++++++----- CadSearchGui/Indexer.cs | 14 +- CadSearchGui/PrtMancantiForm.cs | 14 +- CadSearchGui/ReportCompletezzaForm.cs | 62 ++ CadSearchGui/ReportStepForm.cs | 66 ++ README.md | 35 +- 16 files changed, 1144 insertions(+), 249 deletions(-) create mode 100644 CadSearchGui.Tests/AssemblyComponentScannerTests.cs create mode 100644 CadSearchGui.Tests/CadSearchGui.Tests.csproj create mode 100644 CadSearchGui.Tests/CaseInsensitiveLookupIntegrationTests.cs create mode 100644 CadSearchGui.Tests/DistintaBaseImporterTests.cs create mode 100644 CadSearchGui.Tests/IndexerParseFilenameTests.cs create mode 100644 CadSearchGui/ReportCompletezzaForm.cs create mode 100644 CadSearchGui/ReportStepForm.cs diff --git a/.gitignore b/.gitignore index 629568c..9d30acd 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ publish/ /raw/ /step/ *.dxc + +# Report BOM generati manualmente da Creo per debug/validazione: mai una dipendenza +# della pipeline (che deve funzionare senza Creo), solo un aiuto occasionale. +*.bom.* diff --git a/CLAUDE.md b/CLAUDE.md index 7f9cbbe..4933ae0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,9 +5,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Cos'è questo progetto `CadSearchGui` è un'app Windows Forms (.NET 10, WinForms) che indicizza e permette di -cercare i file CAD Pro-ENGINEER/Creo contenuti in sei cartelle di un disco/share -di rete (`__Accessori`, `__B.end`, `__Corpi`, `__Fondi`, `Unificati`, `Commerciali` sotto una radice, -di default `X:\`), salvando i metadati in un DB SQLite locale (`cad_index.db`). +cercare i file CAD Pro-ENGINEER/Creo contenuti in tutte le cartelle di primo livello +di un disco/share di rete (radice di default `X:\`), salvando i metadati in un DB +SQLite locale (`cad_index.db`). ## Comandi @@ -24,7 +24,12 @@ Pubblicazione come eseguibile standalone (single-file, self-contained, Windows x dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish ``` -Non ci sono test automatizzati nel repo. +`CadSearchGui.Tests/` (xUnit) contiene unit e integration test — vedi +`dotnet test` da dentro quella cartella. Riferimento al progetto principale +via `ProjectReference` + `InternalsVisibleTo` (in `CadSearchGui.csproj`), così +i test possono chiamare direttamente `Indexer.ParseFilename` e +`AssemblyComponentScanner.EstraiCodiciReferenziati` senza esporli come API +pubblica. ## Architettura @@ -33,13 +38,32 @@ passato esisteva anche una CLI separata e uno script Python di indicizzazione, entrambi rimossi: ora l'unico programma è questo, con GUI e indicizzazione integrate). - **Program.cs** — entry point WinForms standard, avvia `Form1`. -- **Form1.cs** / **Form1.Designer.cs** — finestra principale. La UI è costruita - interamente in codice in `Form1.BuildUi()` (nessun designer visuale usato); i - controlli sono campi privati (`_txtCerca`, `_cmbTipo`, `_cmbCartella`, `_chkUltima`, - `_grid`, ecc.). `Form1.Designer.cs` contiene solo lo scheletro minimo - (`InitializeComponent`/`Dispose`) generato dal template. +- **Form1.cs** / **Form1.Designer.cs** — finestra principale, costruita come wizard: una + sola fase alla volta occupa tutta la finestra (`_panelFasi[]`/`NomiFasi`), si avanza + con "Avanti" solo quando la fase corrente è pronta (`AvantiConsentito`). La UI è + costruita interamente in codice in `Form1.BuildUi()` (nessun designer visuale usato). + `Form1.Designer.cs` contiene solo lo scheletro minimo (`InitializeComponent`/`Dispose`) + generato dal template. Le fasi, in ordine: + 1. **Database** — carica/aggiorna l'indice, mostra data ultimo aggiornamento; qui vive + anche "Pulisci raw e step" (manutenzione, non fa parte del flusso a valle). + 2. **Importa Distinta Base** — `DistintaBaseImporter.ConvertiInCsv` genera il CSV dei + componenti; se il nome del file segue la convenzione del gestionale + (`DBP__`), l'assieme generale viene aggiunto come riga in più nel + CSV stesso (il foglio elenca solo i componenti sotto l'assieme, non l'assieme in sé). + Da qui in avanti l'assieme generale è un componente come tutti gli altri, nessun + caso speciale nel resto del codice. + 3. **Crea cartella raw** — cerca nell'indice il percorso CAD di ogni componente del CSV + (`TrovaPercorsiCad`) e lo copia in `raw/` (`CopiaFileTrovati`). + 4. **Trova componenti referenziati** — legge euristicamente i metadati binari dei `.asm` + già in `raw/` (`AssemblyComponentScanner`) per risolvere ricorsivamente i + sotto-assiemi/parti che contengono (`RisolviAlberoComponenti`), copiandoli anch'essi + in `raw/`. + 5. **Verifica completezza e STEP** — "Verifica completezza assiemi" (ricorsivo sui + percorsi originali in indice, non su `raw/`) e "Report STEP realizzati" (confronto + con `step/`). - **Indexer.cs** — logica di scansione del disco e scrittura/aggiornamento del DB - SQLite (`Indexer.ScanAsync`). Cammina le 6 cartelle radice (`Indexer.CartelleRadice`) + SQLite (`Indexer.ScanAsync`). Cammina tutte le cartelle di primo livello trovate sotto + la radice (scoperte dinamicamente con `Directory.GetDirectories`, non un elenco fisso) con un walk manuale a stack (`Walk`, più veloce di `EnumerateFiles(..., AllDirectories)` su share di rete con centinaia di migliaia di file), estrae da ogni nome file il pattern Pro-E `..` via regex, e fa un upsert incrementale @@ -65,13 +89,6 @@ file non più presenti su disco a fine scansione). Vista `files_ultima_versione`: stessa struttura, filtrata alla sola versione più recente per codice+tipo. -### Comportamento di ricerca di default - -La ricerca di default (nessun filtro esplicito dall'utente) mostra solo i tipi -`prt`/`asm` e solo l'ultima versione — vedi `OpzionePrtAsm`/`TipiPredefiniti` in -`Form1.cs`. L'utente può comunque scegliere "Tutti i tipi" o un tipo specifico dal -combo, e togliere la spunta "Solo ultima versione". - ### Percorso del DB All'avvio la app cerca `cad_index.db` accanto all'eseguibile o nella working diff --git a/CadSearchGui.Tests/AssemblyComponentScannerTests.cs b/CadSearchGui.Tests/AssemblyComponentScannerTests.cs new file mode 100644 index 0000000..60b129e --- /dev/null +++ b/CadSearchGui.Tests/AssemblyComponentScannerTests.cs @@ -0,0 +1,60 @@ +using CadSearchGui; + +namespace CadSearchGui.Tests; + +public class AssemblyComponentScannerTests +{ + private static string ScriviAsmFinto(string testo) + { + string percorso = Path.GetTempFileName(); + File.WriteAllBytes(percorso, System.Text.Encoding.Latin1.GetBytes(testo)); + return percorso; + } + + [Fact] + public void Scarta_codici_con_una_sola_occorrenza() + { + // "11112222" compare 2 volte (riferimento vero), "99998888" una sola (rumore). + string percorso = ScriviAsmFinto("blah 11112222 blah 99998888 blah 11112222 fine"); + try + { + var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso); + Assert.Contains("11112222", codici); + Assert.DoesNotContain("99998888", codici); + } + finally { File.Delete(percorso); } + } + + [Fact] + public void Riconosce_sotto_assiemi_con_suffisso_stile_D100() + { + // Visto su 9244228573.asm: i sotto-assiemi hanno nomi come _D100/_D200/_D300. + string percorso = ScriviAsmFinto("x 9244228573_D100 y 9244228573_D100 z 9244228573_D200 9244228573_D200"); + try + { + var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso); + Assert.Contains("9244228573_D100", codici); + Assert.Contains("9244228573_D200", codici); + } + finally { File.Delete(percorso); } + } + + [Fact] + public void Non_estrae_nulla_da_una_sequenza_di_cifre_troppo_lunga() + { + // Una sequenza di 15 cifre di fila non contiene nessun confine di parola valido + // per un blocco di 8-12 cifre: non deve produrre falsi positivi troncati. + string percorso = ScriviAsmFinto("123456789012345 123456789012345"); + try + { + var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso); + Assert.Empty(codici); + } + finally { File.Delete(percorso); } + } + + [Fact] + public void File_inesistente_lancia_eccezione_IO() => + Assert.Throws(() => + AssemblyComponentScanner.EstraiCodiciReferenziati(@"C:\non\esiste\pippo.asm.1")); +} diff --git a/CadSearchGui.Tests/CadSearchGui.Tests.csproj b/CadSearchGui.Tests/CadSearchGui.Tests.csproj new file mode 100644 index 0000000..bf8c38e --- /dev/null +++ b/CadSearchGui.Tests/CadSearchGui.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0-windows + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CadSearchGui.Tests/CaseInsensitiveLookupIntegrationTests.cs b/CadSearchGui.Tests/CaseInsensitiveLookupIntegrationTests.cs new file mode 100644 index 0000000..f5c63d7 --- /dev/null +++ b/CadSearchGui.Tests/CaseInsensitiveLookupIntegrationTests.cs @@ -0,0 +1,124 @@ +using CadSearchGui; +using Microsoft.Data.Sqlite; + +namespace CadSearchGui.Tests; + +/// Ricrea lo schema reale del DB (stessa costante SQL usata da Indexer.ScanAsync) e +/// verifica, con dati inseriti a mano, i due comportamenti che in passato hanno causato bug +/// silenziosi: il confronto case-insensitive su codice/tipo_file e la vista +/// files_ultima_versione che tiene solo l'ultima versione per cartella_radice+codice+tipo. +public class CaseInsensitiveLookupIntegrationTests : IDisposable +{ + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"cadsearch_test_{Guid.NewGuid():N}.db"); + private readonly SqliteConnection _connection; + + public CaseInsensitiveLookupIntegrationTests() + { + _connection = new SqliteConnection($"Data Source={_dbPath}"); + _connection.Open(); + using var schemaCmd = _connection.CreateCommand(); + schemaCmd.CommandText = Indexer.Schema; + schemaCmd.ExecuteNonQuery(); + } + + public void Dispose() + { + _connection.Dispose(); + SqliteConnection.ClearPool(_connection); + File.Delete(_dbPath); + } + + private void InserisciFile(string percorso, string cartella, string codice, string tipo, int versione, string dataModifica) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO files (percorso_completo, percorso_relativo, nome_file, cartella_radice, + famiglia, codice, tipo_file, versione, dimensione_byte, data_modifica, scansionato_il) + VALUES (@p, @p, @p, @cartella, NULL, @codice, @tipo, @versione, 1, @modifica, @modifica) + """; + cmd.Parameters.AddWithValue("@p", percorso); + cmd.Parameters.AddWithValue("@cartella", cartella); + cmd.Parameters.AddWithValue("@codice", codice); + cmd.Parameters.AddWithValue("@tipo", tipo); + cmd.Parameters.AddWithValue("@versione", versione); + cmd.Parameters.AddWithValue("@modifica", dataModifica); + cmd.ExecuteNonQuery(); + } + + /// Stessa query (LOWER su entrambi i lati) usata da Form1 per risolvere un codice + /// referenziato in un .asm: la scansione euristica del binario può restituire maiuscole + /// diverse da quelle del file reale su disco (es. "_D100" estratto vs "_d100" su X:\). + private List<(string Tipo, string Percorso)> CercaCasoInsensitive(string codiceCercato) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT tipo_file, percorso_completo FROM files_ultima_versione " + + "WHERE LOWER(codice) = LOWER(@codice) AND LOWER(tipo_file) IN ('prt', 'asm') " + + "ORDER BY tipo_file, data_modifica DESC"; + cmd.Parameters.AddWithValue("@codice", codiceCercato); + using var reader = cmd.ExecuteReader(); + var risultati = new List<(string, string)>(); + while (reader.Read()) + risultati.Add((reader.GetString(0), reader.GetString(1))); + return risultati; + } + + [Fact] + public void Trova_il_file_anche_se_la_maiuscola_del_codice_non_corrisponde() + { + InserisciFile(@"X:\__Corpi\442\M_B\9244228573_d100.asm.16", "__Corpi", "9244228573_d100", "asm", 16, "2018-08-22T14:51:19Z"); + + var risultati = CercaCasoInsensitive("9244228573_D100"); + + Assert.Single(risultati); + Assert.Equal("asm", risultati[0].Tipo); + } + + [Fact] + public void Trova_il_file_anche_se_il_tipo_e_maiuscolo() + { + InserisciFile(@"X:\Commerciali\Elettrici\1504240807.PRT.1", "Commerciali", "1504240807", "PRT", 1, "2020-01-01T00:00:00Z"); + + var risultati = CercaCasoInsensitive("1504240807"); + + Assert.Single(risultati); + Assert.Equal("PRT", risultati[0].Tipo); + } + + [Fact] + public void Tra_due_cartelle_radice_diverse_tiene_solo_la_versione_piu_recente() + { + // Stesso codice+tipo duplicato su due cartelle radice diverse (capitato con temp_paris + // vs __Corpi): la vista files_ultima_versione deve restituire il più recente per + // cartella, ma la query applicativa a valle deve poi tenerne solo uno in assoluto + // (comportamento verificato qui a livello di dati grezzi restituiti dalla vista). + InserisciFile(@"X:\temp_paris\gabbia45_modelli_copia\9244228573.asm.1", "temp_paris", "9244228573", "asm", 1, "2014-06-25T07:38:13Z"); + InserisciFile(@"X:\__Corpi\442\M_B\9244228573.asm.41", "__Corpi", "9244228573", "asm", 41, "2019-05-08T13:27:23Z"); + + var risultati = CercaCasoInsensitive("9244228573"); + + // La vista non deduplica tra cartelle radice diverse: la query applicativa ordina per + // data_modifica DESC e tiene solo la prima per tipo, cioè quella più recente. + Assert.Equal(2, risultati.Count); + Assert.Equal(@"X:\__Corpi\442\M_B\9244228573.asm.41", risultati[0].Percorso); + } + + [Fact] + public void La_vista_ultima_versione_scarta_le_versioni_precedenti_nella_stessa_cartella() + { + InserisciFile(@"X:\__Corpi\442\M_B\9244228573.asm.40", "__Corpi", "9244228573", "asm", 40, "2018-08-22T14:51:27Z"); + InserisciFile(@"X:\__Corpi\442\M_B\9244228573.asm.41", "__Corpi", "9244228573", "asm", 41, "2019-05-08T13:27:23Z"); + + var risultati = CercaCasoInsensitive("9244228573"); + + Assert.Single(risultati); + Assert.Contains(".asm.41", risultati[0].Percorso); + } + + [Fact] + public void Nessun_risultato_per_un_codice_non_indicizzato() + { + InserisciFile(@"X:\__Corpi\442\M_B\9244228573.asm.41", "__Corpi", "9244228573", "asm", 41, "2019-05-08T13:27:23Z"); + + Assert.Empty(CercaCasoInsensitive("0000000000")); + } +} diff --git a/CadSearchGui.Tests/DistintaBaseImporterTests.cs b/CadSearchGui.Tests/DistintaBaseImporterTests.cs new file mode 100644 index 0000000..75e895f --- /dev/null +++ b/CadSearchGui.Tests/DistintaBaseImporterTests.cs @@ -0,0 +1,69 @@ +using CadSearchGui; + +namespace CadSearchGui.Tests; + +public class DistintaBaseImporterTests +{ + private const string SpreadsheetMlMinimo = """ + + + + + ComponenteDescrizione + 1234567890Vite +
+
+
+ """; + + private static string ScriviFileDistinta(string nomeFile, string contenuto) + { + string percorso = Path.Combine(Path.GetTempPath(), $"cadsearch_test_{Guid.NewGuid():N}", nomeFile); + Directory.CreateDirectory(Path.GetDirectoryName(percorso)!); + File.WriteAllText(percorso, contenuto); + return percorso; + } + + [Fact] + public void Aggiunge_assieme_generale_dedotto_dal_nome_file_DBP() + { + string percorso = ScriviFileDistinta("DBP_9244228573_20260916144238.xls", SpreadsheetMlMinimo); + try + { + var (_, righe) = DistintaBaseImporter.ConvertiInCsv(percorso); + + Assert.Contains(righe, r => r.Componente == "9244228573" && r.Descrizione == "Assieme generale"); + Assert.Contains(righe, r => r.Componente == "1234567890"); + } + finally { Directory.Delete(Path.GetDirectoryName(percorso)!, recursive: true); } + } + + [Fact] + public void Non_duplica_lassieme_generale_se_gia_presente_tra_i_componenti() + { + // Caso limite: il codice del nome file coincide con un componente già elencato nel foglio. + string contenutoConGeneraleGiaPresente = SpreadsheetMlMinimo.Replace("1234567890", "9244228573"); + string percorso = ScriviFileDistinta("DBP_9244228573_20260916144238.xls", contenutoConGeneraleGiaPresente); + try + { + var (_, righe) = DistintaBaseImporter.ConvertiInCsv(percorso); + + Assert.Single(righe, r => r.Componente == "9244228573"); + } + finally { Directory.Delete(Path.GetDirectoryName(percorso)!, recursive: true); } + } + + [Fact] + public void Nessun_assieme_generale_aggiunto_se_il_nome_file_non_segue_il_pattern_DBP() + { + string percorso = ScriviFileDistinta("estrazione_qualsiasi.xls", SpreadsheetMlMinimo); + try + { + var (_, righe) = DistintaBaseImporter.ConvertiInCsv(percorso); + + Assert.DoesNotContain(righe, r => r.Descrizione == "Assieme generale"); + Assert.Single(righe); + } + finally { Directory.Delete(Path.GetDirectoryName(percorso)!, recursive: true); } + } +} diff --git a/CadSearchGui.Tests/IndexerParseFilenameTests.cs b/CadSearchGui.Tests/IndexerParseFilenameTests.cs new file mode 100644 index 0000000..b8fc5eb --- /dev/null +++ b/CadSearchGui.Tests/IndexerParseFilenameTests.cs @@ -0,0 +1,31 @@ +using CadSearchGui; + +namespace CadSearchGui.Tests; + +public class IndexerParseFilenameTests +{ + [Theory] + [InlineData("9244228573.asm.41", "9244228573", "asm", 41)] + [InlineData("9244228573_D100.asm.16", "9244228573_D100", "asm", 16)] + [InlineData("1504240807.PRT.1", "1504240807", "PRT", 1)] + public void Estrae_codice_tipo_e_versione_dal_nome_file( + string nomeFile, string codiceAtteso, string tipoAtteso, int versioneAttesa) + { + var (codice, tipo, versione) = Indexer.ParseFilename(nomeFile); + Assert.Equal(codiceAtteso, codice); + Assert.Equal(tipoAtteso, tipo); + Assert.Equal(versioneAttesa, versione); + } + + [Theory] + [InlineData("9244228573.edz")] + [InlineData("readme.txt")] + [InlineData("senza_estensioni")] + public void File_senza_pattern_codice_tipo_versione_finisce_in_altro(string nomeFile) + { + var (codice, tipo, versione) = Indexer.ParseFilename(nomeFile); + Assert.Equal(nomeFile, codice); + Assert.Equal("altro", tipo); + Assert.Null(versione); + } +} diff --git a/CadSearchGui/AssemblyComponentScanner.cs b/CadSearchGui/AssemblyComponentScanner.cs index 8329a6f..402ec9e 100644 --- a/CadSearchGui/AssemblyComponentScanner.cs +++ b/CadSearchGui/AssemblyComponentScanner.cs @@ -11,19 +11,25 @@ namespace CadSearchGui; /// vedi Indexer.FilenameRegex) e si lascia poi al chiamante il compito di verificarli /// contro l'indice DB, che scarta automaticamente i falsi positivi (numeri che non /// corrispondono a nessun codice reale). +/// +/// Verificato su un caso reale confrontando con un report BOM generato da Creo: i +/// riferimenti veri compaiono più volte nel file (una volta per feature/placement), +/// mentre il rumore binario casuale che assomiglia a un codice compare quasi sempre +/// una sola volta. Si scartano quindi i candidati con una sola occorrenza. /// static class AssemblyComponentScanner { - private static readonly Regex CodiceRegex = new(@"\b\d{8,12}[A-Za-z]{0,2}\b", RegexOptions.Compiled); + private static readonly Regex CodiceRegex = new(@"\b\d{8,12}(?:_[A-Za-z]\d{2,4}|[A-Za-z]{0,2})\b", RegexOptions.Compiled); + private const int OccorrenzeMinime = 2; public static HashSet EstraiCodiciReferenziati(string percorsoAsm) { var testo = Encoding.Latin1.GetString(File.ReadAllBytes(percorsoAsm)); - var codici = new HashSet(); + var occorrenze = new Dictionary(); foreach (Match m in CodiceRegex.Matches(testo)) { - codici.Add(m.Value); + occorrenze[m.Value] = occorrenze.GetValueOrDefault(m.Value) + 1; } - return codici; + return occorrenze.Where(kv => kv.Value >= OccorrenzeMinime).Select(kv => kv.Key).ToHashSet(); } } diff --git a/CadSearchGui/CadSearchGui.csproj b/CadSearchGui/CadSearchGui.csproj index 77840f6..e51919e 100644 --- a/CadSearchGui/CadSearchGui.csproj +++ b/CadSearchGui/CadSearchGui.csproj @@ -14,4 +14,10 @@ + + + <_Parameter1>CadSearchGui.Tests + + + \ No newline at end of file diff --git a/CadSearchGui/DistintaBaseImporter.cs b/CadSearchGui/DistintaBaseImporter.cs index cb33d1d..9d79374 100644 --- a/CadSearchGui/DistintaBaseImporter.cs +++ b/CadSearchGui/DistintaBaseImporter.cs @@ -14,6 +14,12 @@ static class DistintaBaseImporter private static readonly XNamespace Ss = "urn:schemas-microsoft-com:office:spreadsheet"; private const string NomeFoglio = "Distinta Base"; + // Il gestionale esporta la distinta come "DBP__.xls": + // il foglio elenca solo i componenti SOTTO l'assieme, non l'assieme generale in sé, quindi + // va aggiunto a parte al CSV leggendo questo codice dal nome del file di input. + private static readonly System.Text.RegularExpressions.Regex NomeFileDistintaRegex = + new(@"^DBP_(?\d+)_\d+$", System.Text.RegularExpressions.RegexOptions.Compiled); + static DistintaBaseImporter() { Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); @@ -29,6 +35,14 @@ static class DistintaBaseImporter .Where(r => r.Componente.Length > 0 || r.Descrizione.Length > 0) .ToList(); + var matchGenerale = NomeFileDistintaRegex.Match(Path.GetFileNameWithoutExtension(percorsoInput)); + if (matchGenerale.Success) + { + string codiceGenerale = matchGenerale.Groups["codice"].Value; + if (!righe.Any(r => string.Equals(r.Componente, codiceGenerale, StringComparison.OrdinalIgnoreCase))) + righe.Insert(0, (codiceGenerale, "Assieme generale")); + } + var percorsoCsv = Path.Combine( Path.GetDirectoryName(Path.GetFullPath(percorsoInput))!, Path.GetFileNameWithoutExtension(percorsoInput) + ".csv"); diff --git a/CadSearchGui/Form1.cs b/CadSearchGui/Form1.cs index 36a5475..6f062f1 100644 --- a/CadSearchGui/Form1.cs +++ b/CadSearchGui/Form1.cs @@ -9,13 +9,34 @@ public partial class Form1 : Form private readonly Button _btnAggiorna = new(); private readonly Button _btnCreaRaw = new(); private readonly Button _btnTrovaPrt = new(); + private readonly Button _btnReport = new(); + private readonly Button _btnCompletezza = new(); private readonly Button _btnPulisci = new(); private readonly Label _lblStatus = new(); private readonly Label _lblDb = new(); + private readonly Label _lblUltimoAggiornamento = new(); private readonly DataGridView _gridImport = new(); + private readonly DataGridView _gridRaw = new(); private readonly Label _lblImportStatus = new(); private readonly ToolTip _tipImportStatus = new(); + // Wizard: una fase alla volta occupa tutta la finestra, si avanza solo quando quella + // corrente è "pronta" (vedi AvantiConsentito). NomiFasi/_panelFasi sono paralleli per indice. + private static readonly string[] NomiFasi = + { + "1. Database", + "2. Importa Distinta Base", + "3. Crea cartella raw", + "4. Trova componenti referenziati", + "5. Verifica completezza e STEP", + }; + private readonly Panel[] _panelFasi = new Panel[NomiFasi.Length]; + private readonly Panel _faseContainer = new() { Dock = DockStyle.Fill }; + private readonly Label _lblFase = new(); + private readonly Button _btnIndietro = new(); + private readonly Button _btnAvanti = new(); + private int _faseAttuale; + private static readonly string[] TipiPredefiniti = { "prt", "asm" }; private const string RadiceDiscoDefault = @"X:\"; @@ -42,23 +63,210 @@ public partial class Form1 : Form MinimumSize = new Size(760, 480); Font = new Font("Segoe UI", 9.5f); - var contenutoTop = new TableLayoutPanel + var headerPanel = new Panel { Dock = DockStyle.Top, Height = 40, BackColor = SystemColors.ControlLight }; + _lblFase.Dock = DockStyle.Fill; + _lblFase.TextAlign = ContentAlignment.MiddleLeft; + _lblFase.Padding = new Padding(12, 0, 0, 0); + _lblFase.Font = new Font(Font, FontStyle.Bold); + headerPanel.Controls.Add(_lblFase); + + var navPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 48, + FlowDirection = FlowDirection.RightToLeft, + Padding = new Padding(12, 8, 12, 8), + }; + _btnAvanti.Text = "Avanti >"; + _btnAvanti.Size = new Size(110, 32); + _btnAvanti.FlatStyle = FlatStyle.Flat; + _btnAvanti.FlatAppearance.BorderSize = 0; + _btnAvanti.BackColor = ColorePrimario; + _btnAvanti.ForeColor = Color.White; + _btnAvanti.Font = new Font(Font, FontStyle.Bold); + _btnAvanti.Click += (_, _) => VaiAllaFase(_faseAttuale + 1); + navPanel.Controls.Add(_btnAvanti); + + _btnIndietro.Text = "< Indietro"; + _btnIndietro.Size = new Size(110, 32); + _btnIndietro.Margin = new Padding(8, 0, 0, 0); + _btnIndietro.Click += (_, _) => VaiAllaFase(_faseAttuale - 1); + navPanel.Controls.Add(_btnIndietro); + + var statusPanel = new Panel { Dock = DockStyle.Bottom, Height = 28, BackColor = SystemColors.ControlLight }; + _lblStatus.Dock = DockStyle.Fill; + _lblStatus.TextAlign = ContentAlignment.MiddleLeft; + _lblStatus.Padding = new Padding(12, 0, 0, 0); + _lblStatus.Text = "Pronto."; + statusPanel.Controls.Add(_lblStatus); + + _panelFasi[0] = BuildFaseDatabase(); + _panelFasi[1] = BuildFaseImporta(); + _panelFasi[2] = BuildFaseCreaRaw(); + _panelFasi[3] = BuildFaseTrovaComponenti(); + _panelFasi[4] = BuildFaseVerifica(); + foreach (var panel in _panelFasi) + { + panel.Dock = DockStyle.Fill; + panel.Visible = false; + _faseContainer.Controls.Add(panel); + } + + Controls.Add(_faseContainer); + Controls.Add(navPanel); + Controls.Add(statusPanel); + Controls.Add(headerPanel); + + DragEnter += (_, e) => + e.Effect = e.Data?.GetDataPresent(DataFormats.FileDrop) == true + ? DragDropEffects.Copy + : DragDropEffects.None; + DragDrop += (_, e) => + { + if (e.Data?.GetData(DataFormats.FileDrop) is string[] { Length: > 0 } file) + ImportaDistinta(file[0]); + }; + + VaiAllaFase(0); + } + + /// Nasconde la fase corrente e mostra quella richiesta (se nei limiti e se la fase + /// corrente permette di avanzare); aggiorna titolo e stato dei pulsanti Indietro/Avanti. + private void VaiAllaFase(int fase) + { + if (fase < 0 || fase >= NomiFasi.Length) + return; + if (fase > _faseAttuale && !AvantiConsentito(_faseAttuale)) + return; + + _panelFasi[_faseAttuale].Visible = false; + _faseAttuale = fase; + _panelFasi[_faseAttuale].Visible = true; + + _lblFase.Text = NomiFasi[_faseAttuale]; + _btnIndietro.Visible = _faseAttuale > 0; + _btnAvanti.Visible = _faseAttuale < NomiFasi.Length - 1; + AggiornaStatoAvanti(); + } + + /// Condizione per cui la fase indicata è "conclusa" e si può passare alla successiva. + private bool AvantiConsentito(int fase) => fase switch + { + 0 => _dbPath is not null, + 1 => _righeCorrenti is { Count: > 0 }, + 2 => _ultimaCartellaRaw is not null, + 3 => true, // risoluzione ricorsiva opzionale: non tutti gli assiemi hanno sotto-componenti + _ => true, + }; + + private void AggiornaStatoAvanti() => _btnAvanti.Enabled = AvantiConsentito(_faseAttuale); + + private Panel BuildFaseDatabase() + { + var contenuto = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, ColumnCount = 1, - Padding = new Padding(12, 12, 12, 0), + Padding = new Padding(16), }; - contenutoTop.Controls.Add(BuildGruppoDatabase()); - contenutoTop.Controls.Add(BuildGruppoImporta()); - _lblImportStatus.Dock = DockStyle.Top; + var lblIstruzioni = new Label + { + AutoSize = true, + ForeColor = SystemColors.GrayText, + Margin = new Padding(0, 0, 0, 12), + Text = "L'indice CAD viene caricato automaticamente all'avvio, se già presente. " + + "Usa 'Apri DB...' per selezionarne un altro, oppure 'Aggiorna indice' per " + + "scansionare di nuovo il disco (X:\\).", + }; + contenuto.Controls.Add(lblIstruzioni); + + _lblDb.AutoSize = true; + _lblDb.AutoEllipsis = true; + _lblDb.MaximumSize = new Size(900, 0); + _lblDb.ForeColor = SystemColors.GrayText; + _lblDb.Margin = new Padding(0, 0, 0, 4); + contenuto.Controls.Add(_lblDb); + + _lblUltimoAggiornamento.AutoSize = true; + _lblUltimoAggiornamento.ForeColor = SystemColors.GrayText; + _lblUltimoAggiornamento.Margin = new Padding(0, 0, 0, 12); + contenuto.Controls.Add(_lblUltimoAggiornamento); + + var bottoni = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.LeftToRight }; + + _btnApriDb.Text = "Apri DB..."; + _btnApriDb.Size = new Size(120, 32); + _btnApriDb.Margin = new Padding(0, 0, 8, 0); + _btnApriDb.Click += (_, _) => ScegliDatabase(); + bottoni.Controls.Add(_btnApriDb); + + _btnAggiorna.Text = "Aggiorna indice"; + _btnAggiorna.Size = new Size(120, 32); + _btnAggiorna.Click += (_, _) => AggiornaIndice(); + bottoni.Controls.Add(_btnAggiorna); + + contenuto.Controls.Add(bottoni); + + var lblManutenzione = new Label + { + AutoSize = true, + ForeColor = SystemColors.GrayText, + Margin = new Padding(0, 20, 0, 8), + Text = "Manutenzione: svuota le cartelle 'raw' e 'step' nella root prima di un " + + "nuovo giro (non tocca l'indice, solo le copie locali).", + }; + contenuto.Controls.Add(lblManutenzione); + + _btnPulisci.Text = "Pulisci raw e step"; + _btnPulisci.Size = new Size(200, 30); + _btnPulisci.Click += (_, _) => PulisciRawEStep(); + contenuto.Controls.Add(_btnPulisci); + + var panel = new Panel(); + panel.Controls.Add(contenuto); + return panel; + } + + private Panel BuildFaseImporta() + { + var contenuto = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + Padding = new Padding(16), + }; + + var lblIstruzioni = new Label + { + AutoSize = true, + ForeColor = SystemColors.GrayText, + Margin = new Padding(0, 0, 0, 8), + Text = "Trascina qui il file .xls/.xml esportato dal gestionale (foglio 'Distinta " + + "Base'), oppure scegli il file col pulsante sotto: genera un CSV con Componente " + + "e Descrizione, in anteprima nella griglia.", + }; + contenuto.Controls.Add(lblIstruzioni); + + var btnSfoglia = new Button + { + Text = "Scegli file distinta...", + Size = new Size(220, 32), + Margin = new Padding(0, 0, 0, 8), + }; + btnSfoglia.Click += (_, _) => ScegliFileDistinta(); + contenuto.Controls.Add(btnSfoglia); + _lblImportStatus.AutoSize = false; _lblImportStatus.AutoEllipsis = true; - _lblImportStatus.Height = 28; - _lblImportStatus.Padding = new Padding(12, 6, 12, 6); + _lblImportStatus.Height = 24; + _lblImportStatus.Width = 900; _lblImportStatus.ForeColor = SystemColors.GrayText; + contenuto.Controls.Add(_lblImportStatus); _gridImport.Dock = DockStyle.Fill; _gridImport.ReadOnly = true; @@ -80,36 +288,16 @@ public partial class Form1 : Form _gridImport.DefaultCellStyle.Padding = new Padding(4, 0, 4, 0); _gridImport.Columns.Add("componente", "Componente"); _gridImport.Columns.Add("descrizione", "Descrizione"); - _gridImport.Columns.Add("percorso", "Percorso CAD"); - _gridImport.Columns["componente"]!.FillWeight = 20; - _gridImport.Columns["descrizione"]!.FillWeight = 40; - _gridImport.Columns["percorso"]!.FillWeight = 40; - _gridImport.CellDoubleClick += (_, _) => ApriCartellaFileSelezionato(); + _gridImport.Columns["componente"]!.FillWeight = 30; + _gridImport.Columns["descrizione"]!.FillWeight = 70; - var statusPanel = new Panel { Dock = DockStyle.Bottom, Height = 28, BackColor = SystemColors.ControlLight }; - _lblStatus.Dock = DockStyle.Fill; - _lblStatus.TextAlign = ContentAlignment.MiddleLeft; - _lblStatus.Padding = new Padding(12, 0, 0, 0); - _lblStatus.Text = "Pronto."; - statusPanel.Controls.Add(_lblStatus); - - Controls.Add(_gridImport); - Controls.Add(_lblImportStatus); - Controls.Add(statusPanel); - Controls.Add(contenutoTop); - - DragEnter += (_, e) => - e.Effect = e.Data?.GetDataPresent(DataFormats.FileDrop) == true - ? DragDropEffects.Copy - : DragDropEffects.None; - DragDrop += (_, e) => - { - if (e.Data?.GetData(DataFormats.FileDrop) is string[] { Length: > 0 } file) - ImportaDistinta(file[0]); - }; + var panel = new Panel(); + panel.Controls.Add(_gridImport); + panel.Controls.Add(contenuto); + return panel; } - private GroupBox BuildGruppoDatabase() + private Panel BuildFaseCreaRaw() { var contenuto = new TableLayoutPanel { @@ -117,85 +305,22 @@ public partial class Form1 : Form AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, ColumnCount = 1, + Padding = new Padding(16), }; - _lblDb.AutoSize = true; - _lblDb.AutoEllipsis = true; - _lblDb.MaximumSize = new Size(900, 0); - _lblDb.ForeColor = SystemColors.GrayText; - _lblDb.Margin = new Padding(0, 0, 0, 8); - contenuto.Controls.Add(_lblDb); - - var bottoni = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.LeftToRight }; - - _btnApriDb.Text = "Apri DB..."; - _btnApriDb.Size = new Size(120, 32); - _btnApriDb.Margin = new Padding(0, 0, 8, 0); - _btnApriDb.Click += (_, _) => ScegliDatabase(); - bottoni.Controls.Add(_btnApriDb); - - _btnAggiorna.Text = "Aggiorna indice"; - _btnAggiorna.Size = new Size(120, 32); - _btnAggiorna.Click += (_, _) => AggiornaIndice(); - bottoni.Controls.Add(_btnAggiorna); - - contenuto.Controls.Add(bottoni); - - return new GroupBox - { - Text = "1. Indice CAD (caricato automaticamente all'avvio)", - Dock = DockStyle.Top, - AutoSize = true, - AutoSizeMode = AutoSizeMode.GrowAndShrink, - Padding = new Padding(12, 8, 12, 12), - Margin = new Padding(0, 0, 0, 10), - Controls = { contenuto }, - }; - } - - private GroupBox BuildGruppoImporta() - { - var contenuto = new TableLayoutPanel - { - Dock = DockStyle.Top, - AutoSize = true, - AutoSizeMode = AutoSizeMode.GrowAndShrink, - ColumnCount = 1, - }; - - var lblIstruzioni1 = new Label + var lblIstruzioni = new Label { AutoSize = true, ForeColor = SystemColors.GrayText, - Margin = new Padding(0, 0, 0, 0), - Text = "2. Trascina qui il file .xls/.xml esportato dal gestionale (foglio 'Distinta " - + "Base'), oppure scegli il file col pulsante sotto: genera un CSV con Componente " - + "e Descrizione, in anteprima nella griglia.", + Margin = new Padding(0, 0, 0, 8), + Text = "Cerca nell'indice il percorso CAD di ogni componente importato e copia i " + + "file trovati in una cartella 'raw' accanto al file di distinta originale.", }; - contenuto.Controls.Add(lblIstruzioni1); - - var btnSfoglia = new Button - { - Text = "Scegli file distinta...", - Size = new Size(220, 32), - Margin = new Padding(0, 8, 0, 16), - }; - btnSfoglia.Click += (_, _) => ScegliFileDistinta(); - contenuto.Controls.Add(btnSfoglia); - - var lblIstruzioni2 = new Label - { - AutoSize = true, - ForeColor = SystemColors.GrayText, - Text = "3. Premi qui sotto per cercare il percorso CAD di ogni componente " - + "nell'indice e copiare i file trovati in una cartella 'raw'.", - }; - contenuto.Controls.Add(lblIstruzioni2); + contenuto.Controls.Add(lblIstruzioni); _btnCreaRaw.Text = "Crea cartella raw"; _btnCreaRaw.Size = new Size(220, 36); - _btnCreaRaw.Margin = new Padding(0, 8, 0, 0); - _btnCreaRaw.Enabled = false; + _btnCreaRaw.Margin = new Padding(0, 0, 0, 12); _btnCreaRaw.FlatStyle = FlatStyle.Flat; _btnCreaRaw.FlatAppearance.BorderSize = 0; _btnCreaRaw.BackColor = ColorePrimario; @@ -204,49 +329,115 @@ public partial class Form1 : Form _btnCreaRaw.Click += (_, _) => CreaCartellaRaw(); contenuto.Controls.Add(_btnCreaRaw); - var lblIstruzioni3 = new Label + _gridRaw.Dock = DockStyle.Fill; + _gridRaw.ReadOnly = true; + _gridRaw.BorderStyle = BorderStyle.None; + _gridRaw.BackgroundColor = SystemColors.Window; + _gridRaw.RowHeadersVisible = false; + _gridRaw.AllowUserToAddRows = false; + _gridRaw.AllowUserToDeleteRows = false; + _gridRaw.AllowUserToResizeRows = false; + _gridRaw.SelectionMode = DataGridViewSelectionMode.FullRowSelect; + _gridRaw.MultiSelect = false; + _gridRaw.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill; + _gridRaw.ColumnHeadersHeight = 32; + _gridRaw.ColumnHeadersDefaultCellStyle.Font = new Font(Font, FontStyle.Bold); + _gridRaw.RowTemplate.Height = 26; + _gridRaw.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(245, 247, 250); + _gridRaw.DefaultCellStyle.SelectionBackColor = ColorePrimario; + _gridRaw.DefaultCellStyle.Padding = new Padding(4, 0, 4, 0); + _gridRaw.Columns.Add("componente", "Componente"); + _gridRaw.Columns.Add("percorso", "Percorso CAD"); + _gridRaw.Columns["componente"]!.FillWeight = 25; + _gridRaw.Columns["percorso"]!.FillWeight = 75; + _gridRaw.CellDoubleClick += (_, _) => ApriCartellaFileSelezionato(); + + var panel = new Panel(); + panel.Controls.Add(_gridRaw); + panel.Controls.Add(contenuto); + return panel; + } + + private Panel BuildFaseTrovaComponenti() + { + var contenuto = new TableLayoutPanel { - AutoSize = true, - ForeColor = SystemColors.GrayText, - Margin = new Padding(0, 12, 0, 0), - Text = "4. Gli assiemi referenziano altri PRT e sotto-assiemi al loro interno: premi " - + "qui per risolvere l'albero componenti (ricorsivamente) e copiare tutto ciò che " - + "viene trovato nell'indice dentro 'raw'.", - }; - contenuto.Controls.Add(lblIstruzioni3); - - _btnTrovaPrt.Text = "Trova componenti referenziati"; - _btnTrovaPrt.Size = new Size(220, 32); - _btnTrovaPrt.Margin = new Padding(0, 8, 0, 0); - _btnTrovaPrt.Enabled = false; - _btnTrovaPrt.Click += (_, _) => TrovaPrtReferenziati(); - contenuto.Controls.Add(_btnTrovaPrt); - - var lblManutenzione = new Label - { - AutoSize = true, - ForeColor = SystemColors.GrayText, - Margin = new Padding(0, 16, 0, 0), - Text = "Manutenzione: svuota le cartelle 'raw' e 'step' nella root prima di un " - + "nuovo giro (non tocca l'indice, solo le copie locali).", - }; - contenuto.Controls.Add(lblManutenzione); - - _btnPulisci.Text = "Pulisci raw e step"; - _btnPulisci.Size = new Size(200, 30); - _btnPulisci.Margin = new Padding(0, 6, 0, 0); - _btnPulisci.Click += (_, _) => PulisciRawEStep(); - contenuto.Controls.Add(_btnPulisci); - - return new GroupBox - { - Text = "2-4. Importa Distinta Base, crea la cartella raw e risolvi i componenti referenziati", Dock = DockStyle.Top, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, - Padding = new Padding(12, 8, 12, 12), - Controls = { contenuto }, + ColumnCount = 1, + Padding = new Padding(16), }; + + var lblIstruzioni = new Label + { + AutoSize = true, + ForeColor = SystemColors.GrayText, + Margin = new Padding(0, 0, 0, 12), + Text = "Gli assiemi referenziano altri PRT e sotto-assiemi al loro interno: premi " + + "qui per risolvere l'albero componenti (ricorsivamente) e copiare tutto ciò che " + + "viene trovato nell'indice dentro 'raw'. Passo opzionale: se la distinta contiene " + + "solo parti singole puoi anche passare direttamente alla fase successiva.", + }; + contenuto.Controls.Add(lblIstruzioni); + + _btnTrovaPrt.Text = "Trova componenti referenziati"; + _btnTrovaPrt.Size = new Size(220, 32); + _btnTrovaPrt.Click += (_, _) => TrovaPrtReferenziati(); + contenuto.Controls.Add(_btnTrovaPrt); + + var panel = new Panel(); + panel.Controls.Add(contenuto); + return panel; + } + + private Panel BuildFaseVerifica() + { + var contenuto = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + Padding = new Padding(16), + }; + + var lblIstruzioniCompletezza = new Label + { + AutoSize = true, + ForeColor = SystemColors.GrayText, + Margin = new Padding(0, 0, 0, 8), + Text = "Per ogni assieme della distinta, verifica se tutti i suoi componenti " + + "(ricorsivamente fino ai PRT) sono nell'indice. Nota: la scansione dei .asm " + + "è euristica, quindi qualche falso 'incompleto' è possibile.", + }; + contenuto.Controls.Add(lblIstruzioniCompletezza); + + _btnCompletezza.Text = "Verifica completezza assiemi"; + _btnCompletezza.Size = new Size(220, 32); + _btnCompletezza.Margin = new Padding(0, 0, 0, 20); + _btnCompletezza.Click += (_, _) => VerificaCompletezzaAssiemi(); + contenuto.Controls.Add(_btnCompletezza); + + var lblIstruzioniStep = new Label + { + AutoSize = true, + ForeColor = SystemColors.GrayText, + Margin = new Padding(0, 0, 0, 8), + Text = "Dopo aver esportato gli STEP con Creo, premi qui per confrontare i " + + "componenti della distinta con i file .stp presenti in 'step' e vedere quali " + + "sono già stati realizzati e quali mancano ancora.", + }; + contenuto.Controls.Add(lblIstruzioniStep); + + _btnReport.Text = "Report STEP realizzati"; + _btnReport.Size = new Size(220, 32); + _btnReport.Click += (_, _) => GeneraReportStep(); + contenuto.Controls.Add(_btnReport); + + var panel = new Panel(); + panel.Controls.Add(contenuto); + return panel; } private void ScegliFileDistinta() @@ -271,9 +462,10 @@ public partial class Form1 : Form _gridImport.Rows.Clear(); foreach (var (componente, descrizione) in righe) { - _gridImport.Rows.Add(componente, descrizione, ""); + _gridImport.Rows.Add(componente, descrizione); } - _btnCreaRaw.Enabled = righe.Count > 0; + _gridRaw.Rows.Clear(); + _ultimaCartellaRaw = null; ImpostaStatoImport($"CSV generato ({righe.Count} righe): {csv}", Color.DarkGreen); } @@ -281,10 +473,10 @@ public partial class Form1 : Form { _percorsoInputCorrente = null; _righeCorrenti = null; - _btnCreaRaw.Enabled = false; _gridImport.Rows.Clear(); ImpostaStatoImport($"Errore durante la conversione: {ex.Message}", Color.DarkRed); } + AggiornaStatoAvanti(); } /// Imposta testo/colore dello stato import e il tooltip con il testo completo @@ -301,25 +493,61 @@ public partial class Form1 : Form if (_percorsoInputCorrente is null || _righeCorrenti is null) return; + // L'assieme generale è già una riga di _righeCorrenti (aggiunta da + // DistintaBaseImporter.ConvertiInCsv leggendo il nome del file distinta), quindi viene + // trovato e copiato in raw come qualsiasi altro componente, senza bisogno di un caso speciale. var percorsiTrovati = TrovaPercorsiCad(_righeCorrenti.Select(r => r.Componente)); - for (int i = 0; i < _righeCorrenti.Count; i++) + _gridRaw.Rows.Clear(); + foreach (var (componente, _) in _righeCorrenti) { - string percorsoCad = percorsiTrovati.TryGetValue(_righeCorrenti[i].Componente, out var lista) + string percorsoCad = percorsiTrovati.TryGetValue(componente, out var lista) ? string.Join("; ", lista) : ""; - _gridImport.Rows[i].Cells[2].Value = percorsoCad; + _gridRaw.Rows.Add(componente, percorsoCad); } var cartellaRaw = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(_percorsoInputCorrente))!, "raw"); int copiati = CopiaFileTrovati(percorsiTrovati.Values.SelectMany(p => p).Distinct(), cartellaRaw); _ultimaCartellaRaw = cartellaRaw; - _btnTrovaPrt.Enabled = true; ImpostaStatoImport( $"{percorsiTrovati.Count} componenti con percorso CAD trovato su {_righeCorrenti.Count}. " + $"{copiati} file copiati in {cartellaRaw}", Color.DarkGreen); + AggiornaStatoAvanti(); + } + + /// Confronta i componenti della distinta importata con i file .stp presenti nella + /// cartella "step" (esportati manualmente con Creo Distributed Batch), per dire quali sono + /// già stati realizzati e quali mancano ancora. + private void GeneraReportStep() + { + if (_percorsoInputCorrente is null || _righeCorrenti is null) + return; + + var cartellaStep = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(_percorsoInputCorrente))!, "step"); + if (!Directory.Exists(cartellaStep)) + { + ImpostaStatoImport($"Cartella 'step' non trovata in {cartellaStep}.", Color.DarkRed); + return; + } + + var codiciConStep = new HashSet( + Directory.GetFiles(cartellaStep, "*.stp").Select(Path.GetFileNameWithoutExtension)!); + + var righeReport = _righeCorrenti + .Select(r => (r.Componente, r.Descrizione, Realizzato: codiciConStep.Contains(r.Componente))) + .ToList(); + int realizzati = righeReport.Count(r => r.Realizzato); + + ImpostaStatoImport( + $"Report STEP: {realizzati} componenti realizzati su {righeReport.Count} " + + $"({righeReport.Count - realizzati} mancanti).", + Color.DarkGreen); + + using var dialog = new ReportStepForm(righeReport, cartellaStep); + dialog.ShowDialog(this); } /// Svuota in un colpo solo le cartelle "raw" e "step" nella root (stessa cartella @@ -377,10 +605,77 @@ public partial class Form1 : Form ImpostaStatoImport($"{eliminati} file eliminati da {string.Join(" e ", cartelle)}.", Color.DarkGreen); } - /// Risolve ricorsivamente l'albero dei componenti a partire dagli .asm in "raw": - /// per ogni assieme legge i codici referenziati al suo interno, li cerca nell'indice, copia - /// in "raw" quelli trovati e, se sono a loro volta assiemi, li accoda per essere scansionati - /// finché non restano solo PRT (foglie) o codici non presenti nell'indice. + /// Risolve ricorsivamente l'albero dei componenti a partire dai file .asm indicati + /// (già copiati in "raw"): per ogni assieme legge i codici referenziati al suo interno, li + /// cerca nell'indice, copia in "raw" quelli trovati e, se sono a loro volta assiemi, li + /// accoda per essere scansionati finché non restano solo PRT (foglie) o codici non presenti + /// nell'indice. Usata dal bottone "Trova componenti referenziati". + private (List<(string Codice, string Tipo, string Percorso)> Trovati, List NonTrovati) RisolviAlberoComponenti( + IEnumerable asmIniziali) + { + var daScansionare = new Queue(asmIniziali); + var codiciVisti = new HashSet( + daScansionare.Select(f => Path.GetFileName(f).Split('.')[0]), StringComparer.OrdinalIgnoreCase); + + var trovati = new List<(string Codice, string Tipo, string Percorso)>(); + var codiciNonTrovati = new List(); + if (daScansionare.Count == 0 || _dbPath is null) + return (trovati, codiciNonTrovati); + + using var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadOnly"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT tipo_file, percorso_completo FROM files_ultima_versione " + + "WHERE LOWER(codice) = LOWER(@codice) AND LOWER(tipo_file) IN ('prt', 'asm') " + + "ORDER BY tipo_file, data_modifica DESC"; + var pCodice = command.Parameters.Add("@codice", Microsoft.Data.Sqlite.SqliteType.Text); + + while (daScansionare.Count > 0) + { + var asm = daScansionare.Dequeue(); + foreach (var codice in AssemblyComponentScanner.EstraiCodiciReferenziati(asm)) + { + if (!codiciVisti.Add(codice)) + continue; + + pCodice.Value = codice; + // Lo stesso codice+tipo può esistere in più cartelle radice (duplicati sulla + // share): ordinato per data_modifica DESC, si tiene solo il più recente per tipo. + var componenti = new List<(string Tipo, string Percorso)>(); + var tipiVisti = new HashSet(); + using (var reader = command.ExecuteReader()) + { + while (reader.Read()) + { + string tipoLower = reader.GetString(0).ToLowerInvariant(); + if (tipiVisti.Add(tipoLower)) + { + componenti.Add((tipoLower, reader.GetString(1))); + } + } + } + + if (componenti.Count == 0) + { + codiciNonTrovati.Add(codice); + continue; + } + + foreach (var (tipo, percorso) in componenti) + { + CopiaFileTrovati(new[] { percorso }, _ultimaCartellaRaw!); + trovati.Add((codice, tipo, percorso)); + if (tipo == "asm") + { + daScansionare.Enqueue(Path.Combine(_ultimaCartellaRaw!, Path.GetFileName(percorso))); + } + } + } + } + + return (trovati, codiciNonTrovati); + } + private void TrovaPrtReferenziati() { if (_ultimaCartellaRaw is null || !Directory.Exists(_ultimaCartellaRaw)) @@ -392,69 +687,141 @@ public partial class Form1 : Form return; } - var daScansionare = new Queue( - Directory.GetFiles(_ultimaCartellaRaw).Where(f => Path.GetFileName(f).Contains(".asm."))); - var codiciVisti = new HashSet(daScansionare.Select(f => Path.GetFileName(f).Split('.')[0])); - - var trovati = new List<(string Codice, string Tipo, string Percorso)>(); - int codiciNonTrovati = 0; - - using (var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadOnly")) - { - connection.Open(); - using var command = connection.CreateCommand(); - command.CommandText = "SELECT tipo_file, percorso_completo FROM files_ultima_versione " - + "WHERE codice = @codice AND tipo_file IN ('prt', 'asm')"; - var pCodice = command.Parameters.Add("@codice", Microsoft.Data.Sqlite.SqliteType.Text); - - while (daScansionare.Count > 0) - { - var asm = daScansionare.Dequeue(); - foreach (var codice in AssemblyComponentScanner.EstraiCodiciReferenziati(asm)) - { - if (!codiciVisti.Add(codice)) - continue; - - pCodice.Value = codice; - var componenti = new List<(string Tipo, string Percorso)>(); - using (var reader = command.ExecuteReader()) - { - while (reader.Read()) - { - componenti.Add((reader.GetString(0), reader.GetString(1))); - } - } - - if (componenti.Count == 0) - { - codiciNonTrovati++; - continue; - } - - foreach (var (tipo, percorso) in componenti) - { - CopiaFileTrovati(new[] { percorso }, _ultimaCartellaRaw); - trovati.Add((codice, tipo, percorso)); - if (tipo == "asm") - { - daScansionare.Enqueue(Path.Combine(_ultimaCartellaRaw, Path.GetFileName(percorso))); - } - } - } - } - } + // L'assieme generale è già in raw come qualsiasi altro assieme importato (vedi + // CreaCartellaRaw), quindi rientra qui senza bisogno di un caso speciale. + var asmIniziali = Directory.GetFiles(_ultimaCartellaRaw).Where(f => Path.GetFileName(f).Contains(".asm.")); + var (trovati, codiciNonTrovati) = RisolviAlberoComponenti(asmIniziali); int numPrt = trovati.Count(r => r.Tipo == "prt"); int numAsm = trovati.Count(r => r.Tipo == "asm"); ImpostaStatoImport( $"Componenti referenziati: {numPrt} PRT e {numAsm} sotto-assiemi trovati e copiati in raw, " - + $"{codiciNonTrovati} codici referenziati non trovati nell'indice.", + + $"{codiciNonTrovati.Count} codici referenziati non trovati nell'indice.", Color.DarkGreen); using var dialog = new PrtMancantiForm(trovati, codiciNonTrovati, _ultimaCartellaRaw); dialog.ShowDialog(this); } + /// Per ogni assieme della distinta, verifica ricorsivamente (leggendo i .asm dai + /// percorsi originali sulla share) se tutti i componenti fino ai PRT sono nell'indice. + /// I risultati di ogni file/codice vengono in cache e condivisi tra gli assiemi controllati + /// nella stessa passata, per non rileggere più volte gli stessi sotto-componenti. + private void VerificaCompletezzaAssiemi() + { + if (_righeCorrenti is null) + return; + if (_dbPath is null) + { + ImpostaStatoImport("Nessun database caricato: impossibile verificare la completezza.", Color.DarkRed); + return; + } + + using var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadOnly"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT tipo_file, percorso_completo FROM files_ultima_versione " + + "WHERE LOWER(codice) = LOWER(@codice) AND LOWER(tipo_file) IN ('prt', 'asm') " + + "ORDER BY tipo_file, data_modifica DESC"; + var pCodice = command.Parameters.Add("@codice", Microsoft.Data.Sqlite.SqliteType.Text); + + var cacheComponenti = new Dictionary>(); + var cacheCodiciFile = new Dictionary>(); + + List<(string Tipo, string Percorso)> CercaComponenti(string codice) + { + if (cacheComponenti.TryGetValue(codice, out var giaTrovati)) + return giaTrovati; + + pCodice.Value = codice; + var risultato = new List<(string Tipo, string Percorso)>(); + var tipiVisti = new HashSet(); + using (var reader = command.ExecuteReader()) + { + while (reader.Read()) + { + string tipo = reader.GetString(0).ToLowerInvariant(); + if (tipiVisti.Add(tipo)) + { + risultato.Add((tipo, reader.GetString(1))); + } + } + } + cacheComponenti[codice] = risultato; + return risultato; + } + + bool AssiemeCompleto(string percorsoAsmIniziale) + { + var daVisitare = new Queue(); + daVisitare.Enqueue(percorsoAsmIniziale); + var fileVisitati = new HashSet { percorsoAsmIniziale }; + var codiciVisti = new HashSet { Path.GetFileName(percorsoAsmIniziale).Split('.')[0] }; + + while (daVisitare.Count > 0) + { + var file = daVisitare.Dequeue(); + if (!cacheCodiciFile.TryGetValue(file, out var codiciReferenziati)) + { + try + { + codiciReferenziati = AssemblyComponentScanner.EstraiCodiciReferenziati(file); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; // file irraggiungibile (share offline) -> non verificabile, per prudenza "incompleto" + } + cacheCodiciFile[file] = codiciReferenziati; + } + + foreach (var codice in codiciReferenziati) + { + if (!codiciVisti.Add(codice)) + continue; + + var componenti = CercaComponenti(codice); + if (componenti.Count == 0) + return false; + + foreach (var (tipo, percorso) in componenti) + { + if (tipo == "asm" && fileVisitati.Add(percorso)) + { + daVisitare.Enqueue(percorso); + } + } + } + } + return true; + } + + // L'assieme generale è già una riga di _righeCorrenti (vedi DistintaBaseImporter), quindi + // la sua completezza viene verificata insieme a quella di tutti gli altri componenti. + var righeReport = new List<(string Componente, string Descrizione, bool? Completo)>(); + foreach (var (componente, descrizione) in _righeCorrenti) + { + var componenti = CercaComponenti(componente); + string? percorsoAsm = componenti.Where(c => c.Tipo == "asm").Select(c => c.Percorso).FirstOrDefault(); + + bool? completo = componenti.Count == 0 ? null + : percorsoAsm is null ? true // solo prt trovato: nessuna sotto-struttura da verificare + : AssiemeCompleto(percorsoAsm); + + righeReport.Add((componente, descrizione, completo)); + } + + int completi = righeReport.Count(r => r.Completo == true); + int incompleti = righeReport.Count(r => r.Completo == false); + int nonTrovati = righeReport.Count(r => r.Completo is null); + ImpostaStatoImport( + $"Completezza assiemi: {completi} completi, {incompleti} incompleti, " + + $"{nonTrovati} non trovati nell'indice.", + Color.DarkGreen); + + using var dialogCompletezza = new ReportCompletezzaForm(righeReport); + dialogCompletezza.ShowDialog(this); + } + /// Copia i file trovati nella cartella "raw" indicata, creandola se necessario. /// Ritorna il numero di file copiati con successo. internal static int CopiaFileTrovati(IEnumerable percorsi, string cartellaRaw) @@ -490,9 +857,9 @@ public partial class Form1 : Form var nomiParametri = TipiPredefiniti.Select((_, i) => $"@tipo{i}"); using var command = connection.CreateCommand(); command.CommandText = $""" - SELECT percorso_completo FROM files_ultima_versione - WHERE codice = @codice AND tipo_file IN ({string.Join(",", nomiParametri)}) - ORDER BY tipo_file + SELECT tipo_file, percorso_completo FROM files_ultima_versione + WHERE LOWER(codice) = LOWER(@codice) AND LOWER(tipo_file) IN ({string.Join(",", nomiParametri)}) + ORDER BY tipo_file, data_modifica DESC """; var pCodice = command.Parameters.Add("@codice", Microsoft.Data.Sqlite.SqliteType.Text); for (int i = 0; i < TipiPredefiniti.Length; i++) @@ -505,9 +872,15 @@ public partial class Form1 : Form pCodice.Value = codice; using var reader = command.ExecuteReader(); var percorsi = new List(); + // Lo stesso codice+tipo può esistere in più cartelle radice (duplicati sulla share): + // essendo ordinato per data_modifica DESC, il primo per tipo è il più recente, gli altri si scartano. + var tipiVisti = new HashSet(); while (reader.Read()) { - percorsi.Add(reader.GetString(0)); + if (tipiVisti.Add(reader.GetString(0).ToLowerInvariant())) + { + percorsi.Add(reader.GetString(1)); + } } if (percorsi.Count > 0) risultato[codice] = percorsi; @@ -533,6 +906,8 @@ public partial class Form1 : Form { _lblStatus.Text = "Database non trovato. Usa 'Apri DB...' per selezionarlo oppure 'Aggiorna indice' per crearlo."; _lblDb.Text = "Nessun database caricato. Premi 'Aggiorna indice' per crearlo da X:\\, oppure 'Apri DB...' se ne hai gi\u00e0 uno."; + _lblUltimoAggiornamento.Text = ""; + AggiornaStatoAvanti(); return; } @@ -560,9 +935,14 @@ public partial class Form1 : Form connection.Open(); long totale = (long)(new SqliteCommand("SELECT COUNT(*) FROM files", connection).ExecuteScalar() ?? 0L); + object? ultimaScansioneRaw = new SqliteCommand("SELECT MAX(scansionato_il) FROM files", connection).ExecuteScalar(); _dbPath = path; _lblDb.Text = $"Database: {path} ({totale:N0} file indicizzati)"; + _lblUltimoAggiornamento.Text = ultimaScansioneRaw is string testoData + && DateTime.TryParse(testoData, out var ultimaScansione) + ? $"Ultimo aggiornamento indice: {ultimaScansione.ToLocalTime():dd/MM/yyyy HH:mm}" + : "Ultimo aggiornamento indice: sconosciuto"; _lblStatus.Text = "Pronto."; SalvaUltimoDbUsato(path); } @@ -571,6 +951,7 @@ public partial class Form1 : Form MessageBox.Show(this, $"Impossibile aprire il database:\n{ex.Message}", "Errore", MessageBoxButtons.OK, MessageBoxIcon.Error); } + AggiornaStatoAvanti(); } /// Ricorda l'ultimo DB caricato con successo, così al prossimo avvio si @@ -656,11 +1037,11 @@ public partial class Form1 : Form private void ApriCartellaFileSelezionato() { - if (_gridImport.CurrentRow is null) + if (_gridRaw.CurrentRow is null) { return; } - string? percorsi = _gridImport.CurrentRow.Cells[2].Value as string; + string? percorsi = _gridRaw.CurrentRow.Cells[1].Value as string; if (string.IsNullOrEmpty(percorsi)) { return; diff --git a/CadSearchGui/Indexer.cs b/CadSearchGui/Indexer.cs index 11c9bf2..e04bc1e 100644 --- a/CadSearchGui/Indexer.cs +++ b/CadSearchGui/Indexer.cs @@ -18,12 +18,10 @@ public sealed record ScanProgress( public static class Indexer { - public static readonly string[] CartelleRadice = { "__Accessori", "__B.end", "__Corpi", "__Fondi", "Unificati", "Commerciali" }; - private static readonly Regex FilenameRegex = new(@"^(?.+)\.(?[A-Za-z0-9]+)\.(?\d+)$", RegexOptions.Compiled); - private const string Schema = """ + internal const string Schema = """ CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY, percorso_completo TEXT UNIQUE NOT NULL, @@ -67,8 +65,12 @@ public static class Indexer schemaCmd.ExecuteNonQuery(); } + // Indicizza tutte le cartelle di primo livello sotto la radice (non un elenco fisso), + // così una nuova cartella creata sulla share entra nell'indice senza modificare il codice. + var cartelleRadice = Directory.GetDirectories(root).Select(p => Path.GetFileName(p)!).ToArray(); + var previsti = new Dictionary(); - foreach (var cartella in CartelleRadice) + foreach (var cartella in cartelleRadice) { using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM files WHERE cartella_radice = @c"; @@ -80,7 +82,7 @@ public static class Indexer var sw = Stopwatch.StartNew(); long fattiTotali = 0; - foreach (var cartella in CartelleRadice) + foreach (var cartella in cartelleRadice) { ct.ThrowIfCancellationRequested(); @@ -233,7 +235,7 @@ public static class Indexer cmd.Parameters["@modifica"].Value = modifica; } - private static (string codice, string tipo, int? versione) ParseFilename(string nomeFile) + internal static (string codice, string tipo, int? versione) ParseFilename(string nomeFile) { var match = FilenameRegex.Match(nomeFile); if (match.Success) diff --git a/CadSearchGui/PrtMancantiForm.cs b/CadSearchGui/PrtMancantiForm.cs index 0b2e0e8..a881787 100644 --- a/CadSearchGui/PrtMancantiForm.cs +++ b/CadSearchGui/PrtMancantiForm.cs @@ -6,7 +6,7 @@ namespace CadSearchGui; /// sotto-assiemi trovati nell'indice e già copiati in "raw" a partire dagli assiemi iniziali. public class PrtMancantiForm : Form { - public PrtMancantiForm(List<(string Codice, string Tipo, string Percorso)> righe, int codiciNonTrovati, string cartellaRaw) + public PrtMancantiForm(List<(string Codice, string Tipo, string Percorso)> righe, List codiciNonTrovati, string cartellaRaw) { int numPrt = righe.Count(r => r.Tipo == "prt"); int numAsm = righe.Count(r => r.Tipo == "asm"); @@ -23,9 +23,10 @@ public class PrtMancantiForm : Form Height = 44, Padding = new Padding(12, 8, 12, 8), Text = $"Copiati in \"{cartellaRaw}\": {numPrt} PRT e {numAsm} sotto-assiemi trovati nell'indice." - + (codiciNonTrovati > 0 - ? $" {codiciNonTrovati} codici referenziati non corrispondono a nessun file indicizzato " - + "(probabilmente disegni, o falsi positivi della scansione)." + + (codiciNonTrovati.Count > 0 + ? $" {codiciNonTrovati.Count} codici referenziati non corrispondono a nessun file indicizzato " + + "(righe 'non trovato' sotto: possono essere disegni, componenti davvero mancanti, " + + "o falsi positivi della scansione)." : ""), }; @@ -49,6 +50,11 @@ public class PrtMancantiForm : Form { grid.Rows.Add(codice, tipo, percorso); } + foreach (var codice in codiciNonTrovati.OrderBy(c => c)) + { + int i = grid.Rows.Add(codice, "non trovato", ""); + grid.Rows[i].DefaultCellStyle.ForeColor = Color.DarkRed; + } var btnApriCartella = new Button { Text = "Apri cartella raw", Dock = DockStyle.Bottom, Height = 34 }; btnApriCartella.Click += (_, _) => diff --git a/CadSearchGui/ReportCompletezzaForm.cs b/CadSearchGui/ReportCompletezzaForm.cs new file mode 100644 index 0000000..2192c93 --- /dev/null +++ b/CadSearchGui/ReportCompletezzaForm.cs @@ -0,0 +1,62 @@ +namespace CadSearchGui; + +/// Mostra, per ogni assieme della distinta base, se tutti i suoi componenti +/// (ricorsivamente fino ai PRT) sono presenti nell'indice (v) o mancano (x). +public class ReportCompletezzaForm : Form +{ + public ReportCompletezzaForm(List<(string Componente, string Descrizione, bool? Completo)> righe) + { + int completi = righe.Count(r => r.Completo == true); + int incompleti = righe.Count(r => r.Completo == false); + int nonTrovati = righe.Count(r => r.Completo is null); + + Text = "Completezza assiemi"; + ClientSize = new Size(720, 440); + StartPosition = FormStartPosition.CenterParent; + Font = new Font("Segoe UI", 9.5f); + + var lblInfo = new Label + { + Dock = DockStyle.Top, + AutoSize = false, + Height = 48, + Padding = new Padding(12, 8, 12, 8), + Text = $"{completi} completi, {incompleti} incompleti, {nonTrovati} non trovati nell'indice " + + $"su {righe.Count} componenti. Nota: la scansione dei .asm è euristica, qualche " + + "falso 'incompleto' è possibile.", + }; + + var grid = new DataGridView + { + Dock = DockStyle.Fill, + ReadOnly = true, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + RowHeadersVisible = false, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, + }; + grid.Columns.Add("componente", "Componente"); + grid.Columns.Add("descrizione", "Descrizione"); + grid.Columns.Add("completo", "Completo"); + grid.Columns["componente"]!.FillWeight = 20; + grid.Columns["descrizione"]!.FillWeight = 55; + grid.Columns["completo"]!.FillWeight = 25; + + foreach (var (componente, descrizione, completo) in righe.OrderBy(r => r.Completo).ThenBy(r => r.Componente)) + { + string testo = completo switch { true => "✓", false => "✗", null => "?" }; + int i = grid.Rows.Add(componente, descrizione, testo); + grid.Rows[i].DefaultCellStyle.ForeColor = completo switch + { + true => Color.DarkGreen, + false => Color.DarkRed, + null => Color.Gray, + }; + grid.Rows[i].Cells["completo"].Style.Alignment = DataGridViewContentAlignment.MiddleCenter; + } + + Controls.Add(grid); + Controls.Add(lblInfo); + } +} diff --git a/CadSearchGui/ReportStepForm.cs b/CadSearchGui/ReportStepForm.cs new file mode 100644 index 0000000..693bd82 --- /dev/null +++ b/CadSearchGui/ReportStepForm.cs @@ -0,0 +1,66 @@ +using System.Diagnostics; + +namespace CadSearchGui; + +/// Mostra, per ogni componente della distinta base, se lo STEP corrispondente +/// è già stato esportato (file .stp presente in "step") o manca ancora. +public class ReportStepForm : Form +{ + public ReportStepForm(List<(string Componente, string Descrizione, bool Realizzato)> righe, string cartellaStep) + { + int realizzati = righe.Count(r => r.Realizzato); + + Text = "Report STEP realizzati"; + ClientSize = new Size(720, 440); + StartPosition = FormStartPosition.CenterParent; + Font = new Font("Segoe UI", 9.5f); + + var lblInfo = new Label + { + Dock = DockStyle.Top, + AutoSize = false, + Height = 32, + Padding = new Padding(12, 8, 12, 8), + Text = $"{realizzati} componenti realizzati su {righe.Count} ({righe.Count - realizzati} mancanti), " + + $"confrontati con \"{cartellaStep}\".", + }; + + var grid = new DataGridView + { + Dock = DockStyle.Fill, + ReadOnly = true, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + RowHeadersVisible = false, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, + }; + grid.Columns.Add("componente", "Componente"); + grid.Columns.Add("descrizione", "Descrizione"); + grid.Columns.Add("step", "STEP"); + grid.Columns["componente"]!.FillWeight = 20; + grid.Columns["descrizione"]!.FillWeight = 55; + grid.Columns["step"]!.FillWeight = 25; + + foreach (var (componente, descrizione, realizzato) in righe.OrderBy(r => r.Realizzato).ThenBy(r => r.Componente)) + { + int i = grid.Rows.Add(componente, descrizione, realizzato ? "Realizzato" : "Mancante"); + grid.Rows[i].DefaultCellStyle.ForeColor = realizzato ? Color.DarkGreen : Color.DarkRed; + } + + var btnApriCartella = new Button { Text = "Apri cartella step", Dock = DockStyle.Bottom, Height = 34 }; + btnApriCartella.Click += (_, _) => + { + try { Process.Start("explorer.exe", cartellaStep); } + catch (Exception ex) + { + MessageBox.Show(this, $"Impossibile aprire Esplora risorse:\n{ex.Message}", "Errore", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + }; + + Controls.Add(grid); + Controls.Add(lblInfo); + Controls.Add(btnApriCartella); + } +} diff --git a/README.md b/README.md index a2a3183..4014cb9 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,25 @@ CAD di ogni componente. 1. **Indice CAD** — all'avvio carica automaticamente `cad_index.db` (se presente accanto all'eseguibile). Se manca, si può selezionare un DB esistente con **Apri DB...** o generarne uno nuovo con **Aggiorna - indice**, che scansiona il disco (di default `X:\`, cartelle - `__Accessori`, `__B.end`, `__Corpi`, `__Fondi`, `Unificati`, `Commerciali`) e indicizza tutti i file. + indice**, che scansiona il disco (di default `X:\`, tutte le cartelle di primo + livello che trova) e indicizza tutti i file. 2. **Importa Distinta Base** — trascina (o scegli da file) l'estrazione `.xls`/`.xml` del gestionale (foglio "Distinta Base", colonne Componente/Descrizione): viene generato un CSV con anteprima in griglia. + Se il file segue la convenzione di nome `DBP__` del + gestionale, l'assieme generale dell'estrazione viene aggiunto come riga + in più nel CSV (il foglio elenca solo i componenti sotto l'assieme, non + l'assieme stesso). 3. **Crea cartella raw** — cerca nell'indice il percorso CAD di ogni - componente importato e copia i file trovati in una cartella `raw/` - accanto al file di distinta originale. + componente importato (assieme generale incluso) e copia i file trovati + in una cartella `raw/` accanto al file di distinta originale. +4. **Trova componenti referenziati** — legge i metadati dei file `.asm` in + `raw/` per trovare ricorsivamente i sotto-assiemi/parti che li + compongono, copiandoli a loro volta in `raw/`. +5. **Verifica completezza assiemi** / **Report STEP realizzati** — confronta + la distinta con l'indice (per capire se manca qualche sotto-componente) + e con la cartella `step/` (per capire quali STEP sono già stati + realizzati). ## Requisiti @@ -75,7 +86,17 @@ indicizzazione integrate): - `ProgressForm.cs` — dialog di avanzamento durante l'aggiornamento indice. Il database generato (`cad_index.db`), i file Excel/CSV della distinta -trascinati nella root e la cartella `raw/` sono rigenerabili e non sono -tracciati in git (vedi `.gitignore`). +trascinati nella root e le cartelle `raw/`/`step/` sono rigenerabili e non +sono tracciati in git (vedi `.gitignore`). -Non ci sono test automatizzati nel repo. +## Test + +`CadSearchGui.Tests/` contiene unit test (regex di estrazione dei componenti +dai `.asm`, parsing del nome file Pro-E) e integration test (schema/vista SQL +reale, con focus sui bug di case-sensitivity/duplicati già risolti). Da +eseguire: + +```powershell +cd CadSearchGui.Tests +dotnet test +```