diff --git a/app/Console/Commands/SyncAmazonCreatorsCatalogCommand.php b/app/Console/Commands/SyncAmazonCreatorsCatalogCommand.php new file mode 100644 index 0000000..a893908 --- /dev/null +++ b/app/Console/Commands/SyncAmazonCreatorsCatalogCommand.php @@ -0,0 +1,119 @@ +resolveProduct((string) $this->argument('product')); + if (! $product instanceof Product) { + $this->error('Prodotto non trovato.'); + + return self::FAILURE; + } + + try { + $options = [ + 'asin' => trim((string) $this->option('asin')), + 'query' => trim((string) $this->option('query')), + 'marketplace' => trim((string) $this->option('marketplace')), + 'dry_run' => (bool) $this->option('dry-run'), + 'source' => 'api', + ]; + + $jsonPath = trim((string) $this->option('json')); + if ($jsonPath !== '') { + $options['response'] = $this->readJsonFile($jsonPath); + $options['source'] = 'json'; + } + + $result = $syncService->syncProduct($product, $options); + } catch (\Throwable $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + $this->table( + ['Campo', 'Valore'], + [ + ['Prodotto', (string) ($product->internal_code ?: ('#' . $product->id)) . ' · ' . (string) ($product->name ?? '')], + ['Fonte', (string) ($result['source'] ?? 'api')], + ['Dry run', ! empty($result['dry_run']) ? 'si' : 'no'], + ['ASIN', (string) ($result['asin'] ?? '')], + ['EAN', (string) ($result['ean'] ?? '')], + ['Titolo Amazon', (string) ($result['title'] ?? '')], + ['Prezzo', $this->formatMoney($result['price_amount'] ?? null, (string) ($result['currency'] ?? 'EUR'))], + ['Prezzo listino', $this->formatMoney($result['price_compare_at'] ?? null, (string) ($result['currency'] ?? 'EUR'))], + ['Disponibilita', (string) ($result['availability'] ?? '')], + ['Detail URL', (string) ($result['detail_url'] ?? '')], + ['Referral URL', (string) ($result['referral_url'] ?? '')], + ['Immagine', (string) ($result['image_url'] ?? '')], + ['Codici creati', (string) ($result['identifiers_created'] ?? 0)], + ['Media creati', (string) ($result['media_created'] ?? 0)], + ['Offer ID', (string) ($result['offer_id'] ?? '')], + ] + ); + + return self::SUCCESS; + } + + private function resolveProduct(string $value): ?Product + { + $value = trim($value); + if ($value === '') { + return null; + } + + $query = Product::query(); + + if (is_numeric($value)) { + $product = (clone $query)->find((int) $value); + if ($product instanceof Product) { + return $product; + } + } + + return (clone $query) + ->where('internal_code', $value) + ->orWhere('canonical_key', $value) + ->first(); + } + + /** @return array */ + private function readJsonFile(string $path): array + { + if (! is_file($path)) { + throw new RuntimeException('File JSON non trovato: ' . $path); + } + + $decoded = json_decode((string) file_get_contents($path), true); + if (! is_array($decoded)) { + throw new RuntimeException('Il file JSON non contiene un payload valido.'); + } + + return $decoded; + } + + private function formatMoney(mixed $amount, string $currency): string + { + return is_numeric($amount) + ? number_format((float) $amount, 2, ',', '.') . ' ' . strtoupper(trim($currency) !== '' ? $currency : 'EUR') + : '-'; + } +} \ No newline at end of file diff --git a/app/Filament/Pages/Gescon/FornitoreScheda.php b/app/Filament/Pages/Gescon/FornitoreScheda.php index ea6cc0b..430664e 100644 --- a/app/Filament/Pages/Gescon/FornitoreScheda.php +++ b/app/Filament/Pages/Gescon/FornitoreScheda.php @@ -612,7 +612,7 @@ private function refreshCatalogRows(): void ->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'); + $amazonOffer = $offers->first(fn($offer) => in_array((string) ($offer->source_type ?? ''), ['amazon_referral', 'amazon_creators_api'], true)); return [ 'id' => (int) $product->id, @@ -886,7 +886,7 @@ private function hydrateBoxData(User $user): void 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(); + $this->box['catalogo_modulo']['amazon_links'] = (int) (clone $offersBase)->whereIn('source_type', ['amazon_referral', 'amazon_creators_api'])->count(); } } diff --git a/app/Filament/Pages/Strumenti/DocumentiArchivio.php b/app/Filament/Pages/Strumenti/DocumentiArchivio.php index 8439d9f..f04a2fe 100644 --- a/app/Filament/Pages/Strumenti/DocumentiArchivio.php +++ b/app/Filament/Pages/Strumenti/DocumentiArchivio.php @@ -23,6 +23,7 @@ use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; use Filament\Pages\Page; +use Filament\Support\Enums\Width; use Filament\Tables\Columns\BadgeColumn; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; @@ -31,7 +32,9 @@ use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Livewire\Attributes\Url; @@ -43,6 +46,8 @@ class DocumentiArchivio extends Page implements HasTable use InteractsWithTable; use WithFileUploads; + protected Width|string|null $maxContentWidth = 'full'; + private const ARCHIVE_HUB_SETTINGS_KEY = 'documenti.archivio_hub.settings'; #[Url( as : 'tab')] @@ -63,6 +68,8 @@ class DocumentiArchivio extends Page implements HasTable public array $genericLabelForm = []; + public string $genericLabelTemplateLabel = ''; + public array $physicalArchiveForm = []; public array $physicalArchiveMoveForm = []; @@ -73,9 +80,16 @@ class DocumentiArchivio extends Page implements HasTable public string $physicalArchiveTypeFilter = ''; - protected static ?string $navigationLabel = 'Documenti (PDF)'; + #[Url( as : 'code')] + public string $archiveLookupCode = ''; - protected static ?string $title = 'Documenti (PDF)'; + public string $archiveLookupSearch = ''; + + public ?int $archiveLookupSelectedId = null; + + protected static ?string $navigationLabel = 'Documenti'; + + protected static ?string $title = 'Documenti'; protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-document-text'; @@ -104,6 +118,11 @@ public function mount(): void $this->initializeScannerAcquisitionForm(); $this->initializeGenericLabelForm(); $this->initializeDriveTemplateForm(); + + if (trim($this->archiveLookupCode) !== '') { + $this->applyArchiveLookupInput($this->archiveLookupCode); + } + $this->mountInteractsWithTable(); } @@ -122,6 +141,47 @@ public function selectHubTab(string $tab): void if ($this->hubTab === 'template-drive') { $this->initializeDriveTemplateForm(); } + + if ($this->hubTab === 'lettore-codici' && trim($this->archiveLookupCode) !== '') { + $this->applyArchiveLookupInput($this->archiveLookupCode); + } + } + + public function updatedArchiveLookupCode(string $value): void + { + $value = trim($value); + + if ($value === '') { + $this->archiveLookupSelectedId = null; + + return; + } + + $this->applyArchiveLookupInput($value); + } + + public function openArchiveLookupFromCode(): void + { + $this->applyArchiveLookupInput($this->archiveLookupCode); + } + + public function selectArchiveLookupItem(int $itemId): void + { + $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); + + $item = DocumentoArchivioFisicoItem::query() + ->when($stabileId > 0, fn(Builder $query) => $query->where('stabile_id', $stabileId)) + ->find($itemId); + + if (! $item instanceof DocumentoArchivioFisicoItem) { + Notification::make()->title('Voce archivio non trovata')->danger()->send(); + + return; + } + + $this->archiveLookupSelectedId = (int) $item->id; + $this->archiveLookupCode = (string) $item->codice_univoco; + $this->hubTab = 'lettore-codici'; } public function setProtocolloView(string $view): void @@ -182,35 +242,35 @@ public function saveScannedDocument(): void } $this->createDocumentoFromForm([ - 'stabile_id' => $stabileId, - 'cartella_archivio_id' => $this->scannerAcquisitionForm['cartella_archivio_id'] ?? null, - 'categoria' => (string) ($this->scannerAcquisitionForm['categoria'] ?? 'altro'), - 'archivio_classe' => (string) ($this->scannerAcquisitionForm['archivio_classe'] ?? 'altro'), - 'titolo' => (string) ($this->scannerAcquisitionForm['titolo'] ?? ''), - 'descrizione' => (string) ($this->scannerAcquisitionForm['descrizione'] ?? ''), - 'fornitore' => (string) ($this->scannerAcquisitionForm['fornitore'] ?? ''), - 'data_documento' => $this->scannerAcquisitionForm['data_documento'] ?? now()->toDateString(), - 'data_scadenza' => $this->scannerAcquisitionForm['data_scadenza'] ?? null, - 'importo' => $this->scannerAcquisitionForm['importo'] ?? null, - 'movimento_contabile_id' => null, - 'faldone' => (string) ($this->scannerAcquisitionForm['faldone'] ?? ''), - 'supporto_fisico' => $this->scannerAcquisitionForm['supporto_fisico'] ?? null, - 'modello_etichetta' => $this->scannerAcquisitionForm['modello_etichetta'] ?? '11354', - 'busta_numero' => $this->scannerAcquisitionForm['busta_numero'] ?? null, - 'fascicolo_numero' => $this->scannerAcquisitionForm['fascicolo_numero'] ?? null, - 'contenitore_numero' => $this->scannerAcquisitionForm['contenitore_numero'] ?? null, - 'periodo_riferimento_da' => $this->scannerAcquisitionForm['periodo_riferimento_da'] ?? null, - 'periodo_riferimento_a' => $this->scannerAcquisitionForm['periodo_riferimento_a'] ?? null, - 'visibility_scope' => (string) ($this->scannerAcquisitionForm['visibility_scope'] ?? 'interno'), - 'visibility_roles' => [], - 'visibility_groups' => [], - 'allowed_user_ids' => [], - 'pdf' => $storedPath, - 'canale_acquisizione' => (string) ($this->scannerAcquisitionForm['canale_acquisizione'] ?? 'scanner_upload'), - 'scanner_profile' => (string) ($this->scannerAcquisitionForm['scanner_profile'] ?? ''), - 'riferimento_acquisizione'=> $riferimentoAcquisizione, - 'scanner_note' => (string) ($this->scannerAcquisitionForm['scanner_note'] ?? ''), - 'origine_documento' => 'scanner_documenti_hub', + 'stabile_id' => $stabileId, + 'cartella_archivio_id' => $this->scannerAcquisitionForm['cartella_archivio_id'] ?? null, + 'categoria' => (string) ($this->scannerAcquisitionForm['categoria'] ?? 'altro'), + 'archivio_classe' => (string) ($this->scannerAcquisitionForm['archivio_classe'] ?? 'altro'), + 'titolo' => (string) ($this->scannerAcquisitionForm['titolo'] ?? ''), + 'descrizione' => (string) ($this->scannerAcquisitionForm['descrizione'] ?? ''), + 'fornitore' => (string) ($this->scannerAcquisitionForm['fornitore'] ?? ''), + 'data_documento' => $this->scannerAcquisitionForm['data_documento'] ?? now()->toDateString(), + 'data_scadenza' => $this->scannerAcquisitionForm['data_scadenza'] ?? null, + 'importo' => $this->scannerAcquisitionForm['importo'] ?? null, + 'movimento_contabile_id' => null, + 'faldone' => (string) ($this->scannerAcquisitionForm['faldone'] ?? ''), + 'supporto_fisico' => $this->scannerAcquisitionForm['supporto_fisico'] ?? null, + 'modello_etichetta' => $this->scannerAcquisitionForm['modello_etichetta'] ?? '11354', + 'busta_numero' => $this->scannerAcquisitionForm['busta_numero'] ?? null, + 'fascicolo_numero' => $this->scannerAcquisitionForm['fascicolo_numero'] ?? null, + 'contenitore_numero' => $this->scannerAcquisitionForm['contenitore_numero'] ?? null, + 'periodo_riferimento_da' => $this->scannerAcquisitionForm['periodo_riferimento_da'] ?? null, + 'periodo_riferimento_a' => $this->scannerAcquisitionForm['periodo_riferimento_a'] ?? null, + 'visibility_scope' => (string) ($this->scannerAcquisitionForm['visibility_scope'] ?? 'interno'), + 'visibility_roles' => [], + 'visibility_groups' => [], + 'allowed_user_ids' => [], + 'pdf' => $storedPath, + 'canale_acquisizione' => (string) ($this->scannerAcquisitionForm['canale_acquisizione'] ?? 'scanner_upload'), + 'scanner_profile' => (string) ($this->scannerAcquisitionForm['scanner_profile'] ?? ''), + 'riferimento_acquisizione' => $riferimentoAcquisizione, + 'scanner_note' => (string) ($this->scannerAcquisitionForm['scanner_note'] ?? ''), + 'origine_documento' => 'scanner_documenti_hub', ], false); $this->initializeScannerAcquisitionForm(); @@ -306,7 +366,8 @@ public function table(Table $table): Table ->toggleable() ->formatStateUsing(fn($state, Documento $record) => $state ?: ($record->tipologia ?: '—')), TextColumn::make('archive_reference') - ->label('Percorso archivio') + ->label('Codice archivio') + ->state(fn(Documento $record): string => $record->resolveArchiveDisplayReference()) ->limit(55) ->wrap() ->toggleable(), @@ -448,7 +509,10 @@ public function table(Table $table): Table $meta['periodo_da'] = $data['periodo_riferimento_da'] ?? null; $meta['periodo_a'] = $data['periodo_riferimento_a'] ?? null; $meta['watermark_predefinito'] = (bool) ($data['watermark_predefinito'] ?? false); - $record->metadati_archivio = $meta; + if (trim((string) ($meta['codice_univoco_documento'] ?? '')) === '') { + $meta['codice_univoco_documento'] = $record->resolveArchiveCode(); + } + $record->metadati_archivio = $meta; $record->save(); Notification::make() @@ -680,6 +744,9 @@ public function getGenericLabelRowsProperty(): array ->get() ->filter(fn(DocumentoArchivioFisicoItem $item): bool => (bool) data_get($item->metadati, 'generic_label', false)) ->map(function (DocumentoArchivioFisicoItem $item): array { + $printOptions = $this->buildGenericLabelPrintOptions((int) $item->id); + $defaultPrintOption = ($item->modello_etichetta ?: '11354') === '99014' ? '99014' : '11354'; + return [ 'id' => (int) $item->id, 'codice_univoco' => (string) $item->codice_univoco, @@ -694,12 +761,199 @@ public function getGenericLabelRowsProperty(): array ]))), 'linea_secondaria' => (string) data_get($item->metadati, 'linea_secondaria', ''), 'amazon_url' => (string) data_get($item->metadati, 'amazon_url', ''), + 'preset_label' => (string) ($this->genericLabelPresetOptions[data_get($item->metadati, 'generic_label_template.preset_key', '')] ?? 'Custom'), + 'template_label' => (string) ($this->genericLabelTemplateOptions[data_get($item->metadati, 'generic_label_template.template_name', '')] ?? 'Layout personalizzato'), + 'print_options' => $printOptions, + 'default_print' => array_key_exists($defaultPrintOption, $printOptions) ? $defaultPrintOption : array_key_first($printOptions), ]; }) ->values() ->all(); } + /** @return array */ + private function buildGenericLabelPrintOptions(int $itemId): array + { + $baseUrl = route('filament.archivio-fisico.etichetta', ['item' => $itemId]); + + return [ + '11354' => [ + 'label' => 'DYMO 11354 orizzontale', + 'url' => $baseUrl . '?format=11354&autoprint=1&dymo_fix=0&dymo_rot=0&dymo_dx=0&dymo_dy=-1', + ], + '11354_vertical' => [ + 'label' => 'DYMO 11354 verticale', + 'url' => $baseUrl . '?format=11354&autoprint=1&dymo_fix=1&dymo_rot=90&dymo_dx=0&dymo_dy=0', + ], + '99014' => [ + 'label' => 'DYMO 99014 larga', + 'url' => $baseUrl . '?format=99014&autoprint=1&dymo_fix=0&dymo_rot=0&dymo_dx=3.5&dymo_dy=3.5', + ], + ]; + } + + /** @return array */ + public function getGenericLabelPresetOptionsProperty(): array + { + return collect($this->getGenericLabelPresetDefinitions()) + ->mapWithKeys(fn(array $preset, string $key): array=> [$key => (string) ($preset['label'] ?? Str::headline($key))]) + ->all(); + } + + /** @return array> */ + public function getGenericLabelSavedTemplatesProperty(): array + { + return collect($this->getStoredGenericLabelPresetDefinitions()) + ->map(function (array $preset, string $key): array { + $templateName = (string) ($preset['template_name'] ?? 'classic-right'); + + return [ + 'key' => $key, + 'label' => (string) ($preset['label'] ?? Str::headline($key)), + 'tipo_item' => (string) ($preset['tipo_item'] ?? 'scatola'), + 'modello' => (string) ($preset['modello_etichetta'] ?? '11354'), + 'template_name' => $templateName, + 'template_label' => (string) ($this->genericLabelTemplateOptions[$templateName] ?? 'Template'), + ]; + }) + ->values() + ->all(); + } + + /** @return array */ + public function getGenericLabelTemplateOptionsProperty(): array + { + return [ + 'archive-99014' => 'Archivio 99014 con righe manuali', + 'classic-right' => 'Classica QR a destra', + 'classic-bottom' => 'Classica QR in basso', + 'band-top' => 'Fascia alta + QR laterale', + 'compact-grid' => 'Compatta a griglia', + ]; + } + + /** @return array */ + public function getGenericLabelTemplateFieldOptionsProperty(): array + { + return [ + 'mnemonic_code' => 'Codice mnemonico', + 'titolo' => 'Titolo etichetta', + 'linea_secondaria' => 'Seconda riga', + 'tipo_label' => 'Tipologia contenitore', + 'supporto_fisico' => 'Supporto', + 'percorso_compatto' => 'Percorso archivio', + 'ubicazione' => 'Ubicazione', + 'note' => 'Nota breve', + 'stabile' => 'Stabile attivo', + 'stabile_code' => 'Codice stabile', + 'stabile_address' => 'Indirizzo stabile', + 'stabile_summary' => 'Stabile compatto', + 'codice_univoco' => 'Codice univoco', + 'amazon_url' => 'Link acquisto', + 'none' => 'Nessun campo', + ]; + } + + /** @return array */ + public function getGenericLabelStableFieldOptionsProperty(): array + { + return [ + 'stabile' => 'Nome stabile', + 'stabile_code' => 'Codice stabile', + 'stabile_address' => 'Indirizzo stabile', + 'stabile_summary' => 'Stabile compatto', + 'blank_line' => 'Riga bianca scrivibile', + 'none' => 'Nessuna riga', + ]; + } + + /** @return array */ + public function getGenericLabelPreviewProperty(): array + { + $templateName = trim((string) ($this->genericLabelForm['template_name'] ?? 'classic-right')); + $template = $this->getGenericLabelTemplateDefinition($templateName); + $stabile = $this->resolveActiveStabile(); + $mnemonicCode = trim((string) ($this->genericLabelForm['mnemonic_code'] ?? '')); + if ($mnemonicCode === '') { + $mnemonicCode = (string) ($this->mnemonicCodeHint ?? 'GER00079'); + } + + $stabileCode = ''; + $stabileAddress = ''; + if ($stabile instanceof Stabile) { + $stabileCode = trim((string) ($stabile->codice_stabile ?? '')); + $stabileAddress = trim((string) ($stabile->indirizzo_completo ?? '')); + } + + $amazonUrl = trim((string) ($this->normalizeAmazonPurchaseUrl($this->genericLabelForm['amazon_url'] ?? null) ?? '')); + + $payload = [ + 'mnemonic_code' => $mnemonicCode, + 'titolo' => trim((string) ($this->genericLabelForm['titolo'] ?? '')), + 'linea_secondaria' => trim((string) ($this->genericLabelForm['linea_secondaria'] ?? '')), + 'tipo_label' => DocumentoArchivioFisicoItem::TIPI[(string) ($this->genericLabelForm['tipo_item'] ?? 'scatola')] ?? 'Contenitore', + 'supporto_fisico' => trim((string) ($this->genericLabelForm['supporto_fisico'] ?? '')), + 'percorso_compatto' => trim(implode(' / ', array_filter([ + $this->resolveActiveStabile()?->denominazione, + $this->genericLabelForm['supporto_fisico'] ?? null, + $this->genericLabelForm['titolo'] ?? null, + ]))), + 'ubicazione' => trim(implode(' · ', array_filter([ + $this->genericLabelForm['magazzino'] ?? null, + $this->genericLabelForm['scaffale'] ?? null, + $this->genericLabelForm['ripiano'] ?? null, + $this->genericLabelForm['ubicazione_dettaglio'] ?? null, + ]))), + 'note' => trim((string) ($this->genericLabelForm['note'] ?? '')), + 'stabile' => trim((string) ($this->resolveActiveStabile()?->denominazione ?? 'Stabile attivo')), + 'stabile_code' => $stabileCode, + 'stabile_address' => $stabileAddress, + 'stabile_summary' => trim(implode(' · ', array_filter([ + trim((string) ($this->resolveActiveStabile()?->denominazione ?? '')), + $stabileCode, + $stabileAddress, + ]))), + 'codice_univoco' => 'AF-QR-PREVIEW', + 'qr_payload' => implode('|', array_filter([ + 'NGDOC', + 'AF:AF-QR-PREVIEW', + 'MN:' . $mnemonicCode, + 'TIPO:' . Str::upper((string) ($this->genericLabelForm['tipo_item'] ?? 'SCATOLA')), + 'STB:' . ((int) ($this->resolveActiveStabile()?->id ?? 0)), + trim((string) ($this->genericLabelForm['parent_id'] ?? '')) !== '' ? 'PARENT:COLLEGATO' : 'PARENT:RADICE', + ])), + 'amazon_url' => $amazonUrl, + ]; + + $stableLineOneSource = (string) ($this->genericLabelForm['stable_line_one_source'] ?? 'stabile_summary'); + $stableLineTwoSource = (string) ($this->genericLabelForm['stable_line_two_source'] ?? 'blank_line'); + $blankLinesCount = max(0, min(4, (int) ($this->genericLabelForm['blank_lines_count'] ?? 2))); + + $stableLines = [ + $this->resolveGenericLabelStableLineValue($stableLineOneSource, $payload), + $this->resolveGenericLabelStableLineValue($stableLineTwoSource, $payload), + ]; + + return [ + 'template_name' => $templateName, + 'template' => $template, + 'format' => (string) ($this->genericLabelForm['modello_etichetta'] ?? '11354'), + 'qr_position' => (string) ($this->genericLabelForm['qr_position'] ?? ($template['qr_position'] ?? 'right-center')), + 'qr_scale' => (string) ($this->genericLabelForm['qr_scale'] ?? 'md'), + 'slots' => [ + 'title' => $this->resolveGenericLabelTemplateValue((string) ($this->genericLabelForm['title_source'] ?? 'titolo'), $payload), + 'subtitle' => $this->resolveGenericLabelTemplateValue((string) ($this->genericLabelForm['subtitle_source'] ?? 'linea_secondaria'), $payload), + 'meta' => $this->resolveGenericLabelTemplateValue((string) ($this->genericLabelForm['meta_source'] ?? 'percorso_compatto'), $payload), + 'note' => $this->resolveGenericLabelTemplateValue((string) ($this->genericLabelForm['note_source'] ?? 'note'), $payload), + ], + 'payload' => $payload, + 'mnemonic_code' => $mnemonicCode, + 'stable_lines' => $stableLines, + 'blank_lines' => array_fill(0, $blankLinesCount, ''), + 'amazon_url' => $amazonUrl, + ]; + } + /** @return array */ public function getAudienceGroupsProperty(): array { @@ -965,6 +1219,7 @@ public function savePhysicalArchiveEntry(): void $meta = is_array($item->documento->metadati_archivio ?? null) ? $item->documento->metadati_archivio : []; $meta['physical_archive_item_id'] = (int) $item->id; $meta['physical_archive_code'] = (string) $item->codice_univoco; + $meta['codice_univoco_documento'] = (string) $item->codice_univoco; $meta['contenitore_numero'] = $parent?->codice_univoco; $meta['supporto_fisico'] = $item->supporto_fisico; $item->documento->forceFill([ @@ -1025,9 +1280,23 @@ public function saveGenericArchiveLabel(): void 'created_by' => (int) $user->id, 'updated_by' => (int) $user->id, 'metadati' => [ - 'generic_label' => true, - 'linea_secondaria' => $this->normalizeNullableText($this->genericLabelForm['linea_secondaria'] ?? null), - 'amazon_url' => $amazonUrl, + 'generic_label' => true, + 'linea_secondaria' => $this->normalizeNullableText($this->genericLabelForm['linea_secondaria'] ?? null), + 'amazon_url' => $amazonUrl, + 'generic_label_template' => [ + 'preset_key' => $this->normalizeNullableText($this->genericLabelForm['preset_key'] ?? null), + 'template_name' => $this->normalizeNullableText($this->genericLabelForm['template_name'] ?? null) ?: 'classic-right', + 'mnemonic_code' => $this->normalizeNullableText($this->genericLabelForm['mnemonic_code'] ?? null) ?: $this->mnemonicCodeHint, + 'title_source' => $this->normalizeNullableText($this->genericLabelForm['title_source'] ?? null) ?: 'titolo', + 'subtitle_source' => $this->normalizeNullableText($this->genericLabelForm['subtitle_source'] ?? null) ?: 'linea_secondaria', + 'meta_source' => $this->normalizeNullableText($this->genericLabelForm['meta_source'] ?? null) ?: 'percorso_compatto', + 'note_source' => $this->normalizeNullableText($this->genericLabelForm['note_source'] ?? null) ?: 'note', + 'stable_line_one_source' => $this->normalizeNullableText($this->genericLabelForm['stable_line_one_source'] ?? null) ?: 'stabile_summary', + 'stable_line_two_source' => $this->normalizeNullableText($this->genericLabelForm['stable_line_two_source'] ?? null) ?: 'blank_line', + 'blank_lines_count' => max(0, min(4, (int) ($this->genericLabelForm['blank_lines_count'] ?? 2))), + 'qr_position' => $this->normalizeNullableText($this->genericLabelForm['qr_position'] ?? null) ?: 'right-center', + 'qr_scale' => $this->normalizeNullableText($this->genericLabelForm['qr_scale'] ?? null) ?: 'md', + ], ], ]); @@ -1040,6 +1309,40 @@ public function saveGenericArchiveLabel(): void ->send(); } + public function saveCurrentGenericLabelAsTemplate(): void + { + $label = trim($this->genericLabelTemplateLabel); + if ($label === '') { + Notification::make()->title('Inserisci un nome template')->danger()->send(); + return; + } + + $settings = $this->getArchiveHubSettings(); + $stored = is_array($settings['generic_label_templates'] ?? null) ? $settings['generic_label_templates'] : []; + + $existingKey = collect($stored)->search(function (mixed $template) use ($label): bool { + return is_array($template) && trim((string) ($template['label'] ?? '')) === $label; + }); + + $templateKey = is_string($existingKey) ? $existingKey : $this->makeGenericLabelTemplateKey($label, $stored); + $stored[$templateKey] = $this->buildGenericLabelTemplatePayload($label); + + $settings['generic_label_templates'] = $stored; + + $this->upsertArchiveHubSetting( + json_encode($settings, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + 'Impostazioni archivio hub: template drive, link acquisto e template etichette.' + ); + + $this->genericLabelForm['preset_key'] = $templateKey; + + Notification::make() + ->title('Template etichetta salvato') + ->body('Template: ' . $label) + ->success() + ->send(); + } + public function saveDriveTemplateSettings(): void { if (! $this->canManageDriveTemplates) { @@ -1062,12 +1365,9 @@ public function saveDriveTemplateSettings(): void 'amazon_base_url' => trim((string) ($this->driveTemplateForm['amazon_base_url'] ?? '')), ]; - Impostazione::query()->updateOrCreate( - ['chiave' => self::ARCHIVE_HUB_SETTINGS_KEY], - [ - 'valore' => json_encode($settings, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), - 'descrizione' => 'Impostazioni archivio hub: template drive e link acquisto etichette.', - ] + $this->upsertArchiveHubSetting( + json_encode($settings, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + 'Impostazioni archivio hub: template drive e link acquisto etichette.' ); $this->initializeDriveTemplateForm(); @@ -1124,6 +1424,97 @@ public function movePhysicalArchiveItem(): void ->send(); } + public function getArchiveLookupResultsProperty(): array + { + $search = trim($this->archiveLookupSearch); + $codeInput = trim($this->archiveLookupCode); + $needle = $search !== '' ? $search : $codeInput; + $exactCode = $this->extractArchiveCodeFromInput($codeInput !== '' ? $codeInput : $search); + $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); + + $query = DocumentoArchivioFisicoItem::query() + ->with(['parent:id,codice_univoco,titolo', 'documento:id,nome,nome_file,archive_reference']) + ->withCount('children') + ->when($stabileId > 0, fn(Builder $builder) => $builder->where('stabile_id', $stabileId)) + ->orderByDesc('updated_at') + ->orderByDesc('id'); + + if ($needle !== '') { + $query->where(function (Builder $builder) use ($needle, $exactCode): void { + $builder->where('codice_univoco', 'like', '%' . $needle . '%') + ->orWhere('titolo', 'like', '%' . $needle . '%') + ->orWhere('note', 'like', '%' . $needle . '%') + ->orWhere('nfc_reference', 'like', '%' . $needle . '%') + ->orWhere('qr_code_data', 'like', '%' . $needle . '%'); + + if ($exactCode !== '') { + $builder->orWhere('codice_univoco', $exactCode) + ->orWhere('qr_code_data', 'like', '%AF:' . $exactCode . '%'); + } + }); + } + + return $query + ->limit($needle !== '' ? 18 : 12) + ->get() + ->map(fn(DocumentoArchivioFisicoItem $item): array=> [ + 'id' => (int) $item->id, + 'codice_univoco' => (string) $item->codice_univoco, + 'titolo' => (string) $item->titolo, + 'tipo' => (string) $item->tipo_label, + 'parent' => $item->parent ? trim($item->parent->codice_univoco . ' · ' . $item->parent->titolo) : 'Radice', + 'children_count' => (int) $item->children_count, + 'documento' => $item->documento ? trim((string) ($item->documento->nome_file ?: $item->documento->nome ?: ('Documento #' . $item->documento->id))): null, + 'percorso' => (string) $item->percorso_fisico, + 'ubicazione' => collect([$item->magazzino, $item->scaffale, $item->ripiano, $item->ubicazione_dettaglio])->filter()->implode(' · '), + ]) + ->all(); + } + + public function getArchiveLookupSelectedItemProperty(): ?DocumentoArchivioFisicoItem + { + $selectedId = (int) ($this->archiveLookupSelectedId ?? 0); + if ($selectedId <= 0) { + return null; + } + + $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); + + return DocumentoArchivioFisicoItem::query() + ->with([ + 'parent.parent', + 'children.parent', + 'children.documento', + 'documento', + 'stabile', + ]) + ->when($stabileId > 0, fn(Builder $builder) => $builder->where('stabile_id', $stabileId)) + ->find($selectedId); + } + + public function getArchiveLookupSelectedChainProperty(): array + { + $item = $this->archiveLookupSelectedItem; + if (! $item instanceof DocumentoArchivioFisicoItem) { + return []; + } + + $chain = []; + $current = $item; + + while ($current instanceof DocumentoArchivioFisicoItem) { + array_unshift($chain, [ + 'id' => (int) $current->id, + 'codice_univoco' => (string) $current->codice_univoco, + 'titolo' => (string) $current->titolo, + 'tipo' => (string) $current->tipo_label, + ]); + $current = $current->parent; + } + + return $chain; + } + public function getVisibleFolderRowsProperty(): array { $user = Auth::user(); @@ -1184,11 +1575,9 @@ public function getProtocolloComunicazioniStatsProperty(): array return [ 'totale' => (clone $query)->count(), - 'con_pdf' => (clone $query) - ->where(function (Builder $builder): void { - $builder->whereNotNull('percorso_file') - ->orWhereNotNull('path_file'); - }) + 'con_file' => (clone $query) + ->get() + ->filter(fn(Documento $documento): bool => $this->hasStoredFile($documento)) ->count(), 'questo_mese' => (clone $query) ->whereDate('created_at', '>=', now()->startOfMonth()->toDateString()) @@ -1230,8 +1619,9 @@ public function getProtocolloComunicazioniRowsProperty(): array ->limit($limit) ->get() ->map(function (Documento $documento) use ($labels): array { - $openUrl = route('filament.documenti.download', $documento) . '?inline=1'; - $tipo = trim((string) ($documento->tipo_documento ?? '')); + $openUrl = route('filament.documenti.download', $documento) . '?inline=1'; + $tipo = trim((string) ($documento->tipo_documento ?? '')); + $fileMeta = $this->resolveDocumentoMediaMeta($documento); return [ 'id' => (int) $documento->id, @@ -1243,11 +1633,17 @@ public function getProtocolloComunicazioniRowsProperty(): array 'data' => $documento->data_documento?->format('d/m/Y') ?: ($documento->created_at?->format('d/m/Y') ?: '—'), 'cartella' => trim((string) ($documento->cartellaArchivio?->nome ?? 'Protocollo generale')), + 'archive_code' => $documento->resolveArchiveCode(), 'archive_reference' => trim((string) ($documento->archive_reference ?? '')), - 'has_pdf' => $this->hasPdf($documento), - 'preview_url' => $openUrl, - 'download_url' => route('filament.documenti.download', $documento), - 'print_url' => $openUrl, + 'has_file' => $fileMeta['has_file'], + 'has_pdf' => $fileMeta['is_pdf'], + 'can_print' => $fileMeta['can_print'], + 'file_extension' => $fileMeta['extension'], + 'file_kind' => $fileMeta['kind_label'], + 'file_badge' => $fileMeta['badge_label'], + 'preview_url' => $fileMeta['has_file'] ? $openUrl : null, + 'download_url' => $fileMeta['has_file'] ? route('filament.documenti.download', $documento) : null, + 'print_url' => $fileMeta['can_print'] ? $openUrl : null, ]; }) ->all(); @@ -1466,6 +1862,8 @@ private function initializePhysicalArchiveForms(): void private function initializeGenericLabelForm(): void { $this->genericLabelForm = [ + 'preset_key' => '', + 'mnemonic_code' => (string) ($this->mnemonicCodeHint ?? 'GER00079'), 'titolo' => '', 'linea_secondaria' => '', 'note' => '', @@ -1478,7 +1876,19 @@ private function initializeGenericLabelForm(): void 'ubicazione_dettaglio' => '', 'modello_etichetta' => '11354', 'amazon_url' => '', + 'template_name' => 'archive-99014', + 'title_source' => 'titolo', + 'subtitle_source' => 'linea_secondaria', + 'meta_source' => 'percorso_compatto', + 'note_source' => 'note', + 'stable_line_one_source' => 'stabile_summary', + 'stable_line_two_source' => 'blank_line', + 'blank_lines_count' => 2, + 'qr_position' => 'bottom-right', + 'qr_scale' => 'lg', ]; + + $this->genericLabelTemplateLabel = ''; } private function initializeScannerAcquisitionForm(): void @@ -1531,9 +1941,10 @@ private function getArchiveHubSettings(): array 'drive_template_year_model' => (string) config('netgescon.google.drive_template_year_model', ''), 'amazon_affiliate_tag' => '', 'amazon_base_url' => 'https://www.amazon.it/dp', + 'generic_label_templates' => [], ]; - $stored = Impostazione::query()->where('chiave', self::ARCHIVE_HUB_SETTINGS_KEY)->value('valore'); + $stored = $this->readArchiveHubSettingValue(); if (! is_string($stored) || trim($stored) === '') { return $fallback; } @@ -1548,7 +1959,58 @@ private function getArchiveHubSettings(): array private function hasStoredArchiveHubSettings(): bool { - return Impostazione::query()->where('chiave', self::ARCHIVE_HUB_SETTINGS_KEY)->exists(); + return $this->archiveHubSettingsQuery()->exists(); + } + + private function readArchiveHubSettingValue(): ?string + { + $value = $this->archiveHubSettingsQuery()->value('valore'); + + return is_string($value) ? $value : null; + } + + private function upsertArchiveHubSetting(string $value, string $description): void + { + $modelClass = $this->resolveArchiveHubSettingsModelClass(); + if ($modelClass !== null) { + $modelClass::query()->updateOrCreate( + ['chiave' => self::ARCHIVE_HUB_SETTINGS_KEY], + [ + 'valore' => $value, + 'descrizione' => $description, + ] + ); + + return; + } + + DB::table('impostazioni')->updateOrInsert( + ['chiave' => self::ARCHIVE_HUB_SETTINGS_KEY], + [ + 'valore' => $value, + 'descrizione' => $description, + ] + ); + } + + private function archiveHubSettingsQuery(): Builder | \Illuminate\Database\Query\Builder + { + $modelClass = $this->resolveArchiveHubSettingsModelClass(); + + return $modelClass !== null + ? $modelClass::query()->where('chiave', self::ARCHIVE_HUB_SETTINGS_KEY) + : DB::table('impostazioni')->where('chiave', self::ARCHIVE_HUB_SETTINGS_KEY); + } + + private function resolveArchiveHubSettingsModelClass(): ?string + { + $class = Impostazione::class; + + if (! class_exists($class) || ! is_subclass_of($class, Model::class)) { + return null; + } + + return $class; } private function normalizeAmazonPurchaseUrl(mixed $value): ?string @@ -1575,6 +2037,345 @@ private function normalizeAmazonPurchaseUrl(mixed $value): ?string return $input; } + public function applyGenericLabelPreset(string $presetKey): void + { + $preset = $this->getGenericLabelPresetDefinitions()[$presetKey] ?? null; + if (! is_array($preset)) { + return; + } + + $this->genericLabelForm = array_merge($this->genericLabelForm, [ + 'preset_key' => $presetKey, + 'mnemonic_code' => (string) ($preset['mnemonic_code'] ?? ($this->mnemonicCodeHint ?? 'GER00079')), + 'titolo' => (string) ($preset['titolo'] ?? ''), + 'linea_secondaria' => (string) ($preset['linea_secondaria'] ?? ''), + 'tipo_item' => (string) ($preset['tipo_item'] ?? 'scatola'), + 'supporto_fisico' => (string) ($preset['supporto_fisico'] ?? ''), + 'modello_etichetta' => (string) ($preset['modello_etichetta'] ?? '11354'), + 'template_name' => (string) ($preset['template_name'] ?? 'classic-right'), + 'title_source' => (string) data_get($preset, 'sources.title', 'titolo'), + 'subtitle_source' => (string) data_get($preset, 'sources.subtitle', 'linea_secondaria'), + 'meta_source' => (string) data_get($preset, 'sources.meta', 'percorso_compatto'), + 'note_source' => (string) data_get($preset, 'sources.note', 'note'), + 'stable_line_one_source' => (string) ($preset['stable_line_one_source'] ?? 'stabile_summary'), + 'stable_line_two_source' => (string) ($preset['stable_line_two_source'] ?? 'blank_line'), + 'blank_lines_count' => max(0, min(4, (int) ($preset['blank_lines_count'] ?? 2))), + 'qr_position' => (string) ($preset['qr_position'] ?? 'right-center'), + 'qr_scale' => (string) ($preset['qr_scale'] ?? 'md'), + ]); + } + + public function applySelectedGenericLabelPreset(): void + { + $this->applyGenericLabelPreset((string) ($this->genericLabelForm['preset_key'] ?? '')); + } + + /** @return array> */ + private function getGenericLabelPresetDefinitions(): array + { + return array_merge( + $this->getBuiltInGenericLabelPresetDefinitions(), + $this->getStoredGenericLabelPresetDefinitions(), + ); + } + + /** @return array> */ + private function getBuiltInGenericLabelPresetDefinitions(): array + { + return [ + 'foglio-singolo' => [ + 'label' => 'Foglio / documento singolo', + 'titolo' => 'FOGLIO ARCHIVIATO', + 'linea_secondaria' => 'Documento cartaceo singolo', + 'tipo_item' => 'foglio', + 'supporto_fisico' => 'Foglio archiviato', + 'modello_etichetta' => '11354', + 'template_name' => 'compact-grid', + 'qr_position' => 'right-top', + 'qr_scale' => 'sm', + 'sources' => [ + 'title' => 'titolo', + 'subtitle' => 'linea_secondaria', + 'meta' => 'codice_univoco', + 'note' => 'percorso_compatto', + ], + ], + 'cartellina-operativa' => [ + 'label' => 'Cartellina', + 'titolo' => 'CARTELLINA OPERATIVA', + 'linea_secondaria' => 'Primo nodo sotto-documento', + 'tipo_item' => 'cartellina', + 'supporto_fisico' => 'Cartellina archivio', + 'modello_etichetta' => '11354', + 'template_name' => 'classic-right', + 'qr_position' => 'right-center', + 'qr_scale' => 'md', + 'sources' => [ + 'title' => 'titolo', + 'subtitle' => 'stabile', + 'meta' => 'percorso_compatto', + 'note' => 'ubicazione', + ], + ], + 'busta-anelli' => [ + 'label' => 'Busta ad anelli', + 'titolo' => 'BUSTA AD ANELLI', + 'linea_secondaria' => 'Nodo intermedio cartaceo', + 'tipo_item' => 'busta_trasparente', + 'supporto_fisico' => 'Busta trasparente ad anelli', + 'modello_etichetta' => '11354', + 'template_name' => 'compact-grid', + 'qr_position' => 'right-top', + 'qr_scale' => 'sm', + 'sources' => [ + 'title' => 'titolo', + 'subtitle' => 'linea_secondaria', + 'meta' => 'tipo_label', + 'note' => 'codice_univoco', + ], + ], + 'scatola-contratti' => [ + 'label' => 'Scatola contratti', + 'titolo' => 'SCATOLA CONTRATTI', + 'linea_secondaria' => 'Fornitori / manutenzioni / polizze', + 'tipo_item' => 'scatola', + 'supporto_fisico' => 'Scatola archivio', + 'modello_etichetta' => '11354', + 'template_name' => 'classic-right', + 'qr_position' => 'right-center', + 'qr_scale' => 'md', + 'sources' => [ + 'title' => 'titolo', + 'subtitle' => 'linea_secondaria', + 'meta' => 'percorso_compatto', + 'note' => 'ubicazione', + ], + ], + 'raccoglitore-verbali' => [ + 'label' => 'Raccoglitore verbali', + 'titolo' => 'RACCOGLITORE VERBALI', + 'linea_secondaria' => 'Assemblee / delibere / allegati', + 'tipo_item' => 'faldone_anelli', + 'supporto_fisico' => 'Raccoglitore ad anelli', + 'modello_etichetta' => '99014', + 'template_name' => 'band-top', + 'qr_position' => 'bottom-right', + 'qr_scale' => 'lg', + 'sources' => [ + 'title' => 'titolo', + 'subtitle' => 'stabile', + 'meta' => 'linea_secondaria', + 'note' => 'percorso_compatto', + ], + ], + 'busta-documenti' => [ + 'label' => 'Busta documenti', + 'titolo' => 'BUSTA DOCUMENTI', + 'linea_secondaria' => 'Protocollo operativo', + 'tipo_item' => 'busta_trasparente', + 'supporto_fisico' => 'Busta trasparente', + 'modello_etichetta' => '11354', + 'template_name' => 'compact-grid', + 'qr_position' => 'right-top', + 'qr_scale' => 'sm', + 'sources' => [ + 'title' => 'titolo', + 'subtitle' => 'linea_secondaria', + 'meta' => 'tipo_label', + 'note' => 'codice_univoco', + ], + ], + 'trasferimento-amministratore' => [ + 'label' => 'Trasferimento amministratore', + 'titolo' => 'TRASFERIMENTO PROTOCOLLI', + 'linea_secondaria' => 'Contenitore generale di passaggio consegne', + 'tipo_item' => 'scatola', + 'supporto_fisico' => 'Contenitore trasferimento amministratore', + 'modello_etichetta' => '99014', + 'template_name' => 'band-top', + 'qr_position' => 'bottom-right', + 'qr_scale' => 'lg', + 'sources' => [ + 'title' => 'titolo', + 'subtitle' => 'linea_secondaria', + 'meta' => 'stabile', + 'note' => 'percorso_compatto', + ], + ], + 'magazzino-logistico' => [ + 'label' => 'Nodo logistico magazzino', + 'titolo' => 'NODO LOGISTICO', + 'linea_secondaria' => 'Magazzino / scaffale / ripiano', + 'tipo_item' => 'posto', + 'supporto_fisico' => 'Nodo logistico', + 'modello_etichetta' => '99014', + 'template_name' => 'classic-bottom', + 'qr_position' => 'bottom-center', + 'qr_scale' => 'lg', + 'sources' => [ + 'title' => 'titolo', + 'subtitle' => 'ubicazione', + 'meta' => 'stabile', + 'note' => 'note', + ], + ], + 'scaffale-archivio' => [ + 'label' => 'Scaffale archivio', + 'titolo' => 'SCAFFALE ARCHIVIO', + 'linea_secondaria' => 'Contiene scatole e faldoni', + 'tipo_item' => 'scaffale', + 'supporto_fisico' => 'Scaffale', + 'modello_etichetta' => '99014', + 'template_name' => 'band-top', + 'qr_position' => 'bottom-right', + 'qr_scale' => 'lg', + 'sources' => [ + 'title' => 'titolo', + 'subtitle' => 'ubicazione', + 'meta' => 'stabile', + 'note' => 'percorso_compatto', + ], + ], + 'posto-finale' => [ + 'label' => 'Posto / ubicazione finale', + 'titolo' => 'POSTO ARCHIVIO', + 'linea_secondaria' => 'Ultimo nodo della catena QR', + 'tipo_item' => 'posto', + 'supporto_fisico' => 'Ubicazione finale', + 'modello_etichetta' => '99014', + 'template_name' => 'classic-bottom', + 'qr_position' => 'bottom-center', + 'qr_scale' => 'lg', + 'sources' => [ + 'title' => 'titolo', + 'subtitle' => 'ubicazione', + 'meta' => 'stabile', + 'note' => 'note', + ], + ], + 'faldone-archivio-99014' => [ + 'label' => 'Faldone archivio 99014', + 'mnemonic_code' => (string) ($this->mnemonicCodeHint ?? 'GER00079'), + 'titolo' => 'FALDONE ARCHIVIO', + 'linea_secondaria' => 'Contenitore operativo con note manuali', + 'tipo_item' => 'faldone_anelli', + 'supporto_fisico' => 'Faldone con anelli', + 'modello_etichetta' => '99014', + 'template_name' => 'archive-99014', + 'stable_line_one_source' => 'stabile_summary', + 'stable_line_two_source' => 'blank_line', + 'blank_lines_count' => 2, + 'qr_position' => 'bottom-right', + 'qr_scale' => 'lg', + 'sources' => [ + 'title' => 'titolo', + 'subtitle' => 'linea_secondaria', + 'meta' => 'stabile_summary', + 'note' => 'note', + ], + ], + ]; + } + + /** @return array> */ + private function getStoredGenericLabelPresetDefinitions(): array + { + $settings = $this->getArchiveHubSettings(); + $stored = $settings['generic_label_templates'] ?? []; + + if (! is_array($stored)) { + return []; + } + + return collect($stored) + ->filter(fn(mixed $template): bool => is_array($template)) + ->map(function (array $template): array { + $template['label'] = trim((string) ($template['label'] ?? 'Template personalizzato')); + + return $template; + }) + ->all(); + } + + /** @param array> $stored */ + private function makeGenericLabelTemplateKey(string $label, array $stored): string + { + $baseKey = 'custom-' . (Str::slug($label) ?: 'template'); + $key = $baseKey; + $suffix = 2; + + while (array_key_exists($key, $stored)) { + $key = $baseKey . '-' . $suffix; + $suffix++; + } + + return $key; + } + + /** @return array */ + private function buildGenericLabelTemplatePayload(string $label): array + { + return [ + 'label' => $label, + 'mnemonic_code' => trim((string) ($this->genericLabelForm['mnemonic_code'] ?? '')), + 'titolo' => trim((string) ($this->genericLabelForm['titolo'] ?? '')), + 'linea_secondaria' => trim((string) ($this->genericLabelForm['linea_secondaria'] ?? '')), + 'tipo_item' => (string) ($this->genericLabelForm['tipo_item'] ?? 'scatola'), + 'supporto_fisico' => trim((string) ($this->genericLabelForm['supporto_fisico'] ?? '')), + 'modello_etichetta' => (string) ($this->genericLabelForm['modello_etichetta'] ?? '11354'), + 'template_name' => (string) ($this->genericLabelForm['template_name'] ?? 'classic-right'), + 'stable_line_one_source' => (string) ($this->genericLabelForm['stable_line_one_source'] ?? 'stabile_summary'), + 'stable_line_two_source' => (string) ($this->genericLabelForm['stable_line_two_source'] ?? 'blank_line'), + 'blank_lines_count' => max(0, min(4, (int) ($this->genericLabelForm['blank_lines_count'] ?? 2))), + 'qr_position' => (string) ($this->genericLabelForm['qr_position'] ?? 'right-center'), + 'qr_scale' => (string) ($this->genericLabelForm['qr_scale'] ?? 'md'), + 'sources' => [ + 'title' => (string) ($this->genericLabelForm['title_source'] ?? 'titolo'), + 'subtitle' => (string) ($this->genericLabelForm['subtitle_source'] ?? 'linea_secondaria'), + 'meta' => (string) ($this->genericLabelForm['meta_source'] ?? 'percorso_compatto'), + 'note' => (string) ($this->genericLabelForm['note_source'] ?? 'note'), + ], + ]; + } + + /** @return array */ + private function getGenericLabelTemplateDefinition(string $templateName): array + { + $templates = [ + 'archive-99014' => ['qr_position' => 'bottom-right', 'accent' => 'slate', 'mode' => 'archive-99014'], + 'classic-right' => ['qr_position' => 'right-center', 'accent' => 'slate'], + 'classic-bottom' => ['qr_position' => 'bottom-center', 'accent' => 'sky'], + 'band-top' => ['qr_position' => 'bottom-right', 'accent' => 'emerald'], + 'compact-grid' => ['qr_position' => 'right-top', 'accent' => 'amber'], + ]; + + return $templates[$templateName] ?? $templates['classic-right']; + } + + /** @param array $payload */ + private function resolveGenericLabelTemplateValue(string $fieldKey, array $payload): string + { + if ($fieldKey === 'none') { + return ''; + } + + return trim((string) ($payload[$fieldKey] ?? '')); + } + + /** @param array $payload */ + private function resolveGenericLabelStableLineValue(string $fieldKey, array $payload): string + { + if ($fieldKey === 'none') { + return ''; + } + + if ($fieldKey === 'blank_line') { + return '__BLANK_LINE__'; + } + + return trim((string) ($payload[$fieldKey] ?? '')); + } + private function getDefaultStabileId(): ?int { $user = Auth::user(); @@ -1759,11 +2560,62 @@ private function formatPhysicalArchiveLocation(Documento $record): string private function normalizeHubTab(string $tab): string { - $allowed = ['protocollo-comunicazioni', 'archivio-digitale', 'scansione-documenti', 'etichette-generiche', 'movimentazione-codici', 'template-drive', 'archivio-fisico', 'permessi']; + $allowed = ['protocollo-comunicazioni', 'archivio-digitale', 'scansione-documenti', 'etichette-generiche', 'movimentazione-codici', 'lettore-codici', 'template-drive', 'archivio-fisico', 'permessi']; return in_array($tab, $allowed, true) ? $tab : 'protocollo-comunicazioni'; } + private function applyArchiveLookupInput(string $input): void + { + $value = trim($input); + if ($value === '') { + return; + } + + $exactCode = $this->extractArchiveCodeFromInput($value); + $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); + + $item = DocumentoArchivioFisicoItem::query() + ->when($stabileId > 0, fn(Builder $builder) => $builder->where('stabile_id', $stabileId)) + ->where(function (Builder $builder) use ($value, $exactCode): void { + $builder->where('codice_univoco', $value) + ->orWhere('nfc_reference', $value) + ->orWhere('qr_code_data', $value); + + if ($exactCode !== '') { + $builder->orWhere('codice_univoco', $exactCode) + ->orWhere('qr_code_data', 'like', '%AF:' . $exactCode . '%'); + } + }) + ->orderByDesc('id') + ->first(); + + if (! $item instanceof DocumentoArchivioFisicoItem) { + return; + } + + $this->archiveLookupSelectedId = (int) $item->id; + $this->archiveLookupCode = $exactCode !== '' ? $exactCode : $value; + } + + private function extractArchiveCodeFromInput(string $input): string + { + $input = trim($input); + if ($input === '') { + return ''; + } + + if (preg_match('/AF:([A-Z0-9\-]+)/i', $input, $match) === 1) { + return trim((string) ($match[1] ?? '')); + } + + if (preg_match('/\b(AF-[A-Z0-9\-]+)\b/i', $input, $match) === 1) { + return trim((string) ($match[1] ?? '')); + } + + return $input; + } + private function getProtocolloComunicazioniQuery(): Builder { $user = Auth::user(); @@ -1997,14 +2849,14 @@ private function createDocumentoFromForm(array $data, bool $isDemo): void $note = 'Faldone: ' . $faldone; } - $tipologia = $this->mapCategoriaToTipologia($categoria); - $archivioClasse = trim((string) ($data['archivio_classe'] ?? '')); - $archiveReference = $this->buildArchiveReference($stabileId, $categoria, $year, $data); - $canaleAcquisizione = trim((string) ($data['canale_acquisizione'] ?? '')); - $scannerProfile = trim((string) ($data['scanner_profile'] ?? '')); + $tipologia = $this->mapCategoriaToTipologia($categoria); + $archivioClasse = trim((string) ($data['archivio_classe'] ?? '')); + $archiveReference = $this->buildArchiveReference($stabileId, $categoria, $year, $data); + $canaleAcquisizione = trim((string) ($data['canale_acquisizione'] ?? '')); + $scannerProfile = trim((string) ($data['scanner_profile'] ?? '')); $riferimentoAcquisizione = trim((string) ($data['riferimento_acquisizione'] ?? '')); - $scannerNote = trim((string) ($data['scanner_note'] ?? '')); - $origineDocumento = trim((string) ($data['origine_documento'] ?? '')); + $scannerNote = trim((string) ($data['scanner_note'] ?? '')); + $origineDocumento = trim((string) ($data['origine_documento'] ?? '')); if ($scannerNote !== '') { $note = trim($note . ($note !== '' ? "\n" : '') . 'Scanner: ' . $scannerNote); @@ -2034,22 +2886,23 @@ private function createDocumentoFromForm(array $data, bool $isDemo): void 'estensione' => 'pdf', 'dimensione_file' => $size, 'metadati_archivio' => [ - 'classe' => $archivioClasse !== '' ? $archivioClasse : null, - 'classe_label' => $this->getArchivioClasseOptions()[$archivioClasse] ?? null, - 'supporto_fisico' => $this->normalizeNullableText($data['supporto_fisico'] ?? null), - 'modello_etichetta' => $this->normalizeNullableText($data['modello_etichetta'] ?? null), - 'busta_numero' => $this->normalizeNullableText($data['busta_numero'] ?? null), - 'fascicolo_numero' => $this->normalizeNullableText($data['fascicolo_numero'] ?? null), - 'contenitore_numero' => $this->normalizeNullableText($data['contenitore_numero'] ?? null), - 'periodo_da' => $data['periodo_riferimento_da'] ?? null, - 'periodo_a' => $data['periodo_riferimento_a'] ?? null, - 'codice_etichetta' => $archiveReference, - 'canale_acquisizione' => $canaleAcquisizione !== '' ? $canaleAcquisizione : null, - 'scanner_profile' => $scannerProfile !== '' ? $scannerProfile : null, + 'classe' => $archivioClasse !== '' ? $archivioClasse : null, + 'classe_label' => $this->getArchivioClasseOptions()[$archivioClasse] ?? null, + 'supporto_fisico' => $this->normalizeNullableText($data['supporto_fisico'] ?? null), + 'modello_etichetta' => $this->normalizeNullableText($data['modello_etichetta'] ?? null), + 'busta_numero' => $this->normalizeNullableText($data['busta_numero'] ?? null), + 'fascicolo_numero' => $this->normalizeNullableText($data['fascicolo_numero'] ?? null), + 'contenitore_numero' => $this->normalizeNullableText($data['contenitore_numero'] ?? null), + 'periodo_da' => $data['periodo_riferimento_da'] ?? null, + 'periodo_a' => $data['periodo_riferimento_a'] ?? null, + 'codice_etichetta' => $archiveReference, + 'codice_univoco_documento' => $archiveReference, + 'canale_acquisizione' => $canaleAcquisizione !== '' ? $canaleAcquisizione : null, + 'scanner_profile' => $scannerProfile !== '' ? $scannerProfile : null, 'riferimento_acquisizione' => $riferimentoAcquisizione !== '' ? $riferimentoAcquisizione : null, - 'origine_documento' => $origineDocumento !== '' ? $origineDocumento : null, - 'qr_ready' => true, - 'nfc_ready' => true, + 'origine_documento' => $origineDocumento !== '' ? $origineDocumento : null, + 'qr_ready' => true, + 'nfc_ready' => true, ], 'visibility_scope' => (string) ($data['visibility_scope'] ?? 'interno'), 'visibility_roles' => $this->normalizeStringArray($data['visibility_roles'] ?? []), @@ -2269,6 +3122,49 @@ private function hasPdf(Documento $doc): bool return $p !== '' && str_ends_with(strtolower($p), '.pdf'); } + private function hasStoredFile(Documento $doc): bool + { + foreach (array_filter([(string) ($doc->percorso_file ?? ''), (string) ($doc->path_file ?? '')]) as $candidate) { + if (Storage::disk('local')->exists($candidate) || Storage::disk('public')->exists($candidate)) { + return true; + } + } + + return false; + } + + /** @return array{has_file:bool,is_pdf:bool,can_print:bool,extension:string,kind_label:string,badge_label:string} */ + private function resolveDocumentoMediaMeta(Documento $documento): array + { + $filename = trim((string) ($documento->nome_file ?: basename((string) ($documento->percorso_file ?: $documento->path_file ?: '')))); + $extension = strtolower((string) pathinfo($filename, PATHINFO_EXTENSION)); + $hasFile = $this->hasStoredFile($documento); + $imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']; + $textExtensions = ['txt', 'csv', 'md', 'json', 'xml', 'html']; + $officeExtensions = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods']; + $isPdf = $this->hasPdf($documento) || $extension === 'pdf'; + $isImage = in_array($extension, $imageExtensions, true); + $isText = in_array($extension, $textExtensions, true); + $isOffice = in_array($extension, $officeExtensions, true); + $kindLabel = match (true) { + $isPdf => 'PDF', + $isImage => 'Immagine', + $isOffice => 'Office', + $isText => 'Testo', + $extension !== '' => strtoupper($extension), + default => 'File', + }; + + return [ + 'has_file' => $hasFile, + 'is_pdf' => $isPdf, + 'can_print' => $hasFile && ($isPdf || $isImage || $isText), + 'extension' => $extension !== '' ? strtoupper($extension) : 'FILE', + 'kind_label' => $kindLabel, + 'badge_label' => $kindLabel . ($extension !== '' && strtoupper($extension) !== $kindLabel ? ' · ' . strtoupper($extension) : ''), + ]; + } + private function mapCategoriaToTipologia(string $categoria): string { $categoria = strtolower(trim($categoria)); diff --git a/app/Services/Catalog/AmazonCreatorsApiService.php b/app/Services/Catalog/AmazonCreatorsApiService.php new file mode 100644 index 0000000..3d171ce --- /dev/null +++ b/app/Services/Catalog/AmazonCreatorsApiService.php @@ -0,0 +1,154 @@ +credentialId() !== '' + && $this->credentialSecret() !== '' + && $this->tokenUrl() !== '' + && $this->apiBaseUrl() !== ''; + } + + public function getMarketplace(): string + { + return trim((string) config('services.amazon.marketplace', 'www.amazon.it')) ?: 'www.amazon.it'; + } + + public function getAssociateTag(): ?string + { + $tag = trim((string) config('services.amazon.referral_tag', '')); + + return $tag !== '' ? $tag : null; + } + + /** @return array */ + public function searchItems(string $keywords, array $parameters = []): array + { + $keywords = trim($keywords); + if ($keywords === '') { + throw new RuntimeException('Keywords Amazon vuote.'); + } + + return $this->requestOperation('searchItems', array_filter(array_replace([ + 'keywords' => $keywords, + 'marketplace' => $this->getMarketplace(), + 'partnerTag' => $this->getAssociateTag(), + ], $parameters), static fn(mixed $value): bool => $value !== null && $value !== '')); + } + + /** @param array $identifiers + * @return array + */ + public function getItems(array $identifiers, array $parameters = []): array + { + $identifiers = array_values(array_filter(array_map( + static fn(mixed $value): string => trim((string) $value), + $identifiers + ), static fn(string $value): bool => $value !== '')); + + if ($identifiers === []) { + throw new RuntimeException('Specificare almeno un ASIN Amazon.'); + } + + return $this->requestOperation('getItems', array_filter(array_replace([ + 'itemIds' => $identifiers, + 'marketplace' => $this->getMarketplace(), + 'partnerTag' => $this->getAssociateTag(), + ], $parameters), static fn(mixed $value): bool => $value !== null && $value !== '')); + } + + private function credentialId(): string + { + return trim((string) config('services.amazon.creators_credential_id', '')); + } + + private function credentialSecret(): string + { + return trim((string) config('services.amazon.creators_credential_secret', '')); + } + + private function tokenUrl(): string + { + return trim((string) config('services.amazon.creators_token_url', '')); + } + + private function apiBaseUrl(): string + { + return rtrim(trim((string) config('services.amazon.creators_api_base_url', '')), '/'); + } + + /** @return array */ + private function requestOperation(string $operation, array $payload): array + { + if (! $this->isConfigured()) { + throw new RuntimeException('Amazon Creators API non configurata: valorizza le variabili AMAZON_CREATORS_* in ambiente.'); + } + + $path = trim((string) Arr::get(config('services.amazon.creators_paths', []), $operation, ''), '/'); + if ($path === '') { + throw new RuntimeException('Percorso Creators API mancante per l\'operazione ' . $operation . '.'); + } + + $response = Http::timeout(max(5, (int) config('services.amazon.creators_timeout', 20))) + ->acceptJson() + ->asJson() + ->withToken($this->getAccessToken()) + ->post($this->apiBaseUrl() . '/' . $path, $payload); + + if (! $response->successful()) { + throw new RuntimeException('Amazon Creators API ' . $operation . ' ha risposto con HTTP ' . $response->status() . ': ' . substr(trim($response->body()), 0, 400)); + } + + $json = $response->json(); + if (! is_array($json)) { + throw new RuntimeException('Risposta Creators API non valida per ' . $operation . '.'); + } + + return $json; + } + + private function getAccessToken(): string + { + $cacheKey = 'amazon-creators-api-token:' . sha1($this->credentialId() . '|' . $this->tokenUrl()); + $cached = Cache::get($cacheKey); + if (is_string($cached) && trim($cached) !== '') { + return $cached; + } + + $response = Http::timeout(max(5, (int) config('services.amazon.creators_timeout', 20))) + ->asForm() + ->post($this->tokenUrl(), [ + 'grant_type' => 'client_credentials', + 'client_id' => $this->credentialId(), + 'client_secret' => $this->credentialSecret(), + 'version' => trim((string) config('services.amazon.creators_credential_version', 'v3.2')) ?: 'v3.2', + ]); + + if (! $response->successful()) { + throw new RuntimeException('Token Creators API non ottenuto: HTTP ' . $response->status() . ' ' . substr(trim($response->body()), 0, 300)); + } + + $json = $response->json(); + if (! is_array($json)) { + throw new RuntimeException('Token Creators API non valido.'); + } + + $token = trim((string) ($json['access_token'] ?? $json['token'] ?? '')); + if ($token === '') { + throw new RuntimeException('Risposta token Creators API senza access_token.'); + } + + $expiresIn = max(60, (int) ($json['expires_in'] ?? 3600)); + Cache::put($cacheKey, $token, now()->addSeconds(max(60, $expiresIn - 60))); + + return $token; + } +} \ No newline at end of file diff --git a/app/Services/Catalog/AmazonCreatorsCatalogSyncService.php b/app/Services/Catalog/AmazonCreatorsCatalogSyncService.php new file mode 100644 index 0000000..44bad3c --- /dev/null +++ b/app/Services/Catalog/AmazonCreatorsCatalogSyncService.php @@ -0,0 +1,411 @@ + */ + public function syncProduct(Product $product, array $options = []): array + { + $dryRun = (bool) ($options['dry_run'] ?? false); + $item = $this->resolveItemPayload($product, $options); + if (! is_array($item) || $item === []) { + throw new RuntimeException('Nessun item Amazon trovato per il prodotto selezionato.'); + } + + $asin = $this->extractAsin($item); + if ($asin === null) { + throw new RuntimeException('La risposta Amazon non contiene un ASIN utilizzabile.'); + } + + $title = $this->extractTitle($item) ?: (string) ($product->name ?? 'Prodotto Amazon'); + $marketplace = trim((string) ($options['marketplace'] ?? $this->apiService->getMarketplace())) ?: $this->apiService->getMarketplace(); + $associateTag = trim((string) ($options['associate_tag'] ?? $this->apiService->getAssociateTag() ?? '')) ?: null; + $detailUrl = $this->extractDetailPageUrl($item); + $referralUrl = $detailUrl !== null ? $this->appendAssociateTag($detailUrl, $associateTag) : $this->productOfferService->buildAmazonReferralUrl($product, $associateTag, $this->mapMarketplaceToLocale($marketplace)); + $price = $this->extractPriceAmount($item); + $compareAt = $this->extractCompareAtAmount($item); + $currency = $this->extractCurrency($item) ?? 'EUR'; + $availability = $this->extractAvailability($item) ?? 'catalog_synced'; + $externalId = $asin; + $ean = $this->extractEan($item); + $imageUrl = $this->extractPrimaryImageUrl($item); + $payloadSummary = [ + 'asin' => $asin, + 'title' => $title, + 'marketplace' => $marketplace, + 'detail_url' => $detailUrl, + 'image_url' => $imageUrl, + 'price_amount' => $price, + 'currency' => $currency, + ]; + + $identifiersCreated = 0; + $mediaCreated = 0; + $offer = null; + + if (! $dryRun) { + $identifiersCreated += $this->upsertIdentifier($product, 'asin', $asin, 'amazon_creators_api', true) ? 1 : 0; + if ($ean !== null) { + $identifiersCreated += $this->upsertIdentifier($product, 'ean', $ean, 'amazon_creators_api', false) ? 1 : 0; + } + + if ($imageUrl !== null) { + $mediaCreated += $this->upsertRemoteMedia($product, $imageUrl, $title) ? 1 : 0; + } + + $offer = $this->productOfferService->syncOffer($product, [ + 'source_type' => 'amazon_creators_api', + 'source_name' => 'Amazon Creators API', + 'external_product_id' => $externalId, + 'title' => $title, + 'currency' => $currency, + 'price_amount' => $price, + 'price_compare_at' => $compareAt, + 'availability' => $availability, + 'external_url' => $detailUrl, + 'referral_url' => $referralUrl, + 'referral_code' => $associateTag, + 'is_internal' => false, + 'is_active' => true, + 'meta' => [ + 'marketplace' => $marketplace, + 'sync_source' => (string) ($options['source'] ?? 'api'), + 'payload' => $payloadSummary, + ], + ]); + } + + return [ + 'asin' => $asin, + 'title' => $title, + 'detail_url' => $detailUrl, + 'referral_url' => $referralUrl, + 'image_url' => $imageUrl, + 'price_amount' => $price, + 'price_compare_at' => $compareAt, + 'currency' => $currency, + 'availability' => $availability, + 'ean' => $ean, + 'identifiers_created' => $identifiersCreated, + 'media_created' => $mediaCreated, + 'offer_id' => $offer instanceof ProductOffer ? (int) $offer->id : null, + 'source' => (string) ($options['source'] ?? 'api'), + 'dry_run' => $dryRun, + ]; + } + + /** @return array */ + private function resolveItemPayload(Product $product, array $options): array + { + if (is_array($options['item'] ?? null)) { + return $options['item']; + } + + if (is_array($options['response'] ?? null)) { + $candidate = $this->pickFirstItem((array) $options['response']); + + return is_array($candidate) ? $candidate : []; + } + + $asin = trim((string) ($options['asin'] ?? $this->findIdentifierValue($product, ['asin']) ?? '')); + if ($asin !== '') { + $response = $this->apiService->getItems([$asin], [ + 'marketplace' => (string) ($options['marketplace'] ?? $this->apiService->getMarketplace()), + ]); + + return $this->pickFirstItem($response) ?? []; + } + + $query = trim((string) ($options['query'] ?? $this->findIdentifierValue($product, ['ean']) ?? $this->buildSearchQuery($product))); + if ($query === '') { + return []; + } + + $response = $this->apiService->searchItems($query, [ + 'marketplace' => (string) ($options['marketplace'] ?? $this->apiService->getMarketplace()), + ]); + + return $this->pickFirstItem($response) ?? []; + } + + /** @return array|null */ + private function pickFirstItem(array $payload): ?array + { + foreach ([ + 'items', + 'ItemsResult.Items', + 'SearchResult.Items', + 'GetItemsResult.Items', + 'data.items', + 'data.searchItems.items', + 'data.getItems.items', + ] as $path) { + $items = data_get($payload, $path); + if (is_array($items) && isset($items[0]) && is_array($items[0])) { + return $items[0]; + } + } + + return isset($payload['asin']) || isset($payload['ASIN']) ? $payload : null; + } + + private function extractAsin(array $item): ?string + { + foreach (['asin', 'ASIN', 'itemId', 'ItemId'] as $path) { + $value = trim((string) data_get($item, $path, '')); + if ($value !== '') { + return $value; + } + } + + return null; + } + + private function extractEan(array $item): ?string + { + foreach ([ + 'ean', + 'EAN', + 'ExternalIds.EANs.DisplayValues.0', + 'externalIds.eans.0', + 'identifiers.ean', + ] as $path) { + $value = preg_replace('/\s+/', '', trim((string) data_get($item, $path, ''))); + if (is_string($value) && $value !== '') { + return $value; + } + } + + return null; + } + + private function extractTitle(array $item): ?string + { + foreach (['title', 'Title', 'ItemInfo.Title.DisplayValue', 'itemInfo.title.displayValue'] as $path) { + $value = trim((string) data_get($item, $path, '')); + if ($value !== '') { + return $value; + } + } + + return null; + } + + private function extractDetailPageUrl(array $item): ?string + { + foreach (['detailPageURL', 'DetailPageURL', 'detailPageUrl', 'url'] as $path) { + $value = trim((string) data_get($item, $path, '')); + if ($value !== '') { + return $value; + } + } + + return null; + } + + private function extractPrimaryImageUrl(array $item): ?string + { + foreach ([ + 'Images.Primary.Large.URL', + 'Images.Primary.Medium.URL', + 'Images.Primary.Small.URL', + 'images.primary.large.url', + 'images.primary.medium.url', + 'images.0.url', + 'image', + ] as $path) { + $value = trim((string) data_get($item, $path, '')); + if ($value !== '') { + return $value; + } + } + + return null; + } + + private function extractPriceAmount(array $item): ?float + { + foreach ([ + 'Offers.Listings.0.Price.Amount', + 'Offers.Summaries.0.LowestPrice.Amount', + 'offers.listings.0.price.amount', + 'offers.summaries.0.lowestPrice.amount', + 'price.amount', + ] as $path) { + $value = data_get($item, $path); + if (is_numeric($value)) { + return round((float) $value, 2); + } + } + + return null; + } + + private function extractCompareAtAmount(array $item): ?float + { + foreach ([ + 'Offers.Listings.0.SavingBasis.Amount', + 'offers.listings.0.savingBasis.amount', + 'price.compareAt.amount', + ] as $path) { + $value = data_get($item, $path); + if (is_numeric($value)) { + return round((float) $value, 2); + } + } + + return null; + } + + private function extractCurrency(array $item): ?string + { + foreach ([ + 'Offers.Listings.0.Price.Currency', + 'Offers.Summaries.0.LowestPrice.Currency', + 'offers.listings.0.price.currency', + 'offers.summaries.0.lowestPrice.currency', + 'price.currency', + ] as $path) { + $value = trim((string) data_get($item, $path, '')); + if ($value !== '') { + return strtoupper($value); + } + } + + return null; + } + + private function extractAvailability(array $item): ?string + { + foreach ([ + 'Offers.Listings.0.Availability.Message', + 'offers.listings.0.availability.message', + 'availability', + ] as $path) { + $value = trim((string) data_get($item, $path, '')); + if ($value !== '') { + return $value; + } + } + + return null; + } + + private function appendAssociateTag(string $url, ?string $associateTag): string + { + $associateTag = $associateTag !== null ? trim($associateTag) : ''; + if ($associateTag === '') { + return $url; + } + + $separator = str_contains($url, '?') ? '&' : '?'; + + return str_contains($url, 'tag=') ? $url : ($url . $separator . 'tag=' . rawurlencode($associateTag)); + } + + private function mapMarketplaceToLocale(string $marketplace): string + { + return match (Str::lower(trim($marketplace))) { + 'www.amazon.de' => 'de', + 'www.amazon.fr' => 'fr', + 'www.amazon.es' => 'es', + 'www.amazon.co.uk' => 'uk', + default => 'it', + }; + } + + /** @param array $types */ + private function findIdentifierValue(Product $product, array $types): ?string + { + $value = ProductIdentifier::query() + ->where('product_id', (int) $product->id) + ->whereIn('code_type', $types) + ->orderByDesc('is_primary') + ->orderBy('id') + ->value('code_value'); + + $value = trim((string) $value); + + return $value !== '' ? $value : null; + } + + private function buildSearchQuery(Product $product): string + { + $parts = array_values(array_unique(array_filter([ + trim((string) ($product->brand ?? '')), + trim((string) ($product->model ?? '')), + trim((string) ($product->name ?? '')), + ], static fn(string $value): bool => $value !== ''))); + + return Str::limit(implode(' ', $parts), 120, ''); + } + + private function upsertIdentifier(Product $product, string $type, string $value, string $source, bool $isPrimary): bool + { + $value = trim($value); + if ($value === '') { + return false; + } + + $normalized = strtoupper(preg_replace('/[^A-Z0-9]+/', '', $value) ?? ''); + if ($normalized === '') { + return false; + } + + $identifier = ProductIdentifier::query()->firstOrNew([ + 'product_id' => (int) $product->id, + 'code_type' => $type, + 'normalized_code' => $normalized, + ]); + + $created = ! $identifier->exists; + $identifier->code_role = $type; + $identifier->code_value = $value; + $identifier->source = $source; + $identifier->source_reference = $value; + $identifier->is_primary = $isPrimary; + $identifier->payload = ['provider' => 'amazon']; + $identifier->save(); + + return $created; + } + + private function upsertRemoteMedia(Product $product, string $url, string $title): bool + { + $url = trim($url); + if ($url === '' || ! str_starts_with($url, 'http')) { + return false; + } + + $media = ProductMedia::query()->firstOrNew([ + 'product_id' => (int) $product->id, + 'media_type' => 'image', + 'disk' => 'remote-url', + 'path' => $url, + ]); + + $created = ! $media->exists; + $media->title = $title; + $media->meta = [ + 'provider' => 'amazon_creators_api', + 'remote' => true, + ]; + $media->save(); + + return $created; + } +} \ No newline at end of file diff --git a/config/services.php b/config/services.php index 9e0cb9c..a20f936 100755 --- a/config/services.php +++ b/config/services.php @@ -48,4 +48,27 @@ 'timeout' => (int) env('ISTAT_TIMEOUT', 30), ], + 'amazon' => [ + 'referral_tag' => env('AMAZON_REFERRAL_TAG', ''), + 'marketplace' => env('AMAZON_MARKETPLACE', 'www.amazon.it'), + 'creators_enabled' => (bool) env('AMAZON_CREATORS_ENABLED', false), + 'creators_api_base_url' => env('AMAZON_CREATORS_API_BASE_URL', ''), + 'creators_token_url' => env('AMAZON_CREATORS_TOKEN_URL', ''), + 'creators_credential_id' => env('AMAZON_CREATORS_CREDENTIAL_ID', ''), + 'creators_credential_secret' => env('AMAZON_CREATORS_CREDENTIAL_SECRET', ''), + 'creators_credential_version' => env('AMAZON_CREATORS_CREDENTIAL_VERSION', 'v3.2'), + 'creators_timeout' => (int) env('AMAZON_CREATORS_TIMEOUT', 20), + 'creators_paths' => [ + 'searchItems' => env('AMAZON_CREATORS_SEARCH_PATH', ''), + 'getItems' => env('AMAZON_CREATORS_GET_ITEMS_PATH', ''), + ], + ], + + 'telegram' => [ + 'offers_bot_token' => env('TELEGRAM_OFFERS_BOT_TOKEN', ''), + 'offers_chat_id' => env('TELEGRAM_OFFERS_CHAT_ID', ''), + 'offers_channel_name' => env('TELEGRAM_OFFERS_CHANNEL_NAME', ''), + 'offers_webhook_secret' => env('TELEGRAM_OFFERS_WEBHOOK_SECRET', ''), + ], + ]; diff --git a/docs/AMAZON-CREATORS-API.md b/docs/AMAZON-CREATORS-API.md new file mode 100644 index 0000000..07e17aa --- /dev/null +++ b/docs/AMAZON-CREATORS-API.md @@ -0,0 +1,85 @@ +# Amazon Creators API e canale offerte + +## Stato attuale nel codice + +NetGescon aveva gia una base catalogo con prodotti, identificativi, media, seriali e offerte. +Questa slice aggiunge: + +- configurazione ambiente per Amazon Creators API in `config/services.php`; +- servizio `AmazonCreatorsApiService` per token OAuth e chiamate configurabili; +- servizio `AmazonCreatorsCatalogSyncService` per mappare item Amazon su `ProductOffer`, `ProductIdentifier` e `ProductMedia`; +- comando artisan `catalog:sync-amazon-creators` per sincronizzare un prodotto singolo. + +## Variabili ambiente da valorizzare + +Queste chiavi vanno messe solo in `.env` locale o staging, mai nel repository: + +```env +AMAZON_REFERRAL_TAG=netgescon-21 +AMAZON_MARKETPLACE=www.amazon.it + +AMAZON_CREATORS_ENABLED=true +AMAZON_CREATORS_CREDENTIAL_ID= +AMAZON_CREATORS_CREDENTIAL_SECRET= +AMAZON_CREATORS_CREDENTIAL_VERSION=v3.2 + +AMAZON_CREATORS_TOKEN_URL= +AMAZON_CREATORS_API_BASE_URL= +AMAZON_CREATORS_SEARCH_PATH= +AMAZON_CREATORS_GET_ITEMS_PATH= +AMAZON_CREATORS_TIMEOUT=20 + +TELEGRAM_OFFERS_BOT_TOKEN= +TELEGRAM_OFFERS_CHAT_ID= +TELEGRAM_OFFERS_CHANNEL_NAME= +TELEGRAM_OFFERS_WEBHOOK_SECRET= +``` + +## Cosa manca per il collegamento live + +Le credenziali ci sono gia, ma per la chiamata HTTP reale servono ancora dal materiale Amazon ufficiale: + +- endpoint OAuth preciso per il token; +- base URL API Creators per il marketplace Italia; +- path esatti delle operazioni `SearchItems` e `GetItems`; +- un esempio reale di request e response, oppure direttamente lo zip dello SDK PHP Creators API. + +Appena ci sono questi quattro dati, il client puo essere puntato ai path reali senza inventare nulla. + +## Uso del comando + +Sincronizzazione da API, quando gli endpoint sono configurati: + +```bash +php artisan catalog:sync-amazon-creators ART-000123 +php artisan catalog:sync-amazon-creators 123 --asin=B0ABCDEF12 +php artisan catalog:sync-amazon-creators ART-000123 --query="detergente caldaia" +``` + +Sincronizzazione da JSON esportato dallo SDK Amazon, utile anche prima del collegamento live completo: + +```bash +php artisan catalog:sync-amazon-creators ART-000123 --json=/tmp/amazon-item.json +php artisan catalog:sync-amazon-creators ART-000123 --json=/tmp/amazon-response.json --dry-run +``` + +## Dati che il sync salva + +- offerta `product_offers.source_type = amazon_creators_api`; +- ASIN come `ProductIdentifier` primario, se presente; +- EAN come `ProductIdentifier`, se presente; +- immagine Amazon come `ProductMedia` remoto; +- referral URL con `tag=netgescon-21` se il tag e configurato. + +## Cosa serve per il canale Telegram offerte + +Per pubblicare offerte in automatico dal catalogo NetGescon servono: + +- un bot Telegram creato con BotFather; +- il bot aggiunto come admin del canale offerte; +- `chat_id` o username del canale; +- una regola chiara di pubblicazione, per esempio solo offerte Amazon con prezzo valido e prodotto attivo; +- una frequenza di invio, per esempio push immediato o digest orario; +- un criterio anti-spam, per esempio non ripubblicare lo stesso ASIN nelle ultime 24 ore. + +La prossima slice naturale e un publisher che prende le offerte attive dal catalogo e invia il post al canale con titolo, prezzo, immagine e referral URL. \ No newline at end of file diff --git a/docs/DOCUMENTI-NFC-E-ACQUISIZIONE.md b/docs/DOCUMENTI-NFC-E-ACQUISIZIONE.md index d895ebd..3515850 100644 --- a/docs/DOCUMENTI-NFC-E-ACQUISIZIONE.md +++ b/docs/DOCUMENTI-NFC-E-ACQUISIZIONE.md @@ -58,9 +58,16 @@ ## Dashboard protocollo comunicazioni - ricerca live su protocollo, titolo, descrizione, ente e riferimento archivio; - filtro per categoria documento; -- vista a box con anteprima PDF incorporata; +- vista a box universale per allegati PDF, immagini, file Office e testi; - vista lista compatta per consultazione rapida; -- contatori rapidi su totale documenti, PDF disponibili, inserimenti del mese e categorie presenti. +- contatori rapidi su totale documenti, allegati disponibili, inserimenti del mese e categorie presenti. + +Aggiornamento operativo: + +- la griglia box e stata estesa fino a 4 schede per riga sui layout ampi; +- il pulsante `Apri` usa lo stesso endpoint inline gia esistente e quindi non resta limitato ai soli PDF; +- il pulsante `Stampa` viene mostrato solo per i tipi file che il browser puo gestire in modo utile in preview o print; +- i documenti senza file restano comunque visibili come metadati di protocollo, senza forzare una falsa anteprima. Scelta implementativa: @@ -80,6 +87,14 @@ ## Archivio fisico ed etichette - stampa etichette nei formati `DYMO 11354` e `DYMO 99014`; - pagina pubblica firmata per dettaglio contenitore o documento tramite QR. +Etichette generiche evolute: + +- preset rapidi per scatole, raccoglitori, buste e nodi logistici; +- scelta layout con varianti QR a destra o in basso; +- binding dei campi DB nelle zone titolo, sottotitolo, meta e nota; +- preview grafica live prima del salvataggio; +- salvataggio del template dentro `metadati.generic_label_template`, cosi la stampa reale segue lo stesso disegno scelto in tab. + Taratura operativa corrente: - `11354`: layout orizzontale principale con correzione verticale leggera verso l'alto; @@ -118,6 +133,8 @@ ## QR pubblico e dominio esterno - il link finale viene costruito usando `APP_PUBLIC_URL`, con fallback su `APP_URL`; - la validazione avviene lato controller con firma relativa, cosi il QR resta valido anche dietro dominio pubblico o reverse proxy. +Questo rende il QR gia abbastanza universale anche nel caso di passaggio del materiale tra amministratori, perche il riferimento stabile e l'item fisico firmato e non l'identita del singolo operatore che ha creato l'etichetta. + Configurazione richiesta in ambiente: 1. impostare `APP_PUBLIC_URL` con il dominio realmente raggiungibile dall'esterno; diff --git a/resources/views/filament/pages/strumenti/partials/generic-label-builder.blade.php b/resources/views/filament/pages/strumenti/partials/generic-label-builder.blade.php new file mode 100644 index 0000000..d866529 --- /dev/null +++ b/resources/views/filament/pages/strumenti/partials/generic-label-builder.blade.php @@ -0,0 +1,388 @@ + + Etichette generiche per contenitori + Area dedicata al template etichetta: prima configuri e vedi subito l'anteprima, poi scegli come stampare le etichette create. + + @php + $preview = $this->genericLabelPreview; + $qrPosition = $preview['qr_position'] ?? 'right-center'; + $qrScaleMap = ['sm' => 'h-14 w-14', 'md' => 'h-20 w-20', 'lg' => 'h-24 w-24']; + $qrClass = $qrScaleMap[$preview['qr_scale'] ?? 'md'] ?? $qrScaleMap['md']; + $templateName = $preview['template_name'] ?? 'classic-right'; + $isArchive99014 = $templateName === 'archive-99014' && ($preview['format'] ?? '11354') === '99014'; + $isBottomQr = str_starts_with($qrPosition, 'bottom'); + $format = $preview['format'] ?? '11354'; + $isWideFormat = $format === '99014'; + $accentClass = match(data_get($preview, 'template.accent')) { + 'emerald' => 'from-emerald-600 to-emerald-500', + 'amber' => 'from-amber-500 to-amber-400', + 'sky' => 'from-sky-600 to-sky-500', + default => 'from-slate-700 to-slate-600', + }; + @endphp + +
+
+
+
+
+
Parametri etichetta
+
A sinistra imposti contenitore, layout, binding campi e formato di stampa. A destra vedi sempre come viene.
+
+
Layout split
+
+ +
+ Le etichette create qui restano memorizzate nell'archivio documentale e possono diventare preset riusabili per altre sezioni del programma. +
+ +
+
+
+ + +
Salvi layout, formato etichetta, binding dei campi e posizione QR per riusarli più avanti.
+
+ +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + +
Suggerito: {{ $this->mnemonicCodeHint ?? 'GER00079' }}
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + @if(data_get($preview, 'amazon_url') !== '') + + @endif +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + +
+ +
+
+
+
+
Template riusabili salvati
+
Libreria separata dal builder, così la preview non finisce sotto il form.
+
+
Libreria template
+
+ +
+ @forelse($this->genericLabelSavedTemplates as $savedTemplate) +
+
+
+
{{ $savedTemplate['label'] }}
+
{{ $savedTemplate['template_label'] }} · {{ $savedTemplate['modello'] }} · {{ \App\Models\DocumentoArchivioFisicoItem::TIPI[$savedTemplate['tipo_item']] ?? $savedTemplate['tipo_item'] }}
+
+ +
+
+ @empty +
Nessun template personalizzato salvato ancora. Prepara il layout nel builder, verifica la preview e poi usa “Memorizza template”.
+ @endforelse +
+
+ +
+
+
Ultime etichette generiche
+
Scelta esplicita del formato di stampa
+
+
+ @forelse($this->genericLabelRows as $row) +
+
+
+
+ {{ $row['codice_univoco'] }} + {{ $row['tipo'] }} +
+
{{ $row['titolo'] }}
+ @if($row['linea_secondaria'] !== '') +
{{ $row['linea_secondaria'] }}
+ @endif +
{{ $row['preset_label'] }} · {{ $row['template_label'] }}
+
Percorso: {{ $row['percorso'] }}
+ @if($row['ubicazione'] !== '') +
Ubicazione: {{ $row['ubicazione'] }}
+ @endif + @if($row['amazon_url'] !== '') + + @endif +
+
+ +
+ + Stampa +
+
+
+
+ @empty +
Nessuna etichetta generica creata ancora per lo stabile attivo.
+ @endforelse +
+
+
+
+
\ No newline at end of file diff --git a/resources/views/filament/print/etichetta-archivio-fisico.blade.php b/resources/views/filament/print/etichetta-archivio-fisico.blade.php index 6a91d9a..ddbb0c4 100644 --- a/resources/views/filament/print/etichetta-archivio-fisico.blade.php +++ b/resources/views/filament/print/etichetta-archivio-fisico.blade.php @@ -4,15 +4,69 @@ Etichetta Archivio Fisico - @php($fmt = in_array(($format ?? '11354'), ['11354','99014'], true) ? $format : '11354') - @php($dymoFix = isset($dymoFix) ? (bool) $dymoFix : false) - @php($dymoRot = isset($dymoRot) ? (int) $dymoRot : 0) - @php($dymoDx = isset($dymoDx) ? (float) $dymoDx : 0.0) - @php($dymoDy = isset($dymoDy) ? (float) $dymoDy : 0.0) - @php($autoPrint = request()->has('autoprint') && filter_var(request()->query('autoprint'), FILTER_VALIDATE_BOOL)) - @php($labelPrinterMode = isset($labelPrinterMode) ? (bool) $labelPrinterMode : false) - @php($pageWidth = $fmt === '99014' ? '54mm' : '57mm') - @php($pageHeight = $fmt === '99014' ? '101mm' : '32mm') + @php + $fmt = in_array(($format ?? '11354'), ['11354','99014'], true) ? $format : '11354'; + $dymoFix = isset($dymoFix) ? (bool) $dymoFix : false; + $dymoRot = isset($dymoRot) ? (int) $dymoRot : 0; + $dymoDx = isset($dymoDx) ? (float) $dymoDx : 0.0; + $dymoDy = isset($dymoDy) ? (float) $dymoDy : 0.0; + $autoPrint = request()->has('autoprint') && filter_var(request()->query('autoprint'), FILTER_VALIDATE_BOOL); + $labelPrinterMode = isset($labelPrinterMode) ? (bool) $labelPrinterMode : false; + $pageWidth = $fmt === '99014' ? '54mm' : '57mm'; + $pageHeight = $fmt === '99014' ? '101mm' : '32mm'; + $template = is_array(data_get($item->metadati, 'generic_label_template')) ? data_get($item->metadati, 'generic_label_template') : []; + $templateName = (string) ($template['template_name'] ?? 'classic-right'); + $qrPosition = (string) ($template['qr_position'] ?? ($fmt === '99014' ? 'bottom-right' : 'right-center')); + $qrScale = (string) ($template['qr_scale'] ?? ($fmt === '99014' ? 'lg' : 'md')); + $isArchive99014 = $fmt === '99014' && $templateName === 'archive-99014'; + $payload = [ + 'mnemonic_code' => (string) ($template['mnemonic_code'] ?? ''), + 'titolo' => (string) $item->titolo, + 'linea_secondaria' => (string) data_get($item->metadati, 'linea_secondaria', ''), + 'tipo_label' => (string) $item->tipo_label, + 'supporto_fisico' => (string) ($item->supporto_fisico ?? ''), + 'percorso_compatto' => (string) ($item->percorso_fisico ?? ''), + 'ubicazione' => trim(implode(' · ', array_filter([$item->magazzino, $item->scaffale, $item->ripiano, $item->ubicazione_dettaglio]))), + 'note' => (string) ($item->note ?? ''), + 'stabile' => (string) ($item->stabile->denominazione ?? ''), + 'stabile_code' => (string) ($item->stabile->codice_stabile ?? ''), + 'stabile_address' => (string) ($item->stabile->indirizzo_completo ?? ''), + 'stabile_summary' => trim(implode(' · ', array_filter([ + (string) ($item->stabile->denominazione ?? ''), + (string) ($item->stabile->codice_stabile ?? ''), + (string) ($item->stabile->indirizzo_completo ?? ''), + ]))), + 'codice_univoco' => (string) $item->codice_univoco, + 'amazon_url' => (string) data_get($item->metadati, 'amazon_url', ''), + ]; + $slotValue = static function (string $key, array $payload): string { + if ($key === 'none') { + return ''; + } + + return trim((string) ($payload[$key] ?? '')); + }; + $titleText = $slotValue((string) ($template['title_source'] ?? 'titolo'), $payload); + $subtitleText = $slotValue((string) ($template['subtitle_source'] ?? 'linea_secondaria'), $payload); + $metaText = $slotValue((string) ($template['meta_source'] ?? 'percorso_compatto'), $payload); + $noteText = $slotValue((string) ($template['note_source'] ?? 'note'), $payload); + $stableLineValue = static function (string $key, array $payload): string { + if ($key === 'none') { + return ''; + } + + if ($key === 'blank_line') { + return '__BLANK_LINE__'; + } + + return trim((string) ($payload[$key] ?? '')); + }; + $stableLines = [ + $stableLineValue((string) ($template['stable_line_one_source'] ?? 'stabile_summary'), $payload), + $stableLineValue((string) ($template['stable_line_two_source'] ?? 'blank_line'), $payload), + ]; + $blankLinesCount = max(0, min(4, (int) ($template['blank_lines_count'] ?? 2))); + @endphp