Add archive 99014 labels and Amazon Creators sync scaffold
This commit is contained in:
parent
c2dc58f0b9
commit
1ebcb95bff
119
app/Console/Commands/SyncAmazonCreatorsCatalogCommand.php
Normal file
119
app/Console/Commands/SyncAmazonCreatorsCatalogCommand.php
Normal 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')
|
||||
: '-';
|
||||
}
|
||||
}
|
||||
|
|
@ -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
154
app/Services/Catalog/AmazonCreatorsApiService.php
Normal file
154
app/Services/Catalog/AmazonCreatorsApiService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
411
app/Services/Catalog/AmazonCreatorsCatalogSyncService.php
Normal file
411
app/Services/Catalog/AmazonCreatorsCatalogSyncService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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', ''),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
85
docs/AMAZON-CREATORS-API.md
Normal file
85
docs/AMAZON-CREATORS-API.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user