markRunning($this->importId); $postoUnico->markRunning($this->importId); $stateService->appendLog($this->importId, 'Worker avviato. Inizio elaborazione dei PDF.'); // Una chiave puo' raccogliere piu' territori ("12B" e "12 B"): non si // tiene l'ultimo arrivato, si tengono tutti, cosi' l'abbinamento li vede // e scarta il file come ambiguo invece di sceglierne uno a caso. $territoriMap = []; foreach (Territorio::withTrashed()->get() as $territorio) { $chiave = $this->chiaveTerritorio((string) $territorio->numero); if ($chiave !== '') { $territoriMap[$chiave][] = $territorio; } } $actor = User::find($this->actorId); $seenNumbers = []; try { foreach ($this->files as $file) { $originalName = $file['original_name'] ?? 'file-sconosciuto.pdf'; $stateService->increment($this->importId, 'processed'); $territoryMatch = $this->resolveTerritoryFromFilename($originalName, $territoriMap); if ($territoryMatch === null) { $stateService->increment($this->importId, 'skipped'); $stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - nessun numero territorio riconosciuto nel nome file.'); $stateService->addIssue($this->importId, [ 'file' => $originalName, 'type' => 'no-match', 'message' => 'Nessun numero territorio riconosciuto nel nome file.', ]); continue; } if (($territoryMatch['type'] ?? 'single') === 'ambiguous') { $stateService->increment($this->importId, 'skipped'); $stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - nome ambiguo, possibili territori: ' . implode(', ', $territoryMatch['matched_numbers']) . '.'); $stateService->addIssue($this->importId, [ 'file' => $originalName, 'type' => 'ambiguous', 'message' => 'Nome ambiguo.', 'matched_numbers' => $territoryMatch['matched_numbers'], ]); continue; } if (($territoryMatch['type'] ?? 'single') === 'unknown-suffix') { $codici = implode(', ', $territoryMatch['codes']); $esistenti = implode(', ', $territoryMatch['matched_numbers']); $stateService->increment($this->importId, 'skipped'); $stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - il nome indica il territorio ' . $codici . ', che non esiste (esiste ' . $esistenti . '): file non associato, per non sostituire il PDF di un altro territorio.'); $stateService->addIssue($this->importId, [ 'file' => $originalName, 'type' => 'unknown-suffix', 'message' => 'Il nome indica il territorio ' . $codici . ', che non esiste: non associato a ' . $esistenti . '.', 'matched_numbers' => $territoryMatch['matched_numbers'], ]); continue; } $matchedNumber = $territoryMatch['matched_number']; $territorio = $territoryMatch['territorio']; // Per id, non per numero: due file che nominano lo stesso // territorio scritto in due modi ("12B.pdf", "12 B.pdf") sono // comunque un doppione. $normalizedNumber = $territorio->id; if (isset($seenNumbers[$normalizedNumber])) { $stateService->increment($this->importId, 'skipped'); $stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - territorio ' . $matchedNumber . ' presente piu volte nella stessa importazione.'); $stateService->addIssue($this->importId, [ 'file' => $originalName, 'type' => 'duplicate-in-batch', 'message' => 'Territorio presente piu volte nella stessa importazione.', 'matched_numbers' => [$matchedNumber], ]); continue; } $seenNumbers[$normalizedNumber] = true; $sourcePath = Storage::disk('local')->path($file['stored_path']); if (! is_file($sourcePath)) { $stateService->increment($this->importId, 'errors'); $stateService->appendLog($this->importId, '[ERR] ' . $originalName . ' - file temporaneo non trovato.'); $stateService->addIssue($this->importId, [ 'file' => $originalName, 'type' => 'missing-temp-file', 'message' => 'File temporaneo non trovato.', 'matched_numbers' => [$matchedNumber], ]); continue; } try { // The territory id is part of the filename because the slug // alone is not unique: "1 A" and "1-A" are distinct // territory numbers that both slug to "territorio-1-a". // Without it the second import overwrites the first's PDF // and both rows point at one file — then removing the PDF // from either territory breaks the other. // // Il nuovo file si scrive per primo, con un nome che non // coincide con quello in vigore; solo dopo il vecchio va // nello storico, come per le piantine. Prima si cancellava // il vecchio e poi si scriveva: con una scrittura fallita il // territorio restava senza PDF, e un import sbagliato non si // poteva annullare. $storedFilename = Str::slug('territorio-' . $territorio->numero) . '-' . $territorio->id . '-' . now()->format('YmdHis') . '.pdf'; $publicPath = 'territori-pdf/' . $storedFilename; if (! Storage::disk('public')->put($publicPath, file_get_contents($sourcePath))) { throw new \RuntimeException('impossibile scrivere il file sul disco: il PDF in vigore non è stato toccato.'); } $pdfService->archiviaCorrente($territorio, 'import'); $thumbnailPath = $thumbnailService->generate($publicPath); $territorio->update([ 'pdf_path' => $publicPath, 'thumbnail_path' => $thumbnailPath, ]); if ($actor) { activity()->causedBy($actor) ->performedOn($territorio) ->withProperties([ 'numero' => $territorio->numero, 'pdf' => $originalName, 'bulk_import' => true, ]) ->log('bulk_uploaded_pdf'); } $stateService->increment($this->importId, 'updated'); $stateService->appendLog( $this->importId, '[OK] ' . $originalName . ' - aggiornato territorio ' . $territorio->numero . ($thumbnailPath ? ' con thumbnail generata.' : ' ma la thumbnail non e stata generata.') ); } catch (\Throwable $exception) { $stateService->increment($this->importId, 'errors'); $stateService->appendLog($this->importId, '[ERR] ' . $originalName . ' - ' . $exception->getMessage()); $stateService->addIssue($this->importId, [ 'file' => $originalName, 'type' => 'processing-error', 'message' => $exception->getMessage(), 'matched_numbers' => [$matchedNumber], ]); } } $stateService->appendLog($this->importId, 'Import completato.'); $stateService->markCompleted($this->importId); $postoUnico->markCompleted($this->importId); } catch (\Throwable $exception) { $stateService->increment($this->importId, 'errors'); $stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage()); $stateService->markFailed($this->importId); $postoUnico->markFailed($this->importId, $exception->getMessage()); throw $exception; } finally { Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId); } } /** * Runs when the job exceeds $timeout or dies outside handle()'s try block. * * Without this the import state is stranded in "running" — the UI polls it * until the 24h cache entry expires — and the uploaded temp directory is * never reclaimed, since handle()'s finally never executes. * * Libera anche il posto unico: senza, un import ucciso dal timeout * lascerebbe backup, ripristino e azzeramento bloccati per l'ora intera * del TTL, con un'operazione che non avanza piu'. */ public function failed(?\Throwable $exception): void { $stateService = app(TerritorioPdfImportState::class); $stateService->appendLog( $this->importId, '[ERR] Job terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout') ); $stateService->markFailed($this->importId); app(BackupOperationState::class)->markFailed( $this->importId, 'Import PDF terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout') ); Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId); } /** * Abbina il nome di un file al territorio, oppure spiega perche' non lo fa. * * La regola: nel nome si cercano i "numeri di territorio", cioe' cifre * seguite al massimo da UNA lettera, attaccata o separata da uno spazio, un * trattino, un trattino basso o un punto ("12", "012", "12B", "12 B", * "12-b"). Le forme scritte in modo diverso dello stesso numero sono lo * stesso numero: "12 B", "12B" e "12-B" sono tutte 12B, mai 12. Il numero * deve stare da solo — "T12" o "12AB" non contano — e quelli che non * corrispondono a nessun territorio (un anno, un CAP) vengono ignorati. * * Prima bastava che il numero di un territorio *comparisse* nel nome: "1-A" * conteneva "1", e in assenza di un territorio 1A il PDF finiva sul * territorio 1, sostituendone la mappa senza avvisare. Adesso una lettera * dopo il numero fa parte del numero, e se quel territorio non esiste il * file si scarta: si nomina il territorio 1 nel messaggio, ma non lo si * tocca. * * Il file si scarta anche quando l'abbinamento non e' univoco: due numeri * di territori esistenti nello stesso nome, o due territori che si * scrivono diversi ma sono lo stesso numero ("12B" e "12 B"). Sostituire * la mappa sbagliata e' peggio che non sostituirne nessuna: un file * scartato si ricarica, un PDF sovrascritto non torna. * * I territori con numeri di altra forma (lettere davanti, piu' lettere in * fondo) non si abbinano da nome file: il PDF va caricato dalla loro * scheda. */ protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array { $trovati = []; $suffissiSconosciuti = []; foreach ($this->numeriNelNomeFile($filename) as $codice) { if (isset($territoriMap[$codice])) { foreach ($territoriMap[$codice] as $territorio) { $trovati[$territorio->id] = $territorio; } continue; } // "1A" che non esiste, mentre 1 si': e' il caso che prima // sovrascriveva il territorio 1. Lo si annota per spiegarlo. $base = preg_replace('/\p{L}$/u', '', $codice); if ($base !== $codice && isset($territoriMap[$base])) { $suffissiSconosciuti[$codice] = $territoriMap[$base]; } } $numeri = fn (array $territori) => array_values(array_unique(array_map(fn (Territorio $t) => $t->numero, $territori))); if (count($trovati) > 1 || ($trovati !== [] && $suffissiSconosciuti !== [])) { return [ 'type' => 'ambiguous', 'matched_numbers' => $numeri(array_merge(array_values($trovati), ...array_values($suffissiSconosciuti))), ]; } if ($trovati === [] && $suffissiSconosciuti !== []) { return [ 'type' => 'unknown-suffix', 'codes' => array_keys($suffissiSconosciuti), 'matched_numbers' => $numeri(array_merge(...array_values($suffissiSconosciuti))), ]; } if ($trovati === []) { return null; } $territorio = reset($trovati); return [ 'type' => 'single', 'normalized_number' => $this->chiaveTerritorio($territorio->numero), 'matched_number' => $territorio->numero, 'territorio' => $territorio, ]; } /** * I numeri di territorio presenti nel nome del file, gia' nella forma di * chiaveTerritorio(). Vedi la regola in resolveTerritoryFromFilename(). * * @return array */ protected function numeriNelNomeFile(string $filename): array { $nome = mb_strtoupper(pathinfo($filename, PATHINFO_FILENAME)); // Il lookahead finale e' quello che separa "12 B" (numero 12B) da // "12 Via Roma" (numero 12): la lettera vale come parte del numero solo // se dopo di lei la parola finisce. preg_match_all('/(?