2006 lines
95 KiB
PHP
2006 lines
95 KiB
PHP
<?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\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;
|
||
|
||
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 $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();
|
||
}
|
||
|
||
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 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')
|
||
->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,
|
||
'user_id' => $userId,
|
||
'user_label' => $userId > 0 ? ((string) ($d->user?->name ?: ('Utente #' . $userId))): '-',
|
||
];
|
||
})
|
||
->all();
|
||
}
|
||
|
||
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<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 === '' || mb_strlen($clean) < 3) {
|
||
return null;
|
||
}
|
||
|
||
$map = [
|
||
'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 = [];
|
||
|
||
$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 ($ids): void {
|
||
$q->where('fornitore_id', $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]);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
$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);
|
||
|
||
// 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)];
|
||
}
|
||
}
|
||
|
||
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()),
|
||
];
|
||
}
|
||
}
|