netgescon-day0/app/Filament/Pages/Gescon/FornitoreScheda.php

2192 lines
103 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Filament\Pages\Gescon;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Models\Documento;
use App\Models\FatturaElettronica;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\FornitoreStabileImpostazione;
use App\Models\Product;
use App\Models\RegistroRitenuteAcconto;
use App\Models\RubricaUniversale;
use App\Models\Stabile;
use App\Models\StabileServizio;
use App\Models\StgFatturaAde;
use App\Models\User;
use App\Models\VoceSpesa;
use App\Modules\Contabilita\Models\FatturaFornitore as ContabilitaFatturaFornitore;
use App\Modules\Contabilita\Models\PianoConti;
use App\Services\Catalog\FornitoreProductCatalogService;
use App\Services\Catalog\ProductAssetIngestionService;
use App\Services\Catalog\ProductOfferService;
use App\Services\Consumi\AcquaPdfTextParser;
use App\Services\Consumi\ConsumiAcquaIngestionService;
use App\Services\Consumi\ConsumiAcquaTariffeIngestionService;
use App\Services\Documenti\PdfTextExtractionService;
use App\Services\FattureElettroniche\FatturaElettronicaProtocolloService;
use App\Support\Livewire\SupportsRubricaLookup;
use App\Support\StabileContext;
use Filament\Actions\Action;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class FornitoreScheda extends Page
{
use ResolvesOperatoreContext;
use SupportsRubricaLookup;
protected static ?string $title = 'Scheda fornitore';
protected static ?string $slug = 'anagrafica/fornitori/{record}';
protected static bool $shouldRegisterNavigation = false;
protected string $view = 'filament.pages.gescon.fornitore-scheda';
public Fornitore $fornitore;
/** @var array<string, mixed> */
public array $box = [];
/** @var array<int, array<string, mixed>> */
public array $anteprimaAde = [];
/** @var array<int, array<string, mixed>> */
public array $anteprimaFe = [];
/** @var array<int, array<string, mixed>> */
public array $anteprimaContabilita = [];
/** @var array<int, array<string, mixed>> */
public array $fattureAssociate = [];
/** @var array<int, array<string, mixed>> */
public array $raVersateRows = [];
/** @var array<int, array<string, mixed>> */
public array $catalogoProdottiRows = [];
/** @var array<int, array<string, mixed>> */
public array $tecnorepairRows = [];
public string $tagsInput = '';
/** @var array<int, string> */
public array $tagSuggestions = [];
/** @var array<int, array<string, mixed>> */
public array $dipendentiRows = [];
public string $nuovoDipendenteNome = '';
public string $nuovoDipendenteCognome = '';
public string $nuovoDipendenteEmail = '';
public string $nuovoDipendenteTelefono = '';
public string $dipendenteRubricaSearch = '';
public ?int $dipendenteRubricaId = null;
public string $sectionTab = 'profilo';
/** @var array<int, array<string, mixed>> */
public array $dipendenteRubricaMatches = [];
public ?string $lastGeneratedPassword = null;
public function cleanDisplayValue(?string $value, string $fallback = '-'): string
{
$v = trim((string) $value);
if ($v === '') {
return $fallback;
}
$upper = strtoupper($v);
if (in_array($upper, ['NULL', 'N/D', 'ND', 'N.A.', '-', '--', '[]', '{}'], true)) {
return $fallback;
}
return $v;
}
public function mount(int | string $record): void
{
$user = Auth::user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore', 'fornitore'])) {
abort(403);
}
if ($user->hasRole('fornitore') && ! $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore'])) {
[$resolvedFornitore] = $this->resolveOperatoreContext((int) $record);
$this->fornitore = $resolvedFornitore->loadMissing(['rubrica', 'amministratore']);
} else {
$this->fornitore = Fornitore::query()->with(['rubrica', 'amministratore'])->findOrFail((int) $record);
// Tenant guard: impedisce accesso cross-amministratore via URL.
if (! $user->hasAnyRole(['super-admin', 'admin'])) {
$allowedAdminIds = [];
$activeStabile = StabileContext::getActiveStabile($user);
$activeAdminId = (int) ($activeStabile?->amministratore_id ?: 0);
if ($activeAdminId > 0) {
$allowedAdminIds[] = $activeAdminId;
}
$userAdminId = (int) ($user->amministratore?->id ?: 0);
if ($userAdminId > 0) {
$allowedAdminIds[] = $userAdminId;
}
$ownerAdminId = (int) ($user->amministratoreOwner?->id ?: 0);
if ($ownerAdminId > 0) {
$allowedAdminIds[] = $ownerAdminId;
}
// Collaboratori: includi anche gli amministratori degli stabili assegnati.
try {
$assigned = $user->stabiliAssegnati()->pluck('amministratore_id')->all();
foreach ($assigned as $aid) {
$aid = (int) $aid;
if ($aid > 0) {
$allowedAdminIds[] = $aid;
}
}
} catch (\Throwable) {
// ignore
}
$allowedAdminIds = array_values(array_unique(array_filter($allowedAdminIds, fn($v) => (int) $v > 0)));
$fornitoreAdminIds = array_values(array_unique(array_filter([
(int) ($this->fornitore->amministratore_id ?? 0),
(int) ($this->fornitore->rubrica?->amministratore_id ?? 0),
], fn($v) => (int) $v > 0)));
if ($fornitoreAdminIds === []) {
if (! $user->hasAnyRole(['admin', 'amministratore'])) {
abort(404);
}
} else {
$hasIntersection = count(array_intersect($fornitoreAdminIds, $allowedAdminIds)) > 0;
if (! $hasIntersection) {
abort(404);
}
}
}
}
$this->hydrateBoxData($user);
$this->tagsInput = (string) ($this->fornitore->tags ?? '');
$this->loadTagSuggestions();
$this->refreshDipendentiRows();
$this->refreshCatalogRows();
$requestedTab = (string) request()->query('tab', 'profilo');
if ($this->isValidSectionTab($requestedTab)) {
$this->sectionTab = $requestedTab;
}
}
public function setSectionTab(string $tab): void
{
if ($this->isValidSectionTab($tab)) {
$this->sectionTab = $tab;
}
}
private function isValidSectionTab(string $tab): bool
{
return in_array($tab, ['profilo', 'accessi', 'assistenza', 'catalogo', 'contabilita'], true);
}
public function creaDipendenteFornitore(): void
{
$nome = trim($this->nuovoDipendenteNome);
$email = mb_strtolower(trim($this->nuovoDipendenteEmail));
if ($nome === '' || $email === '') {
Notification::make()->title('Nome ed email sono obbligatori')->warning()->send();
return;
}
$exists = FornitoreDipendente::query()
->where('fornitore_id', (int) $this->fornitore->id)
->whereRaw('LOWER(email) = ?', [$email])
->exists();
if ($exists) {
Notification::make()->title('Dipendente gia presente per questo fornitore')->warning()->send();
return;
}
FornitoreDipendente::query()->create([
'fornitore_id' => (int) $this->fornitore->id,
'nome' => $nome,
'cognome' => trim($this->nuovoDipendenteCognome) ?: null,
'email' => $email,
'telefono' => trim($this->nuovoDipendenteTelefono) ?: null,
'attivo' => true,
'created_by_user_id' => Auth::id(),
'updated_by_user_id' => Auth::id(),
]);
$this->nuovoDipendenteNome = '';
$this->nuovoDipendenteCognome = '';
$this->nuovoDipendenteEmail = '';
$this->nuovoDipendenteTelefono = '';
$this->refreshDipendentiRows();
Notification::make()->title('Dipendente aggiunto')->success()->send();
}
public function updatedDipendenteRubricaSearch(): void
{
$this->searchDipendenteRubricaMatches();
}
public function selezionaDipendenteRubrica(int $rubricaId): void
{
$rubrica = RubricaUniversale::query()->find($rubricaId);
if (! $rubrica instanceof RubricaUniversale) {
return;
}
$this->dipendenteRubricaId = (int) $rubrica->id;
$this->dipendenteRubricaSearch = $this->formatRubricaLookupLabel($rubrica);
$this->dipendenteRubricaMatches = [$this->mapRubricaLookupRow($rubrica)];
}
public function resetDipendenteRubricaSelection(): void
{
$this->dipendenteRubricaId = null;
$this->dipendenteRubricaSearch = '';
$this->dipendenteRubricaMatches = [];
}
public function collegaDipendenteDaRubrica(): void
{
$rubricaId = (int) ($this->dipendenteRubricaId ?? 0);
if ($rubricaId <= 0) {
Notification::make()->title('Seleziona un nominativo rubrica')->warning()->send();
return;
}
$rubrica = RubricaUniversale::query()->find($rubricaId);
if (! $rubrica instanceof RubricaUniversale) {
Notification::make()->title('Nominativo rubrica non trovato')->danger()->send();
return;
}
$email = mb_strtolower(trim((string) ($rubrica->email ?? '')));
$query = FornitoreDipendente::query()
->where('fornitore_id', (int) $this->fornitore->id)
->where(function ($builder) use ($rubricaId, $email, $rubrica): void {
$builder->where('rubrica_id', $rubricaId);
if ($email !== '') {
$builder->orWhereRaw('LOWER(email) = ?', [$email]);
}
$builder->orWhere(function ($sub) use ($rubrica): void {
$sub->where('nome', (string) ($rubrica->nome ?? ''))
->where('cognome', (string) ($rubrica->cognome ?? ''));
});
});
$dipendente = $query->first();
if (! $dipendente instanceof FornitoreDipendente) {
$dipendente = new FornitoreDipendente();
$dipendente->fornitore_id = (int) $this->fornitore->id;
$dipendente->created_by_user_id = Auth::id();
}
$dipendente->rubrica_id = $rubricaId;
$dipendente->nome = (string) ($rubrica->nome ?: $rubrica->ragione_sociale ?: 'Dipendente');
$dipendente->cognome = trim((string) ($rubrica->cognome ?? '')) !== '' ? (string) $rubrica->cognome : null;
$dipendente->email = $email !== '' ? $email : null;
$dipendente->telefono = trim((string) ($rubrica->telefono_cellulare ?: $rubrica->telefono_ufficio ?: $rubrica->telefono_casa)) ?: null;
$dipendente->attivo = true;
$dipendente->updated_by_user_id = Auth::id();
$dipendente->save();
$this->resetDipendenteRubricaSelection();
$this->refreshDipendentiRows();
Notification::make()->title('Dipendente collegato dalla rubrica')->success()->send();
}
public function abilitaAccessoDipendente(int $dipendenteId): void
{
$dipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $this->fornitore->id)
->find($dipendenteId);
if (! $dipendente instanceof FornitoreDipendente) {
Notification::make()->title('Dipendente non trovato')->danger()->send();
return;
}
$email = trim((string) ($dipendente->email ?? ''));
if ($email === '') {
Notification::make()->title('Email dipendente mancante')->warning()->send();
return;
}
$targetUser = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first();
$created = false;
if (! $targetUser instanceof User) {
$generatedPassword = Str::random(12);
$targetUser = User::query()->create([
'name' => trim((string) ($dipendente->nome_completo ?: 'Utente fornitore')),
'email' => $email,
'password' => Hash::make($generatedPassword),
'email_verified_at' => now(),
'is_active' => true,
]);
$this->lastGeneratedPassword = $generatedPassword;
$created = true;
}
$targetUser->assignRole('fornitore');
$dipendente->user_id = (int) $targetUser->id;
$dipendente->attivo = true;
$dipendente->updated_by_user_id = Auth::id();
$dipendente->save();
$this->refreshDipendentiRows();
Notification::make()
->title('Accesso dipendente abilitato')
->body($created
? 'Accesso creato. Password temporanea: ' . ($this->lastGeneratedPassword ?? '(non disponibile)')
: 'Accesso collegato ad utente esistente.')
->success()
->send();
}
public function resetPasswordDipendenteUser(int $userId): void
{
$dipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $this->fornitore->id)
->where('user_id', $userId)
->first();
if (! $dipendente instanceof FornitoreDipendente) {
Notification::make()->title('Utente non collegato a questo fornitore')->danger()->send();
return;
}
$target = User::query()->find($userId);
if (! $target instanceof User) {
Notification::make()->title('Utente non trovato')->danger()->send();
return;
}
$newPassword = Str::random(12);
$target->password = Hash::make($newPassword);
$target->save();
$this->lastGeneratedPassword = $newPassword;
Notification::make()
->title('Password dipendente resettata')
->body('Nuova password temporanea: ' . $newPassword)
->success()
->send();
}
public function toggleDipendenteAttivo(int $dipendenteId): void
{
$dipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $this->fornitore->id)
->find($dipendenteId);
if (! $dipendente instanceof FornitoreDipendente) {
Notification::make()->title('Dipendente non trovato')->danger()->send();
return;
}
$dipendente->attivo = ! (bool) $dipendente->attivo;
$dipendente->updated_by_user_id = Auth::id();
$dipendente->save();
$this->refreshDipendentiRows();
Notification::make()->title('Stato dipendente aggiornato')->success()->send();
}
public function saveTags(): void
{
if (! Schema::hasColumn('fornitori', 'tags')) {
Notification::make()->title('Colonna tags non presente')->warning()->send();
return;
}
$normalized = $this->normalizeTags($this->tagsInput);
$this->fornitore->tags = implode(', ', $normalized);
$this->fornitore->save();
$this->tagsInput = (string) ($this->fornitore->tags ?? '');
$this->loadTagSuggestions();
Notification::make()->title('Tag fornitore salvati')->success()->send();
}
public function importLegacyTags(): void
{
if (! Schema::hasColumn('fornitori', 'tags')) {
Notification::make()->title('Colonna tags non presente')->warning()->send();
return;
}
try {
Artisan::call('fornitori:import-legacy-tags', [
'--fornitore-id' => [(int) $this->fornitore->id],
]);
} catch (\Throwable $e) {
Notification::make()->title('Import tag legacy fallito')->body($e->getMessage())->danger()->send();
return;
}
$this->fornitore->refresh();
$this->tagsInput = (string) ($this->fornitore->tags ?? '');
$this->loadTagSuggestions();
Notification::make()->title('Tag legacy importati')->success()->send();
}
public function syncRubricaFromFornitore(): void
{
$payload = [
'tipo_contatto' => 'persona_giuridica',
'categoria' => 'fornitore',
'stato' => 'attivo',
'ragione_sociale' => $this->fornitore->ragione_sociale ?: trim(($this->fornitore->nome ?? '') . ' ' . ($this->fornitore->cognome ?? '')),
'nome' => $this->fornitore->nome,
'cognome' => $this->fornitore->cognome,
'partita_iva' => $this->fornitore->partita_iva,
'codice_fiscale' => $this->fornitore->codice_fiscale,
'indirizzo' => $this->fornitore->indirizzo,
'civico' => $this->fornitore->civico,
'cap' => $this->fornitore->cap,
'citta' => $this->fornitore->citta,
'provincia' => $this->fornitore->provincia,
'nazione' => $this->fornitore->nazione,
'email' => $this->fornitore->email,
'pec' => $this->fornitore->pec,
'telefono_ufficio' => $this->fornitore->telefono,
'telefono_cellulare' => $this->fornitore->cellulare,
'sito_web' => $this->fornitore->sito_web,
'note' => $this->fornitore->note,
'data_ultima_modifica' => now()->toDateString(),
];
$rubrica = null;
if ((int) ($this->fornitore->rubrica_id ?? 0) > 0) {
$rubrica = RubricaUniversale::query()->find((int) $this->fornitore->rubrica_id);
}
if (! $rubrica instanceof RubricaUniversale) {
$rubrica = RubricaUniversale::query()->create(array_merge($payload, [
'data_inserimento' => now()->toDateString(),
]));
} else {
$rubrica->fill($payload);
$rubrica->save();
}
if ((int) ($this->fornitore->rubrica_id ?? 0) !== (int) $rubrica->id) {
$this->fornitore->rubrica_id = (int) $rubrica->id;
$this->fornitore->save();
}
$this->fornitore->refresh();
Notification::make()->title('Rubrica aggiornata dal fornitore')->success()->send();
}
private function loadTagSuggestions(): void
{
if (! Schema::hasColumn('fornitori', 'tags')) {
$this->tagSuggestions = [];
return;
}
$all = Fornitore::query()
->whereNotNull('tags')
->pluck('tags')
->all();
$pool = [];
foreach ($all as $row) {
foreach ($this->splitTags((string) $row) as $tag) {
$pool[] = $tag;
}
}
$pool = array_values(array_unique($pool));
sort($pool, SORT_NATURAL | SORT_FLAG_CASE);
$this->tagSuggestions = array_slice($pool, 0, 300);
}
private function refreshDipendentiRows(): void
{
$this->dipendentiRows = FornitoreDipendente::query()
->with(['user:id,name,email', 'rubrica:id,nome,cognome,ragione_sociale'])
->where('fornitore_id', (int) $this->fornitore->id)
->orderByDesc('attivo')
->orderBy('nome')
->orderBy('cognome')
->get()
->map(function (FornitoreDipendente $d): array {
$userId = (int) ($d->user_id ?? 0);
return [
'id' => (int) $d->id,
'nome' => (string) ($d->nome_completo ?: 'Dipendente #' . $d->id),
'email' => (string) ($d->email ?? ''),
'telefono' => (string) ($d->telefono ?? ''),
'attivo' => (bool) $d->attivo,
'rubrica_id' => (int) ($d->rubrica_id ?? 0),
'rubrica' => $d->rubrica ? $this->formatRubricaLookupLabel($d->rubrica) : '',
'user_id' => $userId,
'user_label' => $userId > 0 ? ((string) ($d->user?->name ?: ('Utente #' . $userId))): '-',
];
})
->all();
}
private function searchDipendenteRubricaMatches(): void
{
$this->dipendenteRubricaMatches = $this->searchRubricaLookupMatches(
$this->dipendenteRubricaSearch,
$this->dipendenteRubricaId,
null,
12,
(int) ($this->fornitore->amministratore_id ?? 0)
);
}
private function refreshCatalogRows(): void
{
$this->catalogoProdottiRows = [];
$this->tecnorepairRows = [];
if (Schema::hasTable('products')) {
$query = Product::query()
->withCount(['identifiers', 'serials', 'media'])
->where('default_fornitore_id', (int) $this->fornitore->id)
->orderBy('name')
->limit(20);
if (Schema::hasTable('product_offers')) {
$query->with(['offers' => function ($offerQuery): void {
$offerQuery->where('is_active', true)->orderBy('price_amount');
}]);
}
$this->catalogoProdottiRows = $query
->get(['id', 'internal_code', 'name', 'brand', 'model', 'color_label', 'track_serials', 'meta'])
->map(function (Product $product): array {
$offers = Schema::hasTable('product_offers') ? ($product->offers ?? collect()) : collect();
$internalOffer = $offers->first(fn($offer) => (bool) ($offer->is_internal ?? false));
$bestCompetitor = $offers
->filter(fn($offer) => ! (bool) ($offer->is_internal ?? false) && filled($offer->price_amount))
->sortBy('price_amount')
->first();
$amazonOffer = $offers->first(fn($offer) => in_array((string) ($offer->source_type ?? ''), ['amazon_referral', 'amazon_creators_api'], true));
return [
'id' => (int) $product->id,
'internal_code' => (string) ($product->internal_code ?? ''),
'name' => (string) ($product->name ?? ''),
'brand' => (string) ($product->brand ?? ''),
'model' => (string) ($product->model ?? ''),
'color_label' => (string) ($product->color_label ?? ''),
'track_serials' => (bool) $product->track_serials,
'identifiers_count' => (int) ($product->identifiers_count ?? 0),
'serials_count' => (int) ($product->serials_count ?? 0),
'media_count' => (int) ($product->media_count ?? 0),
'source' => (string) (($product->meta['source'] ?? 'manual') ?: 'manual'),
'publication_mode' => (string) (($product->meta['catalog']['publication_mode'] ?? '') ?: ''),
'public_reference' => (string) (($product->meta['catalog']['public_reference'] ?? '') ?: ($product->internal_code ?? '')),
'private_links' => count(array_filter(is_array($product->meta['catalog']['private_source_links'] ?? null) ? $product->meta['catalog']['private_source_links'] : [], fn($value) => filled($value))),
'categories' => (string) (($product->meta['catalog']['categories'] ?? '') ?: ''),
'stock_quantity' => is_numeric($product->meta['stock']['quantity'] ?? null) ? (int) $product->meta['stock']['quantity'] : null,
'price_wholesale' => is_numeric($product->meta['pricing']['wholesale'] ?? null) ? (float) $product->meta['pricing']['wholesale'] : null,
'offers_count' => $offers->count(),
'own_offer_price' => $internalOffer && filled($internalOffer->price_amount) ? (float) $internalOffer->price_amount : null,
'best_competitor_price' => $bestCompetitor && filled($bestCompetitor->price_amount) ? (float) $bestCompetitor->price_amount : null,
'best_competitor_source' => $bestCompetitor ? (string) ($bestCompetitor->source_name ?? $bestCompetitor->source_type ?? '') : '',
'amazon_referral_url' => $amazonOffer ? (string) ($amazonOffer->referral_url ?? '') : '',
];
})
->all();
}
if (Schema::hasTable('assistenza_tecnorepair_schede_legacy')) {
$this->tecnorepairRows = $this->fornitore->tecnorepairSchede()
->withCount('allegati')
->orderByRaw("CASE status_bucket WHEN 'open' THEN 0 WHEN 'waiting' THEN 1 WHEN 'closed' THEN 2 ELSE 3 END")
->orderByDesc('date_received')
->limit(12)
->get(['id', 'legacy_numero_scheda', 'product_model', 'product_code', 'serial_number', 'status_bucket', 'status_label'])
->map(fn($scheda) => [
'id' => (int) $scheda->id,
'legacy_numero' => (string) ($scheda->legacy_numero_scheda ?? ''),
'product_model' => (string) ($scheda->product_model ?? ''),
'product_code' => (string) ($scheda->product_code ?? ''),
'serial_number' => (string) ($scheda->serial_number ?? ''),
'status_bucket' => (string) ($scheda->status_bucket ?? ''),
'status_label' => (string) ($scheda->status_label ?? ''),
'allegati_count' => (int) ($scheda->allegati_count ?? 0),
])
->all();
}
}
private function resolveFornitoreAdminCode(): ?string
{
$admin = $this->fornitore->amministratore;
if (! $admin) {
return null;
}
$code = trim((string) ($admin->codice_amministratore ?? ''));
return $code !== '' ? $code : (string) $admin->id;
}
private function suggestWholesaleCsvPath(): string
{
$basePath = base_path('Miki-Bug-workspace/Fornitori/Listini da importare');
if (! is_dir($basePath)) {
return '';
}
$normalizedName = Str::of((string) ($this->fornitore->ragione_sociale ?? ''))
->ascii()
->lower()
->replaceMatches('/[^a-z0-9]+/', ' ')
->trim()
->value();
$candidates = glob($basePath . '/*/wholesale.csv') ?: [];
foreach ($candidates as $candidate) {
$segment = Str::of((string) basename(dirname($candidate)))
->ascii()
->lower()
->replaceMatches('/[^a-z0-9]+/', ' ')
->trim()
->value();
if ($segment !== '' && $normalizedName !== '' && Str::contains($normalizedName, $segment)) {
return $candidate;
}
}
return (string) ($candidates[0] ?? '');
}
private function refreshOperationalBoxes(): void
{
$user = Auth::user();
if ($user instanceof User) {
$this->hydrateBoxData($user);
}
$this->fornitore->refresh();
$this->refreshCatalogRows();
}
/**
* @return array<int, string>
*/
private function normalizeTags(string $input): array
{
$tags = [];
foreach ($this->splitTags($input) as $tag) {
$normalized = $this->canonicalizeTag($tag);
if ($normalized !== null) {
$tags[] = $normalized;
}
}
$tags = array_values(array_unique($tags));
sort($tags, SORT_NATURAL | SORT_FLAG_CASE);
return $tags;
}
/**
* @return array<int, string>
*/
private function splitTags(string $value): array
{
$parts = preg_split('/[,;|\n\r\/]+/', $value) ?: [];
return array_values(array_filter(array_map(function (string $part): string {
$clean = trim($part);
$clean = preg_replace('/\s+/', ' ', $clean) ?? '';
return $clean;
}, $parts), fn(string $p): bool => $p !== ''));
}
private function canonicalizeTag(string $raw): ?string
{
$clean = trim(mb_strtolower($raw));
if ($clean === 'pc') {
return 'pc';
}
if ($clean === '' || mb_strlen($clean) < 3) {
return null;
}
$map = [
'informat' => 'informatica',
'assist' => 'assistenza',
'computer' => 'pc',
'apple' => 'apple',
'idr' => 'idraulico',
'idraul' => 'idraulico',
'elett' => 'elettricista',
'elettric' => 'elettricista',
'ascens' => 'ascensorista',
'puliz' => 'pulizie',
'giardin' => 'giardiniere',
'assicur' => 'assicurazione',
'manut' => 'manutenzione',
];
foreach ($map as $prefix => $canonical) {
if (str_starts_with($clean, $prefix)) {
return $canonical;
}
}
return $clean;
}
private function hydrateBoxData(User $user): void
{
$this->fattureAssociate = [];
$this->raVersateRows = [];
$identityCandidates = $this->getSupplierIdentityCandidates();
$this->box = [
'stabile_id' => null,
'fornitore_features' => [],
'fornitore_defaults' => [
'voce_spesa_default_id' => null,
'conto_costo_default_id' => null,
],
'catalogo_modulo' => [
'scope_code' => null,
'fe_products' => 0,
'csv_products' => 0,
'manual_products' => 0,
'internal_only' => 0,
'private_links' => 0,
'offers' => 0,
'amazon_links' => 0,
],
'prodotti' => [
'count' => 0,
'serializzati' => 0,
'identifiers' => 0,
'with_media' => 0,
],
'tecnorepair' => [
'schede' => 0,
'aperte' => 0,
'chiuse' => 0,
'seriali' => 0,
],
'ade' => ['count' => 0, 'lordo' => 0.0],
'fe' => ['count' => 0, 'totale' => 0.0, 'contabilizzate' => 0],
'contabilita' => [
'aperte_count' => 0,
'aperte_netto' => 0.0,
'pagate_count' => 0,
'pagate_netto' => 0.0,
],
'da_pagare' => ['source' => null, 'importo' => 0.0],
'ra' => [
'da_versare' => 0.0,
'versata' => 0.0,
'compensata' => 0.0,
],
];
$activeStabileId = StabileContext::resolveActiveStabileId($user);
if (! $activeStabileId) {
return;
}
$stabile = Stabile::query()->select(['id', 'amministratore_id'])->find($activeStabileId);
$amministratoreId = (int) ($stabile?->amministratore_id ?: 0);
if ($amministratoreId <= 0) {
return;
}
$this->box['stabile_id'] = $activeStabileId;
if (Schema::hasTable('products')) {
$productsBase = Product::query()->where('default_fornitore_id', (int) $this->fornitore->id);
$catalogProducts = (clone $productsBase)->get(['id', 'meta']);
$this->box['prodotti']['count'] = (int) (clone $productsBase)->count();
$this->box['prodotti']['serializzati'] = (int) (clone $productsBase)->where('track_serials', true)->count();
$this->box['prodotti']['with_media'] = (int) (clone $productsBase)->whereHas('media')->count();
$this->box['prodotti']['identifiers'] = (int) (clone $productsBase)->withCount('identifiers')->get()->sum('identifiers_count');
$scopeCode = trim((string) ($this->fornitore->codice_univoco ?? ''));
$this->box['catalogo_modulo']['scope_code'] = $scopeCode !== '' ? $scopeCode : ('FORN-' . str_pad((string) $this->fornitore->id, 6, '0', STR_PAD_LEFT));
foreach ($catalogProducts as $product) {
$meta = is_array($product->meta ?? null) ? $product->meta : [];
$source = (string) ($meta['source'] ?? 'manual');
$catalog = is_array($meta['catalog'] ?? null) ? $meta['catalog'] : [];
$privateLinks = is_array($catalog['private_source_links'] ?? null) ? $catalog['private_source_links'] : [];
if ($source === 'fattura_elettronica') {
$this->box['catalogo_modulo']['fe_products']++;
} elseif ($source === 'ncom_wholesale_csv') {
$this->box['catalogo_modulo']['csv_products']++;
} else {
$this->box['catalogo_modulo']['manual_products']++;
}
if ((string) ($catalog['publication_mode'] ?? '') === 'internal_only') {
$this->box['catalogo_modulo']['internal_only']++;
}
$this->box['catalogo_modulo']['private_links'] += count(array_filter($privateLinks, fn($value) => filled($value)));
}
if (Schema::hasTable('product_offers')) {
$offersBase = $this->fornitore->productOffers()->where('is_active', true);
$this->box['catalogo_modulo']['offers'] = (int) (clone $offersBase)->count();
$this->box['catalogo_modulo']['amazon_links'] = (int) (clone $offersBase)->whereIn('source_type', ['amazon_referral', 'amazon_creators_api'])->count();
}
}
if (Schema::hasTable('assistenza_tecnorepair_schede_legacy')) {
$stats = $this->fornitore->tecnorepair_stats;
$this->box['tecnorepair']['schede'] = (int) ($stats['total'] ?? 0);
$this->box['tecnorepair']['aperte'] = (int) ($stats['open'] ?? 0);
$this->box['tecnorepair']['chiuse'] = (int) ($stats['closed'] ?? 0);
$this->box['tecnorepair']['seriali'] = (int) ($stats['serials'] ?? 0);
}
// Feature flags & defaults (per stabile) + fallback (per fornitore)
$settings = null;
if (Schema::hasTable('fornitore_stabile_impostazioni')) {
$settings = FornitoreStabileImpostazione::query()
->where('stabile_id', (int) $activeStabileId)
->where('fornitore_id', (int) $this->fornitore->id)
->first();
if ($settings) {
$this->box['fornitore_defaults']['voce_spesa_default_id'] = $settings->voce_spesa_default_id ? (int) $settings->voce_spesa_default_id : null;
$this->box['fornitore_defaults']['conto_costo_default_id'] = $settings->conto_costo_default_id ? (int) $settings->conto_costo_default_id : null;
}
}
$features = [];
if (Schema::hasColumn('fornitori', 'fe_features')) {
$features = is_array($this->fornitore->fe_features ?? null) ? $this->fornitore->fe_features : [];
} elseif ($settings && is_array($settings->meta ?? null)) {
$features = is_array($settings->meta['fe_features'] ?? null) ? $settings->meta['fe_features'] : [];
}
$this->box['fornitore_features'] = $features;
$normalize = static function (?string $value): string {
$value = strtoupper(trim((string) $value));
$value = str_replace(' ', '', $value);
return $value;
};
$ids = [];
$piva = $normalize($this->fornitore->partita_iva);
$cf = $normalize($this->fornitore->codice_fiscale);
if ($piva !== '') {
$ids[] = $piva;
if (str_starts_with($piva, 'IT') && strlen($piva) > 2) {
$ids[] = substr($piva, 2);
}
}
if ($cf !== '') {
$ids[] = $cf;
}
$ids = array_values(array_unique(array_filter($ids, fn($v) => $v !== '')));
// AdE (staging)
if (! empty($ids) && Schema::hasTable('stg_fatture_ade')) {
$adeBase = StgFatturaAde::query()
->where('amministratore_id', $amministratoreId)
->where(function (\Illuminate\Database\Eloquent\Builder $q) use ($activeStabileId): void {
$q->where('stabile_id', $activeStabileId)->orWhereNull('stabile_id');
})
->where(function (\Illuminate\Database\Eloquent\Builder $q) use ($ids): void {
foreach ($ids as $id) {
$q->orWhereRaw("REPLACE(UPPER(identificativo_fornitore), ' ', '') = ?", [$id]);
}
});
$this->box['ade']['count'] = (int) (clone $adeBase)->count();
$lordo = (float) (clone $adeBase)->selectRaw('COALESCE(SUM(imponibile + imposta), 0) as lordo')->value('lordo');
$this->box['ade']['lordo'] = $lordo;
$this->anteprimaAde = (clone $adeBase)
->orderByDesc('data_emissione')
->orderByDesc('id')
->limit(8)
->get(['id', 'data_emissione', 'numero_documento', 'imponibile', 'imposta', 'sdi_file'])
->map(fn(StgFatturaAde $r) => [
'id' => $r->id,
'data' => $r->data_emissione?->format('d/m/Y'),
'numero' => (string) ($r->numero_documento ?? ''),
'lordo' => (float) ($r->imponibile ?? 0) + (float) ($r->imposta ?? 0),
'sdi_file' => (string) ($r->sdi_file ?? ''),
])
->all();
}
// FE importate
if (Schema::hasTable('fatture_elettroniche')) {
$feBase = FatturaElettronica::query()
->where('stabile_id', $activeStabileId)
->where(function (\Illuminate\Database\Eloquent\Builder $q) use ($identityCandidates): void {
$this->applySupplierIdentityFilterToFeQuery($q, $identityCandidates);
});
$this->box['fe']['count'] = (int) (clone $feBase)->count();
$this->box['fe']['totale'] = (float) (clone $feBase)->selectRaw('COALESCE(SUM(totale), 0) as totale')->value('totale');
$this->box['fe']['contabilizzate'] = (int) (clone $feBase)
->where(function (\Illuminate\Database\Eloquent\Builder $q): void {
$q->whereNotNull('registrazione_contabile_id')->orWhere('stato', 'contabilizzata');
})
->count();
$this->anteprimaFe = (clone $feBase)
->orderByDesc('data_fattura')
->orderByDesc('id')
->limit(8)
->get(['id', 'data_fattura', 'numero_fattura', 'totale', 'sdi_file', 'stato'])
->map(fn(FatturaElettronica $r) => [
'id' => $r->id,
'data' => $r->data_fattura?->format('d/m/Y'),
'numero' => (string) ($r->numero_fattura ?? ''),
'totale' => (float) ($r->totale ?? 0),
'sdi_file' => (string) ($r->sdi_file ?? ''),
'stato' => (string) ($r->stato ?? ''),
])
->all();
}
// Contabilità (fatture fornitori)
if (Schema::hasTable('contabilita_fatture_fornitori')) {
$contabBase = ContabilitaFatturaFornitore::query()
->where('stabile_id', $activeStabileId)
->where('fornitore_id', (int) $this->fornitore->id)
->when($identityCandidates !== [], function ($query) use ($identityCandidates): void {
$query->where(function (\Illuminate\Database\Eloquent\Builder $inner) use ($identityCandidates): void {
$inner->whereNull('fattura_elettronica_id')
->orWhereDoesntHave('fatturaElettronica')
->orWhereHas('fatturaElettronica', function (\Illuminate\Database\Eloquent\Builder $feQuery) use ($identityCandidates): void {
$this->applySupplierIdentityFilterToFeQuery($feQuery, $identityCandidates);
});
});
});
// Coerente con la logica della scheda contabile: aperto = totale - pagato.
$totNetto = (float) (clone $contabBase)->selectRaw('COALESCE(SUM(netto_da_pagare), 0) as netto')->value('netto');
$this->box['contabilita']['pagate_count'] = (int) (clone $contabBase)->where('stato', 'pagato')->count();
$this->box['contabilita']['pagate_netto'] = (float) (clone $contabBase)->where('stato', 'pagato')->selectRaw('COALESCE(SUM(netto_da_pagare), 0) as netto')->value('netto');
$this->box['contabilita']['aperte_netto'] = round($totNetto - (float) $this->box['contabilita']['pagate_netto'], 2);
$this->box['contabilita']['aperte_count'] = (int) (clone $contabBase)->where('stato', '!=', 'pagato')->count();
$this->anteprimaContabilita = (clone $contabBase)
->orderByDesc('data_documento')
->orderByDesc('id')
->limit(8)
->get(['id', 'data_documento', 'numero_documento', 'netto_da_pagare', 'stato'])
->map(fn(ContabilitaFatturaFornitore $r) => [
'id' => $r->id,
'data' => $r->data_documento?->format('d/m/Y'),
'numero' => (string) ($r->numero_documento ?? ''),
'netto' => (float) ($r->netto_da_pagare ?? 0),
'stato' => (string) ($r->stato ?? ''),
])
->all();
$this->fattureAssociate = (clone $contabBase)
->orderByDesc('data_documento')
->orderByDesc('id')
->limit(30)
->get(['id', 'data_documento', 'numero_documento', 'totale', 'netto_da_pagare', 'stato', 'fattura_elettronica_id'])
->map(fn(ContabilitaFatturaFornitore $r) => [
'id' => (int) $r->id,
'data' => $r->data_documento?->format('d/m/Y'),
'numero' => (string) ($r->numero_documento ?? ''),
'totale' => (float) ($r->totale ?? 0),
'netto' => (float) ($r->netto_da_pagare ?? 0),
'stato' => (string) ($r->stato ?? ''),
'source' => ((int) ($r->fattura_elettronica_id ?? 0) > 0) ? 'FE' : 'manuale',
])
->all();
}
// Registro RA
if (Schema::hasTable('registro_ritenute_acconto')) {
$raBase = RegistroRitenuteAcconto::query()
->where('fornitore_id', (int) $this->fornitore->id)
->whereHas('gestione', fn(\Illuminate\Database\Eloquent\Builder $q) => $q->where('stabile_id', (int) $activeStabileId));
foreach (['da_versare', 'versata', 'compensata'] as $stato) {
$this->box['ra'][$stato] = (float) (clone $raBase)
->where('stato_versamento', $stato)
->selectRaw('COALESCE(SUM(importo_ritenuta), 0) as ra')
->value('ra');
}
$this->raVersateRows = (clone $raBase)
->whereIn('stato_versamento', ['versata', 'compensata'])
->orderByDesc('data_versamento')
->orderByDesc('id')
->limit(30)
->get(['id', 'data_versamento', 'importo_ritenuta', 'stato_versamento', 'codice_tributo', 'f24_riferimento'])
->map(fn(RegistroRitenuteAcconto $r) => [
'id' => (int) $r->id,
'data_versamento' => $r->data_versamento?->format('d/m/Y'),
'importo' => (float) ($r->importo_ritenuta ?? 0),
'stato' => (string) ($r->stato_versamento ?? ''),
'tributo' => (string) ($r->codice_tributo ?? ''),
'f24' => (string) ($r->f24_riferimento ?? ''),
])
->all();
}
// Totale da pagare (priorità: contabilità → FE → AdE)
if ((float) ($this->box['contabilita']['aperte_netto'] ?? 0) > 0) {
$this->box['da_pagare'] = ['source' => 'contabilita', 'importo' => (float) $this->box['contabilita']['aperte_netto']];
} elseif ((int) ($this->box['fe']['count'] ?? 0) > 0) {
$this->box['da_pagare'] = ['source' => 'fe', 'importo' => (float) ($this->box['fe']['totale'] ?? 0)];
} elseif ((int) ($this->box['ade']['count'] ?? 0) > 0) {
$this->box['da_pagare'] = ['source' => 'ade', 'importo' => (float) ($this->box['ade']['lordo'] ?? 0)];
}
}
/**
* @return array<int, string>
*/
private function getSupplierIdentityCandidates(): array
{
$values = [];
foreach ([(string) ($this->fornitore->partita_iva ?? ''), (string) ($this->fornitore->codice_fiscale ?? '')] as $raw) {
$normalized = strtoupper(str_replace(' ', '', trim($raw)));
if ($normalized === '' || $normalized === 'ND') {
continue;
}
$values[] = $normalized;
if (str_starts_with($normalized, 'IT') && strlen($normalized) > 2) {
$values[] = substr($normalized, 2);
}
}
$values = array_values(array_unique(array_filter($values, fn(string $value): bool => $value !== '')));
sort($values, SORT_NATURAL | SORT_FLAG_CASE);
return $values;
}
/**
* @param array<int, string> $identityCandidates
*/
private function applySupplierIdentityFilterToFeQuery(\Illuminate\Database\Eloquent\Builder $query, array $identityCandidates): void
{
$fornitoreId = (int) $this->fornitore->id;
if ($identityCandidates === []) {
$query->where('fornitore_id', $fornitoreId);
return;
}
$query->where(function (\Illuminate\Database\Eloquent\Builder $outer) use ($identityCandidates, $fornitoreId): void {
$outer->where(function (\Illuminate\Database\Eloquent\Builder $identityQuery) use ($identityCandidates): void {
foreach ($identityCandidates as $identity) {
$identityQuery->orWhereRaw("REPLACE(UPPER(fornitore_piva), ' ', '') = ?", [$identity])
->orWhereRaw("REPLACE(UPPER(fornitore_cf), ' ', '') = ?", [$identity]);
}
})->orWhere(function (\Illuminate\Database\Eloquent\Builder $fallbackQuery) use ($fornitoreId): void {
$fallbackQuery->where('fornitore_id', $fornitoreId)
->where(function (\Illuminate\Database\Eloquent\Builder $missingPiva): void {
$missingPiva->whereNull('fornitore_piva')
->orWhere('fornitore_piva', '')
->orWhere('fornitore_piva', 'ND');
})
->where(function (\Illuminate\Database\Eloquent\Builder $missingCf): void {
$missingCf->whereNull('fornitore_cf')
->orWhere('fornitore_cf', '')
->orWhere('fornitore_cf', 'ND');
});
});
});
}
protected function getHeaderActions(): array
{
return [
Action::make('apri_rubrica')
->label('Apri anagrafica unica')
->icon('heroicon-o-user')
->visible(fn(): bool => (int) ($this->fornitore->rubrica_id ?? 0) > 0)
->url(fn() => RubricaUniversaleScheda::getUrl([
'record' => (int) $this->fornitore->rubrica_id,
], panel: 'admin-filament')),
Action::make('sync_rubrica')
->label('Sincronizza Rubrica')
->icon('heroicon-o-arrow-path')
->action(fn() => $this->syncRubricaFromFornitore()),
Action::make('importa_tag_legacy')
->label('Importa TAG legacy')
->icon('heroicon-o-tag')
->action(fn() => $this->importLegacyTags()),
Action::make('nuovo_prodotto')
->label('Nuovo prodotto')
->icon('heroicon-o-cube')
->visible(fn(): bool => Schema::hasTable('products'))
->form([
TextInput::make('name')->label('Nome prodotto')->required()->maxLength(255),
TextInput::make('brand')->label('Marca')->maxLength(255),
TextInput::make('model')->label('Modello')->maxLength(255),
TextInput::make('color_label')->label('Colore')->maxLength(255),
TextInput::make('variant_label')->label('Variante')->maxLength(255),
Toggle::make('track_serials')->label('Gestione seriali')->default(false),
TextInput::make('vendor_sku')->label('Codice fornitore')->maxLength(255),
TextInput::make('ean_code')->label('EAN / GTIN')->maxLength(32),
TextInput::make('qr_code')->label('QR / payload')->maxLength(255),
])
->action(function (array $data): void {
$service = app(FornitoreProductCatalogService::class);
$resolved = $service->resolveOrCreateProduct($this->fornitore, [
'name' => (string) ($data['name'] ?? ''),
'brand' => (string) ($data['brand'] ?? ''),
'model' => (string) ($data['model'] ?? ''),
'color_label' => (string) ($data['color_label'] ?? ''),
'variant_label' => (string) ($data['variant_label'] ?? ''),
'track_serials' => (bool) ($data['track_serials'] ?? false),
'type' => 'product',
'unit_measure' => 'pz',
]);
if (filled($data['vendor_sku'] ?? null)) {
$service->upsertIdentifier($resolved['product'], [
'fornitore_id' => (int) $this->fornitore->id,
'code_type' => 'vendor_sku',
'code_role' => 'supplier',
'code_value' => (string) $data['vendor_sku'],
'source' => 'manual',
]);
}
if (filled($data['ean_code'] ?? null)) {
$service->upsertIdentifier($resolved['product'], [
'fornitore_id' => null,
'code_type' => 'ean',
'code_role' => 'barcode',
'code_value' => (string) $data['ean_code'],
'source' => 'manual',
]);
}
if (filled($data['qr_code'] ?? null)) {
$service->upsertIdentifier($resolved['product'], [
'fornitore_id' => null,
'code_type' => 'qr',
'code_role' => 'barcode',
'code_value' => (string) $data['qr_code'],
'source' => 'manual',
]);
}
$this->refreshOperationalBoxes();
Notification::make()->title('Prodotto salvato')->success()->send();
}),
Action::make('estrai_prodotti_fe')
->label('Estrai prodotti da FE')
->icon('heroicon-o-document-magnifying-glass')
->visible(fn(): bool => Schema::hasTable('products'))
->form([
TextInput::make('limit')->label('Numero FE da analizzare')->numeric()->default(40)->required(),
])
->action(function (array $data): void {
$user = Auth::user();
if (! $user instanceof User) {
Notification::make()->title('Utente non valido')->danger()->send();
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
Notification::make()->title('Seleziona uno stabile attivo')->warning()->send();
return;
}
$stats = app(FornitoreProductCatalogService::class)->extractFromFattureElettroniche(
$this->fornitore,
(int) $stabileId,
max(1, (int) ($data['limit'] ?? 40)),
);
$this->refreshOperationalBoxes();
Notification::make()
->title('Estrazione prodotti completata')
->body('FE: ' . $stats['fatture'] . ' | righe: ' . $stats['lines'] . ' | nuovi prodotti: ' . $stats['products'] . ' | codici: ' . $stats['identifiers'])
->success()
->send();
}),
Action::make('importa_listino_csv')
->label('Importa listino CSV')
->icon('heroicon-o-arrow-down-tray')
->visible(fn(): bool => Schema::hasTable('products'))
->form([
TextInput::make('csv_path')
->label('Percorso CSV listino')
->default(fn(): string => $this->suggestWholesaleCsvPath())
->required(),
TextInput::make('limit')->label('Limite righe')->numeric()->default(0),
Toggle::make('dry_run')->label('Solo simulazione')->default(false),
])
->action(function (array $data): void {
try {
$stats = app(FornitoreProductCatalogService::class)->importWholesaleCsv(
$this->fornitore,
(string) ($data['csv_path'] ?? ''),
max(0, (int) ($data['limit'] ?? 0)),
(bool) ($data['dry_run'] ?? false),
);
} catch (\Throwable $e) {
Notification::make()->title('Import listino fallito')->body($e->getMessage())->danger()->send();
return;
}
$this->refreshOperationalBoxes();
Notification::make()
->title((bool) ($data['dry_run'] ?? false) ? 'Simulazione listino completata' : 'Import listino completato')
->body(
'righe: ' . $stats['rows']
. ' | nuovi: ' . $stats['products']
. ' | aggiornati: ' . $stats['updated']
. ' | codici: ' . $stats['identifiers']
. ' | offerte: ' . ($stats['offers'] ?? 0)
. ' | link sorgente interni: ' . ($stats['private_links'] ?? 0)
. ' | media remoti rimossi: ' . ($stats['remote_media_pruned'] ?? 0)
)
->success()
->send();
}),
Action::make('scarica_asset_catalogo')
->label('Internalizza asset')
->icon('heroicon-o-photo')
->visible(fn(): bool => Schema::hasTable('products'))
->form([
TextInput::make('limit')->label('Limite prodotti')->numeric()->default(50)->required(),
Toggle::make('dry_run')->label('Solo simulazione')->default(false),
])
->action(function (array $data): void {
$stats = app(ProductAssetIngestionService::class)->ingestForFornitore(
$this->fornitore,
max(1, (int) ($data['limit'] ?? 50)),
(bool) ($data['dry_run'] ?? false),
);
$this->refreshOperationalBoxes();
Notification::make()
->title((bool) ($data['dry_run'] ?? false) ? 'Simulazione asset completata' : 'Asset catalogo internalizzati')
->body(
'prodotti: ' . $stats['products']
. ' | immagini: ' . $stats['images']
. ' | documenti: ' . $stats['documents']
. ' | esistenti: ' . $stats['skipped_existing']
. ' | non supportati: ' . $stats['skipped_unsupported']
. ' | errori: ' . $stats['failed']
)
->success()
->send();
}),
Action::make('genera_referral_amazon')
->label('Prepara link Amazon')
->icon('heroicon-o-shopping-bag')
->visible(fn(): bool => Schema::hasTable('products') && Schema::hasTable('product_offers'))
->form([
TextInput::make('associate_tag')->label('Referral tag Amazon')->default((string) config('catalog.amazon.associate_tag', ''))->maxLength(64),
Select::make('locale')->label('Marketplace')->options([
'it' => 'Amazon IT',
'de' => 'Amazon DE',
'fr' => 'Amazon FR',
'es' => 'Amazon ES',
'uk' => 'Amazon UK',
])->default((string) config('catalog.amazon.default_locale', 'it'))->required(),
TextInput::make('limit')->label('Limite prodotti')->numeric()->default(200)->required(),
])
->action(function (array $data): void {
$service = app(ProductOfferService::class);
$products = Product::query()
->where('default_fornitore_id', (int) $this->fornitore->id)
->orderBy('id')
->limit(max(1, (int) ($data['limit'] ?? 200)))
->get(['id', 'name', 'brand', 'model']);
$created = 0;
foreach ($products as $product) {
$offer = $service->syncAmazonReferralOffer($product, (string) ($data['associate_tag'] ?? ''), (string) ($data['locale'] ?? 'it'));
if ($offer !== null) {
$created++;
}
}
$this->refreshOperationalBoxes();
Notification::make()
->title('Link Amazon preparati')
->body('prodotti elaborati: ' . $products->count() . ' | link attivi: ' . $created)
->success()
->send();
}),
Action::make('importa_tecnorepair')
->label('Importa TecnoRepair')
->icon('heroicon-o-wrench-screwdriver')
->form([
TextInput::make('mdb_path')
->label('Percorso MDB TecnoRepair')
->default('/home/michele/netgescon/netgescon-day0/Miki-Bug-workspace/screenshot/Assistenza gestionale/Archivi/TecnoRepairDB.mdb')
->required(),
TextInput::make('limit')->label('Limite schede')->numeric()->default(0),
Toggle::make('dry_run')->label('Solo simulazione')->default(false),
Toggle::make('force_primary')->label('Marca come centro principale')->default(true),
])
->action(function (array $data): void {
$adminCode = $this->resolveFornitoreAdminCode();
if ($adminCode === null) {
Notification::make()->title('Amministratore fornitore non trovato')->danger()->send();
return;
}
$params = [
'amministratore' => $adminCode,
'--mdb' => (string) ($data['mdb_path'] ?? ''),
'--fornitore-id' => (int) $this->fornitore->id,
];
$limit = max(0, (int) ($data['limit'] ?? 0));
if ($limit > 0) {
$params['--limit'] = $limit;
}
if ((bool) ($data['dry_run'] ?? false)) {
$params['--dry-run'] = true;
}
if ((bool) ($data['force_primary'] ?? true)) {
$params['--force-primary'] = true;
}
try {
Artisan::call('tecnorepair:import-legacy', $params);
} catch (\Throwable $e) {
Notification::make()->title('Import TecnoRepair fallito')->body($e->getMessage())->danger()->send();
return;
}
$this->refreshOperationalBoxes();
Notification::make()
->title('Import TecnoRepair completato')
->body(trim(Artisan::output()) ?: 'Operazione completata.')
->success()
->send();
}),
Action::make('impostazioni_fe')
->label('Impostazioni FE')
->icon('heroicon-o-adjustments-horizontal')
->visible(fn(): bool => Schema::hasColumn('fornitori', 'escludi_righe_fe'))
->form([
Toggle::make('escludi_righe_fe')
->label('Escludi righe FE (import e visualizzazione)')
->helperText('Utile per utenze (acqua/luce/gas): si userà il PDF per i dati di consumo invece delle righe XML.')
->default((bool) ($this->fornitore->escludi_righe_fe ?? false)),
Select::make('conto_costo_default_id')
->label('Voce spesa predefinita (sottoconto costo)')
->helperText('Valore suggerito per imputare i costi quando le righe FE non hanno sottoconto o sono assenti.')
->options(function (): array {
if (! Schema::hasTable('contabilita_piano_conti')) {
return [];
}
return PianoConti::query()
->orderBy('codice')
->limit(1000)
->get(['id', 'codice', 'descrizione'])
->mapWithKeys(fn(PianoConti $c) => [(string) $c->id => $c->codice . ' - ' . $c->descrizione])
->all();
})
->searchable()
->nullable()
->default(fn(): ?int => $this->box['fornitore_defaults']['conto_costo_default_id'] ? (int) $this->box['fornitore_defaults']['conto_costo_default_id'] : null),
Select::make('voce_spesa_default_id')
->label('Voce di spesa predefinita (ripartizione)')
->helperText('Collegamento “logico” alla voce (utile per consumi/statistiche/ripartizione; es. acqua).')
->options(function (): array {
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId || ! Schema::hasTable('voci_spesa')) {
return [];
}
return VoceSpesa::query()
->where('stabile_id', (int) $stabileId)
->orderBy('descrizione')
->limit(800)
->get(['id', 'codice', 'descrizione'])
->mapWithKeys(function (VoceSpesa $v) {
$label = trim((string) ($v->codice ?? ''));
if ($label !== '') {
$label .= ' — ';
}
$label .= (string) ($v->descrizione ?? ('Voce #' . $v->id));
return [(string) $v->id => $label];
})
->all();
})
->searchable()
->nullable()
->default(fn(): ?int => $this->box['fornitore_defaults']['voce_spesa_default_id'] ? (int) $this->box['fornitore_defaults']['voce_spesa_default_id'] : null),
])
->action(function (array $data): void {
if (! Schema::hasColumn('fornitori', 'escludi_righe_fe')) {
Notification::make()->title('Colonna mancante: escludi_righe_fe')->warning()->send();
return;
}
$this->fornitore->escludi_righe_fe = (bool) ($data['escludi_righe_fe'] ?? false);
$this->fornitore->save();
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
return;
}
if (! Schema::hasTable('fornitore_stabile_impostazioni')) {
return;
}
$voceSpesaId = isset($data['voce_spesa_default_id']) && is_numeric($data['voce_spesa_default_id']) ? (int) $data['voce_spesa_default_id'] : null;
$contoCostoId = isset($data['conto_costo_default_id']) && is_numeric($data['conto_costo_default_id']) ? (int) $data['conto_costo_default_id'] : null;
FornitoreStabileImpostazione::query()->updateOrCreate(
[
'stabile_id' => (int) $stabileId,
'fornitore_id' => (int) $this->fornitore->id,
],
[
'voce_spesa_default_id' => $voceSpesaId ?: null,
'conto_costo_default_id' => $contoCostoId ?: null,
]
);
$this->hydrateBoxData($user);
})
->successNotificationTitle('Impostazioni FE salvate'),
Action::make('acqua_config')
->label('Configura acqua')
->icon('heroicon-o-beaker')
->form([
Toggle::make('acqua_enabled')
->label('Abilita acquisizione dati acqua (PDF)')
->default((bool) ((is_array($this->box['fornitore_features'] ?? null) ? ($this->box['fornitore_features']['acqua']['enabled'] ?? false) : false)))
->helperText('Attiva le funzioni acqua su questo fornitore (scan + letture/periodi).'),
Toggle::make('acqua_scan_enabled')
->label('Abilita scansione massiva su FE già importate')
->default((bool) ((is_array($this->box['fornitore_features'] ?? null) ? ($this->box['fornitore_features']['acqua']['scan_enabled'] ?? false) : false))),
Toggle::make('acqua_ticket_on_anomaly')
->label('Apri ticket su anomalie/mismatch')
->default(true)
->helperText('Se il parser non trova dati o cambia utenza/contatore, apre un ticket interno.'),
Repeater::make('utenze_acqua')
->label('Utenze acqua (stabile attivo)')
->helperText('Compila i campi per ogni contatore/utenza (lo stabile può averne più di uno).')
->default(function (): array {
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId || ! Schema::hasTable('stabile_servizi')) {
return [];
}
return StabileServizio::query()
->where('stabile_id', (int) $stabileId)
->where('tipo', 'acqua')
->orderByDesc('attivo')
->orderBy('nome')
->limit(50)
->get([
'id',
'nome',
'attivo',
'voce_spesa_id',
'contatore_matricola',
'codice_utenza',
'codice_cliente',
'codice_contratto',
])
->map(function (StabileServizio $s): array {
return [
'id' => (int) $s->id,
'nome' => (string) ($s->nome ?? ''),
'attivo' => (bool) $s->attivo,
'voce_spesa_id' => $s->voce_spesa_id ? (int) $s->voce_spesa_id : null,
'contatore_matricola' => (string) ($s->contatore_matricola ?? ''),
'codice_utenza' => (string) ($s->codice_utenza ?? ''),
'codice_cliente' => (string) ($s->codice_cliente ?? ''),
'codice_contratto' => (string) ($s->codice_contratto ?? ''),
];
})
->all();
})
->schema([
Hidden::make('id'),
TextInput::make('nome')
->label('Nome utenza')
->maxLength(255)
->nullable(),
Toggle::make('attivo')
->label('Attiva')
->default(true),
TextInput::make('contatore_matricola')
->label('Matricola contatore')
->maxLength(64)
->nullable(),
TextInput::make('codice_utenza')
->label('Codice utenza')
->maxLength(64)
->nullable(),
TextInput::make('codice_cliente')
->label('Codice cliente')
->maxLength(64)
->nullable(),
TextInput::make('codice_contratto')
->label('Codice contratto')
->maxLength(64)
->nullable(),
Select::make('voce_spesa_id')
->label('Voce di spesa (solo acqua)')
->options(function (): array {
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId || ! Schema::hasTable('voci_spesa')) {
return [];
}
return VoceSpesa::query()
->where('stabile_id', (int) $stabileId)
->where('categoria', 'acqua')
->orderBy('descrizione')
->limit(300)
->get(['id', 'codice', 'descrizione'])
->mapWithKeys(function (VoceSpesa $v) {
$label = trim((string) ($v->codice ?? ''));
if ($label !== '') {
$label .= ' — ';
}
$label .= (string) ($v->descrizione ?? ('Voce #' . $v->id));
return [(string) $v->id => $label];
})
->all();
})
->searchable()
->nullable(),
])
->addActionLabel('Aggiungi utenza acqua')
->reorderable(false)
->collapsed(false),
])
->action(function (array $data): void {
$user = Auth::user();
if (! $user instanceof User) {
Notification::make()->title('Utente non valido')->danger()->send();
return;
}
// Salva toggle feature (preferenza: colonna fornitori.fe_features, fallback: meta per-stabile)
$features = is_array($this->box['fornitore_features'] ?? null) ? $this->box['fornitore_features'] : [];
$features['acqua'] = array_merge(is_array($features['acqua'] ?? null) ? $features['acqua'] : [], [
'enabled' => (bool) ($data['acqua_enabled'] ?? false),
'scan_enabled' => (bool) ($data['acqua_scan_enabled'] ?? false),
'ticket_on_anomaly' => (bool) ($data['acqua_ticket_on_anomaly'] ?? true),
]);
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
Notification::make()->title('Seleziona uno stabile attivo')->warning()->send();
return;
}
if (Schema::hasColumn('fornitori', 'fe_features')) {
$this->fornitore->fe_features = $features;
$this->fornitore->save();
} else {
if (! Schema::hasTable('fornitore_stabile_impostazioni')) {
Notification::make()
->title('Impossibile salvare feature')
->body('Manca la colonna fornitori.fe_features e non esiste la tabella fornitore_stabile_impostazioni (eseguire migrazioni).')
->danger()
->send();
return;
}
$settings = FornitoreStabileImpostazione::query()->firstOrNew([
'stabile_id' => (int) $stabileId,
'fornitore_id' => (int) $this->fornitore->id,
]);
$meta = is_array($settings->meta ?? null) ? $settings->meta : [];
$meta['fe_features'] = $features;
$settings->meta = $meta;
$settings->save();
}
if (! Schema::hasTable('stabile_servizi')) {
Notification::make()->title('Tabella servizi non presente')->warning()->send();
return;
}
$utenze = is_array($data['utenze_acqua'] ?? null) ? $data['utenze_acqua'] : [];
foreach ($utenze as $row) {
$rowId = isset($row['id']) && is_numeric($row['id']) ? (int) $row['id'] : null;
$nome = is_string($row['nome'] ?? null) ? trim((string) $row['nome']) : '';
$attivo = (bool) ($row['attivo'] ?? true);
$voceSpesaId = isset($row['voce_spesa_id']) && is_numeric($row['voce_spesa_id']) ? (int) $row['voce_spesa_id'] : null;
$matricola = is_string($row['contatore_matricola'] ?? null) ? trim((string) $row['contatore_matricola']) : '';
$utenzaCod = is_string($row['codice_utenza'] ?? null) ? trim((string) $row['codice_utenza']) : '';
$cliente = is_string($row['codice_cliente'] ?? null) ? trim((string) $row['codice_cliente']) : '';
$contratto = is_string($row['codice_contratto'] ?? null) ? trim((string) $row['codice_contratto']) : '';
$hasAny = ($nome !== '') || ($matricola !== '') || ($utenzaCod !== '') || ($cliente !== '') || ($contratto !== '') || ($voceSpesaId && $voceSpesaId > 0);
if (! $hasAny) {
continue;
}
$servizio = null;
if ($rowId) {
$servizio = StabileServizio::query()
->where('stabile_id', (int) $stabileId)
->where('tipo', 'acqua')
->find($rowId);
}
if (! $servizio) {
$autoName = 'Acqua';
if ($matricola !== '') {
$autoName .= ' - Contatore ' . $matricola;
} elseif ($utenzaCod !== '') {
$autoName .= ' - Utenza ' . $utenzaCod;
}
$servizio = new StabileServizio([
'stabile_id' => (int) $stabileId,
'tipo' => 'acqua',
'nome' => $nome !== '' ? $nome : $autoName,
'attivo' => true,
]);
}
$servizio->fornitore_id = (int) $this->fornitore->id;
$servizio->attivo = $attivo;
if ($nome !== '') {
$servizio->nome = $nome;
}
$servizio->voce_spesa_id = $voceSpesaId ?: null;
$servizio->contatore_matricola = $matricola !== '' ? $matricola : null;
$servizio->codice_utenza = $utenzaCod !== '' ? $utenzaCod : null;
$servizio->codice_cliente = $cliente !== '' ? $cliente : null;
$servizio->codice_contratto = $contratto !== '' ? $contratto : null;
$servizio->save();
}
$this->hydrateBoxData($user);
Notification::make()->title('Configurazione acqua salvata')->success()->send();
}),
Action::make('acqua_scan')
->label('Scansiona FE acqua')
->icon('heroicon-o-magnifying-glass')
->requiresConfirmation()
->form([
Toggle::make('solo_stabile_attivo')
->label('Solo stabile attivo')
->default(true),
Toggle::make('solo_non_estratte')
->label('Solo FE non ancora estratte (acqua)')
->default(true)
->helperText('Processa solo fatture dove non risulta già salvato consumo acqua.'),
Select::make('voce_spesa_id')
->label('Voce di spesa (opzionale)')
->options(function (): array {
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId || ! Schema::hasTable('voci_spesa')) {
return [];
}
return VoceSpesa::query()
->where('stabile_id', (int) $stabileId)
->orderBy('descrizione')
->where('categoria', 'acqua')
->limit(300)
->get(['id', 'codice', 'descrizione'])
->mapWithKeys(function (VoceSpesa $v) {
$label = trim((string) ($v->codice ?? ''));
if ($label !== '') {
$label .= ' — ';
}
$label .= (string) ($v->descrizione ?? ('Voce #' . $v->id));
return [(string) $v->id => $label];
})
->all();
})
->searchable()
->nullable(),
Select::make('force_servizio_id')
->label('Forza servizio acqua (opzionale)')
->helperText('Se impostato, blocca laggancio se il PDF indica un contatore/utenza diversa (apre ticket).')
->options(function (): array {
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId || ! Schema::hasTable('stabile_servizi')) {
return [];
}
return StabileServizio::query()
->where('stabile_id', (int) $stabileId)
->where('tipo', 'acqua')
->orderByDesc('attivo')
->orderBy('nome')
->limit(200)
->get(['id', 'nome', 'contatore_matricola', 'codice_utenza'])
->mapWithKeys(function (StabileServizio $s) {
$label = (string) ($s->nome ?: ('Servizio #' . $s->id));
$bits = [];
if (is_string($s->contatore_matricola) && $s->contatore_matricola !== '') {
$bits[] = 'Contatore ' . $s->contatore_matricola;
}
if (is_string($s->codice_utenza) && $s->codice_utenza !== '') {
$bits[] = 'Utenza ' . $s->codice_utenza;
}
if (count($bits) > 0) {
$label .= ' — ' . implode(' · ', $bits);
}
return [(string) $s->id => $label];
})
->all();
})
->searchable()
->nullable(),
TextInput::make('limit')
->label('Limite FE da processare')
->numeric()
->default(50)
->minValue(1)
->maxValue(500),
Toggle::make('crea_ticket')
->label('Crea ticket su anomalie')
->default(true),
])
->action(function (array $data): void {
$user = Auth::user();
if (! $user instanceof User) {
Notification::make()->title('Utente non valido')->danger()->send();
return;
}
$features = is_array($this->box['fornitore_features'] ?? null) ? $this->box['fornitore_features'] : [];
$acqua = is_array($features['acqua'] ?? null) ? $features['acqua'] : [];
if (! (bool) ($acqua['enabled'] ?? false)) {
Notification::make()->title('Acqua non attiva su questo fornitore')->warning()->send();
return;
}
$soloStabileAttivo = (bool) ($data['solo_stabile_attivo'] ?? true);
$soloNonEstratte = (bool) ($data['solo_non_estratte'] ?? true);
$activeStabileId = StabileContext::resolveActiveStabileId($user);
if ($soloStabileAttivo && ! $activeStabileId) {
Notification::make()->title('Seleziona uno stabile attivo')->warning()->send();
return;
}
$stabileIds = [];
if ($soloStabileAttivo) {
$stabileIds = [(int) $activeStabileId];
} else {
$stabileIds = Stabile::query()
->where('amministratore_id', (int) $this->fornitore->amministratore_id)
->pluck('id')
->map(fn($v) => (int) $v)
->all();
}
if (count($stabileIds) < 1) {
Notification::make()->title('Nessuno stabile trovato')->warning()->send();
return;
}
$limit = isset($data['limit']) && is_numeric($data['limit']) ? (int) $data['limit'] : 50;
$limit = max(1, min(500, $limit));
$voceSpesaId = isset($data['voce_spesa_id']) && is_numeric($data['voce_spesa_id']) ? (int) $data['voce_spesa_id'] : null;
$forceServizioId = isset($data['force_servizio_id']) && is_numeric($data['force_servizio_id']) ? (int) $data['force_servizio_id'] : null;
$creaTicket = (bool) ($data['crea_ticket'] ?? true);
$normalize = static function (?string $value): string {
$value = strtoupper(trim((string) $value));
$value = str_replace(' ', '', $value);
return $value;
};
$ids = [];
$piva = $normalize($this->fornitore->partita_iva);
$cf = $normalize($this->fornitore->codice_fiscale);
if ($piva !== '') {
$ids[] = $piva;
if (str_starts_with($piva, 'IT') && strlen($piva) > 2) {
$ids[] = substr($piva, 2);
}
}
if ($cf !== '') {
$ids[] = $cf;
}
$ids = array_values(array_unique(array_filter($ids, fn($v) => $v !== '')));
$feQuery = FatturaElettronica::query()
->whereIn('stabile_id', $stabileIds)
->where(function (\Illuminate\Database\Eloquent\Builder $q) use ($ids): void {
$q->where('fornitore_id', (int) $this->fornitore->id);
if (! empty($ids)) {
$q->orWhere(function (\Illuminate\Database\Eloquent\Builder $qq) use ($ids): void {
foreach ($ids as $id) {
$qq->orWhereRaw("REPLACE(UPPER(fornitore_piva), ' ', '') = ?", [$id])
->orWhereRaw("REPLACE(UPPER(fornitore_cf), ' ', '') = ?", [$id]);
}
});
}
});
if ($soloNonEstratte) {
$feQuery->where(function (\Illuminate\Database\Eloquent\Builder $q): void {
$q->whereNull('consumo_raw')
->orWhere('consumo_raw', 'not like', '%"type":"acqua"%');
});
}
$feQuery
->orderByDesc('data_fattura')
->orderByDesc('id')
->limit($limit);
$fatture = $feQuery->get(['id', 'stabile_id', 'fornitore_id', 'sdi_file', 'totale', 'data_fattura', 'allegato_pdf_path', 'allegato_pdf_hash', 'xml_content', 'consumo_raw']);
$processed = 0;
$ok = 0;
$skipped = 0;
$tickets = 0;
foreach ($fatture as $fe) {
$processed++;
// Ensure Documento (best-effort)
try {
if ((int) $user->id > 0) {
app(FatturaElettronicaProtocolloService::class)->ensureDocumentoProtocollato($fe, (int) $user->id);
$fe->refresh();
}
} catch (\Throwable) {
// ignore
}
$doc = Documento::query()
->where('documentable_type', FatturaElettronica::class)
->where('documentable_id', (int) $fe->id)
->first();
if (! $doc) {
$skipped++;
if ($creaTicket) {
try {
$ing = app(ConsumiAcquaIngestionService::class)->ingest(
$fe,
['error' => 'Documento non presente', 'consumi' => []],
$voceSpesaId,
(int) $user->id,
true,
$forceServizioId
);
$tickets += (int) ($ing['tickets'] ?? 0);
} catch (\Throwable) {
// ignore
}
}
continue;
}
$text = is_string($doc->contenuto_ocr) ? trim($doc->contenuto_ocr) : '';
if ($text === '') {
$res = app(PdfTextExtractionService::class)->extractAndStore($doc, false);
if (($res['status'] ?? null) !== 'ok') {
$skipped++;
if ($creaTicket) {
try {
$ing = app(ConsumiAcquaIngestionService::class)->ingest(
$fe,
['error' => (string) ($res['message'] ?? 'OCR fallita'), 'consumi' => []],
$voceSpesaId,
(int) $user->id,
true,
$forceServizioId
);
$tickets += (int) ($ing['tickets'] ?? 0);
} catch (\Throwable) {
// ignore
}
}
continue;
}
$doc->refresh();
$text = is_string($doc->contenuto_ocr) ? trim($doc->contenuto_ocr) : '';
}
if ($text === '') {
$skipped++;
if ($creaTicket) {
try {
$ing = app(ConsumiAcquaIngestionService::class)->ingest(
$fe,
['error' => 'Testo OCR vuoto', 'consumi' => []],
$voceSpesaId,
(int) $user->id,
true,
$forceServizioId
);
$tickets += (int) ($ing['tickets'] ?? 0);
} catch (\Throwable) {
// ignore
}
}
continue;
}
$parsed = app(AcquaPdfTextParser::class)->parse($text);
$codici = is_array($parsed['codici'] ?? null) ? $parsed['codici'] : [];
$contatore = is_array($parsed['contatore'] ?? null) ? $parsed['contatore'] : [];
$consumi = is_array($parsed['consumi'] ?? null) ? $parsed['consumi'] : [];
$generale = is_array($parsed['generale'] ?? null) ? $parsed['generale'] : [];
$finestra = is_array($parsed['finestra_autolettura'] ?? null) ? $parsed['finestra_autolettura'] : [];
$letture = is_array($parsed['riepilogo_letture'] ?? null) ? $parsed['riepilogo_letture'] : [];
$quadro = is_array($parsed['quadro_dettaglio'] ?? null) ? $parsed['quadro_dettaglio'] : [];
$tariffe = is_array($parsed['tariffe'] ?? null) ? $parsed['tariffe'] : [];
$ivaInfo = is_array($parsed['iva'] ?? null) ? $parsed['iva'] : [];
$hasTariffData = count(array_filter([
$generale['periodicita_fatturazione'] ?? null,
$tariffe['profilo'] ?? null,
$ivaInfo['codice'] ?? null,
$quadro['quota_fissa'] ?? null,
], static fn($v): bool => $v !== null && $v !== '')) > 0;
$hasAny = false;
foreach ([$codici['utenza'] ?? null, $codici['cliente'] ?? null, $codici['contratto'] ?? null, $contatore['matricola'] ?? null] as $v) {
if (is_string($v) && trim($v) !== '') {
$hasAny = true;
break;
}
}
if (! $hasAny && count($consumi) < 1 && ! $hasTariffData) {
$skipped++;
if ($creaTicket) {
try {
$ing = app(ConsumiAcquaIngestionService::class)->ingest($fe, $parsed, $voceSpesaId, (int) $user->id, true, $forceServizioId);
$tickets += (int) ($ing['tickets'] ?? 0);
} catch (\Throwable) {
// ignore
}
}
continue;
}
// Salva anche su FE (consumo_raw + campi generici)
$payload = [
'type' => 'acqua',
'source' => 'pdf_ocr',
'codici' => [
'utenza' => $codici['utenza'] ?? null,
'cliente' => $codici['cliente'] ?? null,
'contratto' => $codici['contratto'] ?? null,
],
'contatore' => [
'matricola' => $contatore['matricola'] ?? null,
],
'consumi' => array_values($consumi),
'generale' => $generale,
'finestra_autolettura' => $finestra,
'riepilogo_letture' => array_values($letture),
'quadro_dettaglio' => $quadro,
'tariffe' => $tariffe,
'iva' => [
'codice' => $ivaInfo['codice'] ?? null,
'aliquota_percentuale' => $ivaInfo['aliquota_percentuale'] ?? null,
'descrizione' => $ivaInfo['descrizione'] ?? null,
],
'parsed_at' => now()->toISOString(),
];
$totMc = null;
$ref = null;
if (count($consumi) > 0) {
$sum = 0.0;
$has = false;
foreach ($consumi as $c) {
if (isset($c['valore']) && is_numeric($c['valore'])) {
$sum += (float) $c['valore'];
$has = true;
}
}
if ($has) {
$totMc = $sum;
}
$first = $consumi[0] ?? [];
$dal = $first['dal'] ?? null;
$al = $first['al'] ?? null;
if (is_string($dal) && is_string($al) && $dal !== '' && $al !== '') {
$ref = $dal . ' - ' . $al;
}
}
$fe->consumo_unita = 'mc';
$fe->consumo_valore = $totMc;
$fe->consumo_riferimento = $ref;
$fe->consumo_raw = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$fe->save();
try {
app(ConsumiAcquaTariffeIngestionService::class)->ingest($fe, $parsed, null);
} catch (\Throwable) {
// best-effort
}
$ing = app(ConsumiAcquaIngestionService::class)->ingest($fe, $parsed, $voceSpesaId, (int) $user->id, $creaTicket, $forceServizioId);
if (($ing['status'] ?? null) === 'ok') {
try {
app(ConsumiAcquaTariffeIngestionService::class)->ingest(
$fe,
$parsed,
isset($ing['servizio_id']) && is_numeric($ing['servizio_id']) ? (int) $ing['servizio_id'] : null
);
} catch (\Throwable) {
// best-effort
}
}
if (($ing['status'] ?? null) === 'ok') {
$ok++;
} elseif (($ing['status'] ?? null) === 'no-data' && $hasTariffData) {
$ok++;
} elseif (($ing['status'] ?? null) === 'mismatch') {
$tickets += (int) ($ing['tickets'] ?? 1);
$skipped++;
} elseif (($ing['status'] ?? null) === 'no-data') {
$tickets += (int) ($ing['tickets'] ?? 0);
$skipped++;
} else {
$skipped++;
}
}
$body = 'Processate: ' . $processed . ' · OK: ' . $ok . ' · Skipped: ' . $skipped;
if ($tickets > 0) {
$body .= ' · Ticket: ' . $tickets;
}
Notification::make()->title('Scansione acqua completata')->body($body)->success()->send();
}),
Action::make('torna')
->label('Torna')
->icon('heroicon-o-arrow-left')
->url(fn() => url()->previous()),
];
}
}