Add archive 99014 labels and Amazon Creators sync scaffold

This commit is contained in:
michele 2026-04-27 15:44:03 +00:00
parent c2dc58f0b9
commit 1ebcb95bff
10 changed files with 2424 additions and 113 deletions

View File

@ -0,0 +1,119 @@
<?php
namespace App\Console\Commands;
use App\Models\Product;
use App\Services\Catalog\AmazonCreatorsCatalogSyncService;
use Illuminate\Console\Command;
use RuntimeException;
class SyncAmazonCreatorsCatalogCommand extends Command
{
protected $signature = 'catalog:sync-amazon-creators
{product : ID o internal_code del prodotto}
{--asin= : ASIN da forzare}
{--query= : Query da forzare per SearchItems}
{--json= : File JSON con payload item o risposta SDK/API}
{--marketplace= : Marketplace, es. www.amazon.it}
{--dry-run : Risolve il mapping senza scrivere offerte/media/codici}';
protected $description = 'Sincronizza un prodotto NetGescon con Amazon Creators API o con un payload JSON esportato dallo SDK.';
public function handle(AmazonCreatorsCatalogSyncService $syncService): int
{
$product = $this->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<string, mixed> */
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')
: '-';
}
}

View File

@ -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();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,154 @@
<?php
namespace App\Services\Catalog;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class AmazonCreatorsApiService
{
public function isConfigured(): bool
{
return (bool) config('services.amazon.creators_enabled', false)
&& $this->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<string, mixed> */
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<int, string> $identifiers
* @return array<string, mixed>
*/
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<string, mixed> */
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;
}
}

View File

@ -0,0 +1,411 @@
<?php
namespace App\Services\Catalog;
use App\Models\Product;
use App\Models\ProductIdentifier;
use App\Models\ProductMedia;
use App\Models\ProductOffer;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use RuntimeException;
class AmazonCreatorsCatalogSyncService
{
public function __construct(
private readonly AmazonCreatorsApiService $apiService,
private readonly ProductOfferService $productOfferService,
) {
}
/** @return array<string, mixed> */
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<string, mixed> */
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<string, mixed>|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<int, string> $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;
}
}

View File

@ -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', ''),
],
];

View File

@ -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.

View File

@ -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;

View File

@ -0,0 +1,388 @@
<x-filament::section>
<x-slot name="heading">Etichette generiche per contenitori</x-slot>
<x-slot name="description">Area dedicata al template etichetta: prima configuri e vedi subito l'anteprima, poi scegli come stampare le etichette create.</x-slot>
@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
<div class="space-y-4">
<div class="grid items-start gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(360px,0.8fr)] 2xl:grid-cols-[minmax(0,1.25fr)_minmax(420px,0.75fr)]">
<div class="space-y-4 rounded-xl border bg-white p-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">Parametri etichetta</div>
<div class="mt-1 text-xs text-slate-500">A sinistra imposti contenitore, layout, binding campi e formato di stampa. A destra vedi sempre come viene.</div>
</div>
<div class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">Layout split</div>
</div>
<div class="mt-4 rounded-xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm text-sky-900">
Le etichette create qui restano memorizzate nell'archivio documentale e possono diventare preset riusabili per altre sezioni del programma.
</div>
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-end">
<div class="min-w-0 flex-1">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Salva configurazione come template</label>
<input type="text" wire:model.live.debounce.200ms="genericLabelTemplateLabel" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. Scatola bilanci annuali" />
<div class="mt-1 text-xs text-slate-500">Salvi layout, formato etichetta, binding dei campi e posizione QR per riusarli più avanti.</div>
</div>
<button type="button" wire:click="saveCurrentGenericLabelAsTemplate" class="inline-flex items-center justify-center rounded-lg border border-amber-300 bg-white px-4 py-2 text-sm font-medium text-amber-800 shadow-sm hover:bg-amber-100">Memorizza template</button>
</div>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Preset rapido</label>
<div class="flex gap-2">
<select wire:model.live="genericLabelForm.preset_key" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
<option value="">Seleziona preset</option>
@foreach($this->genericLabelPresetOptions as $presetKey => $presetLabel)
<option value="{{ $presetKey }}">{{ $presetLabel }}</option>
@endforeach
</select>
<button type="button" wire:click="applySelectedGenericLabelPreset" class="inline-flex shrink-0 items-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50">Applica</button>
</div>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Template layout</label>
<select wire:model.live="genericLabelForm.template_name" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->genericLabelTemplateOptions as $templateKey => $templateLabel)
<option value="{{ $templateKey }}">{{ $templateLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Codice mnemonico</label>
<input type="text" wire:model.live.debounce.150ms="genericLabelForm.mnemonic_code" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. GER00079" />
<div class="mt-1 text-[11px] text-slate-500">Suggerito: {{ $this->mnemonicCodeHint ?? 'GER00079' }}</div>
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Titolo etichetta</label>
<input type="text" wire:model.live.debounce.150ms="genericLabelForm.titolo" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. SCATOLA CONTRATTI ASCENSORI" />
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Seconda riga</label>
<input type="text" wire:model.live.debounce.150ms="genericLabelForm.linea_secondaria" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. Stabile Roma 12 · 2024/2026" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Tipologia</label>
<select wire:model.live="genericLabelForm.tipo_item" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->physicalArchiveTypeOptions as $typeKey => $typeLabel)
@if($typeKey !== 'documento')
<option value="{{ $typeKey }}">{{ $typeLabel }}</option>
@endif
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Modello etichetta</label>
<select wire:model.live="genericLabelForm.modello_etichetta" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->getModelliEtichettaOptions() as $labelKey => $labelText)
<option value="{{ $labelKey }}">{{ $labelText }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Dentro contenitore</label>
<select wire:model.live="genericLabelForm.parent_id" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
<option value="">Radice archivio</option>
@foreach($this->physicalArchiveParentOptions as $parentId => $parentLabel)
<option value="{{ $parentId }}">{{ $parentLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Supporto</label>
<input type="text" wire:model.live.debounce.150ms="genericLabelForm.supporto_fisico" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. Scatola o raccoglitore" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Riga stabile 1</label>
<select wire:model.live="genericLabelForm.stable_line_one_source" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->genericLabelStableFieldOptions as $fieldKey => $fieldLabel)
<option value="{{ $fieldKey }}">{{ $fieldLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Riga stabile 2</label>
<select wire:model.live="genericLabelForm.stable_line_two_source" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->genericLabelStableFieldOptions as $fieldKey => $fieldLabel)
<option value="{{ $fieldKey }}">{{ $fieldLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Righe bianche scrivibili</label>
<select wire:model.live="genericLabelForm.blank_lines_count" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
<option value="0">Nessuna</option>
<option value="1">1 riga</option>
<option value="2">2 righe</option>
<option value="3">3 righe</option>
<option value="4">4 righe</option>
</select>
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Prodotto Amazon</label>
<input type="text" wire:model.live.debounce.200ms="genericLabelForm.amazon_url" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="URL completo o ASIN Amazon" />
@if(data_get($preview, 'amazon_url') !== '')
<div class="mt-2">
<a href="{{ data_get($preview, 'amazon_url') }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-900 shadow-sm hover:bg-amber-100">Apri prodotto Amazon</a>
</div>
@endif
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Campo titolo</label>
<select wire:model.live="genericLabelForm.title_source" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->genericLabelTemplateFieldOptions as $fieldKey => $fieldLabel)
<option value="{{ $fieldKey }}">{{ $fieldLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Campo sottotitolo</label>
<select wire:model.live="genericLabelForm.subtitle_source" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->genericLabelTemplateFieldOptions as $fieldKey => $fieldLabel)
<option value="{{ $fieldKey }}">{{ $fieldLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Campo meta</label>
<select wire:model.live="genericLabelForm.meta_source" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->genericLabelTemplateFieldOptions as $fieldKey => $fieldLabel)
<option value="{{ $fieldKey }}">{{ $fieldLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Campo nota preview</label>
<select wire:model.live="genericLabelForm.note_source" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->genericLabelTemplateFieldOptions as $fieldKey => $fieldLabel)
<option value="{{ $fieldKey }}">{{ $fieldLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Posizione QR</label>
<select wire:model.live="genericLabelForm.qr_position" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
<option value="right-center">Destra centrale</option>
<option value="right-top">Destra alta</option>
<option value="bottom-right">Basso destra</option>
<option value="bottom-center">Basso centro</option>
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Dimensione QR</label>
<select wire:model.live="genericLabelForm.qr_scale" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
<option value="sm">Compatto</option>
<option value="md">Medio</option>
<option value="lg">Grande</option>
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Magazzino</label>
<input type="text" wire:model.live.debounce.150ms="genericLabelForm.magazzino" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. Deposito EUR" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Scaffale</label>
<input type="text" wire:model.live.debounce.150ms="genericLabelForm.scaffale" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. B2" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Ripiano</label>
<input type="text" wire:model.live.debounce.150ms="genericLabelForm.ripiano" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. 4" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Dettaglio ubicazione</label>
<input type="text" wire:model.live.debounce.150ms="genericLabelForm.ubicazione_dettaglio" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. corridoio destro" />
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Nota breve</label>
<textarea wire:model.live.debounce.200ms="genericLabelForm.note" rows="3" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Contenuto, periodo, riferimenti operativi..."></textarea>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2 border-t border-slate-200 pt-4">
<button type="button" wire:click="saveGenericArchiveLabel" class="inline-flex items-center rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-slate-800">Crea etichetta</button>
</div>
</div>
<aside class="space-y-4 lg:sticky lg:top-6 lg:self-start">
<div class="rounded-xl border bg-white p-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">Anteprima live</div>
<div class="mt-1 text-xs text-slate-500">Questa colonna segue il form e resta sempre visibile sul desktop.</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">{{ $this->genericLabelTemplateOptions[$templateName] ?? 'Template' }}</div>
<div class="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700">{{ $format === '99014' ? 'Preview 99014' : 'Preview 11354' }}</div>
</div>
</div>
<div class="mt-4 rounded-2xl border border-slate-300 bg-slate-50 p-4">
@if($isArchive99014)
<div class="mx-auto w-full max-w-[430px] overflow-hidden rounded-xl border-2 border-slate-900 bg-white shadow-sm">
<div class="grid min-h-[260px] grid-cols-[1fr_82px] gap-4 p-4">
<div class="min-w-0">
<div class="border-b-2 border-slate-900 pb-2 text-[22px] font-black uppercase tracking-[0.16em] text-slate-900">{{ $preview['mnemonic_code'] ?? ($this->mnemonicCodeHint ?? 'GER00079') }}</div>
<div class="mt-3 text-[19px] font-bold uppercase leading-tight text-slate-900">{{ data_get($preview, 'slots.title') !== '' ? data_get($preview, 'slots.title') : 'Titolo etichetta' }}</div>
<div class="mt-2 text-[13px] font-medium leading-snug text-slate-700">{{ data_get($preview, 'slots.subtitle') !== '' ? data_get($preview, 'slots.subtitle') : 'Seconda riga descrittiva' }}</div>
<div class="mt-4 space-y-2 text-[12px] leading-snug text-slate-700">
@foreach(($preview['stable_lines'] ?? []) as $stableLine)
@if($stableLine === '__BLANK_LINE__')
<div class="h-5 border-b border-slate-400"></div>
@elseif($stableLine !== '')
<div>{{ $stableLine }}</div>
@endif
@endforeach
</div>
<div class="mt-4 space-y-2">
@foreach(($preview['blank_lines'] ?? []) as $blankLine)
<div class="h-5 border-b border-slate-400"></div>
@endforeach
</div>
@if(data_get($preview, 'slots.note') !== '')
<div class="mt-4 rounded-lg border border-dashed border-slate-300 px-3 py-2 text-[11px] leading-relaxed text-slate-600">{{ data_get($preview, 'slots.note') }}</div>
@endif
</div>
<div class="flex flex-col items-center justify-end gap-3">
<div class="{{ $qrClass }} rounded-xl border border-slate-300 bg-[linear-gradient(135deg,#0f172a_16%,transparent_16%,transparent_34%,#0f172a_34%,#0f172a_50%,transparent_50%,transparent_66%,#0f172a_66%,#0f172a_84%,transparent_84%)] bg-[length:16px_16px] bg-white"></div>
<div class="text-center text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">QR</div>
</div>
</div>
</div>
@else
<div class="relative mx-auto overflow-hidden rounded-xl border border-slate-900 bg-white shadow-sm {{ $isWideFormat ? 'min-h-[220px] max-w-[430px]' : 'min-h-[150px] max-w-[320px]' }}">
@if($templateName === 'band-top')
<div class="h-8 bg-gradient-to-r {{ $accentClass }}"></div>
@endif
<div class="relative flex h-full {{ $isBottomQr ? 'flex-col' : 'flex-row' }} gap-3 p-4 {{ $isWideFormat ? 'min-h-[220px]' : 'min-h-[150px]' }}">
<div class="min-w-0 flex-1">
<div class="text-[10px] font-semibold uppercase tracking-[0.25em] text-slate-500">{{ $preview['payload']['codice_univoco'] ?? 'AF-QR-PREVIEW' }}</div>
<div class="mt-2 text-lg font-bold leading-tight text-slate-900">{{ data_get($preview, 'slots.title') !== '' ? data_get($preview, 'slots.title') : 'Titolo etichetta' }}</div>
@if(data_get($preview, 'slots.subtitle') !== '')
<div class="mt-2 text-sm font-medium text-slate-600">{{ data_get($preview, 'slots.subtitle') }}</div>
@endif
@if(data_get($preview, 'slots.meta') !== '')
<div class="mt-3 text-xs leading-relaxed text-slate-500">{{ data_get($preview, 'slots.meta') }}</div>
@endif
@if(data_get($preview, 'slots.note') !== '')
<div class="mt-3 rounded-lg border border-dashed border-slate-200 bg-slate-50 px-2 py-2 text-[11px] text-slate-500">{{ data_get($preview, 'slots.note') }}</div>
@endif
<div class="mt-3 rounded-lg border border-slate-200 bg-white px-2 py-2 text-[11px] text-slate-500">
QR payload: {{ $preview['payload']['qr_payload'] ?? 'NGDOC|AF:AF-QR-PREVIEW|TIPO:SCATOLA|STB:0|PARENT:RADICE' }}
</div>
</div>
<div class="{{ $isBottomQr ? 'flex justify-center pt-1' : ($qrPosition === 'right-top' ? 'flex items-start justify-end' : 'flex items-center justify-end') }} shrink-0">
<div class="{{ $qrClass }} rounded-xl border border-slate-300 bg-[linear-gradient(135deg,#0f172a_16%,transparent_16%,transparent_34%,#0f172a_34%,#0f172a_50%,transparent_50%,transparent_66%,#0f172a_66%,#0f172a_84%,transparent_84%)] bg-[length:16px_16px] bg-white"></div>
</div>
</div>
</div>
@endif
</div>
<div class="mt-4 space-y-3 text-sm text-slate-600">
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">Il codice univoco viene generato automaticamente e diventa il nodo da scansionare e ristampare quando serve.</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">Il QR codifica l'identità archivistica e la relazione col contenitore padre, non l'host o un URL pubblico.</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">Template, binding campi DB e nodo della catena vengono salvati nell'etichetta, così la stampa reale segue il layout scelto.</div>
</div>
</div>
</aside>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div class="rounded-xl border bg-white p-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">Template riusabili salvati</div>
<div class="mt-1 text-xs text-slate-500">Libreria separata dal builder, così la preview non finisce sotto il form.</div>
</div>
<div class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">Libreria template</div>
</div>
<div class="mt-4 space-y-3">
@forelse($this->genericLabelSavedTemplates as $savedTemplate)
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<div class="text-sm font-semibold text-slate-900">{{ $savedTemplate['label'] }}</div>
<div class="mt-1 text-xs text-slate-500">{{ $savedTemplate['template_label'] }} · {{ $savedTemplate['modello'] }} · {{ \App\Models\DocumentoArchivioFisicoItem::TIPI[$savedTemplate['tipo_item']] ?? $savedTemplate['tipo_item'] }}</div>
</div>
<button type="button" wire:click="applyGenericLabelPreset('{{ $savedTemplate['key'] }}')" class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-100">Usa template</button>
</div>
</div>
@empty
<div class="rounded-lg border border-dashed border-slate-300 bg-slate-50 px-3 py-4 text-sm text-slate-500">Nessun template personalizzato salvato ancora. Prepara il layout nel builder, verifica la preview e poi usa “Memorizza template”.</div>
@endforelse
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold text-slate-900">Ultime etichette generiche</div>
<div class="text-xs text-slate-500">Scelta esplicita del formato di stampa</div>
</div>
<div class="mt-4 space-y-3">
@forelse($this->genericLabelRows as $row)
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-slate-900 px-2.5 py-1 text-[11px] font-semibold text-white">{{ $row['codice_univoco'] }}</span>
<span class="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-slate-700">{{ $row['tipo'] }}</span>
</div>
<div class="mt-2 text-sm font-semibold text-slate-900">{{ $row['titolo'] }}</div>
@if($row['linea_secondaria'] !== '')
<div class="mt-1 text-xs text-slate-500">{{ $row['linea_secondaria'] }}</div>
@endif
<div class="mt-1 text-[11px] text-sky-700">{{ $row['preset_label'] }} · {{ $row['template_label'] }}</div>
<div class="mt-1 text-xs text-slate-500">Percorso: {{ $row['percorso'] }}</div>
@if($row['ubicazione'] !== '')
<div class="mt-1 text-xs text-slate-500">Ubicazione: {{ $row['ubicazione'] }}</div>
@endif
@if($row['amazon_url'] !== '')
<div class="mt-2"><a href="{{ $row['amazon_url'] }}" target="_blank" class="text-xs font-medium text-sky-700 underline decoration-sky-300 underline-offset-2">Apri link acquisto</a></div>
@endif
</div>
<div class="w-full max-w-xs shrink-0" x-data="{ preset: @js($row['default_print']), options: @js($row['print_options']) }">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Cosa stampare</label>
<div class="flex gap-2">
<select x-model="preset" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($row['print_options'] as $printKey => $printOption)
<option value="{{ $printKey }}">{{ $printOption['label'] }}</option>
@endforeach
</select>
<a :href="options[preset]?.url || '#'
" target="_blank" class="inline-flex shrink-0 items-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50">Stampa</a>
</div>
</div>
</div>
</div>
@empty
<div class="rounded-lg border border-dashed border-slate-300 bg-white px-4 py-6 text-sm text-slate-500">Nessuna etichetta generica creata ancora per lo stabile attivo.</div>
@endforelse
</div>
</div>
</div>
</div>
</x-filament::section>

View File

@ -4,15 +4,69 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Etichetta Archivio Fisico</title>
@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
<style>
@page {
margin: {{ $labelPrinterMode ? '0' : '10mm' }};
@ -98,6 +152,9 @@
padding: 0.4mm;
box-sizing: border-box;
}
.qr.qr-sm { width: 11mm; height: 11mm; }
.qr.qr-md { width: 15mm; height: 15mm; }
.qr.qr-lg { width: 18mm; height: 18mm; }
.panel.panel-99014 .qr {
position: absolute;
right: 2.8mm;
@ -118,6 +175,110 @@
aspect-ratio: 1 / 1;
}
.path { font-size: {{ $fmt === '99014' ? '9px' : '6.6px' }}; line-height: 1.2; color: #444; margin-top: 1mm; word-break: break-word; }
.archive-99014 {
display: grid;
grid-template-columns: 1fr 18mm;
gap: 2.8mm;
padding: 2.8mm;
align-items: stretch;
}
.archive-99014 .content {
display: flex;
flex-direction: column;
min-width: 0;
}
.archive-99014-code {
border-bottom: 0.35mm solid #111;
padding-bottom: 1.2mm;
font-size: 15px;
line-height: 1.05;
font-weight: 900;
letter-spacing: 0.8px;
text-transform: uppercase;
}
.archive-99014-title {
margin-top: 2mm;
font-size: 13px;
line-height: 1.15;
font-weight: 700;
text-transform: uppercase;
}
.archive-99014-subtitle {
margin-top: 1.5mm;
font-size: 10px;
line-height: 1.25;
}
.archive-99014-stable {
margin-top: 2mm;
display: flex;
flex-direction: column;
gap: 1.4mm;
font-size: 9px;
line-height: 1.2;
}
.archive-99014-line {
min-height: 4.5mm;
}
.archive-99014-line.blank,
.archive-99014-blank {
border-bottom: 0.25mm solid #555;
}
.archive-99014-blanks {
margin-top: 2mm;
display: flex;
flex-direction: column;
gap: 1.5mm;
}
.archive-99014-blank {
min-height: 4.8mm;
}
.archive-99014-note {
margin-top: 2mm;
border: 0.25mm dashed #666;
padding: 1.4mm;
font-size: 8px;
line-height: 1.25;
}
.archive-99014-qr {
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
gap: 1.2mm;
}
.archive-99014-qr-label {
font-size: 7px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #666;
}
.qr-right-top .qr {
align-self: flex-start;
}
.qr-right-center .qr {
align-self: center;
}
.qr-bottom-right,
.qr-bottom-center {
grid-template-columns: 1fr;
}
.qr-bottom-right .content,
.qr-bottom-center .content {
padding-right: 0;
}
.qr-bottom-right .qr,
.qr-bottom-center .qr {
position: absolute;
bottom: {{ $fmt === '99014' ? '2.8mm' : '1.5mm' }};
}
.qr-bottom-right .qr {
right: {{ $fmt === '99014' ? '2.8mm' : '1.5mm' }};
}
.qr-bottom-center .qr {
left: 50%;
transform: translateX(-50%);
}
@media screen {
body {
padding: 12mm 0;
@ -188,34 +349,91 @@
<div class="label">
<div class="calib">
<div class="label-content">
<div @class(['panel', 'panel-99014' => $fmt === '99014'])>
<div class="content">
<div class="code">{{ $item->codice_univoco }}</div>
<div class="title">{{ $item->titolo }}</div>
<div class="meta">
@if($item->stabile)
<div><strong>Stabile:</strong> {{ $item->stabile->denominazione }}</div>
@if($isArchive99014)
<div class="panel panel-99014 archive-99014">
<div class="content">
<div class="archive-99014-code">{{ $payload['mnemonic_code'] !== '' ? $payload['mnemonic_code'] : $item->codice_univoco }}</div>
<div class="archive-99014-title">{{ $titleText !== '' ? $titleText : $item->titolo }}</div>
@if($subtitleText !== '')
<div class="archive-99014-subtitle">{{ $subtitleText }}</div>
@endif
<div><strong>Tipo:</strong> {{ $item->tipo_label }}</div>
@if($item->voce_numero || $item->voce_titolo)
<div><strong>Voce:</strong> {{ $item->voce_numero ? 'Voce ' . $item->voce_numero : '' }} {{ $item->voce_titolo }}</div>
<div class="archive-99014-stable">
@foreach($stableLines as $stableLine)
@if($stableLine === '__BLANK_LINE__')
<div class="archive-99014-line blank"></div>
@elseif($stableLine !== '')
<div class="archive-99014-line">{{ $stableLine }}</div>
@endif
@endforeach
</div>
@if($blankLinesCount > 0)
<div class="archive-99014-blanks">
@for($line = 0; $line < $blankLinesCount; $line++)
<div class="archive-99014-blank"></div>
@endfor
</div>
@endif
@if($item->data_archiviazione)
<div><strong>Data:</strong> {{ $item->data_archiviazione->format('d-m-Y') }}</div>
@endif
@if($item->parent)
<div><strong>Dentro:</strong> {{ $item->parent->codice_univoco }}</div>
@endif
@if($item->documento)
<div><strong>Digitale:</strong> {{ $item->documento->nome_file ?: $item->documento->nome }}</div>
@if($noteText !== '')
<div class="archive-99014-note">{{ $noteText }}</div>
@endif
</div>
<div class="path">{{ $item->percorso_fisico }}</div>
<div class="archive-99014-qr">
<div @class(['qr', 'qr-sm' => $qrScale === 'sm', 'qr-md' => $qrScale === 'md', 'qr-lg' => $qrScale === 'lg'])>
{!! $item->qr_code_image !!}
</div>
<div class="archive-99014-qr-label">QR</div>
</div>
</div>
<div class="qr">
{!! $item->qr_code_image !!}
@else
<div @class([
'panel',
'panel-99014' => $fmt === '99014',
'qr-right-top' => $qrPosition === 'right-top',
'qr-right-center' => $qrPosition === 'right-center',
'qr-bottom-right' => $qrPosition === 'bottom-right',
'qr-bottom-center' => $qrPosition === 'bottom-center',
])>
<div class="content">
<div class="code">{{ $item->codice_univoco }}</div>
<div class="title">{{ $titleText !== '' ? $titleText : $item->titolo }}</div>
<div class="meta">
@if($subtitleText !== '')
<div><strong>Linea:</strong> {{ $subtitleText }}</div>
@endif
@if($metaText !== '')
<div><strong>Info:</strong> {{ $metaText }}</div>
@endif
<div><strong>Tipo:</strong> {{ $item->tipo_label }}</div>
@if($item->voce_numero || $item->voce_titolo)
<div><strong>Voce:</strong> {{ $item->voce_numero ? 'Voce ' . $item->voce_numero : '' }} {{ $item->voce_titolo }}</div>
@endif
@if($item->data_archiviazione)
<div><strong>Data:</strong> {{ $item->data_archiviazione->format('d-m-Y') }}</div>
@endif
@if($item->parent)
<div><strong>Dentro:</strong> {{ $item->parent->codice_univoco }}</div>
@endif
@if($item->documento)
<div><strong>Digitale:</strong> {{ $item->documento->nome_file ?: $item->documento->nome }}</div>
@endif
@if($noteText !== '')
<div><strong>Nota:</strong> {{ $noteText }}</div>
@endif
@if(data_get($item->metadati, 'amazon_url'))
<div><strong>Acquisto:</strong> {{ data_get($item->metadati, 'amazon_url') }}</div>
@endif
</div>
<div class="path">{{ $item->percorso_fisico }}</div>
</div>
<div @class(['qr', 'qr-sm' => $qrScale === 'sm', 'qr-md' => $qrScale === 'md', 'qr-lg' => $qrScale === 'lg'])>
{!! $item->qr_code_image !!}
</div>
</div>
</div>
@endif
</div>
</div>
</div>