netgescon-day0/app/Filament/Pages/Fornitore/ProdottiCatalogo.php

650 lines
28 KiB
PHP
Executable File

<?php
namespace App\Filament\Pages\Fornitore;
use App\Filament\Pages\Contabilita\FatturaElettronicaScheda;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Models\Fornitore;
use App\Models\Product;
use App\Models\ProductIdentifier;
use App\Models\ProductOffer;
use App\Models\ProductSerial;
use App\Models\User;
use App\Modules\Contabilita\Models\FatturaFornitoreRiga;
use App\Services\Catalog\AmazonCreatorsApiService;
use App\Services\Catalog\AmazonCreatorsCatalogSyncService;
use App\Services\Catalog\FornitoreProductCatalogService;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use RuntimeException;
use UnitEnum;
class ProdottiCatalogo extends Page
{
use ResolvesOperatoreContext;
protected static ?string $navigationLabel = 'Prodotti';
protected static ?string $title = 'Catalogo prodotti fornitore';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-archive-box';
protected static UnitEnum|string|null $navigationGroup = 'Fornitore';
protected static ?int $navigationSort = 4;
protected static ?string $slug = 'fornitore/prodotti';
protected string $view = 'filament.pages.fornitore.prodotti-catalogo';
public ?int $fornitoreId = null;
public ?string $fornitoreLabel = null;
public bool $missingAdminContext = false;
public string $search = '';
public string $activeTab = 'panoramica';
public string $amazonQuery = '';
public int $feExtractionLimit = 500;
public bool $amazonConfigured = false;
/** @var array<int, array<string, mixed>> */
public array $amazonRows = [];
/** @var array<int, array<string, mixed>> */
public array $rows = [];
public ?int $activeStabileId = null;
public ?string $activeStabileLabel = null;
/** @var array<int, array<string, mixed>> */
public array $invoiceRows = [];
/** @var array<int, array<string, mixed>> */
public array $serialRows = [];
public ?int $selectedProductId = null;
/** @var array<string, mixed>|null */
public ?array $detailCard = null;
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore', 'fornitore']);
}
public function mount(): void
{
[$fornitore] = $this->resolveOperatoreContext(allowAdminWithoutSupplier: true);
if (! $fornitore instanceof Fornitore) {
$this->missingAdminContext = true;
return;
}
$this->fornitoreId = (int) $fornitore->id;
$this->fornitoreLabel = $this->getFornitoreLabel($fornitore);
$this->amazonConfigured = app(AmazonCreatorsApiService::class)->isConfigured();
$requestedTab = (string) request()->query('tab', 'panoramica');
if ($this->isValidTab($requestedTab)) {
$this->activeTab = $requestedTab;
}
$this->search = trim((string) request()->query('q', ''));
$this->selectedProductId = request()->integer('product');
$user = Auth::user();
if ($user instanceof User) {
$stabile = StabileContext::getActiveStabile($user);
$this->activeStabileId = $stabile ? (int) $stabile->id : null;
$this->activeStabileLabel = $stabile
? trim(implode(' - ', array_filter([
trim((string) ($stabile->codice_stabile ?? '')),
trim((string) ($stabile->denominazione ?? '')),
])))
: null;
}
$this->refreshRows();
if (($this->selectedProductId ?? 0) > 0) {
$this->openProductDetail((int) $this->selectedProductId);
}
}
public function updatedSearch(): void
{
$this->refreshRows();
}
public function setActiveTab(string $tab): void
{
if ($this->isValidTab($tab)) {
$this->activeTab = $tab;
}
}
public function refreshRows(): void
{
if (! $this->fornitoreId) {
$this->rows = [];
$this->serialRows = [];
return;
}
$fornitore = Fornitore::query()->find((int) $this->fornitoreId);
$supplierIds = $fornitore instanceof Fornitore ? $fornitore->catalogScopeSupplierIds() : [(int) $this->fornitoreId];
$term = trim($this->search);
$normalized = preg_replace('/[^A-Za-z0-9]+/', '', strtoupper($term)) ?? '';
$products = Product::query()
->with(['identifiers', 'offers'])
->withCount(['identifiers', 'offers', 'serials'])
->where('is_active', true)
->where(function ($query) use ($supplierIds): void {
$query->whereNull('default_fornitore_id')
->orWhereIn('default_fornitore_id', $supplierIds)
->orWhereHas('offers', fn($offerQuery) => $offerQuery->whereIn('fornitore_id', $supplierIds));
})
->when($term !== '', function ($query) use ($term, $normalized): void {
$query->where(function ($builder) use ($term, $normalized): void {
$builder->where('name', 'like', '%' . $term . '%')
->orWhere('brand', 'like', '%' . $term . '%')
->orWhere('model', 'like', '%' . $term . '%')
->orWhere('internal_code', 'like', '%' . $term . '%');
if ($normalized !== '') {
$builder->orWhereHas('identifiers', function ($identifierQuery) use ($normalized): void {
$identifierQuery->where('normalized_code', $normalized)
->orWhere('code_value', 'like', '%' . $normalized . '%');
});
}
});
})
->limit(120)
->get();
$this->rows = $products->map(function (Product $product) use ($supplierIds): array {
$identifier = $product->identifiers
->first(fn(ProductIdentifier $item): bool => in_array((int) ($item->fornitore_id ?? 0), $supplierIds, true)) ?? $product->identifiers->first();
$offer = $product->offers
->first(fn(ProductOffer $item): bool => in_array((int) ($item->fornitore_id ?? 0), $supplierIds, true)) ?? $product->offers->first();
return [
'id' => (int) $product->id,
'internal_code' => (string) ($product->internal_code ?? ''),
'label' => trim(implode(' ', array_filter([(string) $product->name, (string) ($product->brand ?? ''), (string) ($product->model ?? '')]))),
'barcode' => (string) ($identifier->code_value ?? ''),
'source' => (string) ($offer?->source_name ?: 'Catalogo interno'),
'price' => $offer?->price_amount !== null ? number_format((float) $offer->price_amount, 2, ',', '.') . ' ' . (string) ($offer->currency ?? 'EUR') : '-',
'external_url' => (string) ($offer?->referral_url ?: $offer?->external_url ?: $this->buildAmazonSearchUrl((string) $product->name)),
'serials_count' => (int) ($product->serials_count ?? 0),
'offers_count' => (int) ($product->offers_count ?? 0),
'identifiers_count' => (int) ($product->identifiers_count ?? 0),
];
})->all();
if (($this->selectedProductId ?? 0) > 0 && ! collect($this->rows)->contains(fn(array $row): bool => (int) ($row['id'] ?? 0) === (int) $this->selectedProductId)) {
$this->detailCard = null;
}
$this->refreshInvoiceRows($term, $normalized);
$this->refreshSerialRows($term, $normalized);
}
public function getFornitoreSchedaUrl(): ?string
{
if (! $this->fornitoreId) {
return null;
}
return \App\Filament\Pages\Gescon\FornitoreScheda::getUrl(['record' => (int) $this->fornitoreId], panel: 'admin-filament');
}
public function getSerialiUrl(): string
{
return SerialiCatalogo::getUrl([
'fornitore' => (int) ($this->fornitoreId ?? 0),
'q' => trim($this->search),
], panel: 'admin-filament');
}
public function getContabilitaFornitoriUrl(): string
{
return \App\Filament\Pages\Contabilita\FornitoriArchivio::getUrl(panel: 'admin-filament');
}
public function scanFattureElettronicheCatalogo(): void
{
if (! $this->fornitoreId || ! $this->activeStabileId) {
Notification::make()
->title('Contesto fornitore o stabile non disponibile')
->warning()
->send();
return;
}
$fornitore = Fornitore::query()->find((int) $this->fornitoreId);
if (! $fornitore instanceof Fornitore) {
Notification::make()->title('Fornitore non trovato')->danger()->send();
return;
}
$limit = max(1, min(5000, (int) $this->feExtractionLimit));
$stats = app(FornitoreProductCatalogService::class)
->extractFromFattureElettroniche($fornitore, (int) $this->activeStabileId, $limit);
$this->refreshRows();
$this->activeTab = 'seriali';
Notification::make()
->title('Scansione FE completata')
->body(
'Fatture: ' . (int) ($stats['fatture'] ?? 0)
. ' · righe: ' . (int) ($stats['lines'] ?? 0)
. ' · nuovi prodotti: ' . (int) ($stats['products'] ?? 0)
. ' · identificativi: ' . (int) ($stats['identifiers'] ?? 0)
. ' · righe agganciate: ' . (int) ($stats['linked_rows'] ?? 0)
. ' · saltate: ' . (int) ($stats['skipped'] ?? 0)
)
->success()
->send();
}
public function searchAmazonCatalog(): void
{
if (! $this->amazonConfigured) {
Notification::make()
->title('Amazon Creators API non configurata')
->body('Valorizza prima le variabili AMAZON_CREATORS_* in ambiente.')
->danger()
->send();
return;
}
$query = trim($this->amazonQuery);
if ($query === '') {
Notification::make()->title('Inserisci una query Amazon')->warning()->send();
return;
}
try {
$this->amazonRows = app(AmazonCreatorsCatalogSyncService::class)
->searchCatalog($query, 8);
} catch (RuntimeException $e) {
$this->amazonRows = [];
Notification::make()->title('Ricerca Amazon non riuscita')->body($e->getMessage())->danger()->send();
return;
}
if ($this->amazonRows === []) {
Notification::make()->title('Nessun risultato Amazon')->warning()->send();
}
}
public function saveAmazonCandidate(string $asin): void
{
$asin = trim($asin);
$row = collect($this->amazonRows)->first(fn(array $item): bool => (string) ($item['asin'] ?? '') === $asin);
if (! is_array($row) || ! is_array($row['item_payload'] ?? null)) {
Notification::make()->title('Risultato Amazon non disponibile')->danger()->send();
return;
}
$fornitore = $this->resolveCurrentFornitore();
if (! $fornitore instanceof Fornitore) {
Notification::make()->title('Fornitore non trovato')->danger()->send();
return;
}
try {
$result = app(AmazonCreatorsCatalogSyncService::class)
->importItemForSupplier($fornitore, $row['item_payload'], [
'source' => 'amazon_catalog_search_ui',
'search_query' => trim($this->amazonQuery),
]);
} catch (RuntimeException $e) {
Notification::make()->title('Import Amazon non riuscito')->body($e->getMessage())->danger()->send();
return;
}
$product = $result['product'] ?? null;
Notification::make()
->title(! empty($result['created']) ? 'Prodotto Amazon memorizzato' : 'Prodotto Amazon aggiornato')
->body($product instanceof Product
? 'Articolo salvato nel catalogo come candidato da verificare: ' . ((string) ($product->internal_code ?? '') !== '' ? $product->internal_code . ' · ' : '') . (string) ($product->name ?? '')
: 'Import completato.')
->success()
->send();
$this->refreshRows();
$this->searchAmazonCatalog();
}
public function saveAllAmazonCandidates(): void
{
if ($this->amazonRows === []) {
Notification::make()->title('Nessun risultato Amazon da salvare')->warning()->send();
return;
}
$fornitore = $this->resolveCurrentFornitore();
if (! $fornitore instanceof Fornitore) {
Notification::make()->title('Fornitore non trovato')->danger()->send();
return;
}
$result = app(AmazonCreatorsCatalogSyncService::class)->importSearchResultsForSupplier($fornitore, $this->amazonRows, [
'source' => 'amazon_catalog_search_bulk_ui',
'search_query' => trim($this->amazonQuery),
]);
$body = 'Nuovi: ' . (int) ($result['created'] ?? 0)
. ' · aggiornati: ' . (int) ($result['updated'] ?? 0)
. ' · saltati: ' . (int) ($result['skipped'] ?? 0);
$errors = array_values(array_filter(array_map(static fn(mixed $value): string => trim((string) $value), (array) ($result['errors'] ?? []))));
if ($errors !== []) {
$body .= ' · errori: ' . count($errors) . ' (' . Str::limit(implode(' | ', $errors), 180) . ')';
}
Notification::make()
->title('Import Amazon completato')
->body($body)
->success()
->send();
$this->refreshRows();
$this->searchAmazonCatalog();
}
public function getLavorazioniUrl(): string
{
return LavorazioniOperative::getUrl(['fornitore' => (int) $this->fornitoreId], panel: 'admin-filament');
}
public function openProductDetail(int $productId): void
{
$scopeSupplierIds = [(int) $this->fornitoreId];
if ($this->fornitoreId) {
$fornitore = Fornitore::query()->find((int) $this->fornitoreId);
if ($fornitore instanceof Fornitore) {
$scopeSupplierIds = $fornitore->catalogScopeSupplierIds();
}
}
$product = Product::query()
->with([
'identifiers.fornitore' => fn($query) => $query->orderBy('ragione_sociale')->orderBy('nome')->orderBy('cognome'),
'identifiers' => fn($query) => $query->orderBy('code_type')->orderBy('code_value'),
'offers.fornitore' => fn($query) => $query->orderBy('ragione_sociale')->orderBy('nome')->orderBy('cognome'),
'offers' => fn($query) => $query->orderByDesc('last_seen_at')->orderByDesc('id'),
'serials' => fn($query) => $query->orderByDesc('purchase_date')->orderByDesc('id')->limit(8),
])
->find($productId);
if (! $product instanceof Product) {
$this->selectedProductId = null;
$this->detailCard = null;
return;
}
$identifiers = $product->identifiers
->filter(fn(ProductIdentifier $identifier): bool => (int) ($identifier->fornitore_id ?? 0) === 0 || in_array((int) ($identifier->fornitore_id ?? 0), $scopeSupplierIds, true))
->values();
$offers = $product->offers
->filter(fn(ProductOffer $offer): bool => (int) ($offer->fornitore_id ?? 0) === 0 || in_array((int) ($offer->fornitore_id ?? 0), $scopeSupplierIds, true))
->values();
$linkedSuppliers = collect()
->merge($identifiers->pluck('fornitore'))
->merge($offers->pluck('fornitore'))
->filter(fn(mixed $supplier): bool => $supplier instanceof Fornitore)
->unique(fn(Fornitore $supplier): int => (int) $supplier->id)
->map(fn(Fornitore $supplier): array=> [
'id' => (int) $supplier->id,
'label' => $this->getFornitoreLabel($supplier),
])
->values()
->all();
$this->selectedProductId = (int) $product->id;
$this->detailCard = [
'id' => (int) $product->id,
'internal_code' => (string) ($product->internal_code ?? ''),
'name' => (string) ($product->name ?? ''),
'brand' => (string) ($product->brand ?? ''),
'model' => (string) ($product->model ?? ''),
'description' => (string) ($product->description ?? ''),
'track_serials' => (bool) ($product->track_serials ?? false),
'linked_suppliers' => $linkedSuppliers,
'scope_supplier_ids' => $scopeSupplierIds,
'identifiers' => $identifiers->map(fn(ProductIdentifier $identifier): array=> [
'type' => (string) ($identifier->code_type ?? ''),
'role' => (string) ($identifier->code_role ?? ''),
'value' => (string) ($identifier->code_value ?? ''),
'supplier' => $identifier->fornitore instanceof Fornitore ? $this->getFornitoreLabel($identifier->fornitore) : '',
])->all(),
'offers' => $offers->map(fn(ProductOffer $offer): array=> [
'source' => (string) ($offer->source_name ?? 'Catalogo interno'),
'title' => (string) ($offer->title ?? ''),
'price' => $offer->price_amount !== null ? number_format((float) $offer->price_amount, 2, ',', '.') . ' ' . (string) ($offer->currency ?? 'EUR') : '-',
'availability' => (string) ($offer->availability ?? ''),
'url' => (string) ($offer->referral_url ?: $offer->external_url ?: ''),
'supplier' => $offer->fornitore instanceof Fornitore ? $this->getFornitoreLabel($offer->fornitore) : '',
])->all(),
'serials' => $product->serials->map(fn(ProductSerial $serial): array=> [
'serial' => (string) ($serial->serial_number ?? $serial->serial_number_2 ?? ''),
'invoice' => (string) ($serial->purchase_invoice_ref ?? ''),
'date' => $serial->purchase_date?->format('d/m/Y') ?: '',
'price' => $serial->purchase_price !== null ? number_format((float) $serial->purchase_price, 2, ',', '.') . ' ' . (string) ($serial->purchase_currency ?? 'EUR') : '-',
])->all(),
];
}
public function getRubricaUrl(): string
{
return RubricaClienti::getUrl(['fornitore' => (int) $this->fornitoreId], panel: 'admin-filament');
}
protected function buildAmazonSearchUrl(string $term): string
{
$query = ['k' => trim($term)];
$tag = trim((string) config('services.amazon.referral_tag', ''));
if ($tag !== '') {
$query['tag'] = $tag;
}
return 'https://www.amazon.it/s?' . http_build_query($query);
}
private function refreshInvoiceRows(string $term, string $normalized): void
{
$this->invoiceRows = [];
if (
$term === ''
|| ! $this->activeStabileId
|| ! Schema::hasTable('contabilita_fatture_fornitori_righe')
|| ! Schema::hasTable('contabilita_fatture_fornitori')
) {
return;
}
$righe = FatturaFornitoreRiga::query()
->with([
'fattura:id,stabile_id,fornitore_id,fattura_elettronica_id,data_documento,numero_documento',
'fattura.fornitore:id,ragione_sociale,nome,cognome',
'product:id,internal_code,name',
])
->whereHas('fattura', fn(Builder $query) => $query->where('stabile_id', (int) $this->activeStabileId))
->where(function (Builder $query) use ($term, $normalized): void {
$query->where('descrizione', 'like', '%' . $term . '%');
if ($normalized !== '') {
$query->orWhereHas('product', function (Builder $productQuery) use ($term, $normalized): void {
$productQuery->where('internal_code', 'like', '%' . $term . '%')
->orWhere('name', 'like', '%' . $term . '%')
->orWhereHas('identifiers', function (Builder $identifierQuery) use ($normalized): void {
$identifierQuery->where('normalized_code', $normalized)
->orWhere('code_value', 'like', '%' . $normalized . '%');
});
});
}
})
->orderByDesc('id')
->limit(24)
->get();
$this->invoiceRows = $righe->map(function (FatturaFornitoreRiga $riga): array {
$fattura = $riga->fattura;
$fornitore = $fattura?->fornitore;
$fornitoreLabel = trim((string) ($fornitore?->ragione_sociale ?? ''));
if ($fornitoreLabel === '') {
$fornitoreLabel = trim(implode(' ', array_filter([
trim((string) ($fornitore?->nome ?? '')),
trim((string) ($fornitore?->cognome ?? '')),
])));
}
return [
'fattura_id' => (int) ($fattura?->id ?? 0),
'fattura_elettronica_id' => (int) ($fattura?->fattura_elettronica_id ?? 0),
'fornitore' => $fornitoreLabel !== '' ? $fornitoreLabel : 'Fornitore non associato',
'numero_documento' => trim((string) ($fattura?->numero_documento ?? '')),
'data_documento' => $fattura?->data_documento?->format('d/m/Y') ?: '',
'descrizione' => trim((string) ($riga->descrizione ?? '')),
'totale_euro' => is_numeric($riga->totale_euro) ? (float) $riga->totale_euro : null,
'product_id' => (int) ($riga->product_id ?? 0),
'product_label' => trim(implode(' · ', array_filter([
trim((string) ($riga->product?->internal_code ?? '')),
trim((string) ($riga->product?->name ?? '')),
]))),
'fe_url' => ($fattura?->fattura_elettronica_id ?? null)
? FatturaElettronicaScheda::getUrl(['record' => (int) $fattura->fattura_elettronica_id], panel : 'admin-filament')
: null,
];
})->all();
}
private function refreshSerialRows(string $term, string $normalized): void
{
if (! $this->fornitoreId) {
$this->serialRows = [];
return;
}
$rows = ProductSerial::query()
->with(['product:id,internal_code,name,brand,model'])
->where('fornitore_id', (int) $this->fornitoreId)
->when($term !== '', function ($query) use ($term, $normalized): void {
$query->where(function ($builder) use ($term, $normalized): void {
$builder->where('serial_number', 'like', '%' . $term . '%')
->orWhere('serial_number_2', 'like', '%' . $term . '%')
->orWhere('product_model', 'like', '%' . $term . '%')
->orWhere('product_code', 'like', '%' . $term . '%')
->orWhere('purchase_invoice_ref', 'like', '%' . $term . '%');
if ($normalized !== '') {
$builder->orWhereHas('product', function (Builder $productQuery) use ($term, $normalized): void {
$productQuery->where('internal_code', 'like', '%' . $term . '%')
->orWhere('name', 'like', '%' . $term . '%')
->orWhereHas('identifiers', function (Builder $identifierQuery) use ($normalized): void {
$identifierQuery->where('normalized_code', $normalized)
->orWhere('code_value', 'like', '%' . $normalized . '%');
});
});
}
});
})
->orderByDesc('purchase_date')
->orderByDesc('id')
->limit(40)
->get();
$this->serialRows = $rows->map(function (ProductSerial $serial): array {
return [
'id' => (int) $serial->id,
'serial_number' => (string) ($serial->serial_number ?? ''),
'serial_number_2' => (string) ($serial->serial_number_2 ?? ''),
'product_label' => trim(implode(' · ', array_filter([
trim((string) ($serial->product?->internal_code ?? '')),
trim((string) ($serial->product?->name ?? '')),
trim((string) ($serial->product_model ?? '')),
]))),
'purchase_invoice_ref' => (string) ($serial->purchase_invoice_ref ?? ''),
'purchase_date' => $serial->purchase_date?->format('d/m/Y') ?: '',
'purchase_price' => $serial->purchase_price !== null ? (float) $serial->purchase_price : null,
'purchase_currency' => (string) ($serial->purchase_currency ?? 'EUR'),
'source' => (string) ($serial->source ?? ''),
];
})->all();
}
private function isValidTab(string $tab): bool
{
return in_array($tab, ['panoramica', 'catalogo', 'seriali', 'acquisti'], true);
}
private function resolveCurrentFornitore(): ?Fornitore
{
return $this->fornitoreId ? Fornitore::query()->find((int) $this->fornitoreId) : null;
}
/** @return array<int, array<string, mixed>> */
public function getAmazonSetupChecklistProperty(): array
{
$items = [
['key' => 'AMAZON_CREATORS_ENABLED', 'required' => true, 'value' => (bool) config('services.amazon.creators_enabled', false) ? 'true' : 'false'],
['key' => 'AMAZON_CREATORS_API_BASE_URL', 'required' => true, 'value' => (string) config('services.amazon.creators_api_base_url', '')],
['key' => 'AMAZON_CREATORS_TOKEN_URL', 'required' => true, 'value' => (string) config('services.amazon.creators_token_url', '')],
['key' => 'AMAZON_CREATORS_CREDENTIAL_ID', 'required' => true, 'value' => (string) config('services.amazon.creators_credential_id', '')],
['key' => 'AMAZON_CREATORS_CREDENTIAL_SECRET', 'required' => true, 'value' => (string) config('services.amazon.creators_credential_secret', '')],
['key' => 'AMAZON_CREATORS_SEARCH_PATH', 'required' => true, 'value' => (string) data_get(config('services.amazon.creators_paths', []), 'searchItems', '')],
['key' => 'AMAZON_CREATORS_GET_ITEMS_PATH', 'required' => true, 'value' => (string) data_get(config('services.amazon.creators_paths', []), 'getItems', '')],
['key' => 'AMAZON_MARKETPLACE', 'required' => false, 'value' => (string) config('services.amazon.marketplace', 'www.amazon.it')],
['key' => 'AMAZON_REFERRAL_TAG', 'required' => false, 'value' => (string) config('services.amazon.referral_tag', '')],
];
return array_map(static function (array $item): array {
$rawValue = trim((string) ($item['value'] ?? ''));
$isBoolean = ($item['key'] ?? '') === 'AMAZON_CREATORS_ENABLED';
$configured = $isBoolean ? $rawValue === 'true' : $rawValue !== '';
return [
'key' => (string) ($item['key'] ?? ''),
'required' => (bool) ($item['required'] ?? false),
'configured' => $configured,
'display' => $rawValue !== '' ? Str::limit($rawValue, 54) : 'non valorizzata',
];
}, $items);
}
}