*/ public array $box = []; /** @var array> */ public array $anteprimaAde = []; /** @var array> */ public array $anteprimaFe = []; /** @var array> */ public array $anteprimaContabilita = []; /** @var array> */ public array $fattureAssociate = []; /** @var array> */ public array $raVersateRows = []; /** @var array> */ public array $catalogoProdottiRows = []; /** @var array> */ public array $tecnorepairRows = []; public string $tagsInput = ''; /** @var array */ public array $tagSuggestions = []; /** @var array> */ 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> */ 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) => (string) ($offer->source_type ?? '') === 'amazon_referral'); 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 */ 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 */ 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)->where('source_type', 'amazon_referral')->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 */ 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 $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 l’aggancio 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()), ]; } }