Aggiorna flussi operativi e refresh staging sicuro

This commit is contained in:
michele 2026-04-28 13:19:26 +00:00
parent 5f248f825c
commit d7e9a1c39d
87 changed files with 7226 additions and 1740 deletions

View File

@ -0,0 +1,480 @@
<?php
namespace App\Console\Commands;
use App\Models\Fornitore;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class GesconFornitoriDedupCommand extends Command
{
protected $signature = 'gescon:fornitori-dedup
{--apply : Applica la bonifica}
{--limit= : Limita il numero di gruppi elaborati}
{--admin-id= : In caso di parita preferisce il fornitore di questo amministratore}';
protected $description = 'Bonifica i doppioni fornitori mantenendo un record canonico per identita e salvando gli alias cod_forn legacy.';
/** @var array<int, int> */
private array $referenceCounts = [];
/** @var array<int, array{table:string,column:string}> */
private array $referenceColumns = [];
public function handle(): int
{
if (! Schema::hasTable('fornitori')) {
$this->error('Tabella fornitori non disponibile.');
return self::FAILURE;
}
$apply = (bool) $this->option('apply');
$limit = $this->option('limit') ? max(1, (int) $this->option('limit')) : null;
$preferredAdminId = $this->option('admin-id') !== null ? max(0, (int) $this->option('admin-id')) : null;
$groups = $this->loadDuplicateGroups($limit);
if ($groups === []) {
$this->info('Nessun doppione fornitore rilevato.');
return self::SUCCESS;
}
$this->referenceColumns = $this->discoverSupplierReferenceColumns();
$this->referenceCounts = $this->loadSupplierReferenceCounts();
$stats = [
'groups' => 0,
'duplicates' => 0,
'merged' => 0,
'deleted' => 0,
'moved_links' => 0,
'skipped_delete' => 0,
'errors' => 0,
];
$previewRows = [];
foreach ($groups as $groupKey => $suppliers) {
$stats['groups']++;
$stats['duplicates'] += count($suppliers) - 1;
$master = $this->pickMaster($suppliers, $preferredAdminId);
$duplicates = array_values(array_filter($suppliers, fn(Fornitore $supplier): bool => (int) $supplier->id !== (int) $master->id));
$previewRows[] = [
'group' => $groupKey,
'keep' => '#' . (int) $master->id . ' ' . trim((string) ($master->ragione_sociale ?? '')),
'keep_code' => $master->cod_forn ?: '—',
'drop' => implode(', ', array_map(fn(Fornitore $supplier): string => '#' . (int) $supplier->id, $duplicates)),
];
if (! $apply) {
continue;
}
try {
DB::transaction(function () use ($master, $duplicates, &$stats): void {
$masterModel = Fornitore::query()->findOrFail((int) $master->id);
$duplicatesModels = Fornitore::query()
->whereIn('id', array_map(fn(Fornitore $supplier): int => (int) $supplier->id, $duplicates))
->get();
if ($duplicatesModels->isEmpty()) {
return;
}
$masterPayload = $this->buildMasterPayload($masterModel, $duplicatesModels->all());
$masterModel->fill($masterPayload);
if ($masterModel->isDirty()) {
$masterModel->save();
}
foreach ($duplicatesModels as $duplicate) {
$stats['moved_links'] += $this->moveSupplierReferences((int) $duplicate->id, (int) $masterModel->id);
if ($this->hasRemainingReferences((int) $duplicate->id)) {
$stats['skipped_delete']++;
continue;
}
$duplicate->delete();
$stats['deleted']++;
$stats['merged']++;
}
});
} catch (\Throwable $exception) {
$stats['errors']++;
$this->warn('Gruppo ' . $groupKey . ' non bonificato: ' . $exception->getMessage());
}
}
$this->table(['Gruppo', 'Canonico', 'cod_forn', 'Duplicati'], array_slice($previewRows, 0, 20));
$this->line(json_encode($stats, JSON_UNESCAPED_UNICODE));
$this->info($apply ? 'Bonifica fornitori completata.' : 'Dry-run bonifica fornitori completato.');
return self::SUCCESS;
}
/** @return array<string, array<int, Fornitore>> */
private function loadDuplicateGroups(?int $limit = null): array
{
$suppliers = Fornitore::query()->get([
'id',
'amministratore_id',
'cod_forn',
'old_id',
'rubrica_id',
'ragione_sociale',
'partita_iva',
'codice_fiscale',
'email',
'pec',
'telefono',
'cellulare',
'iban',
'codice_univoco',
'fe_features',
'operational_config',
'note',
]);
$groups = [];
foreach ($suppliers as $supplier) {
$groupKey = $this->buildIdentityKey($supplier);
if ($groupKey === null) {
continue;
}
$groups[$groupKey] ??= [];
$groups[$groupKey][] = $supplier;
}
$groups = array_filter($groups, fn(array $items): bool => count($items) > 1);
ksort($groups);
if ($limit !== null) {
$groups = array_slice($groups, 0, $limit, true);
}
return $groups;
}
private function buildIdentityKey(Fornitore $supplier): ?string
{
$rubricaId = (int) ($supplier->rubrica_id ?? 0);
if ($rubricaId > 0) {
return 'rubrica:' . $rubricaId;
}
$piva = $this->normalizeNumericIdentity($supplier->partita_iva);
if ($piva !== null) {
return 'piva:' . $piva;
}
$cf = $this->normalizeAlphaNumericIdentity($supplier->codice_fiscale);
if ($cf !== null) {
return 'cf:' . $cf;
}
return null;
}
/** @param array<int, Fornitore> $suppliers */
private function pickMaster(array $suppliers, ?int $preferredAdminId): Fornitore
{
$best = null;
$bestScore = null;
foreach ($suppliers as $supplier) {
$score = 0;
if ((int) ($supplier->cod_forn ?? 0) > 0) {
$score += 2000;
}
if ((int) ($supplier->rubrica_id ?? 0) > 0) {
$score += 400;
}
if ($preferredAdminId !== null && (int) ($supplier->amministratore_id ?? 0) === $preferredAdminId) {
$score += 300;
}
if (! empty($supplier->fe_features)) {
$score += 150;
}
if (! empty($supplier->operational_config)) {
$score += 100;
}
foreach (['partita_iva', 'codice_fiscale', 'email', 'pec', 'telefono', 'cellulare', 'iban', 'codice_univoco'] as $field) {
if (trim((string) ($supplier->{$field} ?? '')) !== '') {
$score += 20;
}
}
$score += ((int) ($this->referenceCounts[(int) $supplier->id] ?? 0)) * 25;
if ($best === null || $bestScore === null || $score > $bestScore || ($score === $bestScore && (int) $supplier->id < (int) $best->id)) {
$best = $supplier;
$bestScore = $score;
}
}
return $best ?? $suppliers[0];
}
/** @param array<int, Fornitore> $duplicates */
private function buildMasterPayload(Fornitore $master, array $duplicates): array
{
$payload = [];
foreach (['rubrica_id', 'partita_iva', 'codice_fiscale', 'email', 'pec', 'telefono', 'cellulare', 'iban', 'codice_univoco'] as $field) {
if (trim((string) ($master->{$field} ?? '')) !== '') {
continue;
}
foreach ($duplicates as $duplicate) {
$value = $duplicate->{$field} ?? null;
if (trim((string) $value) !== '') {
$payload[$field] = $value;
break;
}
}
}
$masterFeFeatures = is_array($master->fe_features ?? null) ? $master->fe_features : [];
$masterOperationalConfig = is_array($master->operational_config ?? null) ? $master->operational_config : [];
$legacyCodFornAliases = [];
$legacyOldIdAliases = [];
$mergedSupplierIds = [];
if ((int) ($master->cod_forn ?? 0) > 0) {
$legacyCodFornAliases[] = (int) $master->cod_forn;
}
if ((int) ($master->old_id ?? 0) > 0) {
$legacyOldIdAliases[] = (int) $master->old_id;
}
foreach ($duplicates as $duplicate) {
if ((int) ($duplicate->cod_forn ?? 0) > 0) {
$legacyCodFornAliases[] = (int) $duplicate->cod_forn;
}
if ((int) ($duplicate->old_id ?? 0) > 0) {
$legacyOldIdAliases[] = (int) $duplicate->old_id;
}
$mergedSupplierIds[] = (int) $duplicate->id;
$masterFeFeatures = $this->mergeConfigRecursive($masterFeFeatures, is_array($duplicate->fe_features ?? null) ? $duplicate->fe_features : []);
$masterOperationalConfig = $this->mergeConfigRecursive($masterOperationalConfig, is_array($duplicate->operational_config ?? null) ? $duplicate->operational_config : []);
}
$legacyCodFornAliases = array_values(array_unique(array_filter(array_map('intval', array_merge(
$legacyCodFornAliases,
array_map('intval', (array) ($masterOperationalConfig['legacy_cod_forn_aliases'] ?? []))
)))));
$legacyOldIdAliases = array_values(array_unique(array_filter(array_map('intval', array_merge(
$legacyOldIdAliases,
array_map('intval', (array) ($masterOperationalConfig['legacy_old_id_aliases'] ?? []))
)))));
$mergedSupplierIds = array_values(array_unique(array_filter(array_map('intval', array_merge(
$mergedSupplierIds,
array_map('intval', (array) ($masterOperationalConfig['merged_supplier_ids'] ?? []))
)))));
$masterOperationalConfig['legacy_cod_forn_aliases'] = $legacyCodFornAliases;
$masterOperationalConfig['legacy_old_id_aliases'] = $legacyOldIdAliases;
$masterOperationalConfig['merged_supplier_ids'] = $mergedSupplierIds;
$payload['fe_features'] = $masterFeFeatures;
$payload['operational_config'] = $masterOperationalConfig;
if (trim((string) ($master->note ?? '')) === '' && $duplicates !== []) {
$payload['note'] = 'Fornitore canonico dopo bonifica duplicati.';
}
return $payload;
}
/** @return array<int, array{table:string,column:string}> */
private function discoverSupplierReferenceColumns(): array
{
$database = DB::getDatabaseName();
$rows = DB::select(
"SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = ?
AND table_name <> 'fornitori'
AND column_name IN ('fornitore_id', 'default_fornitore_id', 'assegnato_a_fornitore_id', 'fornitore_esterno_id')
ORDER BY table_name, column_name",
[$database]
);
return array_values(array_filter(array_map(function (object $row): ?array {
$table = (string) ($row->TABLE_NAME ?? $row->table_name ?? '');
$column = (string) ($row->COLUMN_NAME ?? $row->column_name ?? '');
if ($table === '' || $column === '') {
return null;
}
return ['table' => $table, 'column' => $column];
}, $rows)));
}
/** @return array<int, int> */
private function loadSupplierReferenceCounts(): array
{
$counts = [];
foreach ($this->discoverSupplierReferenceColumns() as $reference) {
$table = $reference['table'];
$column = $reference['column'];
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
continue;
}
$rows = DB::table($table)
->selectRaw($column . ' as supplier_id, COUNT(*) as aggregate_count')
->whereNotNull($column)
->groupBy($column)
->get();
foreach ($rows as $row) {
$supplierId = is_numeric($row->supplier_id ?? null) ? (int) $row->supplier_id : 0;
if ($supplierId <= 0) {
continue;
}
$counts[$supplierId] = (int) ($counts[$supplierId] ?? 0) + (int) ($row->aggregate_count ?? 0);
}
}
return $counts;
}
private function moveSupplierReferences(int $fromSupplierId, int $toSupplierId): int
{
if ($fromSupplierId <= 0 || $toSupplierId <= 0 || $fromSupplierId === $toSupplierId) {
return 0;
}
$moved = 0;
foreach ($this->referenceColumns as $reference) {
$table = $reference['table'];
$column = $reference['column'];
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
continue;
}
if ($table === 'fornitore_stabile_impostazioni' && $column === 'fornitore_id') {
$moved += $this->mergeFornitoreStabileImpostazioni($fromSupplierId, $toSupplierId);
continue;
}
$moved += (int) DB::table($table)
->where($column, $fromSupplierId)
->update([$column => $toSupplierId]);
}
return $moved;
}
private function mergeFornitoreStabileImpostazioni(int $fromSupplierId, int $toSupplierId): int
{
if (! Schema::hasTable('fornitore_stabile_impostazioni')) {
return 0;
}
$moved = 0;
$rows = DB::table('fornitore_stabile_impostazioni')
->where('fornitore_id', $fromSupplierId)
->get();
foreach ($rows as $row) {
$target = DB::table('fornitore_stabile_impostazioni')
->where('stabile_id', (int) $row->stabile_id)
->where('fornitore_id', $toSupplierId)
->first();
if (! $target) {
$moved += (int) DB::table('fornitore_stabile_impostazioni')
->where('id', (int) $row->id)
->update(['fornitore_id' => $toSupplierId]);
continue;
}
if (Schema::hasColumn('fornitore_stabile_impostazioni', 'meta')) {
$sourceMeta = json_decode((string) ($row->meta ?? '[]'), true);
$targetMeta = json_decode((string) ($target->meta ?? '[]'), true);
$mergedMeta = $this->mergeConfigRecursive(is_array($targetMeta) ? $targetMeta : [], is_array($sourceMeta) ? $sourceMeta : []);
DB::table('fornitore_stabile_impostazioni')
->where('id', (int) $target->id)
->update([
'meta' => json_encode($mergedMeta, JSON_UNESCAPED_UNICODE),
'updated_at' => now(),
]);
}
DB::table('fornitore_stabile_impostazioni')->where('id', (int) $row->id)->delete();
$moved++;
}
return $moved;
}
private function hasRemainingReferences(int $supplierId): bool
{
foreach ($this->referenceColumns as $reference) {
$table = $reference['table'];
$column = $reference['column'];
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
continue;
}
if (DB::table($table)->where($column, $supplierId)->exists()) {
return true;
}
}
return false;
}
/** @param array<string, mixed> $master
* @param array<string, mixed> $duplicate
* @return array<string, mixed>
*/
private function mergeConfigRecursive(array $master, array $duplicate): array
{
foreach ($duplicate as $key => $value) {
if (! array_key_exists($key, $master)) {
$master[$key] = $value;
continue;
}
if (is_array($master[$key]) && is_array($value)) {
$master[$key] = $this->mergeConfigRecursive($master[$key], $value);
}
}
return $master;
}
private function normalizeNumericIdentity(mixed $value): ?string
{
$digits = preg_replace('/\D+/', '', trim((string) $value)) ?? '';
if ($digits === '' || preg_match('/^0+$/', $digits) === 1) {
return null;
}
return strlen($digits) < 11 ? str_pad($digits, 11, '0', STR_PAD_LEFT) : $digits;
}
private function normalizeAlphaNumericIdentity(mixed $value): ?string
{
$normalized = strtoupper(trim((string) $value));
$normalized = preg_replace('/\s+/', '', $normalized) ?? '';
if ($normalized === '' || in_array($normalized, ['0', '00', '000', 'ND', 'N/D', 'NULL'], true)) {
return null;
}
return $normalized;
}
}

View File

@ -44,7 +44,7 @@ public function handle(): int
return self::FAILURE;
}
$tableCodes = collect(array_keys($legacyHeaders))->values();
$tableCodes = collect(array_keys($legacyHeaders))->values();
$legacyDetails = $this->loadLegacyDettagli($stableCode, $year, $tableCodes);
$this->assertLegacyVociHaveKnownTables($legacyVoci, $tableCodes);

View File

@ -2828,7 +2828,7 @@ private function stepOperazioni(?int $limit): int
$data = [
'tenant_id' => null,
'gestione_id' => $this->resolveGestioneId($o->anno ?? null, $o->compet ?? null, $o->gestione ?? null),
'gestione_id' => $this->resolveGestioneId($o->anno ?? null, $o->compet ?? null, $o->gestione ?? null, $o->n_stra ?? null),
'legacy_id' => $legacy,
'descrizione' => $o->note ?? ($o->natura ?? 'Operazione'),
'data_operazione' => $o->dt_spe ?? now()->toDateString(),
@ -5782,46 +5782,21 @@ private function resolveGestioneContabileIdForStabile(?int $stabileId, ?string $
return $match?->id;
}
private function resolveGestioneId($anno, $compet, $tipo): ?int
private function resolveGestioneId($anno, $compet, $tipo, $numeroStraordinaria = null): ?int
{
if (! Schema::hasTable('gestioni_contabili')) {
return null;
}
// Normalize tipo from O/R/S to descriptive if needed
$map = ['O' => 'ordinaria', 'R' => 'riscaldamento', 'S' => 'straordinaria'];
$tipo = $map[$tipo ?? 'O'] ?? ($tipo ?: 'ordinaria');
$anno = $anno ?: date('Y');
$row = DB::table('gestioni_contabili')
->where(['anno_gestione' => (int) $anno, 'tipo_gestione' => $tipo])
->first();
if ($row) {
return $row->id;
}
$legacyYear = $anno !== null && $anno !== '' ? (string) $anno : null;
$stabileId = $this->stabileId ?: $this->resolveStabileId();
// In dry-run, avoid creating missing gestione
if ($this->option('dry-run')) {
return null;
}
// Create minimal compatible record satisfying not-null constraints
$data = [
'tenant_id' => 'T1',
'anno_gestione' => (int) $anno,
'tipo_gestione' => $tipo,
'numero_straordinaria' => null,
'denominazione' => strtoupper(substr($tipo, 0, 1)) . $anno,
'data_inizio' => date('Y-01-01', strtotime($anno . '-01-01')),
'data_fine' => date('Y-12-31', strtotime($anno . '-12-31')),
'stato' => 'aperta',
'protocollo_prefix' => strtoupper(substr($tipo, 0, 1)) . $anno,
'ultimo_protocollo' => 0,
'created_at' => now(),
'updated_at' => now(),
];
DB::table('gestioni_contabili')->insert($data);
$row = DB::table('gestioni_contabili')->where(['anno_gestione' => (int) $anno, 'tipo_gestione' => $tipo])->first();
return $row?->id;
return $this->resolveGestioneContabileIdForStabile(
$stabileId,
$legacyYear,
$tipo !== null ? (string) $tipo : null,
is_numeric($numeroStraordinaria) ? (int) $numeroStraordinaria : null,
);
}
private function dynamicInsert(string $table, array $data): void

View File

@ -3,14 +3,21 @@
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
class NetgesconNodeRefreshCommand extends Command
{
protected $signature = 'netgescon:node-refresh
{--remote=origin : Remote Git da usare}
{--branch=main : Branch da allineare}
{--admin-id= : ID amministratore per eventuale backup Drive}
{--drive : Carica il backup pre-update anche su Google Drive}
{--force : Continua anche con working tree sporco lato sync}
{--require-drive : Fallisce se il backup Drive non viene completato}
{--skip-backup : Salta backup pre-update (solo test/debug)}
{--skip-migrate : Salta php artisan migrate --force}
{--skip-maintenance : Salta php artisan down/up (solo test/debug)}
{--skip-view-cache : Salta rebuild view cache}
{--progress-file= : File JSON dove scrivere lo stato avanzamento}';
@ -18,110 +25,292 @@ class NetgesconNodeRefreshCommand extends Command
public function handle(): int
{
$remote = trim((string) $this->option('remote')) ?: 'origin';
$branch = trim((string) $this->option('branch')) ?: 'main';
$force = (bool) $this->option('force');
$skipMigrate = (bool) $this->option('skip-migrate');
$skipViewCache = (bool) $this->option('skip-view-cache');
$remote = trim((string) $this->option('remote')) ?: 'origin';
$branch = trim((string) $this->option('branch')) ?: 'main';
$adminId = (int) $this->option('admin-id');
$force = (bool) $this->option('force');
$driveBackup = (bool) $this->option('drive');
$requireDrive = (bool) $this->option('require-drive');
$skipBackup = (bool) $this->option('skip-backup');
$skipMigrate = (bool) $this->option('skip-migrate');
$skipMaintenance = (bool) $this->option('skip-maintenance');
$skipViewCache = (bool) $this->option('skip-view-cache');
$maintenanceOn = false;
$maintenanceError = null;
$this->writeProgress(2, 'Avvio refresh nodo da Gitea', 'running');
$gitParams = [
'--remote' => $remote,
'--branch' => $branch,
];
$fullOutput = [];
if ($force) {
$gitParams['--force'] = true;
if (! $skipBackup) {
$this->writeProgress(8, 'Backup pre-update in corso', 'running');
$backupArgs = [
'--differential' => true,
'--tag' => 'node-refresh-' . now()->format('Ymd-His'),
];
if ($driveBackup) {
$backupArgs['--drive'] = true;
if ($adminId > 0) {
$backupArgs['--admin-id'] = (string) $adminId;
}
if ($requireDrive) {
$backupArgs['--require-drive'] = true;
}
}
$backupExit = Artisan::call('netgescon:preupdate-backup', $backupArgs);
$backupOutput = trim((string) Artisan::output());
$fullOutput[] = 'NODE REFRESH: php artisan netgescon:preupdate-backup';
$fullOutput[] = $backupOutput;
if ($backupExit !== 0) {
$this->error('Backup pre-update fallito.');
$this->line($backupOutput);
$this->writeProgress(100, 'Backup pre-update fallito', 'failed', $backupExit);
return self::FAILURE;
}
}
$this->info("Sync Git da {$remote}/{$branch}...");
$this->writeProgress(15, 'Sync Git da Gitea in corso', 'running');
$gitExit = Artisan::call('netgescon:git-sync', $gitParams);
$gitOutput = trim((string) Artisan::output());
if (! $skipMaintenance) {
$this->writeProgress(16, 'Blocco accessi e maintenance mode', 'running');
$maintenanceExit = Artisan::call('down', ['--retry' => 60, '--refresh' => 15]);
$maintenanceOutput = trim((string) Artisan::output());
$fullOutput[] = 'NODE REFRESH: php artisan down --retry=60 --refresh=15';
$fullOutput[] = $maintenanceOutput;
if ($gitExit !== 0) {
$this->error('Sync Git fallita.');
$this->line($gitOutput);
$this->writeProgress(100, 'Sync Git fallita', 'failed', $gitExit);
if ($maintenanceExit !== 0) {
$this->error('Impossibile attivare la maintenance mode.');
$this->line($maintenanceOutput);
$this->writeProgress(100, 'Maintenance mode non attivata', 'failed', $maintenanceExit);
return self::FAILURE;
}
$maintenanceOn = true;
}
try {
$gitParams = [
'--remote' => $remote,
'--branch' => $branch,
];
if ($force) {
$gitParams['--force'] = true;
}
$this->info("Sync Git da {$remote}/{$branch}...");
$this->writeProgress(28, 'Sync Git da Gitea in corso', 'running');
$gitExit = Artisan::call('netgescon:git-sync', $gitParams);
$gitOutput = trim((string) Artisan::output());
if ($gitExit !== 0) {
$this->error('Sync Git fallita.');
$this->line($gitOutput);
$this->writeProgress(100, 'Sync Git fallita', 'failed', $gitExit);
return self::FAILURE;
}
$fullOutput[] = 'NODE REFRESH: netgescon:git-sync';
$fullOutput[] = $gitOutput;
$this->writeProgress(46, 'Composer install e autoload', 'running');
$composerResult = $this->runShellStep([
'composer',
'install',
'--no-interaction',
'--prefer-dist',
'--optimize-autoloader',
'--no-progress',
]);
$fullOutput[] = 'NODE REFRESH: composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress';
$fullOutput[] = $composerResult['output'];
if (! $composerResult['success']) {
$this->error('Composer install fallito.');
$this->line($composerResult['output']);
$this->writeProgress(100, 'Composer install fallito', 'failed', $composerResult['exit_code']);
return self::FAILURE;
}
$this->writeProgress(58, 'Build frontend se disponibile', 'running');
$frontendBuild = $this->attemptFrontendBuild();
if ($frontendBuild !== null) {
$fullOutput[] = $frontendBuild['label'];
$fullOutput[] = $frontendBuild['output'];
}
$this->info('Pulizia cache applicative...');
$this->writeProgress(68, 'Pulizia cache applicative', 'running');
$optimizeExit = Artisan::call('optimize:clear');
$optimizeOutput = trim((string) Artisan::output());
$fullOutput[] = 'NODE REFRESH: php artisan optimize:clear';
$fullOutput[] = $optimizeOutput;
if ($optimizeExit !== 0) {
$this->error('optimize:clear fallito.');
$this->line($optimizeOutput);
$this->writeProgress(100, 'Pulizia cache fallita', 'failed', $optimizeExit);
return self::FAILURE;
}
if (! $skipMigrate) {
$this->info('Esecuzione migrate --force...');
$this->writeProgress(76, 'Applicazione migration', 'running');
$migrateExit = Artisan::call('migrate', ['--force' => true]);
$migrateOutput = trim((string) Artisan::output());
$fullOutput[] = 'NODE REFRESH: php artisan migrate --force';
$fullOutput[] = $migrateOutput;
if ($migrateExit !== 0) {
$this->error('Migration fallita.');
$this->line($migrateOutput);
$this->writeProgress(100, 'Migration fallita', 'failed', $migrateExit);
return self::FAILURE;
}
}
if (! $skipViewCache) {
$this->info('Ricostruzione view cache...');
$this->writeProgress(84, 'Ricostruzione view cache', 'running');
$viewClearExit = Artisan::call('view:clear');
$viewClearOutput = trim((string) Artisan::output());
$fullOutput[] = 'NODE REFRESH: php artisan view:clear';
$fullOutput[] = $viewClearOutput;
if ($viewClearExit !== 0) {
$this->error('view:clear fallito.');
$this->line($viewClearOutput);
$this->writeProgress(100, 'view:clear fallito', 'failed', $viewClearExit);
return self::FAILURE;
}
$viewCacheExit = Artisan::call('view:cache');
$viewCacheOutput = trim((string) Artisan::output());
$fullOutput[] = 'NODE REFRESH: php artisan view:cache';
$fullOutput[] = $viewCacheOutput;
if ($viewCacheExit !== 0) {
$this->error('view:cache fallita.');
$this->line($viewCacheOutput);
$this->writeProgress(100, 'view:cache fallita', 'failed', $viewCacheExit);
return self::FAILURE;
}
}
$this->writeProgress(91, 'Rigenerazione cache applicative finali', 'running');
foreach ([
'config:cache' => 'NODE REFRESH: php artisan config:cache',
'event:cache' => 'NODE REFRESH: php artisan event:cache',
'queue:restart' => 'NODE REFRESH: php artisan queue:restart',
] as $artisanCommand => $label) {
$exit = Artisan::call($artisanCommand);
$output = trim((string) Artisan::output());
$fullOutput[] = $label;
$fullOutput[] = $output;
if ($exit !== 0) {
$this->warn($artisanCommand . ' non completato: ' . ($output !== '' ? $output : 'uscita ' . $exit));
}
}
$this->line(implode(PHP_EOL . PHP_EOL, array_filter($fullOutput, static fn(string $chunk): bool => trim($chunk) !== '')));
$this->writeProgress(98, 'Riapertura nodo e chiusura refresh', 'running');
} finally {
if ($maintenanceOn) {
$upExit = Artisan::call('up');
$maintenanceUp = trim((string) Artisan::output());
$fullOutput[] = 'NODE REFRESH: php artisan up';
$fullOutput[] = $maintenanceUp;
if ($upExit !== 0) {
$maintenanceError = $maintenanceUp !== '' ? $maintenanceUp : 'php artisan up fallito';
}
$lockPath = storage_path('framework/down');
if (is_file($lockPath)) {
@unlink($lockPath);
}
}
}
if ($maintenanceError !== null) {
$this->error('Refresh completato ma il nodo non e stato riaperto automaticamente.');
$this->line($maintenanceError);
$this->writeProgress(100, 'Refresh completato ma nodo ancora in maintenance', 'failed', 1);
return self::FAILURE;
}
$fullOutput = [
'NODE REFRESH: netgescon:git-sync',
$gitOutput,
];
if (! $skipMigrate) {
$this->info('Esecuzione migrate --force...');
$this->writeProgress(55, 'Applicazione migration', 'running');
$migrateExit = Artisan::call('migrate', ['--force' => true]);
$migrateOutput = trim((string) Artisan::output());
$fullOutput[] = 'NODE REFRESH: php artisan migrate --force';
$fullOutput[] = $migrateOutput;
if ($migrateExit !== 0) {
$this->error('Migration fallita.');
$this->line($migrateOutput);
$this->writeProgress(100, 'Migration fallita', 'failed', $migrateExit);
return self::FAILURE;
}
}
$this->info('Pulizia cache applicative...');
$this->writeProgress(72, 'Pulizia cache applicative', 'running');
$optimizeExit = Artisan::call('optimize:clear');
$optimizeOutput = trim((string) Artisan::output());
$fullOutput[] = 'NODE REFRESH: php artisan optimize:clear';
$fullOutput[] = $optimizeOutput;
if ($optimizeExit !== 0) {
$this->error('optimize:clear fallito.');
$this->line($optimizeOutput);
$this->writeProgress(100, 'Pulizia cache fallita', 'failed', $optimizeExit);
return self::FAILURE;
}
if (! $skipViewCache) {
$this->info('Ricostruzione view cache...');
$this->writeProgress(88, 'Ricostruzione view cache', 'running');
$viewClearExit = Artisan::call('view:clear');
$viewClearOutput = trim((string) Artisan::output());
$fullOutput[] = 'NODE REFRESH: php artisan view:clear';
$fullOutput[] = $viewClearOutput;
if ($viewClearExit !== 0) {
$this->error('view:clear fallito.');
$this->line($viewClearOutput);
$this->writeProgress(100, 'view:clear fallito', 'failed', $viewClearExit);
return self::FAILURE;
}
$viewCacheExit = Artisan::call('view:cache');
$viewCacheOutput = trim((string) Artisan::output());
$fullOutput[] = 'NODE REFRESH: php artisan view:cache';
$fullOutput[] = $viewCacheOutput;
if ($viewCacheExit !== 0) {
$this->error('view:cache fallita.');
$this->line($viewCacheOutput);
$this->writeProgress(100, 'view:cache fallita', 'failed', $viewCacheExit);
return self::FAILURE;
}
}
$this->line(implode(PHP_EOL . PHP_EOL, array_filter($fullOutput, static fn(string $chunk): bool => trim($chunk) !== '')));
$this->writeProgress(100, 'Refresh nodo completato', 'completed', 0);
return self::SUCCESS;
}
/** @return array{success:bool,exit_code:int,output:string} */
private function runShellStep(array $command): array
{
$result = Process::path(base_path())
->timeout(3600)
->run($command);
return [
'success' => $result->successful(),
'exit_code' => $result->exitCode(),
'output' => trim((string) ($result->output() !== '' ? $result->output() : $result->errorOutput())),
];
}
/** @return array{label:string,output:string}|null */
private function attemptFrontendBuild(): ?array
{
if (! is_file(base_path('package.json'))) {
return null;
}
$nodeCheck = $this->runShellStep(['bash', '-lc', 'command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1']);
if (! $nodeCheck['success']) {
return [
'label' => 'NODE REFRESH: frontend build skipped',
'output' => 'node/npm non disponibili sul nodo: build frontend saltata senza bloccare il deploy.',
];
}
$installCommand = is_file(base_path('package-lock.json'))
? ['npm', 'ci', '--no-audit', '--no-fund']
: ['npm', 'install', '--no-audit', '--no-fund'];
$installResult = $this->runShellStep($installCommand);
if (! $installResult['success']) {
return [
'label' => 'NODE REFRESH: npm install/ci',
'output' => $installResult['output'] !== ''
? $installResult['output']
: 'Installazione dipendenze frontend fallita; build saltata.',
];
}
$buildResult = $this->runShellStep(['npm', 'run', 'build']);
return [
'label' => 'NODE REFRESH: npm run build',
'output' => $buildResult['output'] !== ''
? $buildResult['output']
: ($buildResult['success'] ? 'Build frontend completata.' : 'Build frontend fallita.'),
];
}
private function writeProgress(int $percent, string $message, string $status, ?int $exitCode = null): void
{
$path = trim((string) $this->option('progress-file'));

View File

@ -116,4 +116,4 @@ private function formatMoney(mixed $amount, string $currency): string
? number_format((float) $amount, 2, ',', '.') . ' ' . strtoupper(trim($currency) !== '' ? $currency : 'EUR')
: '-';
}
}
}

View File

@ -1,6 +1,7 @@
<?php
namespace App\Console\Commands;
use App\Models\Fornitore;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@ -70,24 +71,7 @@ public function handle(): int
->values()
->all();
$fornitoreMap = [];
if (! empty($codes) && Schema::hasTable('fornitori')) {
$fornitori = DB::table('fornitori')
->whereIn('old_id', $codes)
->get(['id', 'old_id']);
foreach ($fornitori as $fornitore) {
$raw = trim((string) $fornitore->old_id);
$noZero = ltrim($raw, '0');
if ($raw !== '') {
$fornitoreMap[$raw] = (int) $fornitore->id;
}
if ($noZero !== '') {
$fornitoreMap[$noZero] = (int) $fornitore->id;
}
}
}
$fornitoreMap = $this->buildFornitoreMapByLegacyCode($codes);
$created = 0;
$updated = 0;
@ -415,13 +399,15 @@ private function resolveFatturaElettronicaId(int $stabileId, int $fornitoreId, s
return null;
}
$fornitoreIds = $this->resolveEquivalentFornitoreIds($fornitoreId);
$base = DB::table('fatture_elettroniche')
->where('stabile_id', $stabileId)
->whereIn('fornitore_id', $fornitoreIds)
->whereDate('data_fattura', $dataDocumento)
->whereRaw('LOWER(TRIM(numero_fattura)) = ?', [$numeroNorm]);
$strict = (clone $base)
->where('fornitore_id', $fornitoreId)
->orderByDesc('id')
->value('id');
@ -444,4 +430,118 @@ private function resolveFatturaElettronicaId(int $stabileId, int $fornitoreId, s
return null;
}
/** @param array<int, string> $codes
* @return array<string, int>
*/
private function buildFornitoreMapByLegacyCode(array $codes): array
{
if ($codes === [] || ! Schema::hasTable('fornitori')) {
return [];
}
$map = [];
$suppliers = Fornitore::query()->get(['id', 'cod_forn', 'old_id', 'operational_config']);
foreach ($suppliers as $supplier) {
$directCodes = [];
if ((int) ($supplier->cod_forn ?? 0) > 0) {
$directCodes[] = (string) (int) $supplier->cod_forn;
}
if ((int) ($supplier->old_id ?? 0) > 0) {
$directCodes[] = (string) (int) $supplier->old_id;
}
$operationalConfig = is_array($supplier->operational_config ?? null) ? $supplier->operational_config : [];
$aliasCodes = array_map(
fn($value): string => (string) (int) $value,
array_filter((array) ($operationalConfig['legacy_cod_forn_aliases'] ?? []), fn($value): bool => is_numeric($value) && (int) $value > 0)
);
$aliasOldIds = array_map(
fn($value): string => (string) (int) $value,
array_filter((array) ($operationalConfig['legacy_old_id_aliases'] ?? []), fn($value): bool => is_numeric($value) && (int) $value > 0)
);
foreach (array_merge($directCodes, $aliasCodes, $aliasOldIds) as $code) {
$this->registerFornitoreCodeMap($map, $code, (int) $supplier->id, in_array($code, $directCodes, true));
}
}
foreach ($codes as $code) {
$trim = trim((string) $code);
if ($trim === '') {
continue;
}
$noZero = ltrim($trim, '0');
if ($noZero !== '' && isset($map[$noZero]) && ! isset($map[$trim])) {
$map[$trim] = $map[$noZero];
}
if ($noZero !== '' && isset($map[$trim]) && ! isset($map[$noZero])) {
$map[$noZero] = $map[$trim];
}
}
return $map;
}
/** @param array<string, int> $map */
private function registerFornitoreCodeMap(array &$map, string $code, int $supplierId, bool $isDirectCode): void
{
$trim = trim($code);
if ($supplierId <= 0 || $trim === '') {
return;
}
$keys = array_values(array_unique(array_filter([$trim, ltrim($trim, '0')], fn($value): bool => $value !== '')));
foreach ($keys as $key) {
if (! isset($map[$key]) || $isDirectCode) {
$map[$key] = $supplierId;
}
}
}
/** @return array<int, int> */
private function resolveEquivalentFornitoreIds(int $fornitoreId): array
{
if ($fornitoreId <= 0 || ! Schema::hasTable('fornitori')) {
return [$fornitoreId];
}
$supplier = Fornitore::query()->find($fornitoreId, ['id', 'rubrica_id', 'partita_iva', 'codice_fiscale', 'old_id']);
if (! $supplier) {
return [$fornitoreId];
}
$query = Fornitore::query()->where('id', $fornitoreId);
$rubricaId = (int) ($supplier->rubrica_id ?? 0);
if ($rubricaId > 0) {
$query->orWhere('rubrica_id', $rubricaId);
}
$partitaIva = trim((string) ($supplier->partita_iva ?? ''));
if ($partitaIva !== '') {
$query->orWhere('partita_iva', $partitaIva);
}
$codiceFiscale = trim((string) ($supplier->codice_fiscale ?? ''));
if ($codiceFiscale !== '') {
$query->orWhere('codice_fiscale', $codiceFiscale);
}
$oldId = (int) ($supplier->old_id ?? 0);
if ($oldId > 0) {
$query->orWhere('old_id', $oldId);
}
$ids = $query->pluck('id')
->map(fn($value) => (int) $value)
->filter(fn($value) => $value > 0)
->unique()
->values()
->all();
return $ids !== [] ? $ids : [$fornitoreId];
}
}

View File

@ -185,8 +185,8 @@ public function handle(TecnoRepairMdbReader $reader, FornitoreProductCatalogServ
->orderBy('id')
->get();
$existing = $matchingSchede->first();
$duplicateSchede = $matchingSchede->slice(1);
$existing = $matchingSchede->first();
$duplicateSchede = $matchingSchede->slice(1);
$duplicateSchedaIds = $duplicateSchede
->pluck('id')
->filter(fn($value): bool => is_numeric($value) && (int) $value > 0)
@ -383,7 +383,7 @@ private function consolidateLegacyDuplicates(int $amministratoreId, ?int $fornit
}
/** @var AssistenzaTecnorepairScheda $canonical */
$canonical = $matchingSchede->first();
$canonical = $matchingSchede->first();
$duplicateSchede = $matchingSchede->slice(1);
foreach ($duplicateSchede as $duplicateScheda) {

View File

@ -614,24 +614,24 @@ private function loadAffitti(): void
return [$a->id => $label];
})->all();
$this->affittiList = $rows->map(fn(AffittoImmobile $a) => [
'warnings' => $this->buildAffittoWarnings($a),
'id' => $a->id,
'codice_appartamento' => $a->codice_appartamento,
'descrizione_immobile' => $a->descrizione_immobile,
'nome_inquilino' => $a->nome_inquilino,
'proprietario_nome' => $a->proprietario_nome,
'inizio_contratto' => $a->inizio_contratto,
'warnings' => $this->buildAffittoWarnings($a),
'id' => $a->id,
'codice_appartamento' => $a->codice_appartamento,
'descrizione_immobile' => $a->descrizione_immobile,
'nome_inquilino' => $a->nome_inquilino,
'proprietario_nome' => $a->proprietario_nome,
'inizio_contratto' => $a->inizio_contratto,
'presa_in_carico_operativa_dal' => $a->presa_in_carico_operativa_dal,
'prossima_registrazione' => $a->prossima_registrazione,
'tipo_riga' => $a->tipo_riga,
'stabile_id' => $a->stabile_id,
'dovuti_totale' => (float) ($a->dovuti_totale ?? 0),
'pagati_totale' => (float) ($a->pagati_totale ?? 0),
'saldo_totale' => (float) ($a->dovuti_totale ?? 0) - (float) ($a->pagati_totale ?? 0),
'obsoleto' => (bool) ($a->obsoleto ?? false),
'obsoleto_dal' => $a->obsoleto_dal,
'stabile' => $a->stabile?->codice_univoco ?: ($a->stabile?->codice_stabile ?: $a->codice_stabile_legacy),
'stabile_nome' => $a->stabile?->denominazione,
'prossima_registrazione' => $a->prossima_registrazione,
'tipo_riga' => $a->tipo_riga,
'stabile_id' => $a->stabile_id,
'dovuti_totale' => (float) ($a->dovuti_totale ?? 0),
'pagati_totale' => (float) ($a->pagati_totale ?? 0),
'saldo_totale' => (float) ($a->dovuti_totale ?? 0) - (float) ($a->pagati_totale ?? 0),
'obsoleto' => (bool) ($a->obsoleto ?? false),
'obsoleto_dal' => $a->obsoleto_dal,
'stabile' => $a->stabile?->codice_univoco ?: ($a->stabile?->codice_stabile ?: $a->codice_stabile_legacy),
'stabile_nome' => $a->stabile?->denominazione,
])->all();
}

View File

@ -0,0 +1,470 @@
<?php
namespace App\Filament\Pages\Condomini;
use App\Filament\Pages\Strumenti\DocumentiArchivio;
use App\Models\Documento;
use App\Models\Fornitore;
use App\Models\Scadenza;
use App\Models\Stabile;
use App\Models\StabileContratto;
use App\Models\User;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use UnitEnum;
class ContrattiHub extends Page
{
protected static ?string $navigationLabel = 'HUB Contratti';
protected static ?string $title = 'HUB Contratti';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static UnitEnum|string|null $navigationGroup = 'Stabile';
protected static ?int $navigationSort = 34;
protected static ?string $slug = 'condomini/contratti-hub';
protected string $view = 'filament.pages.condomini.contratti-hub';
public ?Stabile $stabileAttivo = null;
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
public function mount(): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$activeId = StabileContext::resolveActiveStabileId($user);
if (! $activeId) {
return;
}
$this->stabileAttivo = StabileContext::accessibleStabili($user)->firstWhere('id', $activeId);
}
protected function getHeaderActions(): array
{
return [
Action::make('registra_contratto')
->label('Registra contratto')
->icon('heroicon-o-plus')
->form($this->getContrattoFormSchema())
->action(function (array $data): void {
$this->createContratto($data);
}),
Action::make('apri_documenti')
->label('Archivio documenti')
->icon('heroicon-o-folder-open')
->url(fn(): string => DocumentiArchivio::getUrl(panel: 'admin-filament')),
];
}
public function getStatsProperty(): array
{
$stabileId = (int) ($this->stabileAttivo?->id ?? 0);
if ($stabileId <= 0) {
return ['totale' => 0, 'attivi' => 0, 'scadenze' => 0, 'documenti' => 0];
}
$query = StabileContratto::query()->where('stabile_id', $stabileId);
return [
'totale' => (clone $query)->count(),
'attivi' => (clone $query)->where('stato', 'attivo')->count(),
'scadenze' => Scadenza::query()
->where('stabile_id', $stabileId)
->whereNotNull('stabile_contratto_id')
->whereDate('data_scadenza', '>=', now()->toDateString())
->whereDate('data_scadenza', '<=', now()->addDays(45)->toDateString())
->count(),
'documenti' => Documento::query()
->where('stabile_id', $stabileId)
->where('tipo_documento', 'contratto')
->count(),
];
}
public function getContrattiRowsProperty(): Collection
{
$stabileId = (int) ($this->stabileAttivo?->id ?? 0);
if ($stabileId <= 0) {
return collect();
}
return StabileContratto::query()
->with(['fornitore:id,ragione_sociale,nome,cognome', 'documentoPrincipale:id,nome,numero_protocollo'])
->where('stabile_id', $stabileId)
->orderByRaw("CASE WHEN stato = 'attivo' THEN 0 WHEN stato = 'in_revisione' THEN 1 ELSE 2 END")
->orderByDesc('decorrenza_dal')
->orderByDesc('id')
->get();
}
public function getUpcomingScadenzeProperty(): Collection
{
$stabileId = (int) ($this->stabileAttivo?->id ?? 0);
if ($stabileId <= 0) {
return collect();
}
return Scadenza::query()
->with('stabileContratto:id,titolo,tipo_contratto')
->where('stabile_id', $stabileId)
->whereNotNull('stabile_contratto_id')
->whereDate('data_scadenza', '>=', now()->subDays(7)->toDateString())
->orderBy('data_scadenza')
->limit(10)
->get();
}
protected function getContrattoFormSchema(): array
{
return [
Select::make('stabile_id')
->label('Stabile')
->options(fn(): array=> $this->getStabiliOptions())
->default(fn(): ?int => $this->stabileAttivo?->id)
->searchable()
->required(),
Select::make('fornitore_id')
->label('Fornitore / manutentore')
->options(fn(): array=> $this->getFornitoriOptions())
->searchable()
->nullable(),
Select::make('tipo_contratto')
->label('Tipo contratto')
->options($this->getTipoContrattoOptions())
->required(),
TextInput::make('titolo')
->label('Titolo')
->required()
->maxLength(255),
TextInput::make('servizio_label')
->label('Servizio / utenza')
->placeholder('Es. Ascensore scala A, Acqua potabile, Luce comune')
->maxLength(255),
TextInput::make('codice_contratto')
->label('Codice contratto')
->maxLength(80),
TextInput::make('riferimento_esterno')
->label('Riferimento esterno')
->placeholder('Es. numero cliente, riferimento pratica, ID POD/PDR principale')
->maxLength(120),
DatePicker::make('data_stipula')
->label('Data stipula'),
DatePicker::make('decorrenza_dal')
->label('Decorrenza dal')
->required(),
DatePicker::make('decorrenza_al')
->label('Decorrenza al'),
Select::make('frequenza_scadenze')
->label('Frequenza scadenze')
->options($this->getFrequenzaOptions())
->default('annuale')
->required(),
TextInput::make('giorno_scadenza')
->label('Giorno scadenza')
->numeric()
->minValue(1)
->maxValue(28),
TextInput::make('giorni_preavviso')
->label('Giorni preavviso')
->numeric()
->default(30)
->minValue(0)
->maxValue(365),
Toggle::make('rinnovo_automatico')
->label('Rinnovo automatico')
->default(false),
TextInput::make('importo_periodico')
->label('Importo periodico')
->numeric()
->step(0.01)
->minValue(0),
Select::make('documento_principale_id')
->label('Documento principale')
->options(fn(callable $get): array=> $this->getDocumentiContrattoOptions((int) ($get('stabile_id') ?: 0)))
->searchable()
->nullable(),
Repeater::make('codici_collegati')
->label('Codici collegati')
->schema([
TextInput::make('chiave')
->label('Chiave')
->placeholder('Es. POD, PDR, matricola_contatore, codice_cliente, utenza, matricola_ascensore')
->required(),
TextInput::make('valore')
->label('Valore')
->required(),
])
->addActionLabel('Aggiungi codice')
->defaultItems(0)
->columns(2),
Textarea::make('note')
->label('Note')
->rows(4),
];
}
protected function createContratto(array $data): void
{
$user = Auth::user();
if (! $user instanceof User) {
Notification::make()->title('Utente non valido')->danger()->send();
return;
}
$stabileId = (int) ($data['stabile_id'] ?? 0);
$stabile = StabileContext::accessibleStabili($user)->firstWhere('id', $stabileId);
if (! $stabile instanceof Stabile) {
Notification::make()->title('Stabile non valido')->danger()->send();
return;
}
$contract = StabileContratto::query()->create([
'stabile_id' => $stabileId,
'fornitore_id' => isset($data['fornitore_id']) && is_numeric($data['fornitore_id']) ? (int) $data['fornitore_id'] : null,
'documento_principale_id' => isset($data['documento_principale_id']) && is_numeric($data['documento_principale_id']) ? (int) $data['documento_principale_id'] : null,
'titolo' => trim((string) ($data['titolo'] ?? '')),
'tipo_contratto' => trim((string) ($data['tipo_contratto'] ?? 'altro')),
'categoria_impianto' => trim((string) ($data['tipo_contratto'] ?? 'altro')),
'servizio_label' => $this->nullableString($data['servizio_label'] ?? null),
'codice_contratto' => $this->nullableString($data['codice_contratto'] ?? null),
'riferimento_esterno' => $this->nullableString($data['riferimento_esterno'] ?? null),
'stato' => 'attivo',
'data_stipula' => $data['data_stipula'] ?? null,
'decorrenza_dal' => $data['decorrenza_dal'] ?? null,
'decorrenza_al' => $data['decorrenza_al'] ?? null,
'frequenza_scadenze' => $this->nullableString($data['frequenza_scadenze'] ?? null),
'giorno_scadenza' => isset($data['giorno_scadenza']) && is_numeric($data['giorno_scadenza']) ? (int) $data['giorno_scadenza'] : null,
'giorni_preavviso' => isset($data['giorni_preavviso']) && is_numeric($data['giorni_preavviso']) ? (int) $data['giorni_preavviso'] : 30,
'rinnovo_automatico' => (bool) ($data['rinnovo_automatico'] ?? false),
'importo_periodico' => isset($data['importo_periodico']) && is_numeric($data['importo_periodico']) ? (float) $data['importo_periodico'] : null,
'valuta' => 'EUR',
'codici_collegati' => $this->normalizeKeyValueRows($data['codici_collegati'] ?? []),
'dati_contratto' => [
'origine' => 'hub_contratti_stabile',
'documenti_url' => DocumentiArchivio::getUrl(panel: 'admin-filament'),
],
'note' => $this->nullableString($data['note'] ?? null),
'created_by' => (int) $user->id,
'updated_by' => (int) $user->id,
]);
$this->syncScadenzeContratto($contract);
Notification::make()
->title('Contratto registrato')
->body('Scadenze programmate: ' . $contract->scadenze()->count())
->success()
->send();
}
protected function syncScadenzeContratto(StabileContratto $contract): void
{
$contract->scadenze()->delete();
foreach ($this->buildScheduleDates($contract) as $date) {
$popupDal = $date->copy()->subDays(max(0, (int) ($contract->giorni_preavviso ?? 0)));
Scadenza::query()->create([
'stabile_id' => (int) $contract->stabile_id,
'stabile_contratto_id' => (int) $contract->id,
'descrizione_sintetica' => $contract->titolo,
'data_scadenza' => $date->toDateString(),
'natura' => 'contratto_stabile',
'natura_label' => 'Scadenza contratto',
'gestione_tipo' => $contract->tipo_contratto,
'fatto' => 'N',
'tipo_riga' => trim((string) ($contract->servizio_label ?: $contract->tipo_contratto)),
'richiede_pop_up' => true,
'giorni_anticipo' => (int) ($contract->giorni_preavviso ?? 0),
'popup_dal' => $popupDal->toDateString(),
'legacy_payload' => [
'source' => 'stabile_contratti',
'codice_contratto' => $contract->codice_contratto,
'riferimento_esterno' => $contract->riferimento_esterno,
'codici_collegati' => $contract->codici_collegati ?? [],
],
]);
}
}
/** @return array<int, Carbon> */
protected function buildScheduleDates(StabileContratto $contract): array
{
$start = $contract->decorrenza_dal instanceof Carbon
? $contract->decorrenza_dal->copy()
: ($contract->decorrenza_al instanceof Carbon ? $contract->decorrenza_al->copy() : now()->copy());
$end = $contract->decorrenza_al instanceof Carbon ? $contract->decorrenza_al->copy() : null;
$frequency = trim((string) ($contract->frequenza_scadenze ?? 'una_tantum'));
if ($contract->giorno_scadenza) {
$start->day(min(28, (int) $contract->giorno_scadenza));
}
if ($frequency === '' || $frequency === 'una_tantum') {
return [$end ?: $start];
}
$dates = [];
$cursor = $start->copy();
$limit = $end
? $end->copy()
: ($contract->rinnovo_automatico ? $start->copy()->addMonths(24) : $start->copy()->addMonths(12));
while ($cursor <= $limit && count($dates) < 60) {
$dates[] = $cursor->copy();
$cursor = match ($frequency) {
'mensile' => $cursor->copy()->addMonthNoOverflow(),
'bimestrale' => $cursor->copy()->addMonthsNoOverflow(2),
'trimestrale' => $cursor->copy()->addMonthsNoOverflow(3),
'semestrale' => $cursor->copy()->addMonthsNoOverflow(6),
default => $cursor->copy()->addYearNoOverflow(),
};
if ($contract->giorno_scadenza) {
$cursor->day(min(28, (int) $contract->giorno_scadenza));
}
}
return $dates;
}
protected function getTipoContrattoOptions(): array
{
return [
'acqua' => 'Acqua',
'energia_elettrica' => 'Energia elettrica',
'ascensore' => 'Ascensore',
'gas' => 'Gas',
'pulizie' => 'Pulizie',
'portierato' => 'Portierato',
'assicurazione' => 'Assicurazione',
'antincendio' => 'Antincendio',
'raffrescamento' => 'Raffrescamento',
'riscaldamento' => 'Riscaldamento',
'telefonia_dati' => 'Telefonia / dati',
'altro' => 'Altro servizio',
];
}
protected function getFrequenzaOptions(): array
{
return [
'una_tantum' => 'Una tantum',
'mensile' => 'Mensile',
'bimestrale' => 'Bimestrale',
'trimestrale' => 'Trimestrale',
'semestrale' => 'Semestrale',
'annuale' => 'Annuale',
];
}
protected function getStabiliOptions(): array
{
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
return StabileContext::accessibleStabili($user)
->mapWithKeys(fn(Stabile $stabile): array=> [(string) $stabile->id => trim((string) ($stabile->denominazione ?: ('Stabile #' . $stabile->id)))])
->all();
}
protected function getFornitoriOptions(): array
{
return Fornitore::query()
->orderByRaw("COALESCE(NULLIF(ragione_sociale, ''), CONCAT(COALESCE(nome, ''), ' ', COALESCE(cognome, ''))) asc")
->limit(1500)
->get(['id', 'ragione_sociale', 'nome', 'cognome'])
->mapWithKeys(function (Fornitore $fornitore): array {
$label = trim((string) ($fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? ''))));
return [(string) $fornitore->id => ($label !== '' ? $label : ('Fornitore #' . $fornitore->id))];
})
->all();
}
protected function getDocumentiContrattoOptions(int $stabileId): array
{
if ($stabileId <= 0) {
return [];
}
return Documento::query()
->where('stabile_id', $stabileId)
->where('tipo_documento', 'contratto')
->orderByDesc('data_documento')
->limit(250)
->get(['id', 'nome', 'numero_protocollo'])
->mapWithKeys(fn(Documento $documento): array=> [
(string) $documento->id => trim((string) (($documento->numero_protocollo ? $documento->numero_protocollo . ' · ' : '') . ($documento->nome ?: ('Documento #' . $documento->id)))),
])
->all();
}
/** @param array<int, mixed> $rows */
protected function normalizeKeyValueRows(array $rows): array
{
return collect($rows)
->map(function ($row): ?array {
$key = trim((string) data_get($row, 'chiave', ''));
$value = trim((string) data_get($row, 'valore', ''));
return ($key !== '' && $value !== '') ? ['chiave' => $key, 'valore' => $value] : null;
})
->filter()
->values()
->all();
}
protected function nullableString(mixed $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
public function contractBadgeColor(string $status): string
{
return match ($status) {
'attivo' => 'emerald',
'in_revisione' => 'amber',
'scaduto' => 'rose',
default => 'slate',
};
}
public function contractTypeLabel(?string $type): string
{
$key = trim((string) $type);
return $this->getTipoContrattoOptions()[$key] ?? ($key !== '' ? Str::headline($key) : 'Contratto');
}
}

View File

@ -195,13 +195,7 @@ public function ensureAcquaAceaContract(): void
return;
}
$fornitoreAcea = Fornitore::query()
->whereRaw('LOWER(ragione_sociale) like ?', ['%acea%ato2%'])
->orderBy('ragione_sociale')
->first() ?? Fornitore::query()
->whereRaw('LOWER(ragione_sociale) like ?', ['%acea%'])
->orderBy('ragione_sociale')
->first();
$fornitoreAcea = $this->resolvePreferredAcquaSupplier($stabileId);
if (! $fornitoreAcea) {
Notification::make()->title('Fornitore Acea Ato2 non trovato in anagrafica fornitori.')->warning()->send();
@ -1241,11 +1235,7 @@ public function getAcquaContrattoStabileProperty(): array
->orderBy('id')
->first();
$fornitoreSuggerito = Fornitore::query()
->whereRaw('LOWER(ragione_sociale) like ?', ['%acea%ato2%'])
->value('ragione_sociale') ?? Fornitore::query()
->whereRaw('LOWER(ragione_sociale) like ?', ['%acea%'])
->value('ragione_sociale');
$fornitoreSuggerito = $this->resolvePreferredAcquaSupplier($stabileId);
return [
'servizio_id' => $servizio?->id,
@ -1256,7 +1246,7 @@ public function getAcquaContrattoStabileProperty(): array
'codice_cliente' => $servizio?->codice_cliente,
'codice_contratto' => $servizio?->codice_contratto,
'matricola' => $servizio?->contatore_matricola,
'suggerito_fornitore' => $fornitoreSuggerito ? (string) $fornitoreSuggerito : null,
'suggerito_fornitore' => $fornitoreSuggerito?->ragione_sociale,
];
}
@ -1429,6 +1419,8 @@ public function getAcquaFatturePerGestioneProperty(): array
->values()
->all();
$fornitoreIds = $this->resolveEquivalentFornitoreIds($fornitoreIds);
if (Schema::hasTable('fornitori') && Schema::hasColumn('fornitori', 'fe_features')) {
$waterEnabledSupplierIds = Fornitore::query()
->where(function (Builder $query) use ($stabileId): Builder {
@ -1452,7 +1444,17 @@ public function getAcquaFatturePerGestioneProperty(): array
->values()
->all();
$fornitoreIds = array_values(array_unique(array_merge($fornitoreIds, $waterEnabledSupplierIds)));
$fornitoreIds = array_values(array_unique(array_merge(
$fornitoreIds,
$this->resolveEquivalentFornitoreIds($waterEnabledSupplierIds)
)));
}
if ($fornitoreIds === []) {
$preferredSupplier = $this->resolvePreferredAcquaSupplier($stabileId);
if ($preferredSupplier) {
$fornitoreIds = $this->resolveEquivalentFornitoreIds([(int) $preferredSupplier->id]);
}
}
if ($fornitoreIds !== []) {
@ -1557,6 +1559,192 @@ public function getAcquaFatturePerGestioneProperty(): array
return $out;
}
private function resolvePreferredAcquaSupplier(?int $stabileId = null): ?Fornitore
{
if (! Schema::hasTable('fornitori')) {
return null;
}
$serviceSupplierId = null;
if ($stabileId) {
$serviceSupplierId = StabileServizio::query()
->where('stabile_id', $stabileId)
->where('tipo', 'acqua')
->whereNotNull('fornitore_id')
->orderByDesc('attivo')
->orderBy('id')
->value('fornitore_id');
}
$candidates = Fornitore::query()
->where(function (Builder $query): Builder {
return $query->whereRaw('LOWER(ragione_sociale) like ?', ['%acea%ato2%'])
->orWhereRaw('LOWER(ragione_sociale) like ?', ['%acea acqua%']);
})
->get([
'id',
'amministratore_id',
'rubrica_id',
'ragione_sociale',
'partita_iva',
'codice_fiscale',
'old_id',
'fe_features',
]);
if ($candidates->isEmpty()) {
return null;
}
$userAdminId = null;
$user = Auth::user();
if ($user instanceof User && isset($user->amministratore_id) && is_numeric($user->amministratore_id)) {
$userAdminId = (int) $user->amministratore_id;
}
$stableInvoiceCounts = [];
if ($stabileId) {
$candidateIds = $candidates->pluck('id')
->map(fn($value) => (int) $value)
->filter(fn($value) => $value > 0)
->all();
if ($candidateIds !== []) {
$feCounts = DB::table('fatture_elettroniche')
->selectRaw('fornitore_id, COUNT(*) as aggregate_count')
->where('stabile_id', $stabileId)
->whereIn('fornitore_id', $candidateIds)
->groupBy('fornitore_id')
->pluck('aggregate_count', 'fornitore_id')
->all();
$contabiliCounts = DB::table('contabilita_fatture_fornitori')
->selectRaw('fornitore_id, COUNT(*) as aggregate_count')
->where('stabile_id', $stabileId)
->whereIn('fornitore_id', $candidateIds)
->groupBy('fornitore_id')
->pluck('aggregate_count', 'fornitore_id')
->all();
foreach ($candidateIds as $candidateId) {
$stableInvoiceCounts[$candidateId] = (int) ($feCounts[$candidateId] ?? 0) + (int) ($contabiliCounts[$candidateId] ?? 0);
}
}
}
$sorted = $candidates->sort(function (Fornitore $left, Fornitore $right) use ($serviceSupplierId, $userAdminId, $stableInvoiceCounts): int {
$leftScore = $this->scorePreferredAcquaSupplier($left, $serviceSupplierId, $userAdminId, $stableInvoiceCounts);
$rightScore = $this->scorePreferredAcquaSupplier($right, $serviceSupplierId, $userAdminId, $stableInvoiceCounts);
if ($leftScore === $rightScore) {
return (int) $left->id <=> (int) $right->id;
}
return $rightScore <=> $leftScore;
});
return $sorted->first();
}
/** @param array<int, int> $supplierIds */
private function resolveEquivalentFornitoreIds(array $supplierIds): array
{
$supplierIds = array_values(array_unique(array_filter(array_map('intval', $supplierIds), fn(int $value): bool => $value > 0)));
if ($supplierIds === [] || ! Schema::hasTable('fornitori')) {
return $supplierIds;
}
$suppliers = Fornitore::query()
->whereIn('id', $supplierIds)
->get(['id', 'rubrica_id', 'partita_iva', 'codice_fiscale', 'old_id']);
if ($suppliers->isEmpty()) {
return $supplierIds;
}
$rubricaIds = $suppliers->pluck('rubrica_id')
->filter(fn($value) => is_numeric($value) && (int) $value > 0)
->map(fn($value) => (int) $value)
->unique()
->values()
->all();
$partiteIva = $suppliers->pluck('partita_iva')
->map(fn($value) => trim((string) $value))
->filter(fn(string $value): bool => $value !== '')
->unique()
->values()
->all();
$codiciFiscali = $suppliers->pluck('codice_fiscale')
->map(fn($value) => trim((string) $value))
->filter(fn(string $value): bool => $value !== '')
->unique()
->values()
->all();
$legacyIds = $suppliers->pluck('old_id')
->filter(fn($value) => is_numeric($value) && (int) $value > 0)
->map(fn($value) => (int) $value)
->unique()
->values()
->all();
$matches = Fornitore::query()
->where(function (Builder $query) use ($supplierIds, $rubricaIds, $partiteIva, $codiciFiscali, $legacyIds): Builder {
$query->whereIn('id', $supplierIds);
if ($rubricaIds !== []) {
$query->orWhereIn('rubrica_id', $rubricaIds);
}
if ($partiteIva !== []) {
$query->orWhereIn('partita_iva', $partiteIva);
}
if ($codiciFiscali !== []) {
$query->orWhereIn('codice_fiscale', $codiciFiscali);
}
if ($legacyIds !== []) {
$query->orWhereIn('old_id', $legacyIds);
}
return $query;
})
->pluck('id')
->map(fn($value) => (int) $value)
->filter(fn($value) => $value > 0)
->unique()
->values()
->all();
return $matches !== [] ? $matches : $supplierIds;
}
/** @param array<int, int> $stableInvoiceCounts */
private function scorePreferredAcquaSupplier(Fornitore $supplier, ?int $serviceSupplierId, ?int $userAdminId, array $stableInvoiceCounts): int
{
$score = 0;
if ($serviceSupplierId && (int) $supplier->id === $serviceSupplierId) {
$score += 4000;
}
$waterFeatures = is_array($supplier->fe_features ?? null) ? $supplier->fe_features : [];
if ((bool) data_get($waterFeatures, 'acqua.enabled', false)) {
$score += 1000;
}
if ($userAdminId && isset($supplier->amministratore_id) && (int) $supplier->amministratore_id === $userAdminId) {
$score += 500;
}
$score += ((int) ($stableInvoiceCounts[(int) $supplier->id] ?? 0)) * 25;
return $score;
}
/** @return array<int, array<string,mixed>> */
public function getAcquaLettureCondominiProperty(): array
{

View File

@ -6,6 +6,7 @@
use App\Models\CommunicationMessage;
use App\Models\Documento;
use App\Models\DocumentoCollegato;
use App\Models\DocumentoStabile;
use App\Models\InsuranceClaim;
use App\Models\InsurancePolicy;
use App\Models\RubricaUniversale;
@ -68,6 +69,10 @@ class StabilePage extends Page
public ?string $gestioneDataInizio = null;
public ?string $presaInCaricoAmministrazione = null;
public $verbaleNominaUpload = null;
public ?string $gestioneDataFine = null;
/** @var array<int> */
@ -274,6 +279,97 @@ public function toggleMostraRiscaldamento(): void
$this->dispatch('refresh-gestioni-stabile');
}
public function saveAmministrazioneSetup(): void
{
if (! $this->stabile instanceof StabileModel) {
return;
}
$config = (array) ($this->stabile->configurazione_avanzata ?? []);
$config['amministrazione'] = array_merge(
(array) ($config['amministrazione'] ?? []),
[
'presa_in_carico_dal' => $this->presaInCaricoAmministrazione ?: null,
]
);
$this->stabile->configurazione_avanzata = $config;
if ($this->presaInCaricoAmministrazione) {
$this->stabile->data_nomina = $this->presaInCaricoAmministrazione;
}
$this->stabile->save();
Notification::make()
->title('Dati amministrazione aggiornati')
->success()
->send();
}
public function uploadVerbaleNomina(): void
{
if (! $this->stabile instanceof StabileModel) {
return;
}
if (! is_object($this->verbaleNominaUpload) || ! method_exists($this->verbaleNominaUpload, 'storeAs')) {
Notification::make()
->title('Seleziona un file da caricare')
->warning()
->send();
return;
}
$ext = strtolower((string) ($this->verbaleNominaUpload->getClientOriginalExtension() ?: 'pdf'));
$fileName = 'stabile-' . (int) $this->stabile->id . '-verbale-nomina.' . $ext;
$path = $this->verbaleNominaUpload->storeAs('stabili/verbali-nomina', $fileName, 'public');
$documento = DocumentoStabile::query()->create([
'stabile_id' => (int) $this->stabile->id,
'nome_file' => $fileName,
'nome_originale' => (string) $this->verbaleNominaUpload->getClientOriginalName(),
'percorso_file' => $path,
'categoria' => 'verbale_nomina',
'tipo_mime' => (string) ($this->verbaleNominaUpload->getClientMimeType() ?: 'application/octet-stream'),
'dimensione' => (int) ($this->verbaleNominaUpload->getSize() ?: 0),
'descrizione' => 'Verbale di nomina amministrazione',
'pubblico' => false,
'protetto' => false,
'versione' => 1,
'caricato_da' => Auth::id(),
]);
$config = (array) ($this->stabile->configurazione_avanzata ?? []);
$config['amministrazione'] = array_merge(
(array) ($config['amministrazione'] ?? []),
['verbale_nomina_documento_id' => (int) $documento->id]
);
$this->stabile->configurazione_avanzata = $config;
$this->stabile->save();
$this->verbaleNominaUpload = null;
Notification::make()
->title('Verbale di nomina caricato')
->success()
->send();
}
public function getVerbaleNominaDocumento(): ?DocumentoStabile
{
if (! $this->stabile instanceof StabileModel) {
return null;
}
$documentId = $this->stabile->getNominaVerbaleDocumentoId();
if (! $documentId) {
return null;
}
return DocumentoStabile::query()->find($documentId);
}
public function savePeriodiEmissione(): void
{
if (! $this->stabile) {
@ -1037,6 +1133,9 @@ private function initializeOfficialMailSettings(): void
if ($this->officialMailboxes === []) {
$this->officialMailboxes = $this->buildDefaultOfficialMailboxes();
}
$this->presaInCaricoAmministrazione = data_get($config, 'amministrazione.presa_in_carico_dal')
?: ($this->stabile->data_nomina?->format('Y-m-d'));
}
/**

View File

@ -16,16 +16,16 @@
use App\Modules\Contabilita\Models\MovimentoBanca;
use App\Modules\Contabilita\Models\PianoConti;
use App\Modules\Contabilita\Models\SaldoConto;
use App\Modules\Contabilita\Services\MovimentoBancaRiconciliazioneService;
use App\Modules\Contabilita\Services\MovimentoBancaPrimaNotaService;
use App\Modules\Contabilita\Services\MovimentoBancaRiconciliazioneService;
use App\Modules\Contabilita\Services\PagamentoFornitorePrimaNotaService;
use App\Services\Contabilita\ExcelMovimentiParser;
use App\Services\Contabilita\IntesaCsvParser;
use App\Services\Contabilita\MovimentiBancaImporter;
use App\Services\Contabilita\MpsQifParser;
use App\Services\Contabilita\UnicreditWriParser;
use App\Support\ArchivioPaths;
use App\Support\AnnoGestioneContext;
use App\Support\ArchivioPaths;
use App\Support\GestioneContext;
use App\Support\StabileContext;
use BackedEnum;
@ -38,7 +38,7 @@
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
@ -795,16 +795,7 @@ protected function getHeaderActions(): array
return [];
}
return GestioneContabile::query()
->where('stabile_id', $stabileId)
->orderByDesc('anno_gestione')
->orderBy('tipo_gestione')
->orderByDesc('id')
->get(['id', 'denominazione', 'protocollo_prefix', 'stato'])
->mapWithKeys(fn(GestioneContabile $g) => [
(string) $g->id => trim(($g->protocollo_prefix ? $g->protocollo_prefix . ' · ' : '') . $g->denominazione) . ($g->stato !== 'aperta' ? (' (' . $g->stato . ')') : ''),
])
->all();
return $this->getVisibleGestioneOptions($stabileId);
})
->default(function (): ?string {
$user = Auth::user();
@ -1277,16 +1268,7 @@ public function table(Table $table): Table
return [];
}
return GestioneContabile::query()
->where('stabile_id', $stabileId)
->orderByDesc('anno_gestione')
->orderBy('tipo_gestione')
->orderByDesc('id')
->get(['id', 'denominazione', 'protocollo_prefix', 'stato'])
->mapWithKeys(fn(GestioneContabile $g) => [
(string) $g->id => trim(($g->protocollo_prefix ? $g->protocollo_prefix . ' · ' : '') . $g->denominazione) . ($g->stato !== 'aperta' ? (' (' . $g->stato . ')') : ''),
])
->all();
return $this->getVisibleGestioneOptions($stabileId);
})
->searchable(),
])
@ -1310,6 +1292,13 @@ public function table(Table $table): Table
->date('d/m/Y')
->sortable(),
IconColumn::make('riconciliazione_stato')
->label('Sem')
->alignCenter()
->icon(fn(MovimentoBanca $record): string => $this->getMovimentoSemaphoreMeta($record)['icon'])
->color(fn(MovimentoBanca $record): string => $this->getMovimentoSemaphoreMeta($record)['color'])
->tooltip(fn(MovimentoBanca $record): string => $this->getMovimentoSemaphoreMeta($record)['label']),
TextColumn::make('valuta')
->label('Valuta')
->date('d/m/Y'),
@ -1359,8 +1348,9 @@ public function table(Table $table): Table
])
->actions([
Action::make('dettaglio_movimento')
->label('Apri dati')
->hiddenLabel()
->icon('heroicon-o-eye')
->tooltip('Apri dati')
->modalWidth('7xl')
->action(function (MovimentoBanca $record): void {
$this->detailMovementId = $record->id;
@ -1375,8 +1365,9 @@ public function table(Table $table): Table
]);
}),
Action::make('conferma_movimento')
->label('Conferma')
->hiddenLabel()
->icon('heroicon-o-check-circle')
->tooltip('Conferma')
->visible(fn(MovimentoBanca $record): bool => (bool) ($record->da_confermare ?? false))
->action(function (MovimentoBanca $record): void {
if (! Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) {
@ -1408,16 +1399,7 @@ public function table(Table $table): Table
return [];
}
return GestioneContabile::query()
->where('stabile_id', $stabileId)
->orderByDesc('anno_gestione')
->orderBy('tipo_gestione')
->orderByDesc('id')
->get(['id', 'denominazione', 'protocollo_prefix', 'stato'])
->mapWithKeys(fn(GestioneContabile $g) => [
(string) $g->id => trim(($g->protocollo_prefix ? $g->protocollo_prefix . ' · ' : '') . $g->denominazione) . ($g->stato !== 'aperta' ? (' (' . $g->stato . ')') : ''),
])
->all();
return $this->getVisibleGestioneOptions($stabileId);
}),
DatePicker::make('data')
@ -1531,8 +1513,9 @@ public function table(Table $table): Table
}),
Action::make('genera_prima_nota')
->label('Genera prima nota')
->hiddenLabel()
->icon('heroicon-o-book-open')
->tooltip('Genera prima nota')
->visible(function (MovimentoBanca $record): bool {
if (! Schema::hasColumn('contabilita_movimenti_banca', 'registrazione_id')) {
return false;
@ -1557,19 +1540,7 @@ public function table(Table $table): Table
return [];
}
return GestioneContabile::query()
->where('stabile_id', $stabileId)
->orderByDesc('anno_gestione')
->orderBy('tipo_gestione')
->orderBy('numero_straordinaria')
->get(['id', 'denominazione', 'tipo_gestione', 'anno_gestione', 'numero_straordinaria'])
->mapWithKeys(fn(GestioneContabile $g) => [
(string) $g->id => $g->denominazione
. ' (' . $g->tipo_gestione . ' ' . $g->anno_gestione
. ($g->numero_straordinaria ? ' #' . $g->numero_straordinaria : '')
. ')',
])
->all();
return $this->getVisibleGestioneOptions($stabileId);
})
->searchable()
->visible(fn(MovimentoBanca $record) => empty($record->gestione_id))
@ -1633,19 +1604,7 @@ public function table(Table $table): Table
return [];
}
return GestioneContabile::query()
->where('stabile_id', $stabileId)
->orderByDesc('anno_gestione')
->orderBy('tipo_gestione')
->orderBy('numero_straordinaria')
->get(['id', 'denominazione', 'tipo_gestione', 'anno_gestione', 'numero_straordinaria'])
->mapWithKeys(fn(GestioneContabile $g) => [
(string) $g->id => $g->denominazione
. ' (' . $g->tipo_gestione . ' ' . $g->anno_gestione
. ($g->numero_straordinaria ? ' #' . $g->numero_straordinaria : '')
. ')',
])
->all();
return $this->getVisibleGestioneOptions($stabileId);
})
->searchable()
->visible(fn(MovimentoBanca $record) => empty($record->gestione_id))
@ -2074,8 +2033,9 @@ protected function resolveDefaultGestioneId(int $stabileId): ?string
return null;
}
$anno = AnnoGestioneContext::resolveActiveAnno();
$tipo = GestioneContext::resolveActiveGestione();
$anno = AnnoGestioneContext::resolveActiveAnno();
$tipo = GestioneContext::resolveActiveGestione();
$stabile = Stabile::query()->find($stabileId);
$match = GestioneContabile::query()
->where('stabile_id', $stabileId)
@ -2086,6 +2046,10 @@ protected function resolveDefaultGestioneId(int $stabileId): ?string
->orderByDesc('id')
->first();
if ($match && ! $this->isGestioneVisibleForStabile($match, $stabile)) {
$match = null;
}
if (! $match) {
$match = GestioneContabile::query()
->where('stabile_id', $stabileId)
@ -2093,11 +2057,96 @@ protected function resolveDefaultGestioneId(int $stabileId): ?string
->orderByDesc('gestione_attiva')
->orderByDesc('id')
->first();
if ($match && ! $this->isGestioneVisibleForStabile($match, $stabile)) {
$match = GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('stato', 'aperta')
->get(['id', 'denominazione', 'tipo_gestione', 'anno_gestione', 'numero_straordinaria', 'protocollo_prefix', 'stato', 'gestione_attiva'])
->first(fn(GestioneContabile $gestione): bool => $this->isGestioneVisibleForStabile($gestione, $stabile));
}
}
return $match ? (string) $match->id : null;
}
/** @return array<string, string> */
protected function getVisibleGestioneOptions(int $stabileId): array
{
$stabile = Stabile::query()->find($stabileId);
return GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('stato', 'aperta')
->orderByDesc('anno_gestione')
->orderBy('tipo_gestione')
->orderBy('numero_straordinaria')
->orderByDesc('id')
->get(['id', 'denominazione', 'tipo_gestione', 'anno_gestione', 'numero_straordinaria', 'protocollo_prefix', 'stato'])
->filter(fn(GestioneContabile $gestione): bool => $this->isGestioneVisibleForStabile($gestione, $stabile))
->mapWithKeys(fn(GestioneContabile $gestione): array=> [(string) $gestione->id => $this->formatGestioneLabel($gestione, $stabile)])
->all();
}
protected function isGestioneVisibleForStabile(GestioneContabile $gestione, ?Stabile $stabile): bool
{
if (trim((string) ($gestione->stato ?? '')) !== 'aperta') {
return false;
}
if (! $stabile instanceof Stabile) {
return true;
}
if ((string) ($gestione->tipo_gestione ?? '') === 'riscaldamento' && ! $stabile->hasOperationalHeating()) {
return false;
}
$year = is_numeric($gestione->anno_gestione) ? (int) $gestione->anno_gestione : null;
return $stabile->isOperationalYearVisible($year);
}
protected function formatGestioneLabel(GestioneContabile $gestione, ?Stabile $stabile): string
{
$label = trim((string) ($gestione->denominazione ?? ''));
if ($label === '') {
$label = (string) $gestione->anno_gestione . ' - ' . (string) $gestione->tipo_gestione;
}
if (is_numeric($gestione->numero_straordinaria) && (int) $gestione->numero_straordinaria > 0) {
$label .= ' #' . (int) $gestione->numero_straordinaria;
}
$prefix = trim((string) ($gestione->protocollo_prefix ?? ''));
if ($prefix !== '') {
$label = $prefix . ' · ' . $label;
}
if ($stabile instanceof Stabile && $stabile->getTakeoverDate()) {
$year = is_numeric($gestione->anno_gestione) ? (int) $gestione->anno_gestione : null;
if (is_int($year) && $year < (int) $stabile->getTakeoverDate()->year) {
$label .= ' · pre-presa in carico';
}
}
return $label;
}
/** @return array{icon:string,color:string,label:string} */
protected function getMovimentoSemaphoreMeta(MovimentoBanca $record): array
{
if (! empty($record->registrazione_id) || $this->hasOperationalRiconciliazione($record)) {
return ['icon' => 'heroicon-m-check-circle', 'color' => 'success', 'label' => 'Riconciliato'];
}
if ((bool) ($record->da_confermare ?? false)) {
return ['icon' => 'heroicon-m-exclamation-triangle', 'color' => 'warning', 'label' => 'Da confermare'];
}
return ['icon' => 'heroicon-m-minus-circle', 'color' => 'gray', 'label' => 'Da lavorare'];
}
protected function resolveGestioneForYear(int $stabileId, int $year, ?string $preferredType = null): ?GestioneContabile
{
if (! DB::getSchemaBuilder()->hasTable('gestioni_contabili')) {
@ -2110,7 +2159,7 @@ protected function resolveGestioneForYear(int $stabileId, int $year, ?string $pr
if (is_string($preferredType) && trim($preferredType) !== '') {
$preferred = trim((string) $preferredType);
$match = (clone $query)
$match = (clone $query)
->where('tipo_gestione', $preferred)
->orderByDesc('gestione_attiva')
->orderByRaw("CASE WHEN stato = 'aperta' THEN 0 ELSE 1 END")
@ -2430,10 +2479,10 @@ public function getRiconciliazioneStats(): array
$records = (clone $query)->get(['id', 'registrazione_id', 'match_data']);
$totale = $records->count();
$totale = $records->count();
$senzaPrimaNota = $records->filter(fn(MovimentoBanca $record): bool => empty($record->registrazione_id) && ! $this->hasOperationalRiconciliazione($record))->count();
$collegati = $records->filter(fn(MovimentoBanca $record): bool => ! empty($record->registrazione_id) || $this->hasOperationalRiconciliazione($record))->count();
$daConfermare = Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')
$collegati = $records->filter(fn(MovimentoBanca $record): bool => ! empty($record->registrazione_id) || $this->hasOperationalRiconciliazione($record))->count();
$daConfermare = Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')
? (clone $query)->where('da_confermare', true)->count()
: 0;
@ -2554,8 +2603,8 @@ public function getRiconciliazioneCandidates(): array
public function riconciliaCanoneAffitto(int $canoneDovutoId): void
{
$user = Auth::user();
$current = $this->getRiconciliazioneCurrent();
$user = Auth::user();
$current = $this->getRiconciliazioneCurrent();
$stabileId = $this->getActiveStabileId();
if (! $user instanceof User || ! $current || ! ($current['record'] instanceof MovimentoBanca) || ! $stabileId) {
@ -2589,8 +2638,8 @@ public function riconciliaCanoneAffitto(int $canoneDovutoId): void
public function riconciliaPagamentoFornitore(int $fatturaFornitoreId): void
{
$user = Auth::user();
$current = $this->getRiconciliazioneCurrent();
$user = Auth::user();
$current = $this->getRiconciliazioneCurrent();
$stabileId = $this->getActiveStabileId();
if (! $user instanceof User || ! $current || ! ($current['record'] instanceof MovimentoBanca) || ! $stabileId) {
@ -2614,19 +2663,19 @@ public function riconciliaPagamentoFornitore(int $fatturaFornitoreId): void
$current['record'],
(int) $fattura->fornitore_id,
[
'gestione_id' => (int) (($fattura->gestione_id ?? 0) ?: ($current['record']->gestione_id ?? 0)),
'gestione_id' => (int) (($fattura->gestione_id ?? 0) ?: ($current['record']->gestione_id ?? 0)),
'fattura_fornitore_id' => (int) $fattura->id,
'descrizione' => 'Pagamento fattura fornitore ' . trim((string) ($fattura->numero_documento ?: $fattura->id)),
'descrizione' => 'Pagamento fattura fornitore ' . trim((string) ($fattura->numero_documento ?: $fattura->id)),
]
);
$matchData = is_array($current['record']->match_data ?? null) ? $current['record']->match_data : [];
$matchData = is_array($current['record']->match_data ?? null) ? $current['record']->match_data : [];
$matchData['riconciliato_operativo'] = true;
$matchData['riconciliazione_tipo'] = 'pagamento_fattura_fornitore';
$matchData['riconciliazione_label'] = 'Pagamento fattura fornitore';
$matchData['riconciliazione_at'] = now()->toDateTimeString();
$matchData['fattura_fornitore_id'] = (int) $fattura->id;
$matchData['fornitore_id'] = (int) $fattura->fornitore_id;
$matchData['riconciliazione_tipo'] = 'pagamento_fattura_fornitore';
$matchData['riconciliazione_label'] = 'Pagamento fattura fornitore';
$matchData['riconciliazione_at'] = now()->toDateTimeString();
$matchData['fattura_fornitore_id'] = (int) $fattura->id;
$matchData['fornitore_id'] = (int) $fattura->fornitore_id;
if (Schema::hasColumn('contabilita_movimenti_banca', 'match_data')) {
$current['record']->match_data = $matchData;
@ -2767,15 +2816,15 @@ protected function buildIncassoCandidates(MovimentoBanca $movimento, int $stabil
->get()
->map(function (IncassoPagamento $incasso) use ($movimento, $descrizione, $importo, $dateColumn, $importoColumn): array {
$candidateDateRaw = data_get($incasso, $dateColumn);
$candidateDate = $candidateDateRaw instanceof Carbon
$candidateDate = $candidateDateRaw instanceof Carbon
? $candidateDateRaw
: ($candidateDateRaw ? Carbon::parse((string) $candidateDateRaw) : null);
$candidateImporto = (float) data_get($incasso, $importoColumn, 0);
$candidateLabel = trim(
$candidateLabel = trim(
(string) (data_get($incasso, 'causale')
?: data_get($incasso, 'descrizione')
?: data_get($incasso, 'modalita_pagamento_label')
?: 'Incasso')
?: data_get($incasso, 'descrizione')
?: data_get($incasso, 'modalita_pagamento_label')
?: 'Incasso')
);
$candidateNote = trim((string) (data_get($incasso, 'note_bancarie') ?: data_get($incasso, 'descrizione') ?: ''));
@ -2812,7 +2861,7 @@ protected function buildAffittoCandidates(MovimentoBanca $movimento, int $stabil
return [];
}
$importo = abs((float) $movimento->importo);
$importo = abs((float) $movimento->importo);
$descrizione = (string) ($movimento->descrizione_estesa_pulita ?? $movimento->descrizione_estesa ?? $movimento->descrizione ?? '');
return AffittoCanoneDovuto::query()
@ -2862,7 +2911,7 @@ protected function buildAffittoCandidates(MovimentoBanca $movimento, int $stabil
$managementStart = Carbon::parse($affitto->inizio_contratto)->startOfMonth();
}
if (! empty($affitto->presa_in_carico_operativa_dal)) {
$takeoverDate = Carbon::parse($affitto->presa_in_carico_operativa_dal)->startOfMonth();
$takeoverDate = Carbon::parse($affitto->presa_in_carico_operativa_dal)->startOfMonth();
$managementStart = $managementStart ? $managementStart->max($takeoverDate) : $takeoverDate;
}
@ -2875,8 +2924,8 @@ protected function buildAffittoCandidates(MovimentoBanca $movimento, int $stabil
return $candidateMonth->gte($managementStart);
})
->map(function (AffittoCanoneDovuto $canone) use ($movimento, $descrizione, $importo): array {
$affitto = $canone->affitto;
$tenantLabel = $this->resolveRubricaLabel($affitto?->rubricaInquilino) ?: trim((string) ($affitto?->nome_inquilino ?? 'Inquilino'));
$affitto = $canone->affitto;
$tenantLabel = $this->resolveRubricaLabel($affitto?->rubricaInquilino) ?: trim((string) ($affitto?->nome_inquilino ?? 'Inquilino'));
$immobileLabel = trim((string) ($affitto?->descrizione_immobile ?? $affitto?->indirizzo_immobile ?? 'immobile'));
$score = $this->scoreCandidate(
@ -2889,20 +2938,20 @@ protected function buildAffittoCandidates(MovimentoBanca $movimento, int $stabil
);
if ((int) ($affitto?->conto_bancario_id ?? 0) > 0 && (int) ($movimento->conto_id ?? 0) === (int) $affitto->conto_bancario_id) {
$score['totale'] = min(100, $score['totale'] + 10);
$score['totale'] = min(100, $score['totale'] + 10);
$score['motivo'] .= ' · stesso conto affitto';
}
return [
'tipo' => 'Canone affitto aperto',
'titolo' => trim($tenantLabel . ' · ' . $immobileLabel),
'score' => $score['totale'],
'importo' => (float) $canone->totale,
'data' => ($canone->data_scadenza ?? $canone->data_emissione)?->format('d/m/Y') ?? '—',
'dettaglio' => 'Canone ' . str_pad((string) $canone->mese, 2, '0', STR_PAD_LEFT) . '/' . (string) $canone->anno . ($canone->n_ricevuta ? (' · ricevuta ' . $canone->n_ricevuta) : ''),
'motivo' => $score['motivo'],
'azione' => 'riconcilia_affitto',
'canone_dovuto_id' => (int) $canone->id,
'tipo' => 'Canone affitto aperto',
'titolo' => trim($tenantLabel . ' · ' . $immobileLabel),
'score' => $score['totale'],
'importo' => (float) $canone->totale,
'data' => ($canone->data_scadenza ?? $canone->data_emissione)?->format('d/m/Y') ?? '—',
'dettaglio' => 'Canone ' . str_pad((string) $canone->mese, 2, '0', STR_PAD_LEFT) . '/' . (string) $canone->anno . ($canone->n_ricevuta ? (' · ricevuta ' . $canone->n_ricevuta) : ''),
'motivo' => $score['motivo'],
'azione' => 'riconcilia_affitto',
'canone_dovuto_id' => (int) $canone->id,
];
})
->sortByDesc('score')
@ -2915,8 +2964,8 @@ protected function buildAffittoCandidates(MovimentoBanca $movimento, int $stabil
protected function buildFatturaCandidates(MovimentoBanca $movimento, int $stabileId): array
{
if (Schema::hasTable('contabilita_fatture_fornitori')) {
$importo = abs((float) $movimento->importo);
$descrizione = (string) ($movimento->descrizione_estesa_pulita ?? $movimento->descrizione_estesa ?? $movimento->descrizione ?? '');
$importo = abs((float) $movimento->importo);
$descrizione = (string) ($movimento->descrizione_estesa_pulita ?? $movimento->descrizione_estesa ?? $movimento->descrizione ?? '');
$movementHints = is_array($movimento->match_data ?? null) ? $movimento->match_data : [];
return FatturaFornitore::query()
@ -2957,32 +3006,32 @@ protected function buildFatturaCandidates(MovimentoBanca $movimento, int $stabil
$fornitoreLabel . ' ' . trim((string) ($fattura->numero_documento ?? ''))
);
$fatturaHints = $this->extractFatturaPaymentHints($fattura);
$fatturaHints = $this->extractFatturaPaymentHints($fattura);
$movementProvider = strtoupper(trim((string) ($movementHints['payment_provider'] ?? $movementHints['beneficiario'] ?? '')));
$fatturaProvider = strtoupper(trim((string) ($fatturaHints['provider'] ?? $fornitoreLabel)));
$fatturaProvider = strtoupper(trim((string) ($fatturaHints['provider'] ?? $fornitoreLabel)));
if ($movementProvider !== '' && $fatturaProvider !== '' && str_contains($fatturaProvider, $movementProvider)) {
$score['totale'] = min(100, $score['totale'] + 18);
$score['totale'] = min(100, $score['totale'] + 18);
$score['motivo'] .= ' · fornitore coerente con il movimento';
}
if (($movementHints['cbill'] ?? null) && ($fatturaHints['cbill'] ?? null) && (string) $movementHints['cbill'] === (string) $fatturaHints['cbill']) {
$score['totale'] = min(100, $score['totale'] + 35);
$score['totale'] = min(100, $score['totale'] + 35);
$score['motivo'] .= ' · CBILL coincidente';
}
if (($movementHints['sia'] ?? null) && ($fatturaHints['sia'] ?? null) && strtoupper((string) $movementHints['sia']) === strtoupper((string) $fatturaHints['sia'])) {
$score['totale'] = min(100, $score['totale'] + 15);
$score['totale'] = min(100, $score['totale'] + 15);
$score['motivo'] .= ' · SIA coerente';
}
if (($movementHints['suggested_voce_spesa_code'] ?? null) === 'ACQ' && ($fatturaHints['is_water'] ?? false)) {
$score['totale'] = min(100, $score['totale'] + 20);
$score['totale'] = min(100, $score['totale'] + 20);
$score['motivo'] .= ' · spesa acqua/ACQ';
}
if ((float) ($fattura->ritenuta_importo ?? 0) > 0) {
$score['totale'] = min(100, $score['totale'] + 5);
$score['totale'] = min(100, $score['totale'] + 5);
$score['motivo'] .= ' · netto coerente con RA';
}
@ -3001,15 +3050,15 @@ protected function buildFatturaCandidates(MovimentoBanca $movimento, int $stabil
}
return [
'tipo' => 'Fattura fornitore',
'titolo' => $fornitoreLabel . ' · n. ' . trim((string) ($fattura->numero_documento ?: $fattura->id)),
'score' => $score['totale'],
'importo' => $targetImporto,
'data' => $fattura->data_documento?->format('d/m/Y') ?? '—',
'dettaglio' => implode(' · ', $detailBits),
'motivo' => $score['motivo'],
'azione' => 'riconcilia_pagamento_fornitore',
'fattura_fornitore_id'=> (int) $fattura->id,
'tipo' => 'Fattura fornitore',
'titolo' => $fornitoreLabel . ' · n. ' . trim((string) ($fattura->numero_documento ?: $fattura->id)),
'score' => $score['totale'],
'importo' => $targetImporto,
'data' => $fattura->data_documento?->format('d/m/Y') ?? '—',
'dettaglio' => implode(' · ', $detailBits),
'motivo' => $score['motivo'],
'azione' => 'riconcilia_pagamento_fornitore',
'fattura_fornitore_id' => (int) $fattura->id,
];
})
->filter()
@ -3081,15 +3130,15 @@ protected function getOperationalRiconciliazioneLabel(MovimentoBanca $record): ?
}
$matchData = is_array($record->match_data ?? null) ? $record->match_data : [];
$label = trim((string) ($matchData['riconciliazione_label'] ?? ''));
$label = trim((string) ($matchData['riconciliazione_label'] ?? ''));
if ($label !== '') {
return $label;
}
return match ((string) ($matchData['riconciliazione_tipo'] ?? '')) {
'affitto_canone' => 'Canone affitto chiuso',
'affitto_canone' => 'Canone affitto chiuso',
'pagamento_fattura_fornitore' => 'Pagamento fattura fornitore',
default => 'Riconciliato operativamente',
default => 'Riconciliato operativamente',
};
}
@ -3129,12 +3178,16 @@ protected function extractFatturaPaymentHints(FatturaFornitore $fattura): array
$hints['sia'] = trim((string) $payment['sia']);
}
if (empty($hints['sia']) && is_string($fattura->fornitore?->sia_effettivo ?? null) && trim((string) $fattura->fornitore->sia_effettivo) !== '') {
$hints['sia'] = trim((string) $fattura->fornitore->sia_effettivo);
}
if (is_string($payload['voce_spesa_code'] ?? null) && trim((string) $payload['voce_spesa_code']) !== '') {
$hints['voce_spesa_code'] = strtoupper(trim((string) $payload['voce_spesa_code']));
}
$type = strtoupper(trim((string) ($payload['type'] ?? $payload['tipo'] ?? '')));
$fe = $fattura->fatturaElettronica;
$type = strtoupper(trim((string) ($payload['type'] ?? $payload['tipo'] ?? '')));
$fe = $fattura->fatturaElettronica;
$isWater = $type === 'ACQUA'
|| $type === 'WATER'
|| (($hints['voce_spesa_code'] ?? null) === 'ACQ')
@ -3386,12 +3439,12 @@ public function getGestioniContabiliRilevate(): array
$folder = ArchivioPaths::gestioneFolderName($gestione, (int) $gestione->anno_gestione, (string) $gestione->tipo_gestione);
return [
'id' => (int) $gestione->id,
'year' => (int) ($gestione->anno_gestione ?? 0),
'tipo' => (string) ($gestione->tipo_gestione ?? ''),
'id' => (int) $gestione->id,
'year' => (int) ($gestione->anno_gestione ?? 0),
'tipo' => (string) ($gestione->tipo_gestione ?? ''),
'folder' => $folder,
'label' => trim(($gestione->protocollo_prefix ? $gestione->protocollo_prefix . ' · ' : '') . ($gestione->denominazione ?: $folder)),
'stato' => (string) ($gestione->stato ?? ''),
'label' => trim(($gestione->protocollo_prefix ? $gestione->protocollo_prefix . ' · ' : '') . ($gestione->denominazione ?: $folder)),
'stato' => (string) ($gestione->stato ?? ''),
'active' => (bool) ($gestione->gestione_attiva ?? false),
];
})
@ -3403,13 +3456,13 @@ public function getGestioniContabiliRilevate(): array
public function getArchivioOperativoSummary(): array
{
$stabile = $this->getActiveStabile();
$conto = $this->contoId
$conto = $this->contoId
? DatiBancari::query()->where('stabile_id', $this->getActiveStabileId())->whereKey($this->contoId)->first()
: null;
$referenceYear = $this->resolveArchivioReferenceYear();
$referenceYear = $this->resolveArchivioReferenceYear();
$activeGestione = null;
$stabileId = $this->getActiveStabileId();
$stabileId = $this->getActiveStabileId();
if ($stabileId) {
$defaultGestioneId = $this->resolveDefaultGestioneId($stabileId);
if ($defaultGestioneId && is_numeric($defaultGestioneId)) {
@ -3425,15 +3478,15 @@ public function getArchivioOperativoSummary(): array
: null;
return [
'reference_year' => $referenceYear,
'gestione_folder' => ArchivioPaths::gestioneFolderName($activeGestione, $referenceYear, 'ordinaria'),
'gestione_label' => $activeGestione
'reference_year' => $referenceYear,
'gestione_folder' => ArchivioPaths::gestioneFolderName($activeGestione, $referenceYear, 'ordinaria'),
'gestione_label' => $activeGestione
? trim(((string) ($activeGestione->protocollo_prefix ?? '') !== '' ? $activeGestione->protocollo_prefix . ' · ' : '') . (string) ($activeGestione->denominazione ?? 'Gestione'))
: 'Gestione da associare',
'banca_folder' => $bancaFolder,
'banca_folder' => $bancaFolder,
'fatture_xml_folder' => 'fatture_xml/' . $referenceYear,
'fatture_pdf_folder' => 'fatture/' . ArchivioPaths::gestioneFolderName($activeGestione, $referenceYear, 'ordinaria'),
'base_folder' => $stabile ? ArchivioPaths::stabileBase($stabile, $stabile->amministratore) : null,
'base_folder' => $stabile ? ArchivioPaths::stabileBase($stabile, $stabile->amministratore) : null,
];
}
@ -3442,19 +3495,19 @@ public function getRiconciliazioneBucketRows(): array
{
return [
[
'title' => 'Costi / Entrate',
'title' => 'Costi / Entrate',
'detail' => 'Lettura economica standard dei movimenti: incassi, spese, pagamenti, bonifici e competenze correnti.',
],
[
'title' => 'Crediti / Debiti',
'title' => 'Crediti / Debiti',
'detail' => 'Base già pronta con crediti verso condòmini e debiti verso fornitori, utile per incassi rate, FE passive e note di rettifica.',
],
[
'title' => 'Fondi / Accantonamenti',
'title' => 'Fondi / Accantonamenti',
'detail' => 'Predisposti bucket separati per fondo riserva e accantonamenti, così la riconciliazione non confonde disponibilità corrente e somme vincolate.',
],
[
'title' => 'Rimborsi',
'title' => 'Rimborsi',
'detail' => 'Separazione tra rimborsi da distribuire e rimborsi da utilizzare, per tenere distinta la quadratura patrimoniale dalla spesa/ricavo puro.',
],
];
@ -3519,7 +3572,7 @@ protected function archiveImportedBankSourceFile(DatiBancari $conto, string $tem
return null;
}
$year = (int) now()->format('Y');
$year = (int) now()->format('Y');
$gestione = null;
if ($gestioneId && DB::getSchemaBuilder()->hasTable('gestioni_contabili')) {
$gestione = GestioneContabile::query()->find($gestioneId);
@ -3538,15 +3591,15 @@ protected function archiveImportedBankSourceFile(DatiBancari $conto, string $tem
: '';
$originalName = basename($sourcePath);
$safeName = preg_replace('/[^A-Za-z0-9._-]+/', '-', $originalName) ?? $originalName;
$targetPath = $targetDir . '/import/' . now()->format('Ymd-His') . '-' . $formatPrefix . $safeName;
$safeName = preg_replace('/[^A-Za-z0-9._-]+/', '-', $originalName) ?? $originalName;
$targetPath = $targetDir . '/import/' . now()->format('Ymd-His') . '-' . $formatPrefix . $safeName;
Storage::disk('local')->makeDirectory($targetDir . '/import');
Storage::disk('local')->put($targetPath, Storage::disk('local')->get($sourcePath));
$matchData = [
'archived_bank_source_path' => $targetPath,
'gestione_folder' => ArchivioPaths::gestioneFolderName($gestione, $year, 'ordinaria'),
'gestione_folder' => ArchivioPaths::gestioneFolderName($gestione, $year, 'ordinaria'),
];
return $targetPath;

View File

@ -1,5 +1,4 @@
<?php
namespace App\Filament\Pages\Contabilita;
use App\Models\DatiBancari;
@ -9,10 +8,10 @@
use App\Models\User;
use App\Modules\Contabilita\Models\MovimentoBanca;
use App\Modules\Contabilita\Models\SaldoConto;
use App\Services\Contabilita\ExcelMovimentiParser;
use App\Services\Contabilita\IntesaCsvParser;
use App\Services\Contabilita\MovimentiBancaImporter;
use App\Services\Contabilita\UnicreditWriParser;
use App\Services\Contabilita\IntesaCsvParser;
use App\Services\Contabilita\ExcelMovimentiParser;
use App\Support\AnnoGestioneContext;
use App\Support\GestioneContext;
use App\Support\StabileContext;
@ -100,14 +99,14 @@ protected function getHeaderActions(): array
->label('Conto (IBAN)')
->native(false)
->searchable()
->options(fn(): array => $this->getIbanOptions())
->options(fn(): array=> $this->getIbanOptions())
->helperText('Opzionale: se non selezionato, l\'import prova a rilevare il conto dai dati.'),
Select::make('gestione_id')
->label('Gestione (opzionale)')
->native(false)
->searchable()
->options(fn(): array => $this->getGestioniOptions())
->options(fn(): array=> $this->getGestioniOptions())
->default(fn(): ?string => $this->resolveDefaultGestioneId()),
FileUpload::make('file')
@ -141,9 +140,9 @@ protected function getHeaderActions(): array
}
try {
$content = Storage::disk('local')->get($path);
$content = Storage::disk('local')->get($path);
$importer = new MovimentiBancaImporter(new UnicreditWriParser(), new IntesaCsvParser(), new ExcelMovimentiParser());
$res = $importer->importUnicreditWri(
$res = $importer->importUnicreditWri(
$content,
$stabileId,
basename($path),
@ -177,7 +176,7 @@ protected function getHeaderActions(): array
->label('Conto (IBAN)')
->native(false)
->searchable()
->options(fn(): array => $this->getIbanOptions())
->options(fn(): array=> $this->getIbanOptions())
->required(),
Textarea::make('note')
@ -246,7 +245,7 @@ protected function getHeaderActions(): array
$created = 0;
DB::transaction(function () use ($rows, $stabileId, $contoId, $iban, $note, &$created, $user): void {
foreach ($rows as $row) {
$date = $row['data_saldo'] ?? null;
$date = $row['data_saldo'] ?? null;
$saldo = $row['saldo'] ?? null;
if (! $date || ! is_numeric($saldo)) {
@ -255,11 +254,11 @@ protected function getHeaderActions(): array
SaldoConto::query()->create([
'stabile_id' => (int) $stabileId,
'conto_id' => $contoId,
'iban' => $iban,
'conto_id' => $contoId,
'iban' => $iban,
'data_saldo' => $date,
'saldo' => (float) $saldo,
'note' => $note !== '' ? $note : null,
'saldo' => (float) $saldo,
'note' => $note !== '' ? $note : null,
'created_by' => (int) ($user->id ?? 0) ?: null,
'updated_by' => (int) ($user->id ?? 0) ?: null,
]);
@ -314,7 +313,7 @@ public function table(Table $table): Table
Select::make('gestione_ids')
->label('Gestione')
->multiple()
->options(fn(): array => $this->getGestioniOptions())
->options(fn(): array=> $this->getGestioniOptions())
->searchable(),
]),
])
@ -384,10 +383,10 @@ public function getTotaleDisponibilita(): ?float
}
$periodo = $this->getTableFilterState('periodo');
$from = is_array($periodo) ? ($periodo['from'] ?? null) : null;
$to = is_array($periodo) ? ($periodo['to'] ?? null) : null;
$from = is_array($periodo) ? ($periodo['from'] ?? null) : null;
$to = is_array($periodo) ? ($periodo['to'] ?? null) : null;
$gestioni = $this->getTableFilterState('gestioni');
$gestioni = $this->getTableFilterState('gestioni');
$gestioneIds = is_array($gestioni) ? ($gestioni['gestione_ids'] ?? null) : null;
$gestioneIds = is_array($gestioneIds) ? array_values(array_filter(array_map('intval', $gestioneIds), fn(int $v) => $v > 0)) : [];
@ -425,10 +424,10 @@ protected function resolveSaldoConto(DatiBancari $record): ?float
$iban = is_string($record->iban) ? trim((string) $record->iban) : '';
$periodo = $this->getTableFilterState('periodo');
$from = is_array($periodo) ? ($periodo['from'] ?? null) : null;
$to = is_array($periodo) ? ($periodo['to'] ?? null) : null;
$from = is_array($periodo) ? ($periodo['from'] ?? null) : null;
$to = is_array($periodo) ? ($periodo['to'] ?? null) : null;
$gestioni = $this->getTableFilterState('gestioni');
$gestioni = $this->getTableFilterState('gestioni');
$gestioneIds = is_array($gestioni) ? ($gestioni['gestione_ids'] ?? null) : null;
$gestioneIds = is_array($gestioneIds) ? array_values(array_filter(array_map('intval', $gestioneIds), fn(int $v) => $v > 0)) : [];
@ -451,7 +450,7 @@ protected function resolveSaldoConto(DatiBancari $record): ?float
// Base da saldi intermedi: se c'è un saldo <= from (se filtrato) o altrimenti ultimo saldo disponibile.
$baseSaldo = null;
$baseDate = null;
$baseDate = null;
if (DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) {
$qSaldo = SaldoConto::query()
->where('stabile_id', $stabileId)
@ -464,7 +463,7 @@ protected function resolveSaldoConto(DatiBancari $record): ?float
$row = $qSaldo->orderByDesc('data_saldo')->orderByDesc('id')->first(['data_saldo', 'saldo']);
if ($row) {
$baseSaldo = (float) $row->saldo;
$baseDate = $row->data_saldo->toDateString();
$baseDate = $row->data_saldo->toDateString();
}
}
@ -539,13 +538,13 @@ protected function resolveLatestSaldoSnapshotLabel(DatiBancari $record): string
return 'Nessuno estratto registrato';
}
$parts = [];
$parts = [];
$parts[] = $snapshot->tipo_estratto
? ($this->getTipoEstrattoOptions()[$snapshot->tipo_estratto] ?? 'Saldo registrato')
: 'Saldo registrato';
if ($snapshot->periodo_da || $snapshot->periodo_a) {
$from = $snapshot->periodo_da?->format('d/m/Y');
$to = $snapshot->periodo_a?->format('d/m/Y');
$from = $snapshot->periodo_da?->format('d/m/Y');
$to = $snapshot->periodo_a?->format('d/m/Y');
$parts[] = trim(($from ?: '...') . ' - ' . ($to ?: '...'));
}
if ($snapshot->documento instanceof DocumentoStabile) {
@ -558,10 +557,10 @@ protected function resolveLatestSaldoSnapshotLabel(DatiBancari $record): string
protected function getTipoEstrattoOptions(): array
{
return [
'estratto_conto' => 'Estratto conto',
'saldo_banca' => 'Saldo banca',
'estratto_conto' => 'Estratto conto',
'saldo_banca' => 'Saldo banca',
'riconciliazione' => 'Riconciliazione',
'altro' => 'Altro',
'altro' => 'Altro',
];
}
@ -585,12 +584,12 @@ protected function getContiOptions(): array
->orderBy('id')
->get(['id', 'denominazione_banca', 'iban', 'legacy_cod_cassa', 'numero_conto'])
->mapWithKeys(function (DatiBancari $conto): array {
$parts = [];
$parts = [];
$parts[] = trim((string) ($conto->denominazione_banca ?: 'Conto'));
$legacyCodCassa = is_string($conto->legacy_cod_cassa) ? trim((string) $conto->legacy_cod_cassa) : '';
$numeroConto = is_string($conto->numero_conto) ? trim((string) $conto->numero_conto) : '';
$iban = is_string($conto->iban) ? trim((string) $conto->iban) : '';
$numeroConto = is_string($conto->numero_conto) ? trim((string) $conto->numero_conto) : '';
$iban = is_string($conto->iban) ? trim((string) $conto->iban) : '';
if ($legacyCodCassa !== '') {
$parts[] = 'cassa ' . strtoupper($legacyCodCassa);

View File

@ -748,35 +748,18 @@ public function getGestioniStraordinarieList(): array
return [];
}
$stabile = Stabile::query()->find($stabileId);
return GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('tipo_gestione', 'straordinaria')
->where('stato', 'aperta')
->orderByDesc('anno_gestione')
->orderByDesc('numero_straordinaria')
->orderByDesc('id')
->get(['id', 'anno_gestione', 'tipo_gestione', 'denominazione', 'protocollo_prefix', 'numero_straordinaria', 'stato'])
->map(function (GestioneContabile $g): array {
$label = trim((string) ($g->denominazione ?? ''));
if ($label === '') {
$label = (string) $g->anno_gestione . ' - ' . (string) $g->tipo_gestione;
}
if (is_numeric($g->numero_straordinaria) && (int) $g->numero_straordinaria > 0) {
$label .= ' • STR ' . (int) $g->numero_straordinaria;
}
$prefix = trim((string) ($g->protocollo_prefix ?? ''));
if ($prefix !== '') {
$label = $prefix . ' — ' . $label;
}
$stato = trim((string) ($g->stato ?? ''));
if ($stato !== '') {
$label .= ' • ' . strtoupper($stato);
}
return ['id' => (int) $g->id, 'label' => $label];
})
->filter(fn(GestioneContabile $g): bool => $this->isGestioneVisibleForStabile($g, $stabile))
->map(fn(GestioneContabile $g): array=> ['id' => (int) $g->id, 'label' => $this->formatGestioneLabel($g, $stabile)])
->all();
}
@ -1104,47 +1087,10 @@ public function header(Schema $schema): Schema
->schema([
Select::make('gestione_id')
->label('Gestione')
->options(function (): array {
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
$activeStabileId = StabileContext::resolveActiveStabileId($user);
$stabileId = (int) ($this->record?->stabile_id ?: $activeStabileId);
if (! $stabileId) {
return [];
}
return GestioneContabile::query()
->where('stabile_id', (int) $stabileId)
->orderByDesc('anno_gestione')
->orderByDesc('numero_straordinaria')
->orderByDesc('id')
->get(['id', 'anno_gestione', 'tipo_gestione', 'denominazione', 'protocollo_prefix', 'stato', 'numero_straordinaria'])
->mapWithKeys(function (GestioneContabile $g) {
$label = trim((string) ($g->denominazione ?? ''));
if ($label === '') {
$label = (string) $g->anno_gestione . ' - ' . (string) $g->tipo_gestione;
}
if ((string) ($g->tipo_gestione ?? '') === 'straordinaria' && is_numeric($g->numero_straordinaria) && (int) $g->numero_straordinaria > 0) {
$label .= ' (N.' . (int) $g->numero_straordinaria . ')';
}
$prefix = trim((string) ($g->protocollo_prefix ?? ''));
if ($prefix !== '') {
$label = $prefix . ' — ' . $label;
}
if (is_string($g->stato) && $g->stato !== '') {
$label .= ' [' . $g->stato . ']';
}
return [(string) $g->id => $label];
})
->all();
})
->options(fn(): array=> $this->getVisibleGestioneOptions())
->searchable()
->required()
->helperText('Ordinaria / riscaldamento / straordinaria (per anno).'),
->helperText('Solo gestioni aperte e coerenti con lo stabile attivo.'),
Select::make('causale_contabile')
->label('Causale contabile')
@ -2553,6 +2499,11 @@ private function mapFeRitenutaToTributo(?string $rtCode, ?string $causale): stri
private function resolveRiscaldamentoGestioneByDate(int $stabileId, string $date): int
{
$stabile = Stabile::query()->find($stabileId);
if ($stabile instanceof Stabile && ! $stabile->hasOperationalHeating()) {
return 0;
}
try {
$dt = Carbon::parse($date);
} catch (\Throwable) {
@ -2561,7 +2512,8 @@ private function resolveRiscaldamentoGestioneByDate(int $stabileId, string $date
$q = GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('tipo_gestione', 'riscaldamento');
->where('tipo_gestione', 'riscaldamento')
->where('stato', 'aperta');
$match = (clone $q)
->whereNotNull('data_inizio')
@ -2595,6 +2547,8 @@ private function resolveDefaultGestioneId(int $stabileId, ?int $year = null): in
return 0;
}
$stabile = Stabile::query()->find($stabileId);
$baseQuery = GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('stato', 'aperta');
@ -2603,6 +2557,16 @@ private function resolveDefaultGestioneId(int $stabileId, ?int $year = null): in
$baseQuery->where('anno_gestione', $year);
}
if ($stabile instanceof Stabile) {
if (! $stabile->hasOperationalHeating()) {
$baseQuery->where('tipo_gestione', '!=', 'riscaldamento');
}
if ($stabile->getTakeoverDate()) {
$baseQuery->where('anno_gestione', '>=', (int) $stabile->getTakeoverDate()->year);
}
}
$ordinariaId = (clone $baseQuery)
->where('tipo_gestione', 'ordinaria')
->orderByDesc('anno_gestione')
@ -2621,6 +2585,80 @@ private function resolveDefaultGestioneId(int $stabileId, ?int $year = null): in
return is_numeric($fallbackId) ? (int) $fallbackId : 0;
}
/** @return array<string, string> */
private function getVisibleGestioneOptions(): array
{
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
$activeStabileId = StabileContext::resolveActiveStabileId($user);
$stabileId = (int) ($this->record?->stabile_id ?: $activeStabileId);
if ($stabileId <= 0) {
return [];
}
$stabile = Stabile::query()->find($stabileId);
return GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('stato', 'aperta')
->orderByDesc('anno_gestione')
->orderByDesc('numero_straordinaria')
->orderByDesc('id')
->get(['id', 'anno_gestione', 'tipo_gestione', 'denominazione', 'protocollo_prefix', 'stato', 'numero_straordinaria'])
->filter(fn(GestioneContabile $g): bool => $this->isGestioneVisibleForStabile($g, $stabile))
->mapWithKeys(fn(GestioneContabile $g) => [(string) $g->id => $this->formatGestioneLabel($g, $stabile)])
->all();
}
private function isGestioneVisibleForStabile(GestioneContabile $gestione, ?Stabile $stabile): bool
{
if (trim((string) ($gestione->stato ?? '')) !== 'aperta') {
return false;
}
if ($stabile instanceof Stabile) {
if ((string) ($gestione->tipo_gestione ?? '') === 'riscaldamento' && ! $stabile->hasOperationalHeating()) {
return false;
}
$year = is_numeric($gestione->anno_gestione) ? (int) $gestione->anno_gestione : null;
if (! $stabile->isOperationalYearVisible($year)) {
return false;
}
}
return true;
}
private function formatGestioneLabel(GestioneContabile $gestione, ?Stabile $stabile): string
{
$label = trim((string) ($gestione->denominazione ?? ''));
if ($label === '') {
$label = (string) $gestione->anno_gestione . ' - ' . (string) $gestione->tipo_gestione;
}
if ((string) ($gestione->tipo_gestione ?? '') === 'straordinaria' && is_numeric($gestione->numero_straordinaria) && (int) $gestione->numero_straordinaria > 0) {
$label .= ' (N.' . (int) $gestione->numero_straordinaria . ')';
}
$prefix = trim((string) ($gestione->protocollo_prefix ?? ''));
if ($prefix !== '') {
$label = $prefix . ' — ' . $label;
}
if ($stabile instanceof Stabile && $stabile->getTakeoverDate()) {
$year = is_numeric($gestione->anno_gestione) ? (int) $gestione->anno_gestione : null;
if (is_int($year) && $year < (int) $stabile->getTakeoverDate()->year) {
$label .= ' • pre-presa in carico';
}
}
return $label;
}
public function getLinkedFatturaElettronica(): ?FatturaElettronica
{
if ($this->linkedFeCache !== null) {

View File

@ -29,7 +29,7 @@ class SaldiContiArchivio extends Page implements HasTable
{
use InteractsWithTable;
#[Url(as: 'conto_id')]
#[Url( as : 'conto_id')]
public ?int $contoId = null;
protected static ?string $navigationLabel = 'Saldi conti';

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,15 @@
<?php
namespace App\Filament\Pages\Contabilita;
use App\Filament\Pages\Condomini\TabelleMillesimaliProspetto;
use App\Filament\Pages\Contabilita\VoceSpesaMastrino;
use App\Models\GestioneContabile;
use App\Models\RipartizioneSpeseInquilini;
use App\Models\TabellaMillesimale;
use App\Models\User;
use App\Models\VoceSpesa;
use App\Models\GestioneContabile;
use App\Support\StabileContext;
use App\Support\AnnoGestioneContext;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
@ -81,15 +80,15 @@ public static function canAccess(): bool
public function mount(): void
{
$tab = (string) request()->query('tab', 'ordinaria');
$tab = (string) request()->query('tab', 'ordinaria');
$this->tipoGestioneTab = in_array($tab, ['ordinaria', 'acqua', 'riscaldamento', 'straordinaria'], true) ? $tab : 'ordinaria';
$this->gestioneContabileId = $this->resolveGestioneContabileId();
$preset = (string) request()->query('preset', 'manuale');
$preset = (string) request()->query('preset', 'manuale');
$this->presetRipartizione = in_array($preset, ['manuale', 'confedilizia'], true) ? $preset : 'manuale';
$focus = request()->query('tabella');
$focus = request()->query('tabella');
$this->tabellaFocusId = is_numeric($focus) ? (int) $focus : null;
if (! $this->tabellaFocusId || $this->tabellaFocusId <= 0) {
$this->tabellaFocusId = $this->getFirstTabellaIdForFocus();
@ -114,7 +113,7 @@ protected function getHeaderActions(): array
Select::make('anno_sorgente')
->label('Anno sorgente')
->options(function (): array {
$y = (int) now()->year;
$y = (int) now()->year;
$out = [];
for ($i = 0; $i < 8; $i++) {
$out[(string) ($y - $i)] = (string) ($y - $i);
@ -134,7 +133,7 @@ protected function getHeaderActions(): array
])
->action(function (array $data): void {
$annoSorgente = is_numeric($data['anno_sorgente'] ?? null) ? (int) $data['anno_sorgente'] : 0;
$aggiorna = (bool) ($data['aggiorna_esistenti'] ?? true);
$aggiorna = (bool) ($data['aggiorna_esistenti'] ?? true);
$this->importaVociPerAnno($annoSorgente, $aggiorna);
}),
@ -183,7 +182,7 @@ private function mapTabellaToAnno(?int $sourceTabellaId, int $stabileId, int $ta
return $sourceTabellaId;
}
$hasAnno = Schema::hasColumn('tabelle_millesimali', 'anno_gestione');
$hasAnno = Schema::hasColumn('tabelle_millesimali', 'anno_gestione');
$hasLegacyTipo = Schema::hasColumn('tabelle_millesimali', 'tipo');
$q = TabellaMillesimale::query()
@ -219,7 +218,7 @@ private function importaVociPerAnno(int $annoSorgente, bool $aggiornaEsistenti =
return;
}
$tipo = $this->normalizeTipoGestione($this->tipoGestioneTab ?: 'ordinaria');
$tipo = $this->normalizeTipoGestione($this->tipoGestioneTab ?: 'ordinaria');
$annoTarget = AnnoGestioneContext::resolveActiveAnno($user);
$gestioneTargetId = $this->gestioneContabileId ?: $this->resolveGestioneContabileId();
@ -297,7 +296,7 @@ private function importaVociPerAnno(int $annoSorgente, bool $aggiornaEsistenti =
}
$targetId = (int) $existing[$codice];
$target = VoceSpesa::query()->whereKey($targetId)->first();
$target = VoceSpesa::query()->whereKey($targetId)->first();
if (! $target) {
$skipped++;
continue;
@ -327,7 +326,7 @@ private function importaVociPerAnno(int $annoSorgente, bool $aggiornaEsistenti =
// Crea nuova voce per gestione target (preserva codice).
$data = $src->toArray();
unset($data['id'], $data['id_voce'], $data['created_at'], $data['updated_at']);
$data['stabile_id'] = $stabileId;
$data['stabile_id'] = $stabileId;
$data['gestione_contabile_id'] = $gestioneTargetId;
if (Schema::hasColumn('voci_spesa', 'tipo_gestione')) {
$data['tipo_gestione'] = $tipo;
@ -363,8 +362,8 @@ private function ricollegaTabellePerAnnoAttivo(): void
return;
}
$annoTarget = AnnoGestioneContext::resolveActiveAnno($user);
$tipo = $this->normalizeTipoGestione($this->tipoGestioneTab ?: 'ordinaria');
$annoTarget = AnnoGestioneContext::resolveActiveAnno($user);
$tipo = $this->normalizeTipoGestione($this->tipoGestioneTab ?: 'ordinaria');
$gestioneTargetId = $this->gestioneContabileId ?: $this->resolveGestioneContabileId();
if (! $gestioneTargetId) {
Notification::make()->title('Gestione non trovata')->danger()->send();
@ -441,11 +440,11 @@ public function getTabelleMillesimaliOptions(): array
return [];
}
$tipoTab = $this->tipoGestioneTab ?: 'ordinaria';
$tipo = $this->normalizeTipoGestione($tipoTab);
$tipoTab = $this->tipoGestioneTab ?: 'ordinaria';
$tipo = $this->normalizeTipoGestione($tipoTab);
$hasLegacyTipo = Schema::hasColumn('tabelle_millesimali', 'tipo');
$hasAnno = Schema::hasColumn('tabelle_millesimali', 'anno_gestione');
$anno = AnnoGestioneContext::resolveActiveAnno($user);
$hasAnno = Schema::hasColumn('tabelle_millesimali', 'anno_gestione');
$anno = AnnoGestioneContext::resolveActiveAnno($user);
$q = TabellaMillesimale::query()
->where('stabile_id', $stabileId)
@ -481,15 +480,15 @@ public function getTabelleMillesimaliOptions(): array
public function updatedTabellaFocusId($value): void
{
$id = is_numeric($value) ? (int) $value : null;
$id = is_numeric($value) ? (int) $value : null;
$this->tabellaFocusId = $id && $id > 0 ? $id : null;
}
/** @return array{label: string, count: int, totale_prev: float, totale_cons: float, voci: array<int, array{codice: string, descrizione: string, prev: float, cons: float, prop: float, inq: float, attiva: bool}>} */
public function getDettaglioTabellaFocus(): array
{
$id = (int) ($this->tabellaFocusId ?? 0);
$opts = $this->getTabelleMillesimaliOptions();
$id = (int) ($this->tabellaFocusId ?? 0);
$opts = $this->getTabelleMillesimaliOptions();
$label = $id > 0 && array_key_exists($id, $opts) ? (string) $opts[$id] : '—';
if ($id <= 0) {
@ -511,13 +510,13 @@ public function getDettaglioTabellaFocus(): array
$voci = $records->map(function ($r): array {
return [
'codice' => (string) ($r->codice ?? ''),
'codice' => (string) ($r->codice ?? ''),
'descrizione' => (string) ($r->descrizione ?? ''),
'prev' => (float) ($r->importo_default ?? 0),
'cons' => (float) ($r->importo_consuntivo ?? 0),
'prop' => (float) ($r->percentuale_condomino ?? 0),
'inq' => (float) ($r->percentuale_inquilino ?? 0),
'attiva' => (bool) ($r->attiva ?? false),
'prev' => (float) ($r->importo_default ?? 0),
'cons' => (float) ($r->importo_consuntivo ?? 0),
'prop' => (float) ($r->percentuale_condomino ?? 0),
'inq' => (float) ($r->percentuale_inquilino ?? 0),
'attiva' => (bool) ($r->attiva ?? false),
];
})->all();
@ -525,11 +524,11 @@ public function getDettaglioTabellaFocus(): array
$totCons = (float) $records->sum(fn($r) => (float) ($r->importo_consuntivo ?? 0));
return [
'label' => $label,
'count' => count($voci),
'label' => $label,
'count' => count($voci),
'totale_prev' => $totPrev,
'totale_cons' => $totCons,
'voci' => $voci,
'voci' => $voci,
];
}
@ -551,7 +550,7 @@ public function updatedPresetRipartizione(string $value): void
public function updatedGestioneContabileId($value): void
{
$id = is_numeric($value) ? (int) $value : null;
$id = is_numeric($value) ? (int) $value : null;
$this->gestioneContabileId = $id && $id > 0 ? $id : null;
$this->resetPage();
}
@ -580,14 +579,14 @@ public function getGestioniOptions(): array
->get(['id', 'anno_gestione', 'tipo_gestione', 'stato'])
->all();
$options = [];
$options = [];
$seenYears = [];
foreach ($gestioni as $g) {
$anno = (string) ($g->anno_gestione ?? '');
if ($anno === '' || isset($seenYears[$anno])) {
continue;
}
$seenYears[$anno] = true;
$seenYears[$anno] = true;
$options[(int) $g->id] = $anno;
}
@ -603,10 +602,10 @@ private function normalizeTipoGestione(string $tipo): string
private function getLegacyTipoValues(string $tipo): array
{
return match ($tipo) {
'ordinaria' => ['ordinaria', 'O', 'o'],
'ordinaria' => ['ordinaria', 'O', 'o'],
'riscaldamento' => ['riscaldamento', 'R', 'r'],
'straordinaria' => ['straordinaria', 'S', 's'],
default => [$tipo],
default => [$tipo],
};
}
@ -636,8 +635,8 @@ private function applyTipoGestioneFilter(Builder $q, string $tipoTab, bool $hasL
private function getTabellaGroupTitle(VoceSpesa $record): string
{
$tabella = $this->resolveTabellaForAnno($record->tabellaMillesimaleDefault, (int) $record->stabile_id);
$code = trim((string) ($tabella?->codice_tabella ?? ''));
$name = trim((string) ($tabella?->denominazione ?? ''));
$code = trim((string) ($tabella?->codice_tabella ?? ''));
$name = trim((string) ($tabella?->denominazione ?? ''));
if ($name === '') {
$name = trim((string) ($tabella?->nome_tabella ?? ''));
}
@ -724,12 +723,12 @@ private function getNordOptions(): array
$values = $q->selectRaw('DISTINCT ' . $expr . ' as nord_val')
->pluck('nord_val')
->filter(fn ($v) => $v !== null && $v !== '')
->filter(fn($v) => $v !== null && $v !== '')
->unique()
->sort()
->values();
return $values->mapWithKeys(fn ($v) => [(string) $v => (string) $v])->all();
return $values->mapWithKeys(fn($v) => [(string) $v => (string) $v])->all();
}
/** @return array<int, string> */
@ -740,11 +739,11 @@ private function getFirstTabellaCodesToHide(int $stabileId): array
return [];
}
$tipoTab = $this->tipoGestioneTab ?: 'ordinaria';
$tipo = $this->normalizeTipoGestione($tipoTab);
$tipoTab = $this->tipoGestioneTab ?: 'ordinaria';
$tipo = $this->normalizeTipoGestione($tipoTab);
$hasLegacyTipo = Schema::hasColumn('tabelle_millesimali', 'tipo');
$hasAnno = Schema::hasColumn('tabelle_millesimali', 'anno_gestione');
$anno = AnnoGestioneContext::resolveActiveAnno($user);
$hasAnno = Schema::hasColumn('tabelle_millesimali', 'anno_gestione');
$anno = AnnoGestioneContext::resolveActiveAnno($user);
$q = TabellaMillesimale::query()->where('stabile_id', $stabileId);
@ -824,7 +823,7 @@ public function applyPresetRipartizione(): void
}
$categoria = $this->mapVoceCategoriaToConfedilizia((string) $record->categoria);
$default = RipartizioneSpeseInquilini::getDefaultConfedilizia($categoria);
$default = RipartizioneSpeseInquilini::getDefaultConfedilizia($categoria);
$record->update([
'percentuale_condomino' => (float) ($default['proprietario'] ?? 100),
@ -838,23 +837,23 @@ public function applyPresetRipartizione(): void
private function mapVoceCategoriaToConfedilizia(string $categoriaVoce): string
{
return match ($categoriaVoce) {
'riscaldamento' => 'B',
'ascensore' => 'C',
'illuminazione' => 'D',
'pulizia' => 'E',
'acqua' => 'G',
'energia_elettrica' => 'L',
'gas' => 'B',
'giardino_verde' => 'I',
'sicurezza' => 'N',
'amministrazione' => 'O',
'assicurazioni' => 'A',
'tasse_tributi' => 'O',
'spese_legali' => 'O',
'manutenzione_ordinaria' => 'A',
'riscaldamento' => 'B',
'ascensore' => 'C',
'illuminazione' => 'D',
'pulizia' => 'E',
'acqua' => 'G',
'energia_elettrica' => 'L',
'gas' => 'B',
'giardino_verde' => 'I',
'sicurezza' => 'N',
'amministrazione' => 'O',
'assicurazioni' => 'A',
'tasse_tributi' => 'O',
'spese_legali' => 'O',
'manutenzione_ordinaria' => 'A',
'manutenzione_straordinaria' => 'A',
'lavori_miglioramento' => 'A',
default => 'A',
'lavori_miglioramento' => 'A',
default => 'A',
};
}
@ -867,13 +866,13 @@ public function setTipoGestioneTab(string $tab): void
$selectedYear = null;
if ($this->gestioneContabileId && Schema::hasTable('gestioni_contabili')) {
$year = GestioneContabile::query()->whereKey($this->gestioneContabileId)->value('anno_gestione');
$year = GestioneContabile::query()->whereKey($this->gestioneContabileId)->value('anno_gestione');
$selectedYear = is_numeric($year) ? (int) $year : null;
}
$this->tipoGestioneTab = $tab;
$user = Auth::user();
$user = Auth::user();
$stabileId = $user instanceof User ? StabileContext::resolveActiveStabileId($user) : null;
if ($stabileId && $selectedYear) {
$this->gestioneContabileId = $this->resolveGestioneIdForAnnoTipo(
@ -954,7 +953,7 @@ protected function getTableQuery(): Builder
if ($this->hideFirstTwoTabelle) {
$hidden = $this->getFirstTabellaCodesToHide($activeStabileId);
if (!empty($hidden)) {
if (! empty($hidden)) {
$query->where(function (Builder $q) use ($hidden) {
$q->whereNull('tm.codice_tabella')->orWhereNotIn('tm.codice_tabella', $hidden);
});
@ -1044,7 +1043,7 @@ public function getTotaleConsuntivo(): float
return 0.0;
}
$tipoTab = $this->tipoGestioneTab;
$tipoTab = $this->tipoGestioneTab;
$hasLegacyTipo = Schema::hasColumn('tabelle_millesimali', 'tipo');
$q = VoceSpesa::query()->where('voci_spesa.stabile_id', $activeStabileId);
@ -1125,9 +1124,9 @@ public function getTotaliPerTabella(): array
return [];
}
$tipoTab = $this->tipoGestioneTab;
$tipoTab = $this->tipoGestioneTab;
$hasLegacyTipo = Schema::hasColumn('tabelle_millesimali', 'tipo');
$q = VoceSpesa::query()
$q = VoceSpesa::query()
->leftJoin('tabelle_millesimali as tm', 'tm.id', '=', 'voci_spesa.tabella_millesimale_default_id')
->where('voci_spesa.stabile_id', $activeStabileId)
->when(Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && $this->gestioneContabileId, function (Builder $qq) {
@ -1145,7 +1144,7 @@ public function getTotaliPerTabella(): array
->orderBy('tabella')
->get()
->map(fn($r) => [
'tabella' => (string) $r->tabella,
'tabella' => (string) $r->tabella,
'totale_prev' => (float) $r->totale_prev,
'totale_cons' => (float) $r->totale_cons,
])
@ -1160,25 +1159,25 @@ public function table(Table $table): Table
Group::make('tabellaMillesimaleDefault.codice_tabella')
->label('Tabella')
->titlePrefixedWithLabel(false)
->getTitleFromRecordUsing(fn (VoceSpesa $record): string => $this->getTabellaGroupTitle($record)),
->getTitleFromRecordUsing(fn(VoceSpesa $record): string => $this->getTabellaGroupTitle($record)),
])
->defaultGroup('tabellaMillesimaleDefault.codice_tabella')
->groupingSettingsHidden()
->filters([
SelectFilter::make('tabella')
->label('Tabella')
->options(fn (): array => $this->getTabelleMillesimaliOptions())
->options(fn(): array=> $this->getTabelleMillesimaliOptions())
->searchable()
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (!is_numeric($value)) {
if (! is_numeric($value)) {
return $query;
}
return $query->where('voci_spesa.tabella_millesimale_default_id', (int) $value);
}),
SelectFilter::make('nord')
->label('NORD')
->options(fn (): array => $this->getNordOptions())
->options(fn(): array=> $this->getNordOptions())
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if ($value === null || $value === '') {
@ -1230,8 +1229,8 @@ public function table(Table $table): Table
->hiddenLabel()
->icon('heroicon-o-document-chart-bar')
->tooltip('Mastrino')
->url(fn (VoceSpesa $record) => VoceSpesaMastrino::getUrl([
'record' => (int) $record->id,
->url(fn(VoceSpesa $record) => VoceSpesaMastrino::getUrl([
'record' => (int) $record->id,
'gestione' => $this->gestioneContabileId,
], panel: 'admin-filament')),
Action::make('modifica')
@ -1284,18 +1283,18 @@ public function table(Table $table): Table
->fillForm(function (VoceSpesa $record): array {
return [
'tabella_millesimale_default_id' => $record->tabella_millesimale_default_id,
'attiva' => $record->attiva ? 1 : 0,
'categoria' => $record->categoria,
'tipo_gestione' => $record->tipo_gestione,
'importo_default' => $record->importo_default,
'importo_consuntivo' => $record->importo_consuntivo,
'percentuale_condomino' => $record->percentuale_condomino,
'percentuale_inquilino' => $record->percentuale_inquilino,
'attiva' => $record->attiva ? 1 : 0,
'categoria' => $record->categoria,
'tipo_gestione' => $record->tipo_gestione,
'importo_default' => $record->importo_default,
'importo_consuntivo' => $record->importo_consuntivo,
'percentuale_condomino' => $record->percentuale_condomino,
'percentuale_inquilino' => $record->percentuale_inquilino,
];
})
->action(function (array $data, VoceSpesa $record): void {
$cond = isset($data['percentuale_condomino']) ? (float) $data['percentuale_condomino'] : null;
$inq = isset($data['percentuale_inquilino']) ? (float) $data['percentuale_inquilino'] : null;
$inq = isset($data['percentuale_inquilino']) ? (float) $data['percentuale_inquilino'] : null;
if ($cond !== null && $inq === null) {
$inq = max(0.0, 100.0 - $cond);
}
@ -1303,13 +1302,13 @@ public function table(Table $table): Table
'tabella_millesimale_default_id' => is_numeric($data['tabella_millesimale_default_id'] ?? null)
? (int) $data['tabella_millesimale_default_id']
: null,
'attiva' => (bool) ($data['attiva'] ?? true),
'categoria' => $data['categoria'] ?? $record->categoria,
'tipo_gestione' => $data['tipo_gestione'] ?? $record->tipo_gestione,
'importo_default' => $data['importo_default'] ?? $record->importo_default,
'importo_consuntivo' => $data['importo_consuntivo'] ?? $record->importo_consuntivo,
'percentuale_condomino' => $cond ?? $record->percentuale_condomino,
'percentuale_inquilino' => $inq ?? $record->percentuale_inquilino,
'attiva' => (bool) ($data['attiva'] ?? true),
'categoria' => $data['categoria'] ?? $record->categoria,
'tipo_gestione' => $data['tipo_gestione'] ?? $record->tipo_gestione,
'importo_default' => $data['importo_default'] ?? $record->importo_default,
'importo_consuntivo' => $data['importo_consuntivo'] ?? $record->importo_consuntivo,
'percentuale_condomino' => $cond ?? $record->percentuale_condomino,
'percentuale_inquilino' => $inq ?? $record->percentuale_inquilino,
]);
$this->dispatch('$refresh');
}),

View File

@ -69,8 +69,8 @@ public function mount(): void
return;
}
$this->fornitoreId = (int) $fornitore->id;
$this->fornitoreLabel = $this->getFornitoreLabel($fornitore);
$this->fornitoreId = (int) $fornitore->id;
$this->fornitoreLabel = $this->getFornitoreLabel($fornitore);
$this->amazonConfigured = app(AmazonCreatorsApiService::class)->isConfigured();
$this->refreshRows();
}
@ -200,8 +200,8 @@ public function saveAmazonCandidate(string $asin): void
Notification::make()
->title(! empty($result['created']) ? 'Prodotto Amazon memorizzato' : 'Prodotto Amazon aggiornato')
->body($product instanceof Product
? 'Articolo salvato nel catalogo come candidato da verificare: ' . ((string) ($product->internal_code ?? '') !== '' ? $product->internal_code . ' · ' : '') . (string) ($product->name ?? '')
: 'Import completato.')
? 'Articolo salvato nel catalogo come candidato da verificare: ' . ((string) ($product->internal_code ?? '') !== '' ? $product->internal_code . ' · ' : '') . (string) ($product->name ?? '')
: 'Import completato.')
->success()
->send();

View File

@ -18,8 +18,8 @@
use App\Modules\Contabilita\Models\FatturaFornitore as ContabilitaFatturaFornitore;
use App\Modules\Contabilita\Models\PianoConti;
use App\Services\Catalog\FornitoreProductCatalogService;
use App\Services\Catalog\ProductHubViewService;
use App\Services\Catalog\ProductAssetIngestionService;
use App\Services\Catalog\ProductHubViewService;
use App\Services\Catalog\ProductOfferService;
use App\Services\Consumi\AcquaPdfTextParser;
use App\Services\Consumi\ConsumiAcquaIngestionService;

View File

@ -0,0 +1,33 @@
<?php
namespace App\Filament\Pages\Mobile;
use App\Models\User;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class DocumentiMobile extends Page
{
protected static ?string $navigationLabel = 'Documenti Mobile';
protected static ?string $title = 'Documenti Mobile';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-qr-code';
protected static UnitEnum|string|null $navigationGroup = 'Mobile';
protected static ?int $navigationSort = 27;
protected static ?string $slug = 'mobile/documenti';
protected string $view = 'filament.pages.mobile.documenti-mobile';
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
}

View File

@ -744,7 +744,7 @@ public function getGenericLabelRowsProperty(): array
->get()
->filter(fn(DocumentoArchivioFisicoItem $item): bool => (bool) data_get($item->metadati, 'generic_label', false))
->map(function (DocumentoArchivioFisicoItem $item): array {
$printOptions = $this->buildGenericLabelPrintOptions((int) $item->id);
$printOptions = $this->buildGenericLabelPrintOptions((int) $item->id);
$defaultPrintOption = ($item->modello_etichetta ?: '11354') === '99014' ? '99014' : '11354';
return [
@ -777,7 +777,7 @@ private function buildGenericLabelPrintOptions(int $itemId): array
$baseUrl = route('filament.archivio-fisico.etichetta', ['item' => $itemId]);
return [
'11354' => [
'11354' => [
'label' => 'DYMO 11354 orizzontale',
'url' => $baseUrl . '?format=11354&autoprint=1&dymo_fix=0&dymo_rot=0&dymo_dx=0&dymo_dy=-1',
],
@ -785,7 +785,7 @@ private function buildGenericLabelPrintOptions(int $itemId): array
'label' => 'DYMO 11354 verticale',
'url' => $baseUrl . '?format=11354&autoprint=1&dymo_fix=1&dymo_rot=90&dymo_dx=0&dymo_dy=0',
],
'99014' => [
'99014' => [
'label' => 'DYMO 99014 larga',
'url' => $baseUrl . '?format=99014&autoprint=1&dymo_fix=0&dymo_rot=0&dymo_dx=3.5&dymo_dy=3.5',
],
@ -836,7 +836,7 @@ public function getGenericLabelTemplateOptionsProperty(): array
public function getGenericLabelTemplateFieldOptionsProperty(): array
{
return [
'mnemonic_code' => 'Codice mnemonico',
'mnemonic_code' => 'Codice mnemonico',
'titolo' => 'Titolo etichetta',
'linea_secondaria' => 'Seconda riga',
'tipo_label' => 'Tipologia contenitore',
@ -862,11 +862,33 @@ public function getGenericLabelStableFieldOptionsProperty(): array
'stabile_code' => 'Codice stabile',
'stabile_address' => 'Indirizzo stabile',
'stabile_summary' => 'Stabile compatto',
'blank_line' => 'Riga bianca scrivibile',
'none' => 'Nessuna riga',
];
}
/** @return array<string, string> */
public function getGenericLabelLineModeOptionsProperty(): array
{
return [
'source' => 'Campo archivio',
'text' => 'Testo libero',
'blank_line' => 'Riga bianca scrivibile',
'none' => 'Nessuna riga',
];
}
/** @return array<string, string> */
public function getGenericLabelFontSizeOptionsProperty(): array
{
return [
'xs' => 'Extra piccolo',
'sm' => 'Piccolo',
'md' => 'Medio',
'lg' => 'Grande',
'xl' => 'Extra grande',
];
}
/** @return array<string, mixed> */
public function getGenericLabelPreviewProperty(): array
{
@ -878,10 +900,10 @@ public function getGenericLabelPreviewProperty(): array
$mnemonicCode = (string) ($this->mnemonicCodeHint ?? 'GER00079');
}
$stabileCode = '';
$stabileCode = '';
$stabileAddress = '';
if ($stabile instanceof Stabile) {
$stabileCode = trim((string) ($stabile->codice_stabile ?? ''));
$stabileCode = trim((string) ($stabile->codice_stabile ?? ''));
$stabileAddress = trim((string) ($stabile->indirizzo_completo ?? ''));
}
@ -925,14 +947,7 @@ public function getGenericLabelPreviewProperty(): array
'amazon_url' => $amazonUrl,
];
$stableLineOneSource = (string) ($this->genericLabelForm['stable_line_one_source'] ?? 'stabile_summary');
$stableLineTwoSource = (string) ($this->genericLabelForm['stable_line_two_source'] ?? 'blank_line');
$blankLinesCount = max(0, min(4, (int) ($this->genericLabelForm['blank_lines_count'] ?? 2)));
$stableLines = [
$this->resolveGenericLabelStableLineValue($stableLineOneSource, $payload),
$this->resolveGenericLabelStableLineValue($stableLineTwoSource, $payload),
];
$stableLines = $this->buildGenericLabelLineRows($payload);
return [
'template_name' => $templateName,
@ -948,8 +963,15 @@ public function getGenericLabelPreviewProperty(): array
],
'payload' => $payload,
'mnemonic_code' => $mnemonicCode,
'font_sizes' => [
'mnemonic' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['mnemonic_font_size'] ?? 'xl')),
'title' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['title_font_size'] ?? 'xl')),
'subtitle' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['subtitle_font_size'] ?? 'md')),
'lines' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['lines_font_size'] ?? 'md')),
'note' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['note_font_size'] ?? 'sm')),
],
'stable_lines' => $stableLines,
'blank_lines' => array_fill(0, $blankLinesCount, ''),
'blank_lines' => [],
'amazon_url' => $amazonUrl,
];
}
@ -1284,18 +1306,24 @@ public function saveGenericArchiveLabel(): void
'linea_secondaria' => $this->normalizeNullableText($this->genericLabelForm['linea_secondaria'] ?? null),
'amazon_url' => $amazonUrl,
'generic_label_template' => [
'preset_key' => $this->normalizeNullableText($this->genericLabelForm['preset_key'] ?? null),
'template_name' => $this->normalizeNullableText($this->genericLabelForm['template_name'] ?? null) ?: 'classic-right',
'mnemonic_code' => $this->normalizeNullableText($this->genericLabelForm['mnemonic_code'] ?? null) ?: $this->mnemonicCodeHint,
'title_source' => $this->normalizeNullableText($this->genericLabelForm['title_source'] ?? null) ?: 'titolo',
'subtitle_source' => $this->normalizeNullableText($this->genericLabelForm['subtitle_source'] ?? null) ?: 'linea_secondaria',
'meta_source' => $this->normalizeNullableText($this->genericLabelForm['meta_source'] ?? null) ?: 'percorso_compatto',
'note_source' => $this->normalizeNullableText($this->genericLabelForm['note_source'] ?? null) ?: 'note',
'preset_key' => $this->normalizeNullableText($this->genericLabelForm['preset_key'] ?? null),
'template_name' => $this->normalizeNullableText($this->genericLabelForm['template_name'] ?? null) ?: 'classic-right',
'mnemonic_code' => $this->normalizeNullableText($this->genericLabelForm['mnemonic_code'] ?? null) ?: $this->mnemonicCodeHint,
'title_source' => $this->normalizeNullableText($this->genericLabelForm['title_source'] ?? null) ?: 'titolo',
'subtitle_source' => $this->normalizeNullableText($this->genericLabelForm['subtitle_source'] ?? null) ?: 'linea_secondaria',
'meta_source' => $this->normalizeNullableText($this->genericLabelForm['meta_source'] ?? null) ?: 'percorso_compatto',
'note_source' => $this->normalizeNullableText($this->genericLabelForm['note_source'] ?? null) ?: 'note',
'stable_line_one_source' => $this->normalizeNullableText($this->genericLabelForm['stable_line_one_source'] ?? null) ?: 'stabile_summary',
'stable_line_two_source' => $this->normalizeNullableText($this->genericLabelForm['stable_line_two_source'] ?? null) ?: 'blank_line',
'blank_lines_count' => max(0, min(4, (int) ($this->genericLabelForm['blank_lines_count'] ?? 2))),
'qr_position' => $this->normalizeNullableText($this->genericLabelForm['qr_position'] ?? null) ?: 'right-center',
'qr_scale' => $this->normalizeNullableText($this->genericLabelForm['qr_scale'] ?? null) ?: 'md',
'blank_lines_count' => max(0, min(4, (int) ($this->genericLabelForm['blank_lines_count'] ?? 2))),
'qr_position' => $this->normalizeNullableText($this->genericLabelForm['qr_position'] ?? null) ?: 'right-center',
'qr_scale' => $this->normalizeNullableText($this->genericLabelForm['qr_scale'] ?? null) ?: 'md',
'mnemonic_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['mnemonic_font_size'] ?? 'xl')),
'title_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['title_font_size'] ?? 'xl')),
'subtitle_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['subtitle_font_size'] ?? 'md')),
'lines_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['lines_font_size'] ?? 'md')),
'note_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['note_font_size'] ?? 'sm')),
'line_rows' => $this->serializeGenericLabelLineRows(),
],
],
]);
@ -1407,19 +1435,42 @@ public function movePhysicalArchiveItem(): void
return;
}
$source->forceFill([
'parent_id' => (int) $target->id,
'updated_by' => Auth::id(),
])->save();
$supportoFisico = $this->normalizeNullableText($this->physicalArchiveMoveForm['supporto_fisico'] ?? null);
$magazzino = $this->normalizeNullableText($this->physicalArchiveMoveForm['magazzino'] ?? null) ?? $target->magazzino ?? $source->magazzino;
$scaffale = $this->normalizeNullableText($this->physicalArchiveMoveForm['scaffale'] ?? null) ?? $target->scaffale ?? $source->scaffale;
$ripiano = $this->normalizeNullableText($this->physicalArchiveMoveForm['ripiano'] ?? null) ?? $target->ripiano ?? $source->ripiano;
$ubicazioneDettaglio = $this->normalizeNullableText($this->physicalArchiveMoveForm['ubicazione_dettaglio'] ?? null) ?? $target->ubicazione_dettaglio ?? $source->ubicazione_dettaglio;
$payload = [
'parent_id' => (int) $target->id,
'updated_by' => Auth::id(),
'magazzino' => $magazzino,
'scaffale' => $scaffale,
'ripiano' => $ripiano,
'ubicazione_dettaglio' => $ubicazioneDettaglio,
];
if ($supportoFisico !== null) {
$payload['supporto_fisico'] = $supportoFisico;
}
$source->forceFill($payload)->save();
$ubicazione = collect([$magazzino, $scaffale, $ripiano, $ubicazioneDettaglio])->filter()->implode(' · ');
$this->physicalArchiveMoveForm = [
'source_code' => '',
'target_code' => '',
'source_code' => '',
'target_code' => '',
'supporto_fisico' => '',
'magazzino' => '',
'scaffale' => '',
'ripiano' => '',
'ubicazione_dettaglio' => '',
];
Notification::make()
->title('Movimentazione registrata')
->body($source->codice_univoco . ' → ' . $target->codice_univoco)
->body(trim($source->codice_univoco . ' → ' . $target->codice_univoco . ($ubicazione !== '' ? ' · ' . $ubicazione : '')))
->success()
->send();
}
@ -1854,38 +1905,60 @@ private function initializePhysicalArchiveForms(): void
];
$this->physicalArchiveMoveForm = [
'source_code' => '',
'target_code' => '',
'source_code' => '',
'target_code' => '',
'supporto_fisico' => '',
'magazzino' => '',
'scaffale' => '',
'ripiano' => '',
'ubicazione_dettaglio' => '',
];
}
private function initializeGenericLabelForm(): void
{
$this->genericLabelForm = [
'preset_key' => '',
'mnemonic_code' => (string) ($this->mnemonicCodeHint ?? 'GER00079'),
'titolo' => '',
'linea_secondaria' => '',
'note' => '',
'tipo_item' => 'scatola',
'supporto_fisico' => 'Scatola',
'parent_id' => null,
'magazzino' => '',
'scaffale' => '',
'ripiano' => '',
'ubicazione_dettaglio' => '',
'modello_etichetta' => '11354',
'amazon_url' => '',
'template_name' => 'archive-99014',
'title_source' => 'titolo',
'subtitle_source' => 'linea_secondaria',
'meta_source' => 'percorso_compatto',
'note_source' => 'note',
'preset_key' => '',
'mnemonic_code' => (string) ($this->mnemonicCodeHint ?? 'GER00079'),
'titolo' => '',
'linea_secondaria' => '',
'note' => '',
'tipo_item' => 'scatola',
'supporto_fisico' => 'Scatola',
'parent_id' => null,
'magazzino' => '',
'scaffale' => '',
'ripiano' => '',
'ubicazione_dettaglio' => '',
'modello_etichetta' => '11354',
'amazon_url' => '',
'template_name' => 'archive-99014',
'title_source' => 'titolo',
'subtitle_source' => 'linea_secondaria',
'meta_source' => 'percorso_compatto',
'note_source' => 'note',
'stable_line_one_source' => 'stabile_summary',
'stable_line_two_source' => 'blank_line',
'blank_lines_count' => 2,
'qr_position' => 'bottom-right',
'qr_scale' => 'lg',
'blank_lines_count' => 2,
'qr_position' => 'bottom-right',
'qr_scale' => 'lg',
'line_1_mode' => 'source',
'line_1_source' => 'stabile_summary',
'line_1_text' => '',
'line_2_mode' => 'blank_line',
'line_2_source' => 'none',
'line_2_text' => '',
'line_3_mode' => 'blank_line',
'line_3_source' => 'none',
'line_3_text' => '',
'line_4_mode' => 'none',
'line_4_source' => 'none',
'line_4_text' => '',
'mnemonic_font_size' => 'xl',
'title_font_size' => 'xl',
'subtitle_font_size' => 'md',
'lines_font_size' => 'md',
'note_font_size' => 'sm',
];
$this->genericLabelTemplateLabel = '';
@ -2045,24 +2118,39 @@ public function applyGenericLabelPreset(string $presetKey): void
}
$this->genericLabelForm = array_merge($this->genericLabelForm, [
'preset_key' => $presetKey,
'mnemonic_code' => (string) ($preset['mnemonic_code'] ?? ($this->mnemonicCodeHint ?? 'GER00079')),
'titolo' => (string) ($preset['titolo'] ?? ''),
'linea_secondaria' => (string) ($preset['linea_secondaria'] ?? ''),
'tipo_item' => (string) ($preset['tipo_item'] ?? 'scatola'),
'supporto_fisico' => (string) ($preset['supporto_fisico'] ?? ''),
'modello_etichetta' => (string) ($preset['modello_etichetta'] ?? '11354'),
'template_name' => (string) ($preset['template_name'] ?? 'classic-right'),
'title_source' => (string) data_get($preset, 'sources.title', 'titolo'),
'subtitle_source' => (string) data_get($preset, 'sources.subtitle', 'linea_secondaria'),
'meta_source' => (string) data_get($preset, 'sources.meta', 'percorso_compatto'),
'note_source' => (string) data_get($preset, 'sources.note', 'note'),
'preset_key' => $presetKey,
'mnemonic_code' => (string) ($preset['mnemonic_code'] ?? ($this->mnemonicCodeHint ?? 'GER00079')),
'titolo' => (string) ($preset['titolo'] ?? ''),
'linea_secondaria' => (string) ($preset['linea_secondaria'] ?? ''),
'tipo_item' => (string) ($preset['tipo_item'] ?? 'scatola'),
'supporto_fisico' => (string) ($preset['supporto_fisico'] ?? ''),
'modello_etichetta' => (string) ($preset['modello_etichetta'] ?? '11354'),
'template_name' => (string) ($preset['template_name'] ?? 'classic-right'),
'title_source' => (string) data_get($preset, 'sources.title', 'titolo'),
'subtitle_source' => (string) data_get($preset, 'sources.subtitle', 'linea_secondaria'),
'meta_source' => (string) data_get($preset, 'sources.meta', 'percorso_compatto'),
'note_source' => (string) data_get($preset, 'sources.note', 'note'),
'stable_line_one_source' => (string) ($preset['stable_line_one_source'] ?? 'stabile_summary'),
'stable_line_two_source' => (string) ($preset['stable_line_two_source'] ?? 'blank_line'),
'blank_lines_count' => max(0, min(4, (int) ($preset['blank_lines_count'] ?? 2))),
'qr_position' => (string) ($preset['qr_position'] ?? 'right-center'),
'qr_scale' => (string) ($preset['qr_scale'] ?? 'md'),
'blank_lines_count' => max(0, min(4, (int) ($preset['blank_lines_count'] ?? 2))),
'qr_position' => (string) ($preset['qr_position'] ?? 'right-center'),
'qr_scale' => (string) ($preset['qr_scale'] ?? 'md'),
'mnemonic_font_size' => $this->normalizeGenericLabelFontSize((string) ($preset['mnemonic_font_size'] ?? 'xl')),
'title_font_size' => $this->normalizeGenericLabelFontSize((string) ($preset['title_font_size'] ?? 'xl')),
'subtitle_font_size' => $this->normalizeGenericLabelFontSize((string) ($preset['subtitle_font_size'] ?? 'md')),
'lines_font_size' => $this->normalizeGenericLabelFontSize((string) ($preset['lines_font_size'] ?? 'md')),
'note_font_size' => $this->normalizeGenericLabelFontSize((string) ($preset['note_font_size'] ?? 'sm')),
]);
$lineRows = is_array($preset['line_rows'] ?? null) ? $preset['line_rows'] : [];
for ($index = 1; $index <= 4; $index++) {
$row = $lineRows[$index - 1] ?? $this->getLegacyGenericLabelLineRow($index, $preset);
$this->genericLabelForm['line_' . $index . '_mode'] = $this->normalizeGenericLabelLineMode((string) ($row['mode'] ?? 'none'));
$this->genericLabelForm['line_' . $index . '_source'] = (string) ($row['source'] ?? 'none');
$this->genericLabelForm['line_' . $index . '_text'] = (string) ($row['text'] ?? '');
}
}
public function applySelectedGenericLabelPreset(): void
@ -2254,20 +2342,20 @@ private function getBuiltInGenericLabelPresetDefinitions(): array
],
],
'faldone-archivio-99014' => [
'label' => 'Faldone archivio 99014',
'mnemonic_code' => (string) ($this->mnemonicCodeHint ?? 'GER00079'),
'titolo' => 'FALDONE ARCHIVIO',
'linea_secondaria' => 'Contenitore operativo con note manuali',
'tipo_item' => 'faldone_anelli',
'supporto_fisico' => 'Faldone con anelli',
'modello_etichetta' => '99014',
'template_name' => 'archive-99014',
'label' => 'Faldone archivio 99014',
'mnemonic_code' => (string) ($this->mnemonicCodeHint ?? 'GER00079'),
'titolo' => 'FALDONE ARCHIVIO',
'linea_secondaria' => 'Contenitore operativo con note manuali',
'tipo_item' => 'faldone_anelli',
'supporto_fisico' => 'Faldone con anelli',
'modello_etichetta' => '99014',
'template_name' => 'archive-99014',
'stable_line_one_source' => 'stabile_summary',
'stable_line_two_source' => 'blank_line',
'blank_lines_count' => 2,
'qr_position' => 'bottom-right',
'qr_scale' => 'lg',
'sources' => [
'blank_lines_count' => 2,
'qr_position' => 'bottom-right',
'qr_scale' => 'lg',
'sources' => [
'title' => 'titolo',
'subtitle' => 'linea_secondaria',
'meta' => 'stabile_summary',
@ -2316,20 +2404,26 @@ private function makeGenericLabelTemplateKey(string $label, array $stored): stri
private function buildGenericLabelTemplatePayload(string $label): array
{
return [
'label' => $label,
'mnemonic_code' => trim((string) ($this->genericLabelForm['mnemonic_code'] ?? '')),
'titolo' => trim((string) ($this->genericLabelForm['titolo'] ?? '')),
'linea_secondaria' => trim((string) ($this->genericLabelForm['linea_secondaria'] ?? '')),
'tipo_item' => (string) ($this->genericLabelForm['tipo_item'] ?? 'scatola'),
'supporto_fisico' => trim((string) ($this->genericLabelForm['supporto_fisico'] ?? '')),
'modello_etichetta' => (string) ($this->genericLabelForm['modello_etichetta'] ?? '11354'),
'template_name' => (string) ($this->genericLabelForm['template_name'] ?? 'classic-right'),
'label' => $label,
'mnemonic_code' => trim((string) ($this->genericLabelForm['mnemonic_code'] ?? '')),
'titolo' => trim((string) ($this->genericLabelForm['titolo'] ?? '')),
'linea_secondaria' => trim((string) ($this->genericLabelForm['linea_secondaria'] ?? '')),
'tipo_item' => (string) ($this->genericLabelForm['tipo_item'] ?? 'scatola'),
'supporto_fisico' => trim((string) ($this->genericLabelForm['supporto_fisico'] ?? '')),
'modello_etichetta' => (string) ($this->genericLabelForm['modello_etichetta'] ?? '11354'),
'template_name' => (string) ($this->genericLabelForm['template_name'] ?? 'classic-right'),
'stable_line_one_source' => (string) ($this->genericLabelForm['stable_line_one_source'] ?? 'stabile_summary'),
'stable_line_two_source' => (string) ($this->genericLabelForm['stable_line_two_source'] ?? 'blank_line'),
'blank_lines_count' => max(0, min(4, (int) ($this->genericLabelForm['blank_lines_count'] ?? 2))),
'qr_position' => (string) ($this->genericLabelForm['qr_position'] ?? 'right-center'),
'qr_scale' => (string) ($this->genericLabelForm['qr_scale'] ?? 'md'),
'sources' => [
'blank_lines_count' => max(0, min(4, (int) ($this->genericLabelForm['blank_lines_count'] ?? 2))),
'qr_position' => (string) ($this->genericLabelForm['qr_position'] ?? 'right-center'),
'qr_scale' => (string) ($this->genericLabelForm['qr_scale'] ?? 'md'),
'mnemonic_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['mnemonic_font_size'] ?? 'xl')),
'title_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['title_font_size'] ?? 'xl')),
'subtitle_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['subtitle_font_size'] ?? 'md')),
'lines_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['lines_font_size'] ?? 'md')),
'note_font_size' => $this->normalizeGenericLabelFontSize((string) ($this->genericLabelForm['note_font_size'] ?? 'sm')),
'line_rows' => $this->serializeGenericLabelLineRows(),
'sources' => [
'title' => (string) ($this->genericLabelForm['title_source'] ?? 'titolo'),
'subtitle' => (string) ($this->genericLabelForm['subtitle_source'] ?? 'linea_secondaria'),
'meta' => (string) ($this->genericLabelForm['meta_source'] ?? 'percorso_compatto'),
@ -2376,6 +2470,92 @@ private function resolveGenericLabelStableLineValue(string $fieldKey, array $pay
return trim((string) ($payload[$fieldKey] ?? ''));
}
/** @param array<string, string> $payload
* @return array<int, string>
*/
private function buildGenericLabelLineRows(array $payload): array
{
$rows = [];
for ($index = 1; $index <= 4; $index++) {
$mode = $this->normalizeGenericLabelLineMode((string) ($this->genericLabelForm['line_' . $index . '_mode'] ?? 'none'));
if ($mode === 'blank_line') {
$rows[] = '__BLANK_LINE__';
continue;
}
if ($mode === 'text') {
$rows[] = trim((string) ($this->genericLabelForm['line_' . $index . '_text'] ?? ''));
continue;
}
if ($mode === 'source') {
$rows[] = $this->resolveGenericLabelStableLineValue((string) ($this->genericLabelForm['line_' . $index . '_source'] ?? 'none'), $payload);
}
}
return $rows;
}
/** @return array<int, array<string, string>> */
private function serializeGenericLabelLineRows(): array
{
$rows = [];
for ($index = 1; $index <= 4; $index++) {
$rows[] = [
'mode' => $this->normalizeGenericLabelLineMode((string) ($this->genericLabelForm['line_' . $index . '_mode'] ?? 'none')),
'source' => (string) ($this->genericLabelForm['line_' . $index . '_source'] ?? 'none'),
'text' => trim((string) ($this->genericLabelForm['line_' . $index . '_text'] ?? '')),
];
}
return $rows;
}
/** @param array<string, mixed> $preset
* @return array<string, string>
*/
private function getLegacyGenericLabelLineRow(int $index, array $preset): array
{
if ($index === 1) {
return [
'mode' => 'source',
'source' => (string) ($preset['stable_line_one_source'] ?? 'stabile_summary'),
'text' => '',
];
}
if ($index === 2) {
$source = (string) ($preset['stable_line_two_source'] ?? 'blank_line');
return [
'mode' => $source === 'blank_line' ? 'blank_line' : 'source',
'source' => $source,
'text' => '',
];
}
$blankCount = max(0, min(4, (int) ($preset['blank_lines_count'] ?? 0)));
return [
'mode' => $index <= ($blankCount + 2) ? 'blank_line' : 'none',
'source' => 'none',
'text' => '',
];
}
private function normalizeGenericLabelLineMode(string $mode): string
{
return in_array($mode, ['source', 'text', 'blank_line', 'none'], true) ? $mode : 'none';
}
private function normalizeGenericLabelFontSize(string $size): string
{
return in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'md';
}
private function getDefaultStabileId(): ?int
{
$user = Auth::user();

View File

@ -51,7 +51,7 @@ class PostIt extends Page
public ?int $assegnazioneUserId = null;
public ?int $assegnazioneFornitoreId = null;
public ?int $selectedSuggerimentoId = null;
public ?int $selectedSuggerimentoId = null;
public bool $disableAutoStabileAssociation = false;
/** @var \Illuminate\Support\Collection<int, RubricaUniversale> */
@ -407,26 +407,26 @@ public function getRecentiProperty()
->groupBy(fn(ChiamataPostIt $item): string => $this->buildRecenteGroupKey($item))
->map(function (Collection $group): array {
/** @var ChiamataPostIt $primary */
$primary = $group->first();
$primary = $group->first();
$callerLabel = $this->resolveRecenteCallerLabel($group);
$phoneLabel = $this->resolveRecentePhoneLabel($group);
$phoneLabel = $this->resolveRecentePhoneLabel($group);
return [
'primary' => $primary,
'items' => $group->values(),
'count' => $group->count(),
'caller_label' => $callerLabel,
'phone' => $phoneLabel,
'latest_at' => optional($primary->chiamata_il)->format('d/m/Y H:i'),
'primary' => $primary,
'items' => $group->values(),
'count' => $group->count(),
'caller_label' => $callerLabel,
'phone' => $phoneLabel,
'latest_at' => optional($primary->chiamata_il)->format('d/m/Y H:i'),
'contains_selected' => $this->selectedPostItId
? $group->contains(fn(ChiamataPostIt $item): bool => (int) $item->id === (int) $this->selectedPostItId)
? $group->contains(fn(ChiamataPostIt $item) : bool => (int) $item->id === (int) $this->selectedPostItId)
: false,
];
})
->values();
if (! $this->selectedPostItId && $grouped->isNotEmpty()) {
$first = $grouped->first();
$first = $grouped->first();
$this->selectedPostItId = (int) ($this->focusPostItId ?: ($first['primary']->id ?? 0));
$this->loadSelectedPostItAssignment();
}
@ -550,13 +550,13 @@ private function createPostItRecord(): ChiamataPostIt
private function applyQueryPrefill(): void
{
$source = trim((string) request()->query('source', ''));
$source = trim((string) request()->query('source', ''));
$this->disableAutoStabileAssociation = request()->boolean('lock_stabile') || $source === 'topbar_live_call';
$telefono = trim((string) request()->query('telefono', ''));
$nome = trim((string) request()->query('nome', ''));
$nota = trim((string) request()->query('nota', ''));
$oggetto = trim((string) request()->query('oggetto', ''));
$telefono = trim((string) request()->query('telefono', ''));
$nome = trim((string) request()->query('nome', ''));
$nota = trim((string) request()->query('nota', ''));
$oggetto = trim((string) request()->query('oggetto', ''));
$direzione = trim((string) request()->query('direzione', ''));
$rubricaId = (int) request()->query('rubrica_id', 0);
@ -564,7 +564,7 @@ private function applyQueryPrefill(): void
$this->telefono = $telefono;
}
if ($nome !== '') {
$this->nomeChiamante = $nome;
$this->nomeChiamante = $nome;
$this->ricercaChiamante = $nome;
}
if ($nota !== '') {
@ -637,25 +637,25 @@ private function createRubricaDraftFromPhone(string $phone, string $name = ''):
}
$normalizedPhone = PhoneNumber::toE164Italy($phone) ?? $digits;
$adminId = $this->resolveCurrentAmministratoreId();
$cleanName = trim($name);
$adminId = $this->resolveCurrentAmministratoreId();
$cleanName = trim($name);
$payload = [
'amministratore_id' => $adminId > 0 ? $adminId : null,
'tipo_contatto' => str_contains($cleanName, ' ') ? 'persona_fisica' : 'persona_giuridica',
'telefono_cellulare' => $normalizedPhone,
'categoria' => 'altro',
'stato' => 'attivo',
'data_inserimento' => now()->toDateString(),
'amministratore_id' => $adminId > 0 ? $adminId : null,
'tipo_contatto' => str_contains($cleanName, ' ') ? 'persona_fisica' : 'persona_giuridica',
'telefono_cellulare' => $normalizedPhone,
'categoria' => 'altro',
'stato' => 'attivo',
'data_inserimento' => now()->toDateString(),
'data_ultima_modifica' => now()->toDateString(),
'creato_da' => Auth::id(),
'modificato_da' => Auth::id(),
'note_segreteria' => 'Bozza creata dal box Post-it in topbar per una chiamata in ingresso.',
'creato_da' => Auth::id(),
'modificato_da' => Auth::id(),
'note_segreteria' => 'Bozza creata dal box Post-it in topbar per una chiamata in ingresso.',
];
if ($cleanName !== '' && str_contains($cleanName, ' ')) {
$parts = preg_split('/\s+/', $cleanName) ?: [];
$payload['nome'] = array_shift($parts) ?: null;
$parts = preg_split('/\s+/', $cleanName) ?: [];
$payload['nome'] = array_shift($parts) ?: null;
$payload['cognome'] = $parts !== [] ? implode(' ', $parts) : null;
} elseif ($cleanName !== '') {
$payload['ragione_sociale'] = $cleanName;

View File

@ -11,6 +11,7 @@
use App\Models\User;
use App\Services\Anagrafiche\RubricaPhoneLookupService;
use App\Services\Cti\PbxRoutingService;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
@ -18,7 +19,6 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use App\Support\StabileContext;
use UnitEnum;
class PostItGestione extends Page
@ -341,7 +341,7 @@ public function assegnaSelectedAppunto(): void
if ($this->appuntoAssegnazioneTipo === 'fornitore') {
$ticketPayload['assegnato_a_fornitore_id'] = $this->appuntoAssegnazioneFornitoreId;
$ticketPayload['assegnato_a_user_id'] = null;
$ticketPayload['assegnato_a_user_id'] = null;
} else {
$ticketPayload['assegnato_a_user_id'] = $this->appuntoAssegnazioneUserId;
}
@ -535,9 +535,9 @@ public function getSelectedAppuntoProperty(): ?ChiamataPostIt
public function isPostItAssignmentReady(): bool
{
return $this->isPostItTableReady()
&& Schema::hasColumn('chiamate_post_it', 'assegnazione_tipo')
&& Schema::hasColumn('chiamate_post_it', 'assegnato_a_user_id')
&& Schema::hasColumn('chiamate_post_it', 'assegnato_a_fornitore_id');
&& Schema::hasColumn('chiamate_post_it', 'assegnazione_tipo')
&& Schema::hasColumn('chiamate_post_it', 'assegnato_a_user_id')
&& Schema::hasColumn('chiamate_post_it', 'assegnato_a_fornitore_id');
}
public function getOperatoriOptionsProperty(): array
@ -1098,20 +1098,20 @@ private function loadSelectedAppuntoState(): void
{
$postIt = $this->selectedAppunto;
if (! $postIt) {
$this->selectedAppuntoOggetto = null;
$this->selectedAppuntoNota = null;
$this->appuntoAssegnazioneTipo = 'operatore';
$this->appuntoAssegnazioneUserId = null;
$this->selectedAppuntoOggetto = null;
$this->selectedAppuntoNota = null;
$this->appuntoAssegnazioneTipo = 'operatore';
$this->appuntoAssegnazioneUserId = null;
$this->appuntoAssegnazioneFornitoreId = null;
return;
}
$this->selectedAppuntoOggetto = $postIt->oggetto;
$this->selectedAppuntoNota = $postIt->nota;
$this->selectedAppuntoOggetto = $postIt->oggetto;
$this->selectedAppuntoNota = $postIt->nota;
$this->appuntoAssegnazioneTipo = in_array((string) ($postIt->assegnazione_tipo ?? ''), ['operatore', 'collaboratore', 'fornitore'], true)
? (string) $postIt->assegnazione_tipo
: 'operatore';
$this->appuntoAssegnazioneUserId = isset($postIt->assegnato_a_user_id) ? (int) $postIt->assegnato_a_user_id : null;
$this->appuntoAssegnazioneUserId = isset($postIt->assegnato_a_user_id) ? (int) $postIt->assegnato_a_user_id : null;
$this->appuntoAssegnazioneFornitoreId = isset($postIt->assegnato_a_fornitore_id) ? (int) $postIt->assegnato_a_fornitore_id : null;
}

View File

@ -1783,10 +1783,27 @@ private function startNodeRefreshJob(): void
'--progress-file=' . $progressPath,
];
$adminId = $this->resolveAmministratoreId();
$requireDrive = $this->shouldRequireDrivePreupdateBackup();
$driveEnabled = $this->shouldAttemptDrivePreupdateBackup();
if ($this->gitForce) {
$args[] = '--force';
}
if ($this->updateSkipBackup) {
$args[] = '--skip-backup';
}
if ($driveEnabled && $adminId > 0) {
$args[] = '--drive';
$args[] = '--admin-id=' . $adminId;
}
if ($requireDrive) {
$args[] = '--require-drive';
}
$escaped = implode(' ', array_map(static fn(string $arg): string => escapeshellarg($arg), $args));
$shell = 'nohup ' . $escaped . ' > ' . escapeshellarg($logPath) . ' 2>&1 & echo $!';
$started = Process::path(base_path())->run(['bash', '-lc', $shell]);

View File

@ -686,14 +686,14 @@ private function appendPendingWaterLibraryPhotos(): void
private function resetWaterDraftUploadState(): void
{
$this->newWaterPhotos = [];
$this->pendingWaterPhotos = [];
$this->newWaterPhotos = [];
$this->pendingWaterPhotos = [];
$this->pendingWaterLibraryPhotos = [];
$this->waterPhotoDescriptions = [];
$this->waterUploadCodes = [];
$this->waterClientMetadata = [];
$this->waterDraftProtocol = 'TA-' . now()->format('Ymd-His');
$this->waterDraftSequence = 1;
$this->waterPhotoDescriptions = [];
$this->waterUploadCodes = [];
$this->waterClientMetadata = [];
$this->waterDraftProtocol = 'TA-' . now()->format('Ymd-His');
$this->waterDraftSequence = 1;
}
private function syncWaterDescriptionSlots(): void

View File

@ -131,14 +131,9 @@ public function etichetta(Request $request, Documento $documento, DocumentoArchi
$faldone = $this->extractFaldoneFromNote((string) ($documento->note ?? ''));
}
$anno = $documento->data_documento?->format('Y') ?: now()->format('Y');
$protocollo = (string) ($documento->numero_protocollo ?: ('DOC-' . $anno . '-' . str_pad((string) $documento->id, 3, '0', STR_PAD_LEFT)));
$stabileId = (int) ($documento->stabile_id ?: 0);
$codiceUnico = $stabileId > 0 ? ('STB-' . $stabileId . '-' . $protocollo) : $protocollo;
if ($faldone !== '') {
$codiceUnico .= '-F' . Str::upper($faldone);
}
$anno = $documento->data_documento?->format('Y') ?: now()->format('Y');
$protocollo = (string) ($documento->numero_protocollo ?: ('DOC-' . $anno . '-' . str_pad((string) $documento->id, 3, '0', STR_PAD_LEFT)));
$codiceUnico = $documento->resolveArchiveCode();
$openUrl = route('filament.documenti.download', $documento);

View File

@ -195,7 +195,7 @@ public function table(Table $table): Table
->label('Movimenti')
->icon('heroicon-o-receipt-percent')
->url(function (DatiBancari $record): string {
$base = CasseBancheMovimenti::getUrl(panel: 'admin-filament');
$base = CasseBancheMovimenti::getUrl(panel: 'admin-filament');
$contoId = (int) ($record->id ?? 0);
if ($contoId <= 0) {
return $base;

View File

@ -37,13 +37,13 @@ public function createPostIt(): void
$this->redirect(
PostIt::getUrl([
'source' => 'topbar_live_call',
'telefono' => (string) ($this->liveIncomingCall['phone'] ?? ''),
'nome' => (string) ($this->liveIncomingCall['rubrica_nome'] ?? ''),
'rubrica_id' => (int) ($this->liveIncomingCall['rubrica_id'] ?? 0) ?: null,
'direzione' => 'in_arrivo',
'oggetto' => 'Chiamata in arrivo da valutare',
'nota' => (string) ($this->liveIncomingCall['line'] ?? ('Chiamata in ingresso da ' . ($this->liveIncomingCall['phone'] ?? ''))),
'source' => 'topbar_live_call',
'telefono' => (string) ($this->liveIncomingCall['phone'] ?? ''),
'nome' => (string) ($this->liveIncomingCall['rubrica_nome'] ?? ''),
'rubrica_id' => (int) ($this->liveIncomingCall['rubrica_id'] ?? 0) ?: null,
'direzione' => 'in_arrivo',
'oggetto' => 'Chiamata in arrivo da valutare',
'nota' => (string) ($this->liveIncomingCall['line'] ?? ('Chiamata in ingresso da ' . ($this->liveIncomingCall['phone'] ?? ''))),
'lock_stabile' => 1,
], panel: 'admin-filament'),
navigate: true,

View File

@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Str;
class Documento extends Model
{
@ -236,6 +237,53 @@ public function getStatoScadenzaAttribute()
return 'valido';
}
/**
* Metodi di utilità
*/
public function resolveArchiveCode(): string
{
$meta = is_array($this->metadati_archivio ?? null) ? $this->metadati_archivio : [];
foreach ([
$meta['codice_univoco_documento'] ?? null,
$meta['physical_archive_code'] ?? null,
$this->archive_reference ?? null,
] as $candidate) {
$value = trim((string) $candidate);
if ($value !== '') {
return $value;
}
}
$protocollo = trim((string) ($this->numero_protocollo ?? ''));
if ($protocollo === '') {
$protocollo = 'DOC-' . (int) $this->id;
}
$stabileId = (int) ($this->stabile_id ?? 0);
$faldone = trim((string) data_get($meta, 'contenitore_numero', data_get($meta, 'faldone', '')));
$code = $stabileId > 0 ? ('STB-' . $stabileId . '-' . $protocollo) : $protocollo;
if ($faldone !== '') {
$code .= '-F' . Str::upper($faldone);
}
return $code;
}
public function resolveArchiveDisplayReference(): string
{
$code = $this->resolveArchiveCode();
$reference = trim((string) ($this->archive_reference ?? ''));
if ($reference === '' || $reference === $code) {
return $code;
}
return $code . ' · ' . $reference;
}
/**
* Metodi di utilità
*/

View File

@ -1,5 +1,4 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -70,4 +69,4 @@ public function getDisplayPathAttribute(): string
return $parts === [] ? (string) ($this->nome ?? '') : implode(' / ', $parts);
}
}
}

View File

@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
class DocumentoArchivioFisicoItem extends Model
{
@ -49,11 +50,15 @@ class DocumentoArchivioFisicoItem extends Model
public const TIPI = [
'documento' => 'Documento cartaceo',
'foglio' => 'Foglio / documento singolo',
'cartellina' => 'Cartellina',
'busta_trasparente' => 'Busta trasparente con anelli da archiviare',
'cartella_tre_lembi' => 'Cartella a tre lembi',
'faldone_anelli' => 'Faldone con anelli',
'faldone_lacci' => 'Faldone con lacci',
'scatola' => 'Scatola',
'scaffale' => 'Scaffale',
'posto' => 'Posto / ubicazione finale',
];
public function stabile(): BelongsTo
@ -106,7 +111,7 @@ public function getPercorsoFisicoAttribute(): string
public function getQrCodeImageAttribute(): string
{
$data = $this->buildPublicQrUrl();
$data = $this->buildQrPayload();
if ($data !== '' && class_exists(\chillerlan\QRCode\QROptions::class) && class_exists(\chillerlan\QRCode\QRCode::class)) {
$options = new \chillerlan\QRCode\QROptions([
@ -156,10 +161,34 @@ public static function generateCodice(int $stabileId): string
public function refreshQrCodeData(): void
{
$this->forceFill([
'qr_code_data' => $this->buildPublicQrUrl(),
'qr_code_data' => $this->buildQrPayload(),
])->saveQuietly();
}
public function buildQrPayload(): string
{
$parentCode = trim((string) ($this->parent?->codice_univoco ?? 'RADICE'));
$documentCode = $this->documento instanceof Documento
? trim($this->documento->resolveArchiveCode())
: '';
$location = collect([
trim((string) ($this->magazzino ?? '')),
trim((string) ($this->scaffale ?? '')),
trim((string) ($this->ripiano ?? '')),
trim((string) ($this->ubicazione_dettaglio ?? '')),
])->filter()->implode('/');
return implode('|', array_filter([
'NGDOC',
'AF:' . trim((string) $this->codice_univoco),
'TIPO:' . Str::upper((string) $this->tipo_item),
'STB:' . (int) $this->stabile_id,
'PARENT:' . ($parentCode !== '' ? $parentCode : 'RADICE'),
$documentCode !== '' ? 'DOC:' . $documentCode : null,
$location !== '' ? 'LOC:' . $location : null,
]));
}
public function buildPublicQrUrl(): string
{
$relativeSignedPath = URL::temporarySignedRoute(

View File

@ -36,6 +36,7 @@ class Fornitore extends Model
'iban',
'intestazione_cc_esatta',
'bic',
'sia',
'modalita_pagamento_predefinita',
'escludi_righe_fe',
'fe_features',
@ -97,6 +98,18 @@ public function getOperationalConfigSafeAttribute(): array
return is_array($this->operational_config ?? null) ? $this->operational_config : [];
}
public function getSiaEffettivoAttribute(): ?string
{
$sia = trim((string) ($this->sia ?? ''));
if ($sia !== '') {
return $sia;
}
$fallback = trim((string) data_get($this->operational_config_safe, 'payment_identifiers.sia', ''));
return $fallback !== '' ? $fallback : null;
}
protected static function booted(): void
{
static::creating(function (Fornitore $fornitore) {

View File

@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Impostazione extends Model
{
protected $table = 'impostazioni';
protected $fillable = [
'chiave',
'valore',
'descrizione',
];
}

View File

@ -1,5 +1,4 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -14,6 +13,7 @@ class Scadenza extends Model
protected $fillable = [
'legacy_id',
'stabile_id',
'stabile_contratto_id',
'codice_stabile_legacy',
'descrizione_sintetica',
'data_scadenza',
@ -53,6 +53,11 @@ public function stabile()
return $this->belongsTo(Stabile::class, 'stabile_id');
}
public function stabileContratto()
{
return $this->belongsTo(StabileContratto::class, 'stabile_contratto_id');
}
public function getTitoloCalendarioAttribute(): string
{
$parts = array_filter([
@ -62,4 +67,4 @@ public function getTitoloCalendarioAttribute(): string
return implode(' - ', $parts) ?: ('Scadenza #' . (int) $this->id);
}
}
}

View File

@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Carbon;
class Stabile extends Model
{
@ -156,6 +157,69 @@ class Stabile extends Model
'ultima_generazione_unita' => 'datetime',
];
public function hasOperationalHeating(): bool
{
if ((bool) ($this->riscaldamento_centralizzato ?? false)) {
return true;
}
if ((bool) ($this->millesimi_riscaldamento_separati ?? false)) {
return true;
}
$rateRiscaldamento = $this->rate_riscaldamento_mesi ?? [];
if (is_string($rateRiscaldamento)) {
$rateRiscaldamento = json_decode($rateRiscaldamento, true);
}
if (is_array($rateRiscaldamento) && $rateRiscaldamento !== []) {
return true;
}
return (bool) data_get($this->getOperationalConfiguration(), 'mostra_riscaldamento', false);
}
public function getOperationalConfiguration(): array
{
return is_array($this->configurazione_avanzata ?? null) ? $this->configurazione_avanzata : [];
}
public function getTakeoverDate(): ?Carbon
{
$raw = data_get($this->getOperationalConfiguration(), 'amministrazione.presa_in_carico_dal');
if (is_string($raw) && trim($raw) !== '') {
try {
return Carbon::parse($raw);
} catch (\Throwable) {
// ignore invalid stored values
}
}
return $this->data_nomina instanceof Carbon ? $this->data_nomina : null;
}
public function isOperationalYearVisible(?int $year): bool
{
if (! is_int($year) || $year <= 0) {
return true;
}
$takeoverDate = $this->getTakeoverDate();
if (! $takeoverDate) {
return true;
}
return $year >= (int) $takeoverDate->year;
}
public function getNominaVerbaleDocumentoId(): ?int
{
$id = data_get($this->getOperationalConfiguration(), 'amministrazione.verbale_nomina_documento_id');
return is_numeric($id) && (int) $id > 0 ? (int) $id : null;
}
/**
* Relazione con Amministratore
*/
@ -241,6 +305,16 @@ public function gestioniContabili()
->orderBy('numero_straordinaria');
}
/**
* Relazione con Contratti Hub
*/
public function contrattiHub()
{
return $this->hasMany(StabileContratto::class, 'stabile_id', 'id')
->orderByDesc('decorrenza_dal')
->orderByDesc('id');
}
/**
* Relazione con PianoRateizzazione
*/

View File

@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class StabileContratto extends Model
{
use HasFactory;
protected $table = 'stabile_contratti';
protected $fillable = [
'stabile_id',
'fornitore_id',
'documento_principale_id',
'titolo',
'tipo_contratto',
'categoria_impianto',
'servizio_label',
'codice_contratto',
'riferimento_esterno',
'stato',
'data_stipula',
'decorrenza_dal',
'decorrenza_al',
'frequenza_scadenze',
'giorno_scadenza',
'giorni_preavviso',
'rinnovo_automatico',
'importo_periodico',
'valuta',
'dati_contratto',
'codici_collegati',
'note',
'created_by',
'updated_by',
];
protected $casts = [
'data_stipula' => 'date',
'decorrenza_dal' => 'date',
'decorrenza_al' => 'date',
'giorno_scadenza' => 'integer',
'giorni_preavviso' => 'integer',
'rinnovo_automatico' => 'boolean',
'importo_periodico' => 'decimal:2',
'dati_contratto' => 'array',
'codici_collegati' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function stabile(): BelongsTo
{
return $this->belongsTo(Stabile::class, 'stabile_id');
}
public function fornitore(): BelongsTo
{
return $this->belongsTo(Fornitore::class, 'fornitore_id');
}
public function documentoPrincipale(): BelongsTo
{
return $this->belongsTo(Documento::class, 'documento_principale_id');
}
public function scadenze(): HasMany
{
return $this->hasMany(Scadenza::class, 'stabile_contratto_id');
}
}

View File

@ -1,5 +1,4 @@
<?php
namespace App\Modules\Contabilita\Models;
use App\Models\DatiBancari;
@ -18,6 +17,7 @@ class SaldoConto extends Model
'periodo_da',
'periodo_a',
'saldo',
'is_partenza_contabile',
'tipo_estratto',
'note',
'documento_stabile_id',
@ -26,10 +26,11 @@ class SaldoConto extends Model
];
protected $casts = [
'data_saldo' => 'date',
'periodo_da' => 'date',
'periodo_a' => 'date',
'saldo' => 'decimal:2',
'data_saldo' => 'date',
'periodo_da' => 'date',
'periodo_a' => 'date',
'saldo' => 'decimal:2',
'is_partenza_contabile' => 'boolean',
];
public function conto()

View File

@ -0,0 +1,54 @@
<?php
namespace App\Modules\Contabilita\Services;
use App\Models\DatiBancari;
use App\Modules\Contabilita\Models\MovimentoBanca;
use App\Modules\Contabilita\Models\PianoConti;
use RuntimeException;
class ContiFinanziariService
{
public function resolveContoFinanziarioDaMovimentoBanca(MovimentoBanca $movimento): PianoConti
{
$stabileId = (int) ($movimento->stabile_id ?? 0);
if ($stabileId <= 0) {
throw new RuntimeException('Movimento banca senza stabile_id.');
}
$conto = null;
$contoId = (int) ($movimento->conto_id ?? 0);
if ($contoId > 0) {
$conto = DatiBancari::query()->find($contoId);
}
if (! $conto) {
$iban = is_string($movimento->iban ?? null) ? trim((string) $movimento->iban) : '';
if ($iban !== '') {
$conto = DatiBancari::query()
->where('stabile_id', $stabileId)
->where('iban', $iban)
->orderBy('id')
->first();
}
}
if (! $conto) {
throw new RuntimeException('Il movimento banca non ha un conto finanziario risolvibile. Seleziona o collega il conto bancario corretto.');
}
$codice = '1100B' . str_pad((string) $conto->id, 5, '0', STR_PAD_LEFT);
$label = trim((string) ($conto->denominazione_banca ?? 'Banca'));
$iban = is_string($conto->iban ?? null) ? trim((string) $conto->iban) : '';
$numeroConto = is_string($conto->numero_conto ?? null) ? trim((string) $conto->numero_conto) : '';
$descrizione = $label !== '' ? $label : 'Conto banca';
if ($iban !== '') {
$descrizione .= ' · ' . $iban;
} elseif ($numeroConto !== '') {
$descrizione .= ' · n. ' . $numeroConto;
}
return app(ChiusuraGestioneService::class)->resolveOrCreateConto($codice, $descrizione, 'Attività');
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Modules\Contabilita\Services;
use App\Models\AffittoCanoneDovuto;
use App\Models\AffittoCanonePagato;
use App\Models\User;
use App\Modules\Contabilita\Models\MovimentoBanca;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class MovimentoBancaRiconciliazioneService
{
public function registraCanoneAffittoDaMovimento(User $user, MovimentoBanca $movimento, AffittoCanoneDovuto $canone): AffittoCanonePagato
{
$affitto = $canone->affitto;
if (! $affitto) {
throw new RuntimeException('Canone affitto senza contratto collegato.');
}
$pagato = AffittoCanonePagato::query()
->where('affitto_id', (int) $canone->affitto_id)
->where('anno', (int) $canone->anno)
->where('mese', (int) $canone->mese)
->first();
if (! $pagato) {
$descrizioni = array_filter([
$canone->descrizione_1,
$canone->descrizione_2,
$canone->descrizione_3,
$canone->descrizione_4,
], static fn($value): bool => is_string($value) && trim($value) !== '');
$pagato = new AffittoCanonePagato([
'affitto_id' => (int) $canone->affitto_id,
'anno' => (int) $canone->anno,
'mese' => (int) $canone->mese,
'mese_descrizione' => $canone->mese_descrizione,
'data_pagamento' => $movimento->data?->toDateString() ?: now()->toDateString(),
'descrizione' => trim(implode(' · ', $descrizioni)) ?: ('Incasso canone da banca del ' . ($movimento->data?->format('d/m/Y') ?? now()->format('d/m/Y'))),
'fitto' => $canone->fitto,
'istat_percentuale' => $canone->istat_percentuale,
'istat_importo' => $canone->istat_importo,
'descrizione_1' => $canone->descrizione_1,
'descrizione_2' => $canone->descrizione_2,
'importo_1' => $canone->importo_1,
'importo_2' => $canone->importo_2,
'bollo' => $canone->bollo,
'totale' => $canone->totale,
]);
} else {
$pagato->data_pagamento = $pagato->data_pagamento ?: ($movimento->data?->toDateString() ?: now()->toDateString());
$pagato->descrizione = is_string($pagato->descrizione ?? null) && trim((string) $pagato->descrizione) !== ''
? $pagato->descrizione
: 'Incasso canone da banca';
}
$pagato->save();
$matchData = is_array($movimento->match_data ?? null) ? $movimento->match_data : [];
$matchData['riconciliato_operativo'] = true;
$matchData['riconciliazione_tipo'] = 'affitto_canone';
$matchData['riconciliazione_label'] = 'Canone affitto chiuso';
$matchData['riconciliazione_at'] = now()->toDateTimeString();
$matchData['riconciliato_da'] = (int) $user->id;
$matchData['affitto_canone_dovuto_id'] = (int) $canone->id;
$matchData['affitto_canone_pagato_id'] = (int) $pagato->id;
$matchData['affitto_id'] = (int) $canone->affitto_id;
if (Schema::hasColumn('contabilita_movimenti_banca', 'rubrica_mittente_id') && (int) ($affitto->rubrica_inquilino_id ?? 0) > 0) {
$movimento->rubrica_mittente_id = (int) $affitto->rubrica_inquilino_id;
}
if (Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) {
$movimento->da_confermare = false;
}
if (Schema::hasColumn('contabilita_movimenti_banca', 'match_data')) {
$movimento->match_data = $matchData;
}
$movimento->save();
return $pagato;
}
}

View File

@ -1,13 +1,16 @@
<?php
namespace App\Modules\Contabilita\Services;
use App\Models\User;
use App\Models\Fornitore;
use App\Models\RegistroRitenuteAcconto;
use App\Models\Stabile;
use App\Models\User;
use App\Modules\Contabilita\Models\FatturaFornitore;
use App\Modules\Contabilita\Models\Movimento;
use App\Modules\Contabilita\Models\MovimentoBanca;
use App\Modules\Contabilita\Models\PianoConti;
use App\Modules\Contabilita\Models\Registrazione;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
@ -15,7 +18,7 @@
class PagamentoFornitorePrimaNotaService
{
/**
* @param array{gestione_id?:int, conto_finanziario_id?:int, descrizione?:string, data_registrazione?:string} $override
* @param array{gestione_id?:int, conto_finanziario_id?:int, descrizione?:string, data_registrazione?:string, fattura_fornitore_id?:int} $override
*/
public function generaPagamentoDaMovimento(User $user, MovimentoBanca $movimento, int $fornitoreId, array $override = []): Registrazione
{
@ -57,7 +60,9 @@ public function generaPagamentoDaMovimento(User $user, MovimentoBanca $movimento
$contoFinanziarioId = (int) (($override['conto_finanziario_id'] ?? null) ?: 0);
if ($contoFinanziarioId <= 0) {
throw new RuntimeException('Seleziona un conto finanziario (banca/cassa) per registrare il pagamento.');
$contoFinanziarioId = (int) app(ContiFinanziariService::class)
->resolveContoFinanziarioDaMovimentoBanca($movimento)
->id;
}
$contoFinanziario = PianoConti::query()->find($contoFinanziarioId);
@ -65,7 +70,7 @@ public function generaPagamentoDaMovimento(User $user, MovimentoBanca $movimento
throw new RuntimeException('Conto finanziario non valido.');
}
$fornitore = Fornitore::query()->find($fornitoreId);
$fornitore = Fornitore::query()->find($fornitoreId);
$fornitoreLabel = $fornitore && is_string($fornitore->ragione_sociale ?? null)
? trim((string) $fornitore->ragione_sociale)
: null;
@ -73,6 +78,21 @@ public function generaPagamentoDaMovimento(User $user, MovimentoBanca $movimento
? trim((string) $fornitore->codice_univoco)
: null;
$fattura = null;
$fatturaId = (int) (($override['fattura_fornitore_id'] ?? null) ?: 0);
if ($fatturaId > 0 && Schema::hasTable('contabilita_fatture_fornitori')) {
$fattura = FatturaFornitore::query()->find($fatturaId);
if (! $fattura) {
throw new RuntimeException('Fattura fornitore non trovata.');
}
if ((int) ($fattura->fornitore_id ?? 0) !== $fornitoreId) {
throw new RuntimeException('La fattura selezionata non appartiene al fornitore indicato.');
}
if ((int) ($fattura->gestione_id ?? 0) > 0 && $gestioneId <= 0) {
$gestioneId = (int) $fattura->gestione_id;
}
}
$contoDebitoFornitore = app(ContiSoggettiService::class)->resolveContoDebitoFornitore(
$stabileId,
$fornitoreId,
@ -90,20 +110,20 @@ public function generaPagamentoDaMovimento(User $user, MovimentoBanca $movimento
? trim((string) $override['descrizione'])
: '';
if ($descr === '') {
$base = is_string($movimento->descrizione) ? trim((string) $movimento->descrizione) : '';
$base = is_string($movimento->descrizione) ? trim((string) $movimento->descrizione) : '';
$descr = $base !== '' ? $base : 'Pagamento fornitore';
}
return DB::transaction(function () use ($user, $movimento, $stabileId, $gestioneId, $data, $descr, $amount, $contoFinanziarioId, $contoDebitoFornitore) {
return DB::transaction(function () use ($user, $movimento, $stabileId, $gestioneId, $data, $descr, $amount, $contoFinanziarioId, $contoDebitoFornitore, $fornitoreId, $fattura) {
$regPayload = [
'stabile_id' => $stabileId,
'gestione_id' => (Schema::hasColumn('contabilita_registrazioni', 'gestione_id')
? Registrazione::resolveGestioneIdOrDefault($gestioneId, $stabileId, $data)
: null),
'stabile_id' => $stabileId,
'gestione_id' => (Schema::hasColumn('contabilita_registrazioni', 'gestione_id')
? Registrazione::resolveGestioneIdOrDefault($gestioneId, $stabileId, $data)
: null),
'data_registrazione' => $data,
'descrizione' => $descr,
'created_by' => (int) $user->id,
'updated_by' => (int) $user->id,
'descrizione' => $descr,
'created_by' => (int) $user->id,
'updated_by' => (int) $user->id,
];
if (Schema::hasColumn('contabilita_registrazioni', 'user_id')) {
@ -116,26 +136,108 @@ public function generaPagamentoDaMovimento(User $user, MovimentoBanca $movimento
// Dare: diminuisce il debito verso fornitore
Movimento::query()->create([
'registrazione_id' => (int) $reg->id,
'conto_id' => (int) $contoDebitoFornitore->id,
'tipo' => 'dare',
'importo' => $amount,
'conto_id' => (int) $contoDebitoFornitore->id,
'tipo' => 'dare',
'importo' => $amount,
]);
// Avere: diminuisce la banca/cassa
Movimento::query()->create([
'registrazione_id' => (int) $reg->id,
'conto_id' => $contoFinanziarioId,
'tipo' => 'avere',
'importo' => $amount,
'conto_id' => $contoFinanziarioId,
'tipo' => 'avere',
'importo' => $amount,
]);
$reg->assertBilanciata();
$movimento->registrazione_id = (int) $reg->id;
$movimento->gestione_id = $gestioneId;
$movimento->gestione_id = $gestioneId;
if (Schema::hasColumn('contabilita_movimenti_banca', 'fornitore_id')) {
$movimento->fornitore_id = $fornitoreId;
}
$movimento->save();
if ($fattura) {
$fattura->data_pagamento = $fattura->data_pagamento ?: $data;
$fattura->movimento_pagamento_id = (int) $movimento->id;
$fattura->save();
$this->syncRegistroRitenutaDaPagamento($fattura, $gestioneId, $data);
}
return $reg;
});
}
private function syncRegistroRitenutaDaPagamento(FatturaFornitore $fattura, int $gestioneId, string $dataPagamento): void
{
if (! Schema::hasTable('registro_ritenute_acconto')) {
return;
}
$ritenutaImporto = round((float) ($fattura->ritenuta_importo ?? 0), 4);
$ritenutaAliquota = round((float) ($fattura->ritenuta_aliquota ?? 0), 2);
if ($ritenutaImporto <= 0 || $ritenutaAliquota <= 0) {
return;
}
$dataPagamentoCarbon = Carbon::parse($dataPagamento)->startOfDay();
$dataCompetenza = $fattura->data_documento?->copy()
?: $fattura->data_registrazione?->copy()
?: $dataPagamentoCarbon->copy();
$scadenzaVersamento = $dataPagamentoCarbon->copy()->addMonthNoOverflow()->day(16);
$tenantId = '';
if (Schema::hasTable('gestioni_contabili')) {
$gestione = \App\Models\GestioneContabile::query()->find($gestioneId);
$tenantId = is_string($gestione?->tenant_id ?? null) ? trim((string) $gestione->tenant_id) : '';
}
if ($tenantId === '') {
$stabile = Stabile::query()
->with('amministratore:id,codice_amministratore')
->select(['id', 'amministratore_id'])
->find((int) $fattura->stabile_id);
$tenantId = is_string($stabile?->amministratore?->codice_amministratore ?? null)
? trim((string) $stabile->amministratore->codice_amministratore)
: '';
}
$registro = RegistroRitenuteAcconto::query()
->where('gestione_id', $gestioneId)
->where(function ($query) use ($fattura, $dataCompetenza, $ritenutaImporto) {
$query->where('fattura_id', (int) $fattura->id)
->orWhere(function ($sub) use ($fattura, $dataCompetenza, $ritenutaImporto) {
$sub->where('fornitore_id', (int) $fattura->fornitore_id)
->whereDate('data_competenza', $dataCompetenza->toDateString())
->where('importo_ritenuta', $ritenutaImporto);
});
})
->orderByDesc('id')
->first();
if (! $registro) {
$registro = new RegistroRitenuteAcconto();
$registro->gestione_id = $gestioneId;
$registro->numero_progressivo = ((int) (RegistroRitenuteAcconto::query()
->where('gestione_id', $gestioneId)
->lockForUpdate()
->max('numero_progressivo') ?? 0)) + 1;
$registro->stato_versamento = 'da_versare';
$registro->fornitore_id = (int) $fattura->fornitore_id;
$registro->fattura_id = (int) $fattura->id;
}
$registro->tenant_id = $tenantId !== '' ? $tenantId : 'stabile-' . (int) $fattura->stabile_id;
$registro->data_competenza = $dataCompetenza->toDateString();
$registro->data_pagamento_fattura = $dataPagamentoCarbon->toDateString();
$registro->imponibile = round((float) ($fattura->imponibile ?? 0), 4);
$registro->aliquota_ritenuta = $ritenutaAliquota;
$registro->importo_ritenuta = $ritenutaImporto;
$registro->codice_tributo = trim((string) ($fattura->ritenuta_codice_tributo ?? '')) ?: '1040';
$registro->tipo_ritenuta = trim((string) ($fattura->ritenuta_previdenziale_codice ?? '')) ?: 'professionale';
$registro->data_scadenza_versamento = $scadenzaVersamento->toDateString();
$registro->anno_dichiarazione = (int) $dataCompetenza->format('Y');
$registro->save();
}
}

View File

@ -56,41 +56,41 @@ public function panel(Panel $panel): Panel
->group('NetGescon')
->icon('heroicon-o-users')
->url(fn() => RubricaUniversaleArchivio::getUrl(panel: 'admin-filament'))
->visible(function () {
->visible(function (): bool {
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}),
NavigationItem::make('Cellulare / WebApp')
->group('NetGescon')
->icon('heroicon-o-phone')
->url(fn() => route('admin.mobile'))
->visible(function () {
->visible(function (): bool {
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}),
NavigationItem::make('Ticket Mobile')
->group('NetGescon')
->icon('heroicon-o-device-phone-mobile')
->url(fn() => TicketMobile::getUrl(panel: 'admin-filament'))
->visible(function () {
->visible(function (): bool {
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}),
NavigationItem::make('Scheda Stabile')
->group('NetGescon')
->icon('heroicon-o-building-office-2')
->url(fn() => StabilePage::getUrl(panel: 'admin-filament'))
->visible(function () {
->visible(function (): bool {
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}),
NavigationItem::make('Catalogo Fornitore')
->group('Fornitore')
@ -102,8 +102,8 @@ public function panel(Panel $panel): Panel
return $user instanceof User && $user->hasRole('fornitore');
}),
])
->discoverResources(in: app_path('Filament/Resources'), for : 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for : 'App\\Filament\\Pages')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->pages([
Dashboard::class,
SupportoDashboard::class,
@ -111,7 +111,7 @@ public function panel(Panel $panel): Panel
PrimaNotaModifica::class,
ContoMastrino::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for : 'App\\Filament\\Widgets')
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
AccountWidget::class,
TicketMobileOverview::class,
@ -171,5 +171,4 @@ protected function resolveSupplierCatalogUrl(): string
return TicketOperativi::getUrl(panel: 'admin-filament');
}
}

View File

@ -11,10 +11,10 @@ class AmazonCreatorsApiService
public function isConfigured(): bool
{
return (bool) config('services.amazon.creators_enabled', false)
&& $this->credentialId() !== ''
&& $this->credentialSecret() !== ''
&& $this->tokenUrl() !== ''
&& $this->apiBaseUrl() !== '';
&& $this->credentialId() !== ''
&& $this->credentialSecret() !== ''
&& $this->tokenUrl() !== ''
&& $this->apiBaseUrl() !== '';
}
public function getMarketplace(): string
@ -151,4 +151,4 @@ private function getAccessToken(): string
return $token;
}
}
}

View File

@ -6,8 +6,8 @@
use App\Models\ProductIdentifier;
use App\Models\ProductMedia;
use App\Models\ProductOffer;
use Illuminate\Support\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use RuntimeException;
@ -162,9 +162,9 @@ public function importItemForSupplier(Fornitore $fornitore, array $item, array $
throw new RuntimeException('Il risultato Amazon selezionato non contiene un ASIN valido.');
}
$ean = $this->extractEan($item);
$title = $this->extractTitle($item) ?: 'Prodotto Amazon';
$brand = $this->extractBrand($item);
$ean = $this->extractEan($item);
$title = $this->extractTitle($item) ?: 'Prodotto Amazon';
$brand = $this->extractBrand($item);
$product = $this->findExistingProduct($asin, $ean);
$created = false;
@ -180,8 +180,8 @@ public function importItemForSupplier(Fornitore $fornitore, array $item, array $
'track_serials' => false,
'is_active' => true,
'meta' => [
'source' => 'amazon_creators_api',
'catalog' => [
'source' => 'amazon_creators_api',
'catalog' => [
'publication_mode' => 'internal_only',
],
'verification' => [
@ -189,7 +189,7 @@ public function importItemForSupplier(Fornitore $fornitore, array $item, array $
'channel' => 'amazon_catalog_search_ui',
'updated_at' => now()->toIso8601String(),
],
'amazon' => [
'amazon' => [
'asin' => $asin,
'ean' => $ean,
'title' => $title,
@ -221,10 +221,10 @@ public function importItemForSupplier(Fornitore $fornitore, array $item, array $
}
$sync = $this->syncProduct($product, [
'item' => $item,
'source' => (string) ($options['source'] ?? 'amazon_catalog_search_ui'),
'marketplace' => (string) ($options['marketplace'] ?? $this->apiService->getMarketplace()),
'associate_tag'=> (string) ($options['associate_tag'] ?? ($this->apiService->getAssociateTag() ?? '')),
'item' => $item,
'source' => (string) ($options['source'] ?? 'amazon_catalog_search_ui'),
'marketplace' => (string) ($options['marketplace'] ?? $this->apiService->getMarketplace()),
'associate_tag' => (string) ($options['associate_tag'] ?? ($this->apiService->getAssociateTag() ?? '')),
]);
return [

View File

@ -7,7 +7,6 @@
use App\Models\ProductOffer;
use App\Models\TicketIntervento;
use App\Modules\Contabilita\Models\FatturaFornitoreRiga;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
class ProductHubViewService
@ -49,8 +48,8 @@ public function buildRows(Fornitore $fornitore, ?int $stabileId = null, int $lim
}
$productIds = $products->pluck('id')->map(fn($value): int => (int) $value)->all();
$usageByProduct = $this->buildUsageSummary($fornitore, $productIds, $stabileId);
$pricingByProduct = $this->buildPurchaseSummary($fornitore, $productIds);
$usageByProduct = $this->buildUsageSummary($fornitore, $productIds, $stabileId);
$pricingByProduct = $this->buildPurchaseSummary($fornitore, $productIds);
return $products
->map(function (Product $product) use ($fornitore, $usageByProduct, $pricingByProduct): array {
@ -69,42 +68,42 @@ public function buildRows(Fornitore $fornitore, ?int $stabileId = null, int $lim
$purchaseSummary = $pricingByProduct[(int) $product->id] ?? $this->emptyPurchaseSummary();
return [
'id' => (int) $product->id,
'internal_code' => (string) ($product->internal_code ?? ''),
'name' => (string) ($product->name ?? ''),
'brand' => (string) ($product->brand ?? ''),
'model' => (string) ($product->model ?? ''),
'color_label' => (string) ($product->color_label ?? ''),
'track_serials' => (bool) $product->track_serials,
'identifier' => (string) ($identifier->code_value ?? ''),
'identifiers_count' => (int) ($product->identifiers_count ?? 0),
'serials_count' => (int) ($product->serials_count ?? 0),
'media_count' => (int) ($product->media_count ?? 0),
'source' => (string) (($meta['source'] ?? 'manual') ?: 'manual'),
'publication_mode' => (string) (($catalogMeta['publication_mode'] ?? '') ?: ''),
'public_reference' => (string) (($catalogMeta['public_reference'] ?? '') ?: ($product->internal_code ?? '')),
'private_links' => count(array_filter(is_array($catalogMeta['private_source_links'] ?? null) ? $catalogMeta['private_source_links'] : [], fn($value) => filled($value))),
'categories' => (string) (($catalogMeta['categories'] ?? '') ?: ''),
'stock_quantity' => is_numeric($meta['stock']['quantity'] ?? null) ? (int) $meta['stock']['quantity'] : null,
'price_wholesale' => is_numeric($meta['pricing']['wholesale'] ?? null) ? (float) $meta['pricing']['wholesale'] : null,
'offers_count' => $offers->count(),
'own_offer_price' => $internalOffer && filled($internalOffer->price_amount) ? (float) $internalOffer->price_amount : null,
'best_competitor_price' => $bestExternal && filled($bestExternal->price_amount) ? (float) $bestExternal->price_amount : null,
'best_competitor_source' => $bestExternal ? (string) ($bestExternal->source_name ?? $bestExternal->source_type ?? '') : '',
'amazon_referral_url' => $amazonOffer ? (string) ($amazonOffer->referral_url ?? $amazonOffer->external_url ?? '') : '',
'usage_count' => (int) ($usage['usage_count'] ?? 0),
'usage_quantity' => (float) ($usage['usage_quantity'] ?? 0),
'last_used_at' => (string) ($usage['last_used_at'] ?? ''),
'stable_usage_count' => (int) ($usage['stable_usage_count'] ?? 0),
'stable_usage_quantity' => (float) ($usage['stable_usage_quantity'] ?? 0),
'stable_last_used_at' => (string) ($usage['stable_last_used_at'] ?? ''),
'purchase_lines_count' => (int) ($purchaseSummary['purchase_lines_count'] ?? 0),
'last_purchase_total' => $purchaseSummary['last_purchase_total'] ?? null,
'last_purchase_date' => (string) ($purchaseSummary['last_purchase_date'] ?? ''),
'purchase_history_labels' => (array) ($purchaseSummary['purchase_history_labels'] ?? []),
'sort_score' => ((int) ($usage['stable_usage_count'] ?? 0) * 1000)
+ ((int) round((float) ($usage['stable_usage_quantity'] ?? 0) * 100))
+ ((int) ($usage['usage_count'] ?? 0) * 10),
'id' => (int) $product->id,
'internal_code' => (string) ($product->internal_code ?? ''),
'name' => (string) ($product->name ?? ''),
'brand' => (string) ($product->brand ?? ''),
'model' => (string) ($product->model ?? ''),
'color_label' => (string) ($product->color_label ?? ''),
'track_serials' => (bool) $product->track_serials,
'identifier' => (string) ($identifier->code_value ?? ''),
'identifiers_count' => (int) ($product->identifiers_count ?? 0),
'serials_count' => (int) ($product->serials_count ?? 0),
'media_count' => (int) ($product->media_count ?? 0),
'source' => (string) (($meta['source'] ?? 'manual') ?: 'manual'),
'publication_mode' => (string) (($catalogMeta['publication_mode'] ?? '') ?: ''),
'public_reference' => (string) (($catalogMeta['public_reference'] ?? '') ?: ($product->internal_code ?? '')),
'private_links' => count(array_filter(is_array($catalogMeta['private_source_links'] ?? null) ? $catalogMeta['private_source_links'] : [], fn($value) => filled($value))),
'categories' => (string) (($catalogMeta['categories'] ?? '') ?: ''),
'stock_quantity' => is_numeric($meta['stock']['quantity'] ?? null) ? (int) $meta['stock']['quantity'] : null,
'price_wholesale' => is_numeric($meta['pricing']['wholesale'] ?? null) ? (float) $meta['pricing']['wholesale'] : null,
'offers_count' => $offers->count(),
'own_offer_price' => $internalOffer && filled($internalOffer->price_amount) ? (float) $internalOffer->price_amount : null,
'best_competitor_price' => $bestExternal && filled($bestExternal->price_amount) ? (float) $bestExternal->price_amount : null,
'best_competitor_source' => $bestExternal ? (string) ($bestExternal->source_name ?? $bestExternal->source_type ?? '') : '',
'amazon_referral_url' => $amazonOffer ? (string) ($amazonOffer->referral_url ?? $amazonOffer->external_url ?? '') : '',
'usage_count' => (int) ($usage['usage_count'] ?? 0),
'usage_quantity' => (float) ($usage['usage_quantity'] ?? 0),
'last_used_at' => (string) ($usage['last_used_at'] ?? ''),
'stable_usage_count' => (int) ($usage['stable_usage_count'] ?? 0),
'stable_usage_quantity' => (float) ($usage['stable_usage_quantity'] ?? 0),
'stable_last_used_at' => (string) ($usage['stable_last_used_at'] ?? ''),
'purchase_lines_count' => (int) ($purchaseSummary['purchase_lines_count'] ?? 0),
'last_purchase_total' => $purchaseSummary['last_purchase_total'] ?? null,
'last_purchase_date' => (string) ($purchaseSummary['last_purchase_date'] ?? ''),
'purchase_history_labels' => (array) ($purchaseSummary['purchase_history_labels'] ?? []),
'sort_score' => ((int) ($usage['stable_usage_count'] ?? 0) * 1000)
+ ((int) round((float) ($usage['stable_usage_quantity'] ?? 0) * 100))
+ ((int) ($usage['usage_count'] ?? 0) * 10),
];
})
->sort(function (array $left, array $right): int {
@ -160,14 +159,14 @@ private function buildUsageSummary(Fornitore $fornitore, array $productIds, ?int
$summary[$productId]['usage_count']++;
$summary[$productId]['usage_quantity'] += $qty;
$summary[$productId]['last_used_at'] = $summary[$productId]['last_used_at'] !== ''
$summary[$productId]['last_used_at'] = $summary[$productId]['last_used_at'] !== ''
? $summary[$productId]['last_used_at']
: (optional($intervento->updated_at)->format('d/m/Y H:i') ?: '');
if ($stabileId !== null && $stabileId > 0 && $currentStabileId === $stabileId) {
$summary[$productId]['stable_usage_count']++;
$summary[$productId]['stable_usage_quantity'] += $qty;
$summary[$productId]['stable_last_used_at'] = $summary[$productId]['stable_last_used_at'] !== ''
$summary[$productId]['stable_last_used_at'] = $summary[$productId]['stable_last_used_at'] !== ''
? $summary[$productId]['stable_last_used_at']
: (optional($intervento->updated_at)->format('d/m/Y H:i') ?: '');
}
@ -247,4 +246,4 @@ private function emptyPurchaseSummary(): array
'purchase_history_labels' => [],
];
}
}
}

View File

@ -1035,7 +1035,7 @@ private function parseDescrizioneEstesa(string $text): array
$providerUpper = strtoupper(trim((string) ($res['payment_provider'] ?? $res['beneficiario'] ?? '')));
if ($providerUpper !== '') {
if (str_contains($providerUpper, 'ACEA ATO2') || str_contains($providerUpper, 'ACEA ACQUA')) {
$res['utility_type'] = 'acqua';
$res['utility_type'] = 'acqua';
$res['suggested_voce_spesa_code'] = 'ACQ';
} elseif (str_contains($providerUpper, 'A2A')) {
$res['utility_type'] = 'utenza';

View File

@ -245,7 +245,7 @@ private function buildTableSummary(Collection $voci, array $tabellaMap): array
return $voci
->groupBy(fn(array $row): int => (int) ($row['tabella_millesimale_default_id'] ?? 0))
->map(function (Collection $rows, int $tabellaId) use ($tabellaMap): array {
$tabella = $tabellaMap[$tabellaId] ?? [
$tabella = $tabellaMap[$tabellaId] ?? [
'codice' => '',
'label' => '',
'tipo_calcolo' => null,
@ -274,7 +274,7 @@ private function buildTableSummary(Collection $voci, array $tabellaMap): array
'voci' => $rows->values()->all(),
];
})
->sortBy(fn(array $row): string => sprintf('%06d|%s', (int) ($row['nord'] ?? 999999), (string) ($row['codice'] ?? '')))
->sortBy(fn(array $row): string => sprintf('%06d|%s', (int) ($row['nord'] ?? 999999), (string) ($row['codice'] ?? '')))
->mapWithKeys(fn(array $row): array=> [$row['codice'] => $row])
->all();
}

View File

@ -1,9 +1,7 @@
<?php
namespace App\Services\Documenti;
use App\Models\Documento;
use App\Models\DocumentoArchivioCartella;
use App\Models\DocumentoStabile;
use App\Models\User;
use App\Support\StabileContext;
@ -99,11 +97,11 @@ private function applyVisibilityConstraint(Builder $query, User $user, string $t
return;
}
$roles = $user->getRoleNames()->map(fn($role): string => (string) $role)->values()->all();
$groupLabels = $this->resolveAudienceGroupsForUser($user);
$userId = (int) $user->id;
$hasRolesColumn = $this->hasVisibilityColumn($table, 'visibility_roles');
$hasGroupsColumn = $this->hasVisibilityColumn($table, 'visibility_groups');
$roles = $user->getRoleNames()->map(fn($role): string => (string) $role)->values()->all();
$groupLabels = $this->resolveAudienceGroupsForUser($user);
$userId = (int) $user->id;
$hasRolesColumn = $this->hasVisibilityColumn($table, 'visibility_roles');
$hasGroupsColumn = $this->hasVisibilityColumn($table, 'visibility_groups');
$hasAllowedUsersColumn = $this->hasVisibilityColumn($table, 'allowed_user_ids');
$query->where(function (Builder $visibilityQuery) use ($table, $roles, $groupLabels, $userId, $hasRolesColumn, $hasGroupsColumn, $hasAllowedUsersColumn): void {
@ -171,4 +169,4 @@ private function resolveAudienceGroupsForUser(User $user): array
return array_values(array_unique($groups));
}
}
}

View File

@ -548,13 +548,13 @@ private function updateContabilitaFornituraDaPdf(FatturaElettronica $fattura): v
}
$fullPath = $disk->path($resolvedPath);
$text = $this->extractPdfText($fullPath);
$text = $this->extractPdfText($fullPath);
if (! is_string($text) || trim($text) === '') {
return;
}
$gasData = $this->extractGasFornituraDataFromPdfText($text);
$waterData = $this->extractAcquaFornituraDataFromPdfText($text);
$gasData = $this->extractGasFornituraDataFromPdfText($text);
$waterData = $this->extractAcquaFornituraDataFromPdfText($text);
$paymentData = $this->extractPaymentReferenceDataFromPdfText($text, $fattura);
$data = is_array($waterData) && $waterData !== [] ? $waterData : $gasData;
@ -686,11 +686,11 @@ private function extractAcquaFornituraDataFromPdfText(string $text): ?array
return null;
}
$codici = is_array($parsed['codici'] ?? null) ? $parsed['codici'] : [];
$codici = is_array($parsed['codici'] ?? null) ? $parsed['codici'] : [];
$contatore = is_array($parsed['contatore'] ?? null) ? $parsed['contatore'] : [];
$consumi = is_array($parsed['consumi'] ?? null) ? $parsed['consumi'] : [];
$generale = is_array($parsed['generale'] ?? null) ? $parsed['generale'] : [];
$quadro = is_array($parsed['quadro_dettaglio'] ?? null) ? $parsed['quadro_dettaglio'] : [];
$consumi = is_array($parsed['consumi'] ?? null) ? $parsed['consumi'] : [];
$generale = is_array($parsed['generale'] ?? null) ? $parsed['generale'] : [];
$quadro = is_array($parsed['quadro_dettaglio'] ?? null) ? $parsed['quadro_dettaglio'] : [];
$hasIdentifiers = count(array_filter([
$codici['utenza'] ?? null,
@ -705,19 +705,19 @@ private function extractAcquaFornituraDataFromPdfText(string $text): ?array
}
$payload = [
'type' => 'acqua',
'voce_spesa_code' => 'ACQ',
'codici' => [
'utenza' => $codici['utenza'] ?? null,
'cliente' => $codici['cliente'] ?? null,
'type' => 'acqua',
'voce_spesa_code' => 'ACQ',
'codici' => [
'utenza' => $codici['utenza'] ?? null,
'cliente' => $codici['cliente'] ?? null,
'contratto' => $codici['contratto'] ?? null,
],
'contatore' => [
'contatore' => [
'matricola' => $contatore['matricola'] ?? null,
],
'generale' => $generale,
'generale' => $generale,
'quadro_dettaglio' => $quadro,
'consumi' => array_values($consumi),
'consumi' => array_values($consumi),
];
return array_filter($payload, fn($value) => $value !== null && $value !== '');
@ -727,8 +727,8 @@ private function extractAcquaFornituraDataFromPdfText(string $text): ?array
private function extractPaymentReferenceDataFromPdfText(string $text, FatturaElettronica $fattura): array
{
$provider = trim((string) ($fattura->fornitore_denominazione ?? ''));
$cbill = null;
$sia = null;
$cbill = null;
$sia = null;
if (preg_match('/\bCBILL\b(?:\s*N\.)?\s*\.?\s*([0-9]{9,})/i', $text, $m)) {
$cbill = trim((string) ($m[1] ?? ''));
@ -744,8 +744,8 @@ private function extractPaymentReferenceDataFromPdfText(string $text, FatturaEle
$payload = [
'provider' => $provider !== '' ? $provider : null,
'cbill' => $cbill,
'sia' => $sia,
'cbill' => $cbill,
'sia' => $sia,
];
return array_filter($payload, fn($value) => $value !== null && $value !== '');
@ -776,18 +776,18 @@ private function enrichWaterInvoiceFromParsedData(FatturaElettronica $fattura, a
return;
}
$sum = 0.0;
$sum = 0.0;
$hasNumeric = false;
foreach ($consumi as $consumo) {
if (! is_array($consumo) || ! is_numeric($consumo['valore'] ?? null)) {
continue;
}
$sum += (float) $consumo['valore'];
$hasNumeric = true;
$sum += (float) $consumo['valore'];
$hasNumeric = true;
}
$reference = null;
$first = is_array($consumi[0] ?? null) ? $consumi[0] : [];
$first = is_array($consumi[0] ?? null) ? $consumi[0] : [];
if (is_string($first['dal'] ?? null) && is_string($first['al'] ?? null) && $first['dal'] !== '' && $first['al'] !== '') {
$reference = $first['dal'] . ' - ' . $first['al'];
}
@ -795,19 +795,19 @@ private function enrichWaterInvoiceFromParsedData(FatturaElettronica $fattura, a
$dirty = false;
if ($hasNumeric && (! is_numeric($fattura->consumo_valore) || (float) $fattura->consumo_valore <= 0)) {
$fattura->consumo_valore = $sum;
$dirty = true;
$dirty = true;
}
if ((! is_string($fattura->consumo_unita ?? null) || trim((string) $fattura->consumo_unita) === '') && $hasNumeric) {
$fattura->consumo_unita = 'mc';
$dirty = true;
$dirty = true;
}
if ($reference !== null && (! is_string($fattura->consumo_riferimento ?? null) || trim((string) $fattura->consumo_riferimento) === '')) {
$fattura->consumo_riferimento = $reference;
$dirty = true;
$dirty = true;
}
if (! is_string($fattura->consumo_raw ?? null) || trim((string) $fattura->consumo_raw) === '') {
$fattura->consumo_raw = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$dirty = true;
$dirty = true;
}
if ($dirty) {

View File

@ -1,5 +1,4 @@
<?php
namespace App\Services\FattureElettroniche;
use App\Models\Documento;
@ -32,17 +31,17 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u
->findOrFail((int) $fattura->stabile_id);
$dataDoc = $fattura->data_fattura;
$anno = $dataDoc ? (int) $dataDoc->format('Y') : (int) now()->format('Y');
$ym = $dataDoc ? $dataDoc->format('Y/m') : now()->format('Y/m');
$anno = $dataDoc ? (int) $dataDoc->format('Y') : (int) now()->format('Y');
$ym = $dataDoc ? $dataDoc->format('Y/m') : now()->format('Y/m');
$adminCode = $stabile->amministratore?->codice_amministratore;
$stabileCode = $stabile->codice_stabile;
$adminFolder = $adminCode ?: ('ID-' . (int) $stabile->amministratore_id);
$adminCode = $stabile->amministratore?->codice_amministratore;
$stabileCode = $stabile->codice_stabile;
$adminFolder = $adminCode ?: ('ID-' . (int) $stabile->amministratore_id);
$stabileFolder = $stabileCode ?: ('ID-' . (int) $fattura->stabile_id);
$stabileBase = ArchivioPaths::stabileBase($stabile, $stabile->amministratore);
$gestione = $this->resolveGestioneForYear($stabile, $anno);
$pdfBase = ArchivioPaths::fatturePdfGestionePath($stabile, $gestione, $anno, 'ordinaria', $stabile->amministratore)
$gestione = $this->resolveGestioneForYear($stabile, $anno);
$pdfBase = ArchivioPaths::fatturePdfGestionePath($stabile, $gestione, $anno, 'ordinaria', $stabile->amministratore)
?: $this->basePath($adminFolder, $stabileFolder, $anno);
$xmlBase = ArchivioPaths::fattureXmlYearPath($stabile, $anno, $stabile->amministratore)
?: $this->basePath($adminFolder, $stabileFolder, $anno);
@ -80,7 +79,7 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u
$found = $this->findFirstFileStartingWith($dir, $fattura->allegato_pdf_hash . '-');
if ($found) {
$fattura->allegato_pdf_path = $found;
$pdfPath = $found;
$pdfPath = $found;
break;
}
}
@ -96,11 +95,11 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u
: ($pdfPathNow !== '' ? basename($pdfPathNow) : '');
$isProtocollato = ($pdfNameNow !== '' && preg_match('/^FT-\d{4}\b/i', $pdfNameNow) === 1);
$supplierToken = $this->sanitizeFilename((string) ($fattura->fornitore_denominazione ?? ''));
$supplierToken = trim((string) $supplierToken);
$supplierToken = $this->sanitizeFilename((string) ($fattura->fornitore_denominazione ?? ''));
$supplierToken = trim((string) $supplierToken);
if ($isProtocollato && $supplierToken !== '' && mb_strlen($supplierToken) >= 4) {
$nameUpper = mb_strtoupper($pdfNameNow);
$nameUpper = mb_strtoupper($pdfNameNow);
$tokenUpper = mb_strtoupper($supplierToken);
if (! str_contains($nameUpper, $tokenUpper)) {
$fattura->allegato_pdf_path = null;
@ -135,7 +134,7 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u
}
if (! $protocollo) {
if ($protocolCol) {
$next = $this->nextNumeroProtocollo((int) $fattura->stabile_id, $anno);
$next = $this->nextNumeroProtocollo((int) $fattura->stabile_id, $anno);
$protocollo = 'FT-' . str_pad((string) $next, 4, '0', STR_PAD_LEFT);
} else {
// Se la tabella documenti non ha una colonna protocollo, assicuriamo comunque un nome unico per i file.
@ -158,7 +157,7 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u
break;
}
if ($protocolCol) {
$next = $this->nextNumeroProtocollo((int) $fattura->stabile_id, $anno);
$next = $this->nextNumeroProtocollo((int) $fattura->stabile_id, $anno);
$protocollo = 'FT-' . str_pad((string) $next, 4, '0', STR_PAD_LEFT);
} else {
$protocollo = 'FT-' . str_pad((string) ((int) $fattura->id), 6, '0', STR_PAD_LEFT);
@ -168,13 +167,13 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u
}
$sanFornitore = $this->sanitizeFilename((string) ($fattura->fornitore_denominazione ?? 'FORNITORE'));
$sanNumero = $this->sanitizeFilename((string) ($fattura->numero_fattura ?? ('ID-' . $fattura->id)));
$dateForName = $dataDoc ? $dataDoc->format('Ymd') : now()->format('Ymd');
$sanNumero = $this->sanitizeFilename((string) ($fattura->numero_fattura ?? ('ID-' . $fattura->id)));
$dateForName = $dataDoc ? $dataDoc->format('Ymd') : now()->format('Ymd');
// Se manca il PDF (ma abbiamo almeno XML/metadata), generiamo un prospetto PDF archiviabile.
$pdfPathNow = is_string($fattura->allegato_pdf_path) ? $fattura->allegato_pdf_path : '';
$hasPdfNow = is_string($pdfPathNow) && $pdfPathNow !== '' && Storage::disk('local')->exists($pdfPathNow);
$hasXmlNow = (is_string($fattura->xml_content) && trim($fattura->xml_content) !== '')
$hasPdfNow = is_string($pdfPathNow) && $pdfPathNow !== '' && Storage::disk('local')->exists($pdfPathNow);
$hasXmlNow = (is_string($fattura->xml_content) && trim($fattura->xml_content) !== '')
|| (is_string($fattura->xml_path) && $fattura->xml_path !== '' && Storage::disk('local')->exists($fattura->xml_path));
if (! $hasPdfNow && $hasXmlNow) {
$targetName = $protocollo . ' ' . $dateForName . ' ' . $sanFornitore . ' ' . $sanNumero . '.pdf';
@ -219,19 +218,19 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u
$fattura->save();
$docData = [
'utente_id' => $userId,
'nome' => $protocollo . ' - ' . $sanFornitore,
'tipologia' => 'fattura',
'tipo_documento' => 'fattura',
'fornitore' => $fattura->fornitore_denominazione,
'data_documento' => $fattura->data_fattura,
'data_scadenza' => $fattura->data_scadenza,
'utente_id' => $userId,
'nome' => $protocollo . ' - ' . $sanFornitore,
'tipologia' => 'fattura',
'tipo_documento' => 'fattura',
'fornitore' => $fattura->fornitore_denominazione,
'data_documento' => $fattura->data_fattura,
'data_scadenza' => $fattura->data_scadenza,
'importo_collegato' => $fattura->totale,
'numero_protocollo' => $protocollo,
'protocollo' => $protocollo,
'nome_file' => $fattura->allegato_pdf_nome ?: ($fattura->nome_file_xml ?: null),
'path_file' => $fattura->allegato_pdf_path ?: ($fattura->xml_path ?: null),
'percorso_file' => $fattura->allegato_pdf_path ?: ($fattura->xml_path ?: null),
'protocollo' => $protocollo,
'nome_file' => $fattura->allegato_pdf_nome ?: ($fattura->nome_file_xml ?: null),
'path_file' => $fattura->allegato_pdf_path ?: ($fattura->xml_path ?: null),
'percorso_file' => $fattura->allegato_pdf_path ?: ($fattura->xml_path ?: null),
];
// Compatibilità: alcune installazioni non hanno (più) certi campi su documenti.
@ -257,12 +256,12 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u
$createData = array_merge($docData, [
'documentable_type' => FatturaElettronica::class,
'documentable_id' => $fattura->id,
'stabile_id' => (int) $fattura->stabile_id,
'data_upload' => now(),
'approvato' => false,
'archiviato' => false,
'is_demo' => false,
'documentable_id' => $fattura->id,
'stabile_id' => (int) $fattura->stabile_id,
'data_upload' => now(),
'approvato' => false,
'archiviato' => false,
'is_demo' => false,
]);
foreach (array_keys($createData) as $col) {
@ -310,7 +309,7 @@ private function resolveGestioneForYear(Stabile $stabile, int $year): ?GestioneC
private function getDocumentoProtocolColumn(): ?string
{
static $cached = null;
static $cached = null;
static $resolved = false;
if ($resolved) {
@ -339,22 +338,33 @@ private function getDocumentoProtocolColumn(): ?string
private function generateFallbackPdfForFattura(FatturaElettronica $fattura, string $protocollo): string
{
$lines = [];
$lines = [];
$lines[] = 'NETGESCON - Prospetto fattura (generato automaticamente)';
$lines[] = 'Protocollo: ' . $protocollo;
$lines[] = '';
$fornitore = trim((string) ($fattura->fornitore_denominazione ?? ''));
$num = trim((string) ($fattura->numero_fattura ?? ''));
$data = $fattura->data_fattura ? $fattura->data_fattura->format('d/m/Y') : '';
$tot = is_numeric($fattura->totale ?? null) ? number_format((float) $fattura->totale, 2, ',', '.') : '';
$piva = trim((string) ($fattura->fornitore_piva ?? ''));
$cf = trim((string) ($fattura->fornitore_cf ?? ''));
$num = trim((string) ($fattura->numero_fattura ?? ''));
$data = $fattura->data_fattura ? $fattura->data_fattura->format('d/m/Y') : '';
$tot = is_numeric($fattura->totale ?? null) ? number_format((float) $fattura->totale, 2, ',', '.') : '';
$piva = trim((string) ($fattura->fornitore_piva ?? ''));
$cf = trim((string) ($fattura->fornitore_cf ?? ''));
if ($fornitore !== '') $lines[] = 'Fornitore: ' . $fornitore;
if ($piva !== '' || $cf !== '') $lines[] = 'P.IVA/CF: ' . ($piva !== '' ? $piva : '—') . ' / ' . ($cf !== '' ? $cf : '—');
if ($num !== '' || $data !== '') $lines[] = 'Fattura: ' . ($num !== '' ? $num : '—') . ' - ' . ($data !== '' ? $data : '—');
if ($tot !== '') $lines[] = 'Totale: EUR ' . $tot;
if ($fornitore !== '') {
$lines[] = 'Fornitore: ' . $fornitore;
}
if ($piva !== '' || $cf !== '') {
$lines[] = 'P.IVA/CF: ' . ($piva !== '' ? $piva : '—') . ' / ' . ($cf !== '' ? $cf : '—');
}
if ($num !== '' || $data !== '') {
$lines[] = 'Fattura: ' . ($num !== '' ? $num : '—') . ' - ' . ($data !== '' ? $data : '—');
}
if ($tot !== '') {
$lines[] = 'Totale: EUR ' . $tot;
}
$lines[] = '';
$lines[] = 'Nota: il PDF originale non era disponibile; questo documento serve solo per archiviazione e protocollazione.';
@ -451,7 +461,7 @@ private function transformFatturaPaXmlToHtmlWithAssosoftwareXsl(string $xml): ?s
private function injectProtocolloIntoHtml(string $html, string $protocollo): string
{
$safe = htmlspecialchars($protocollo, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$safe = htmlspecialchars($protocollo, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$badge = '<div style="font-size:12px;margin:10px 0 0 0;color:#333;"><strong>Protocollo:</strong> ' . $safe . '</div>';
// Inserisce subito dopo l'apertura del body, se possibile.
@ -527,23 +537,23 @@ private function buildSimplePdf(array $lines): string
}
$content .= "\nET";
$objects = [];
$objects[] = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
$objects[] = "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n";
$objects[] = "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n";
$objects[] = "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n";
$objects[] = "5 0 obj\n<< /Length " . strlen($content) . " >>\nstream\n" . $content . "\nendstream\nendobj\n";
$objects = [];
$objects[] = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
$objects[] = "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n";
$objects[] = "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n";
$objects[] = "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n";
$objects[] = "5 0 obj\n<< /Length " . strlen($content) . " >>\nstream\n" . $content . "\nendstream\nendobj\n";
$pdf = "%PDF-1.4\n";
$pdf = "%PDF-1.4\n";
$offsets = [0];
foreach ($objects as $obj) {
$offsets[] = strlen($pdf);
$pdf .= $obj;
$offsets[] = strlen($pdf);
$pdf .= $obj;
}
$xrefPos = strlen($pdf);
$pdf .= "xref\n0 " . (count($objects) + 1) . "\n";
$pdf .= "0000000000 65535 f \n";
$xrefPos = strlen($pdf);
$pdf .= "xref\n0 " . (count($objects) + 1) . "\n";
$pdf .= "0000000000 65535 f \n";
for ($i = 1; $i <= count($objects); $i++) {
$pdf .= str_pad((string) $offsets[$i], 10, '0', STR_PAD_LEFT) . " 00000 n \n";
}
@ -600,8 +610,8 @@ private function syncDocumentoAndPaths(FatturaElettronica $fattura, Documento $d
if ($fatturaPath && (! is_string($docPath) || $docPath === '' || ! Storage::disk('local')->exists($docPath))) {
$doc->forceFill([
'nome_file' => $fatturaName,
'path_file' => $fatturaPath,
'nome_file' => $fatturaName,
'path_file' => $fatturaPath,
'percorso_file' => $fatturaPath,
])->save();
}
@ -624,10 +634,10 @@ private function recoverMissingPathsFromBase(FatturaElettronica $fattura, string
$files = Storage::disk('local')->files($base);
$needleNumero = trim((string) $this->sanitizeFilename((string) ($fattura->numero_fattura ?? '')));
$needleNumero = trim((string) $this->sanitizeFilename((string) ($fattura->numero_fattura ?? '')));
$needleFornitore = trim((string) $this->sanitizeFilename((string) ($fattura->fornitore_denominazione ?? '')));
$needlePiva = trim((string) ($fattura->fornitore_piva ?? ''));
$needleCf = trim((string) ($fattura->fornitore_cf ?? ''));
$needlePiva = trim((string) ($fattura->fornitore_piva ?? ''));
$needleCf = trim((string) ($fattura->fornitore_cf ?? ''));
// Evita agganci sbagliati: se il numero è vuoto o troppo corto (es. "1", "2"), non proviamo recovery.
// In quei casi è più sicuro rigenerare da XML.
@ -646,14 +656,14 @@ private function recoverMissingPathsFromBase(FatturaElettronica $fattura, string
foreach ($files as $file) {
$name = basename($file);
$nameUpper = mb_strtoupper($name);
$numUpper = mb_strtoupper($needleNumero);
$fornUpper = mb_strtoupper($needleFornitore);
$matchNumero = str_contains($nameUpper, $numUpper);
$nameUpper = mb_strtoupper($name);
$numUpper = mb_strtoupper($needleNumero);
$fornUpper = mb_strtoupper($needleFornitore);
$matchNumero = str_contains($nameUpper, $numUpper);
$matchFornitore = str_contains($nameUpper, $fornUpper);
$matchPiva = ($needlePiva !== '' && str_contains($nameUpper, $needlePiva));
$matchCf = ($needleCf !== '' && str_contains($nameUpper, $needleCf));
$strongMatch = $matchNumero && ($matchFornitore || $matchPiva || $matchCf);
$matchPiva = ($needlePiva !== '' && str_contains($nameUpper, $needlePiva));
$matchCf = ($needleCf !== '' && str_contains($nameUpper, $needleCf));
$strongMatch = $matchNumero && ($matchFornitore || $matchPiva || $matchCf);
if (! $pdfOk && str_ends_with(strtolower($name), '.pdf')) {
if ($strongMatch) {
@ -671,15 +681,15 @@ private function recoverMissingPathsFromBase(FatturaElettronica $fattura, string
if (! $pdfOk && count($pdfCandidates) === 1) {
$fattura->allegato_pdf_path = $pdfCandidates[0]['path'];
$fattura->allegato_pdf_nome = $pdfCandidates[0]['name'];
$pdfOk = true;
$needsSave = true;
$pdfOk = true;
$needsSave = true;
}
if (! $xmlOk && count($xmlCandidates) === 1) {
$fattura->xml_path = $xmlCandidates[0]['path'];
$fattura->xml_path = $xmlCandidates[0]['path'];
$fattura->nome_file_xml = $xmlCandidates[0]['name'];
$xmlOk = true;
$needsSave = true;
$xmlOk = true;
$needsSave = true;
}
if ($needsSave) {

View File

@ -3,8 +3,8 @@
use App\Models\Amministratore;
use App\Models\DatiBancari;
use App\Models\GestioneContabile;
use App\Models\Fornitore;
use App\Models\GestioneContabile;
use App\Models\Stabile;
use App\Models\User;
@ -87,7 +87,7 @@ public static function gestioneFolderName(?GestioneContabile $gestione, ?int $fa
return $prefix . $year;
}
public static function fattureXmlYearPath(Stabile $stabile, int|string $year, ?Amministratore $admin = null): ?string
public static function fattureXmlYearPath(Stabile $stabile, int | string $year, ?Amministratore $admin = null): ?string
{
$base = self::stabileBase($stabile, $admin);
if (! is_string($base) || $base === '') {
@ -113,11 +113,11 @@ public static function bankAccountFolderName(DatiBancari $conto): string
$bankCode = match (true) {
str_contains($bankLabel, 'monte') || str_contains($bankLabel, 'paschi') || str_contains($bankLabel, 'mps') => 'MPS',
str_contains($bankLabel, 'intesa') => 'INTESA',
str_contains($bankLabel, 'unicredit') => 'UNICREDIT',
str_contains($bankLabel, 'bper') => 'BPER',
str_contains($bankLabel, 'bcc') => 'BCC',
default => strtoupper(self::sanitizeFolder((string) ($conto->denominazione_banca ?: 'BANCA'))),
str_contains($bankLabel, 'intesa') => 'INTESA',
str_contains($bankLabel, 'unicredit') => 'UNICREDIT',
str_contains($bankLabel, 'bper') => 'BPER',
str_contains($bankLabel, 'bcc') => 'BCC',
default => strtoupper(self::sanitizeFolder((string) ($conto->denominazione_banca ?: 'BANCA'))),
};
if ($bankCode === '') {
@ -133,7 +133,7 @@ public static function bankAccountFolderName(DatiBancari $conto): string
$identifier = self::sanitizeFolder((string) ($conto->legacy_cod_cassa ?? ''));
}
if ($identifier === '') {
$iban = strtoupper(preg_replace('/[^A-Z0-9]+/', '', (string) ($conto->iban ?? '')) ?? '');
$iban = strtoupper(preg_replace('/[^A-Z0-9]+/', '', (string) ($conto->iban ?? '')) ?? '');
$identifier = $iban !== '' ? substr($iban, -8) : '';
}
if ($identifier === '') {
@ -143,7 +143,7 @@ public static function bankAccountFolderName(DatiBancari $conto): string
return $bankCode . '-' . $typeCode . '-' . $identifier;
}
public static function bankYearPath(Stabile $stabile, DatiBancari $conto, int|string $year, ?Amministratore $admin = null): ?string
public static function bankYearPath(Stabile $stabile, DatiBancari $conto, int | string $year, ?Amministratore $admin = null): ?string
{
$base = self::stabileBase($stabile, $admin);
if (! is_string($base) || $base === '') {

View File

@ -1,7 +1,8 @@
<?php
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider; // <-- La riga che risolve l'errore
use Illuminate\Support\ServiceProvider;
// <-- La riga che risolve l'errore
return [
@ -11,7 +12,7 @@
|--------------------------------------------------------------------------
*/
'name' => env('APP_NAME', 'NetGescon'),
'name' => env('APP_NAME', 'NetGescon'),
/*
|--------------------------------------------------------------------------
@ -19,7 +20,7 @@
|--------------------------------------------------------------------------
*/
'env' => env('APP_ENV', 'production'),
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
@ -27,7 +28,7 @@
|--------------------------------------------------------------------------
*/
'debug' => (bool) env('APP_DEBUG', false),
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
@ -35,11 +36,11 @@
|--------------------------------------------------------------------------
*/
'url' => env('APP_URL', 'http://localhost'),
'url' => env('APP_URL', 'http://localhost'),
'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')),
'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')),
'asset_url' => env('ASSET_URL'),
'asset_url' => env('ASSET_URL'),
/*
|--------------------------------------------------------------------------
@ -47,7 +48,7 @@
|--------------------------------------------------------------------------
*/
'timezone' => 'UTC',
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
@ -55,11 +56,11 @@
|--------------------------------------------------------------------------
*/
'locale' => 'it',
'locale' => 'it',
'fallback_locale' => 'it',
'faker_locale' => 'en_US',
'faker_locale' => 'en_US',
/*
|--------------------------------------------------------------------------
@ -67,9 +68,9 @@
|--------------------------------------------------------------------------
*/
'key' => env('APP_KEY'),
'key' => env('APP_KEY'),
'cipher' => 'AES-256-CBC',
'cipher' => 'AES-256-CBC',
/*
|--------------------------------------------------------------------------
@ -77,7 +78,7 @@
|--------------------------------------------------------------------------
*/
'maintenance' => [
'maintenance' => [
'driver' => 'file',
// 'store' => 'redis',
],
@ -88,7 +89,7 @@
|--------------------------------------------------------------------------
*/
'providers' => ServiceProvider::defaultProviders()->merge([
'providers' => ServiceProvider::defaultProviders()->merge([
/*
* Application Service Providers...
*/
@ -101,14 +102,13 @@
])->toArray(),
/*
|--------------------------------------------------------------------------
| Class Aliases
|--------------------------------------------------------------------------
*/
'aliases' => Facade::defaultAliases()->merge([
'aliases' => Facade::defaultAliases()->merge([
// 'Example' => App\Facades\Example::class,
])->toArray(),

View File

@ -3,49 +3,49 @@
return [
// Conti tecnici usati per chiusure/aperture automatiche.
// Il codice del conto è già alfanumerico (string): puoi usare anche valori tipo "CHIUSURA".
'conti_speciali' => [
'chiusura_codice' => env('CONTABILITA_CONTO_CHIUSURA', '9999'),
'chiusura_descrizione' => env('CONTABILITA_CONTO_CHIUSURA_DESC', 'Conto chiusura esercizio'),
'chiusura_tipo' => env('CONTABILITA_CONTO_CHIUSURA_TIPO', 'Patrimonio Netto'),
'conti_speciali' => [
'chiusura_codice' => env('CONTABILITA_CONTO_CHIUSURA', '9999'),
'chiusura_descrizione' => env('CONTABILITA_CONTO_CHIUSURA_DESC', 'Conto chiusura esercizio'),
'chiusura_tipo' => env('CONTABILITA_CONTO_CHIUSURA_TIPO', 'Patrimonio Netto'),
'apertura_codice' => env('CONTABILITA_CONTO_APERTURA', '9998'),
'apertura_descrizione' => env('CONTABILITA_CONTO_APERTURA_DESC', 'Conto apertura esercizio'),
'apertura_tipo' => env('CONTABILITA_CONTO_APERTURA_TIPO', 'Patrimonio Netto'),
'apertura_codice' => env('CONTABILITA_CONTO_APERTURA', '9998'),
'apertura_descrizione' => env('CONTABILITA_CONTO_APERTURA_DESC', 'Conto apertura esercizio'),
'apertura_tipo' => env('CONTABILITA_CONTO_APERTURA_TIPO', 'Patrimonio Netto'),
// Incassi rate: banca/cassa a Dare, crediti condòmini a Avere.
'crediti_condomini_codice' => env('CONTABILITA_CONTO_CREDITI_CONDOMINI', '1100'),
'crediti_condomini_descrizione' => env('CONTABILITA_CONTO_CREDITI_CONDOMINI_DESC', 'Crediti verso condomini'),
'crediti_condomini_tipo' => env('CONTABILITA_CONTO_CREDITI_CONDOMINI_TIPO', 'Attività'),
'crediti_condomini_codice' => env('CONTABILITA_CONTO_CREDITI_CONDOMINI', '1100'),
'crediti_condomini_descrizione' => env('CONTABILITA_CONTO_CREDITI_CONDOMINI_DESC', 'Crediti verso condomini'),
'crediti_condomini_tipo' => env('CONTABILITA_CONTO_CREDITI_CONDOMINI_TIPO', 'Attività'),
// Fatture fornitori: costi (righe) + IVA a credito a Dare; debiti fornitori + ritenute a Avere.
'debiti_fornitori_codice' => env('CONTABILITA_CONTO_DEBITI_FORNITORI', '2000'),
'debiti_fornitori_descrizione' => env('CONTABILITA_CONTO_DEBITI_FORNITORI_DESC', 'Debiti verso fornitori'),
'debiti_fornitori_tipo' => env('CONTABILITA_CONTO_DEBITI_FORNITORI_TIPO', 'Passività'),
'debiti_fornitori_codice' => env('CONTABILITA_CONTO_DEBITI_FORNITORI', '2000'),
'debiti_fornitori_descrizione' => env('CONTABILITA_CONTO_DEBITI_FORNITORI_DESC', 'Debiti verso fornitori'),
'debiti_fornitori_tipo' => env('CONTABILITA_CONTO_DEBITI_FORNITORI_TIPO', 'Passività'),
'iva_credito_codice' => env('CONTABILITA_CONTO_IVA_CREDITO', '1210'),
'iva_credito_descrizione' => env('CONTABILITA_CONTO_IVA_CREDITO_DESC', 'IVA a credito'),
'iva_credito_tipo' => env('CONTABILITA_CONTO_IVA_CREDITO_TIPO', 'Attività'),
'iva_credito_codice' => env('CONTABILITA_CONTO_IVA_CREDITO', '1210'),
'iva_credito_descrizione' => env('CONTABILITA_CONTO_IVA_CREDITO_DESC', 'IVA a credito'),
'iva_credito_tipo' => env('CONTABILITA_CONTO_IVA_CREDITO_TIPO', 'Attività'),
'ritenute_da_versare_codice' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE', '2010'),
'ritenute_da_versare_descrizione' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE_DESC', 'Erario c/ritenute da versare'),
'ritenute_da_versare_tipo' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE_TIPO', 'Passività'),
'ritenute_da_versare_codice' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE', '2010'),
'ritenute_da_versare_descrizione' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE_DESC', 'Erario c/ritenute da versare'),
'ritenute_da_versare_tipo' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE_TIPO', 'Passività'),
// Bucket patrimoniali e di quadratura da usare nella riconciliazione operativa.
'fondo_riserva_codice' => env('CONTABILITA_CONTO_FONDO_RISERVA', '3100'),
'fondo_riserva_descrizione' => env('CONTABILITA_CONTO_FONDO_RISERVA_DESC', 'Fondo di riserva'),
'fondo_riserva_tipo' => env('CONTABILITA_CONTO_FONDO_RISERVA_TIPO', 'Patrimonio Netto'),
'fondo_riserva_codice' => env('CONTABILITA_CONTO_FONDO_RISERVA', '3100'),
'fondo_riserva_descrizione' => env('CONTABILITA_CONTO_FONDO_RISERVA_DESC', 'Fondo di riserva'),
'fondo_riserva_tipo' => env('CONTABILITA_CONTO_FONDO_RISERVA_TIPO', 'Patrimonio Netto'),
'accantonamenti_codice' => env('CONTABILITA_CONTO_ACCANTONAMENTI', '3110'),
'accantonamenti_descrizione' => env('CONTABILITA_CONTO_ACCANTONAMENTI_DESC', 'Accantonamenti da destinare'),
'accantonamenti_tipo' => env('CONTABILITA_CONTO_ACCANTONAMENTI_TIPO', 'Patrimonio Netto'),
'accantonamenti_codice' => env('CONTABILITA_CONTO_ACCANTONAMENTI', '3110'),
'accantonamenti_descrizione' => env('CONTABILITA_CONTO_ACCANTONAMENTI_DESC', 'Accantonamenti da destinare'),
'accantonamenti_tipo' => env('CONTABILITA_CONTO_ACCANTONAMENTI_TIPO', 'Patrimonio Netto'),
'rimborsi_da_distribuire_codice' => env('CONTABILITA_CONTO_RIMBORSI_DISTRIBUIRE', '2105'),
'rimborsi_da_distribuire_codice' => env('CONTABILITA_CONTO_RIMBORSI_DISTRIBUIRE', '2105'),
'rimborsi_da_distribuire_descrizione' => env('CONTABILITA_CONTO_RIMBORSI_DISTRIBUIRE_DESC', 'Rimborsi da distribuire'),
'rimborsi_da_distribuire_tipo' => env('CONTABILITA_CONTO_RIMBORSI_DISTRIBUIRE_TIPO', 'Passività'),
'rimborsi_da_distribuire_tipo' => env('CONTABILITA_CONTO_RIMBORSI_DISTRIBUIRE_TIPO', 'Passività'),
'rimborsi_da_utilizzare_codice' => env('CONTABILITA_CONTO_RIMBORSI_UTILIZZARE', '1115'),
'rimborsi_da_utilizzare_descrizione' => env('CONTABILITA_CONTO_RIMBORSI_UTILIZZARE_DESC', 'Rimborsi da utilizzare'),
'rimborsi_da_utilizzare_tipo' => env('CONTABILITA_CONTO_RIMBORSI_UTILIZZARE_TIPO', 'Attività'),
'rimborsi_da_utilizzare_codice' => env('CONTABILITA_CONTO_RIMBORSI_UTILIZZARE', '1115'),
'rimborsi_da_utilizzare_descrizione' => env('CONTABILITA_CONTO_RIMBORSI_UTILIZZARE_DESC', 'Rimborsi da utilizzare'),
'rimborsi_da_utilizzare_tipo' => env('CONTABILITA_CONTO_RIMBORSI_UTILIZZARE_TIPO', 'Attività'),
],
'fatture_elettroniche' => [

View File

@ -49,25 +49,25 @@
],
'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', ''),
'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' => [
'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_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

@ -53,4 +53,4 @@ public function down(): void
}
});
}
};
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('contabilita_saldi_conti', function (Blueprint $table): void {
if (! Schema::hasColumn('contabilita_saldi_conti', 'is_partenza_contabile')) {
$table->boolean('is_partenza_contabile')
->default(false)
->after('saldo')
->index();
}
});
}
public function down(): void
{
Schema::table('contabilita_saldi_conti', function (Blueprint $table): void {
if (Schema::hasColumn('contabilita_saldi_conti', 'is_partenza_contabile')) {
$table->dropIndex(['is_partenza_contabile']);
$table->dropColumn('is_partenza_contabile');
}
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('affitti_immobili', function (Blueprint $table) {
if (! Schema::hasColumn('affitti_immobili', 'presa_in_carico_operativa_dal')) {
$table->date('presa_in_carico_operativa_dal')
->nullable()
->after('inizio_contratto');
}
});
}
public function down(): void
{
Schema::table('affitti_immobili', function (Blueprint $table) {
if (Schema::hasColumn('affitti_immobili', 'presa_in_carico_operativa_dal')) {
$table->dropColumn('presa_in_carico_operativa_dal');
}
});
}
};

View File

@ -54,4 +54,4 @@ public function down(): void
Schema::dropIfExists('documento_archivio_cartelle');
}
};
};

View File

@ -23,4 +23,4 @@ public function down(): void
}
});
}
};
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('fornitori') || Schema::hasColumn('fornitori', 'sia')) {
return;
}
Schema::table('fornitori', function (Blueprint $table): void {
$table->string('sia', 32)->nullable()->after('bic')->index();
});
}
public function down(): void
{
if (! Schema::hasTable('fornitori') || ! Schema::hasColumn('fornitori', 'sia')) {
return;
}
Schema::table('fornitori', function (Blueprint $table): void {
$table->dropColumn('sia');
});
}
};

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('stabile_contratti', function (Blueprint $table): void {
$table->id();
$table->foreignId('stabile_id')->constrained('stabili')->cascadeOnDelete();
$table->foreignId('fornitore_id')->nullable()->constrained('fornitori')->nullOnDelete();
$table->foreignId('documento_principale_id')->nullable()->constrained('documenti')->nullOnDelete();
$table->string('titolo');
$table->string('tipo_contratto', 60)->index();
$table->string('categoria_impianto', 60)->nullable()->index();
$table->string('servizio_label')->nullable();
$table->string('codice_contratto', 80)->nullable()->index();
$table->string('riferimento_esterno', 120)->nullable();
$table->string('stato', 30)->default('attivo')->index();
$table->date('data_stipula')->nullable();
$table->date('decorrenza_dal')->nullable()->index();
$table->date('decorrenza_al')->nullable()->index();
$table->string('frequenza_scadenze', 30)->nullable();
$table->unsignedTinyInteger('giorno_scadenza')->nullable();
$table->unsignedSmallInteger('giorni_preavviso')->default(30);
$table->boolean('rinnovo_automatico')->default(false);
$table->decimal('importo_periodico', 12, 2)->nullable();
$table->string('valuta', 8)->default('EUR');
$table->json('dati_contratto')->nullable();
$table->json('codici_collegati')->nullable();
$table->text('note')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('stabile_contratti');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('scadenze') || Schema::hasColumn('scadenze', 'stabile_contratto_id')) {
return;
}
Schema::table('scadenze', function (Blueprint $table): void {
$table->foreignId('stabile_contratto_id')->nullable()->after('stabile_id')->constrained('stabile_contratti')->nullOnDelete();
});
}
public function down(): void
{
if (! Schema::hasTable('scadenze') || ! Schema::hasColumn('scadenze', 'stabile_contratto_id')) {
return;
}
Schema::table('scadenze', function (Blueprint $table): void {
$table->dropConstrainedForeignId('stabile_contratto_id');
});
}
};

View File

@ -0,0 +1,81 @@
services:
app:
image: netgescon/app:${NETGESCON_VERSION:-latest}
build:
context: ..
dockerfile: docker/Dockerfile.prod
container_name: ${COMPOSE_PROJECT_NAME:-netgescon_node}_app
restart: unless-stopped
env_file:
- .env.node-local
environment:
DB_CONNECTION: mysql
DB_HOST: db
DB_PORT: 3306
REDIS_HOST: redis
REDIS_PORT: 6379
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-true}
MIGRATIONS_STRICT: ${MIGRATIONS_STRICT:-true}
RUN_FILAMENT_ASSETS: ${RUN_FILAMENT_ASSETS:-false}
FIX_PERMISSIONS: ${FIX_PERMISSIONS:-true}
WAIT_FOR_DB: ${WAIT_FOR_DB:-true}
DISABLE_SCHEMA_LOAD: ${DISABLE_SCHEMA_LOAD:-true}
volumes:
- node_storage:/var/www/storage
- node_cache:/var/www/bootstrap/cache
- node_public:/var/www/public
- ${LEGACY_ARCHIVES_PATH:-/opt/netgescon/legacy-archives}:/mnt/gescon-archives/gescon:ro
depends_on:
- db
- redis
networks:
- netgescon_node_local
nginx:
image: nginx:alpine
container_name: ${COMPOSE_PROJECT_NAME:-netgescon_node}_nginx
restart: unless-stopped
ports:
- "${NODE_HTTP_PORT:-8080}:80"
volumes:
- node_public:/var/www/public:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- netgescon_node_local
db:
image: mysql:8.0
container_name: ${COMPOSE_PROJECT_NAME:-netgescon_node}_db
restart: unless-stopped
command: --log-bin-trust-function-creators=1
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- node_db:/var/lib/mysql
networks:
- netgescon_node_local
redis:
image: redis:alpine
container_name: ${COMPOSE_PROJECT_NAME:-netgescon_node}_redis
restart: unless-stopped
volumes:
- node_redis:/data
networks:
- netgescon_node_local
volumes:
node_db:
node_storage:
node_cache:
node_public:
node_redis:
networks:
netgescon_node_local:
driver: bridge

View File

@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail
DOCKER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$DOCKER_DIR/.." && pwd)"
ENV_FILE="${ENV_FILE:-$DOCKER_DIR/.env.node-local}"
COMPOSE_FILE="$DOCKER_DIR/docker-compose.node-local.yml"
resolve_compose_cmd() {
if command -v docker >/dev/null 2>&1; then
if docker compose version >/dev/null 2>&1; then
echo "docker compose"
return
fi
fi
if command -v docker-compose >/dev/null 2>&1; then
echo "docker-compose"
return
fi
echo "Errore: non trovo né 'docker compose' né 'docker-compose'." >&2
exit 127
}
upsert_env_var() {
local key="$1"
local value="$2"
if grep -qE "^${key}=" "$ENV_FILE"; then
sed -i "s#^${key}=.*#${key}=${value}#" "$ENV_FILE"
else
printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE"
fi
}
COMPOSE_CMD="$(resolve_compose_cmd)"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Env mancante: $ENV_FILE"
echo "Crea prima il file: cp $DOCKER_DIR/.env.node-local.example $ENV_FILE"
exit 1
fi
source "$ENV_FILE"
if [[ -z "${APP_KEY:-}" ]]; then
GENERATED_APP_KEY="base64:$(openssl rand -base64 32 | tr -d '\n')"
upsert_env_var "APP_KEY" "$GENERATED_APP_KEY"
APP_KEY="$GENERATED_APP_KEY"
echo "APP_KEY generata automaticamente in $ENV_FILE"
fi
if [[ -n "${LEGACY_ARCHIVES_PATH:-}" ]]; then
mkdir -p "$LEGACY_ARCHIVES_PATH"
fi
mkdir -p "$ROOT_DIR/storage/backups"
echo "[1/6] Validazione compose"
$COMPOSE_CMD --env-file "$ENV_FILE" -f "$COMPOSE_FILE" config >/tmp/netgescon-node-local.config
echo "[2/6] Build immagine applicativa"
$COMPOSE_CMD --env-file "$ENV_FILE" -f "$COMPOSE_FILE" build app
echo "[3/6] Avvio database e redis"
$COMPOSE_CMD --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d db redis
echo "[4/6] Avvio applicazione e nginx"
$COMPOSE_CMD --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d app nginx
echo "[5/6] Creazione database import legacy"
$COMPOSE_CMD --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T db sh -lc \
"mysql -uroot -p\"$DB_ROOT_PASSWORD\" -e \"CREATE DATABASE IF NOT EXISTS gescon_import; GRANT ALL PRIVILEGES ON gescon_import.* TO '$DB_USERNAME'@'%'; FLUSH PRIVILEGES;\""
echo "[6/6] Stato finale"
$COMPOSE_CMD --env-file "$ENV_FILE" -f "$COMPOSE_FILE" ps
echo
echo "Installazione nodo locale completata."
echo "URL applicazione: ${APP_URL:-http://localhost:${NODE_HTTP_PORT:-8080}}"
echo "Backup/restore: usa docker/restore-node-backup.sh dopo aver copiato dump SQL e archivio storage sulla VM."

View File

@ -0,0 +1,103 @@
#!/usr/bin/env bash
set -euo pipefail
DOCKER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${ENV_FILE:-$DOCKER_DIR/.env.node-local}"
COMPOSE_FILE="$DOCKER_DIR/docker-compose.node-local.yml"
SQL_DUMP=""
STORAGE_ARCHIVE=""
resolve_compose_cmd() {
if command -v docker >/dev/null 2>&1; then
if docker compose version >/dev/null 2>&1; then
echo "docker compose"
return
fi
fi
if command -v docker-compose >/dev/null 2>&1; then
echo "docker-compose"
return
fi
echo "Errore: non trovo né 'docker compose' né 'docker-compose'." >&2
exit 127
}
usage() {
cat <<EOF
Uso:
$(basename "$0") --sql /percorso/backup.sql[.gz] [--storage /percorso/storage.tar.gz]
Opzioni:
--sql Dump MySQL del sito da ripristinare.
--storage Archivio tar.gz della cartella storage da ripristinare sopra /var/www/storage.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--sql)
SQL_DUMP="$2"
shift 2
;;
--storage)
STORAGE_ARCHIVE="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Argomento non riconosciuto: $1" >&2
usage
exit 1
;;
esac
done
if [[ -z "$SQL_DUMP" ]]; then
usage
exit 1
fi
if [[ ! -f "$ENV_FILE" ]]; then
echo "Env mancante: $ENV_FILE"
exit 1
fi
if [[ ! -f "$SQL_DUMP" ]]; then
echo "Dump SQL non trovato: $SQL_DUMP"
exit 1
fi
if [[ -n "$STORAGE_ARCHIVE" && ! -f "$STORAGE_ARCHIVE" ]]; then
echo "Archivio storage non trovato: $STORAGE_ARCHIVE"
exit 1
fi
COMPOSE_CMD="$(resolve_compose_cmd)"
source "$ENV_FILE"
echo "[1/4] Avvio stack"
$COMPOSE_CMD --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d db redis app nginx
echo "[2/4] Ripristino database principale"
if [[ "$SQL_DUMP" == *.gz ]]; then
gzip -dc "$SQL_DUMP" | $COMPOSE_CMD --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T db sh -lc "mysql -uroot -p\"$DB_ROOT_PASSWORD\" \"$DB_DATABASE\""
else
cat "$SQL_DUMP" | $COMPOSE_CMD --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T db sh -lc "mysql -uroot -p\"$DB_ROOT_PASSWORD\" \"$DB_DATABASE\""
fi
echo "[3/4] Ripristino storage applicativo"
if [[ -n "$STORAGE_ARCHIVE" ]]; then
cat "$STORAGE_ARCHIVE" | $COMPOSE_CMD --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T app sh -lc "tar -xzf - -C /var/www"
else
echo "Nessun archivio storage fornito: salto questo passaggio."
fi
echo "[4/4] Pulizia cache Laravel"
$COMPOSE_CMD --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T app php artisan optimize:clear || true
echo "Restore completato. Verifica login, storage documenti e connessioni locali del nodo."

View File

@ -0,0 +1,194 @@
# Installazione Ubuntu 24.04 automatica
Questa famiglia di script separa tre problemi diversi:
1. `scripts/install/ubuntu2404-laravel-base.sh`
Prepara una macchina Ubuntu 24.04 nuova per progetti Laravel in generale.
Installa stack di base: PHP, Composer, Node.js, MySQL, Nginx, Redis, Supervisor.
Se non disattivato, installa anche XFCE, XRDP e Visual Studio Code.
2. `scripts/install/laravel-site-bootstrap.sh`
Pubblica un singolo progetto Laravel su una macchina gia pronta.
Usa chiavi univoche per sito, file Nginx, cron e Supervisor.
Di default si rifiuta di sovrascrivere directory, vhost e database gia esistenti.
3. `scripts/install/netgescon-node-bootstrap.sh`
Prende una macchina gia pronta con lo stack Laravel e installa un nodo NetGescon pronto per il login iniziale e per la pagina `admin-filament/supporto/aggiornamento-nodo`.
## Installazione via curl
Se il nodo che espone l'applicazione e raggiungibile, puoi usare direttamente gli endpoint pubblici in `public/installers`.
Indice rapido browser:
- `http://192.168.0.200:8000/installers/`
- `https://staging.netgescon.it/installers/`
Script base macchina:
```bash
curl -fsSL http://192.168.0.200:8000/installers/ubuntu2404-laravel-base.php -o /tmp/ubuntu2404-laravel-base.sh
sudo bash /tmp/ubuntu2404-laravel-base.sh
curl -fsSL https://staging.netgescon.it/installers/ubuntu2404-laravel-base.php -o /tmp/ubuntu2404-laravel-base.sh
sudo bash /tmp/ubuntu2404-laravel-base.sh
```
Versione server-only senza XFCE, VS Code e XRDP:
```bash
curl -fsSL http://192.168.0.200:8000/installers/ubuntu2404-laravel-base.php -o /tmp/ubuntu2404-laravel-base.sh
sudo bash /tmp/ubuntu2404-laravel-base.sh --without-xfce --without-vscode --without-xrdp
```
Bootstrap sito Laravel generico:
```bash
curl -fsSL http://192.168.0.200:8000/installers/laravel-site-bootstrap.php -o /tmp/laravel-site-bootstrap.sh
sudo bash /tmp/laravel-site-bootstrap.sh \
--site-key demo-app \
--project-name "Demo App" \
--repo-url git@your-gitea:studio/demo-app.git \
--branch main \
--app-url http://demo.example.local \
--server-name demo.example.local \
--db-password 'StrongDbPass123!'
```
Bootstrap nodo NetGescon:
```bash
curl -fsSL http://192.168.0.200:8000/installers/netgescon-node-bootstrap.php -o /tmp/netgescon-node-bootstrap.sh
sudo bash /tmp/netgescon-node-bootstrap.sh \
--site-key netgescon-prod \
--repo-url git@your-gitea:studio/netgescon.git \
--branch main \
--app-url http://192.168.0.200 \
--server-name 192.168.0.200 \
--db-password 'StrongDbPass123!' \
--admin-email admin@netgescon.local \
--admin-password 'StrongAdminPass123!'
```
La stessa chiamata su staging puo diventare:
```bash
curl -fsSL https://staging.netgescon.it/installers/netgescon-node-bootstrap.php -o /tmp/netgescon-node-bootstrap.sh
sudo bash /tmp/netgescon-node-bootstrap.sh \
--site-key netgescon-staging \
--repo-url git@your-gitea:studio/netgescon.git \
--branch main \
--app-url https://staging.netgescon.it \
--server-name staging.netgescon.it \
--db-password 'StrongDbPass123!' \
--admin-email admin@netgescon.local \
--admin-password 'StrongAdminPass123!'
```
## 1. Base machine Laravel
Esecuzione consigliata:
```bash
sudo bash scripts/install/ubuntu2404-laravel-base.sh
```
Varianti utili:
```bash
sudo bash scripts/install/ubuntu2404-laravel-base.sh --without-xfce --without-vscode
sudo bash scripts/install/ubuntu2404-laravel-base.sh --projects-dir /srv/laravel-projects
```
Cosa fa:
- aggiorna Ubuntu 24.04
- mostra una schermata iniziale con stato corrente macchina e componenti gia presenti
- chiede prima di partire quali blocchi installare o saltare
- installa PHP 8.3 e moduli Laravel comuni
- installa Composer e Node.js
- installa MySQL, Nginx, Redis e Supervisor
- apre firewall per SSH, HTTP, HTTPS e XRDP se richiesto
- crea una base di lavoro per progetti Laravel in `/srv/laravel-projects`
- opzionalmente installa XFCE, XRDP e VS Code
Se vuoi saltare la schermata interattiva e usare lo script in automazione, puoi aggiungere `--non-interactive` al comando.
## 2. Bootstrap sito Laravel generico
Esempio completo:
```bash
sudo bash scripts/install/laravel-site-bootstrap.sh \
--site-key frontoffice-demo \
--project-name "Frontoffice Demo" \
--repo-url git@your-gitea:studio/frontoffice-demo.git \
--branch main \
--app-url https://frontoffice-demo.example.it \
--server-name frontoffice-demo.example.it \
--db-password 'StrongDbPass123!'
```
Cosa fa:
- clona il repository applicativo in una directory dedicata, di default `/var/www/<site-key>`
- crea un database e un utente MySQL dedicati al sito
- prepara `.env`, chiave applicativa e cache Laravel
- installa dipendenze Composer e, se presenti, builda gli asset frontend
- crea un file Nginx dedicato e separato dagli altri siti pubblicati
- configura cron scheduler e, se richiesto, un worker Supervisor dedicato
- rifiuta per default di sovrascrivere directory applicative, database o vhost gia esistenti
Flag utili per riuso controllato:
- `--allow-existing-app-dir` permette di riusare una worktree git Laravel gia esistente
- `--allow-existing-database` permette di riusare database e utente MySQL gia presenti
- `--replace-nginx-site` permette di sostituire esplicitamente un vhost gia presente
- `--with-queue-worker` abilita un worker Supervisor dedicato al sito
Questo e il pezzo da usare per avere 2 o 3 siti diversi sulla stessa macchina, per esempio frontoffice, demo e produzione, senza collisioni sui file di servizio.
## 3. Bootstrap nodo NetGescon
Esempio completo:
```bash
sudo bash scripts/install/netgescon-node-bootstrap.sh \
--site-key netgescon-prod \
--repo-url git@your-gitea:studio/netgescon.git \
--branch main \
--app-url https://netgescon.example.it \
--server-name netgescon.example.it \
--db-password 'StrongDbPass123!' \
--admin-email admin@netgescon.example.it \
--admin-password 'StrongAdminPass123!'
```
Cosa fa:
- clona il repository applicativo in `/var/www/netgescon`
- clona una seconda copia Git sorgente in `/var/www/netgescon-git-source`
- collega il repository sorgente a `storage/app/support/git-source`
- configura `.env` con `NETGESCON_GIT_SYNC_SOURCE_REPO`
- crea database e utente MySQL
- installa dipendenze Composer e, se presenti, builda gli asset frontend
- esegue migrazioni
- crea un utente iniziale con ruoli `super-admin` e `admin`
- configura Nginx, cron scheduler e Supervisor queue worker
- lascia il nodo pronto per aprire `admin-filament/supporto/aggiornamento-nodo`
Guard-rail aggiunti:
- usa `--site-key` per rendere univoci file Nginx, cron e Supervisor
- non sovrascrive piu automaticamente directory, database o vhost gia esistenti
- per riusare una installazione esistente servono flag espliciti come `--allow-existing-app-dir`, `--allow-existing-database` e `--replace-nginx-site`
## Note operative
- Gli script sono pensati per Ubuntu 24.04.
- Lo script NetGescon presuppone che la macchina abbia gia PHP, Composer, Node, MySQL e Nginx installati.
- Lo script Laravel generico e quello da riusare per altri progetti non NetGescon sulla stessa macchina.
- Se il repository Gitea usa SSH, la macchina deve avere gia la chiave deploy o la chiave utente autorizzata.
- Se vuoi una macchina solo server senza desktop, usa la base machine con `--without-xfce --without-vscode --without-xrdp`.
- Dopo il bootstrap del nodo conviene configurare subito HTTPS e backup automatici.
- Gli endpoint curl leggono direttamente gli script versionati nel repository, quindi dopo pull/deploy espongono sempre la versione aggiornata senza copiare file a mano.

View File

@ -82,4 +82,4 @@ ## Cosa serve per il canale Telegram offerte
- 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.
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

@ -0,0 +1,177 @@
# NetGescon 0.9 - Installazione nodo locale da macchina vuota
Questo documento descrive la prova che ha senso fare adesso: una macchina Ubuntu nuova che installa NetGescon come nodo locale singolo, senza dipendere dai servizi cloud pubblici per funzionare nel quotidiano.
Obiettivo pratico:
1. preparare una VM o macchina Ubuntu pulita;
2. installare Docker e lo stack applicativo locale;
3. avviare NetGescon come nodo singolo;
4. ripristinare un backup proveniente dal sito;
5. verificare che il nodo lavori in autonomia con DB, storage e servizi locali.
## Cosa e incluso nel repository
Per questa prova sono pronti i seguenti file:
- `docker/docker-compose.node-local.yml`
- `docker/.env.node-local.example`
- `docker/install-node-local.sh`
- `docker/restore-node-backup.sh`
- `docker/provision-vm.sh`
Questo stack non usa il multisito `www/app/demo`. Porta su un solo nodo applicativo con:
- `app` PHP-FPM Laravel
- `nginx`
- `mysql`
- `redis`
## Cosa serve dalla macchina remota
Per procedere senza perdere tempo mi servono solo queste informazioni:
1. IP della macchina oppure hostname raggiungibile in LAN/VPN.
2. Utente SSH e porta SSH.
3. Conferma versione Ubuntu (`22.04` o `24.04` va bene).
4. RAM, CPU e spazio disco disponibili.
5. Path in cui vuoi installare il progetto, ad esempio `/opt/netgescon`.
6. Se il nodo dovra rispondere solo in LAN o anche con DNS dedicato.
7. Se hai gia pronto il backup da ripristinare:
- dump SQL del DB applicativo;
- archivio `storage` del sito;
- eventuale archivio legacy MDB/Access da montare in `LEGACY_ARCHIVES_PATH`.
## Bootstrap da macchina vuota
### 1. Copia repository sulla macchina
Se la macchina vede Gitea:
```bash
cd /opt
git clone ssh://git@192.168.0.53:2222/michele/netgescon-day0.git netgescon-day0
cd /opt/netgescon-day0
```
Se non vuoi usare Git direttamente sul nodo, puoi copiare un archivio del repository gia allineato.
### 2. Provision base Ubuntu
```bash
cd /opt/netgescon-day0/docker
chmod +x provision-vm.sh install-node-local.sh restore-node-backup.sh
./provision-vm.sh
```
Poi fai logout/login per applicare il gruppo Docker.
### 3. Prepara env locale del nodo
```bash
cd /opt/netgescon-day0
cp docker/.env.node-local.example docker/.env.node-local
nano docker/.env.node-local
```
Campi da impostare subito:
- `APP_URL`
- `NODE_HTTP_PORT`
- `DB_PASSWORD`
- `DB_ROOT_PASSWORD`
- `LEGACY_ARCHIVES_PATH`
`APP_KEY` puo restare vuota: viene generata automaticamente da `install-node-local.sh` al primo avvio.
### 4. Installa e avvia lo stack
```bash
cd /opt/netgescon-day0/docker
./install-node-local.sh
```
Lo script:
1. valida il compose;
2. genera `APP_KEY` se manca;
3. builda l immagine applicativa;
4. avvia DB e Redis;
5. avvia app e nginx;
6. crea anche il database `gescon_import` per gli import legacy.
## Restore backup del sito
Dopo aver copiato sul nodo il dump del database e l archivio storage:
```bash
cd /opt/netgescon-day0/docker
./restore-node-backup.sh --sql /percorso/backup.sql.gz --storage /percorso/storage.tar.gz
```
Note operative:
- il dump SQL puo essere `.sql` oppure `.sql.gz`;
- lo storage e opzionale, ma senza storage il nodo partira senza documenti/allegati storici;
- l archivio storage deve essere un `tar.gz` estraibile con radice compatibile con `/var/www/storage`.
## Verifiche minime dopo restore
```bash
cd /opt/netgescon-day0
docker compose --env-file docker/.env.node-local -f docker/docker-compose.node-local.yml ps
docker compose --env-file docker/.env.node-local -f docker/docker-compose.node-local.yml logs -f --tail=100
```
Controlli applicativi minimi:
1. login backoffice;
2. apertura dashboard;
3. apertura di almeno una pagina contabile;
4. verifica documenti/allegati storici;
5. verifica accesso al DB applicativo;
6. verifica che il nodo non dipenda da servizi cloud per il funzionamento base.
## Spiegazione del messaggio visto su staging
Il pannello `Supporto > Aggiornamento Nodo` mostra due cose diverse:
1. lo stato Git noto al nodo;
2. l eventuale esecuzione dell update applicativo.
Per questo:
- `Ultima sync Git` indica quando il nodo ha registrato l ultima sync Git;
- `Ultimo update` resta `-` se non e mai stato lanciato un update applicativo da quella pagina.
Dopo il push su Gitea di oggi, il nodo che ho verificato mostra gia:
- branch `main`
- commit `b3e626b`
- tracking `ahead 0 / behind 0`
- working tree `pulito`
Quindi il problema non era un guasto Git: era solo assenza di un update applicativo registrato, mentre la parte Git ora e allineata.
## Limiti attuali da tenere presenti
Questa prova locale serve per installazione e ripristino operativo. Restano fuori, salvo configurazione dedicata:
- manifest distribution pubblico;
- licensing per canali;
- backup cloud esterni;
- servizi Google/Gmail/Drive lato studio;
- reverse proxy TLS pubblico multi-dominio.
## Prossimo passo corretto
Appena mi dai i dati della macchina remota, facciamo in ordine:
1. verifica accesso SSH e risorse;
2. provisioning Docker;
3. copia repository o clone da Gitea;
4. creazione `docker/.env.node-local`;
5. avvio stack;
6. restore backup del sito;
7. checklist di collaudo del nodo.

View File

@ -0,0 +1,25 @@
# Integrazione contabile operativa
## Riconciliazione banca MPS
La pagina Contabilita > Casse e banche > Movimenti non e piu solo una vista di suggerimenti.
La coda di riconciliazione ora puo chiudere due casi operativi direttamente dal movimento banca:
- Incasso affitto: il movimento positivo puo essere collegato a un canone aperto del modulo affitti. L'azione crea o completa il record in affitti_canoni_pagati e marca il movimento come riconciliato operativamente.
- Pagamento fornitore: il movimento negativo puo essere collegato a una fattura del modulo contabilita_fatture_fornitori. L'azione genera la prima nota del pagamento, collega il movimento alla fattura e aggiorna o apre la riga nel registro_ritenute_acconto quando la fattura ha ritenuta.
## Regole applicate
- I canoni affitto vengono proposti in base a importo, data, inquilino e coerenza con il conto bancario associato all'affitto.
- Le fatture fornitore vengono proposte usando il netto da pagare quando presente, cosi il match resta coerente anche in presenza di ritenuta d'acconto.
- I movimenti riconciliati nel dominio operativo vengono marcati in match_data con un esito esplicito, cosi escono dalla coda anche quando non generano subito una registrazione contabile autonoma.
## Base per moduli dichiarativi
Questa slice segue la direzione dei moduli dichiarativi:
- il modulo banca espone una coda operativa e i suoi candidati;
- il modulo affitti chiude i canoni nel proprio dominio senza duplicare logica nella UI;
- il modulo contabilita fornitore mantiene la responsabilita di prima nota, debito fornitore e registro RA.
La regola da mantenere nelle prossime slice e semplice: la pagina di regia riconosce il caso e delega la chiusura all'unita funzionale proprietaria del dominio.

View File

@ -0,0 +1,116 @@
# Documenti Archivio DAFARE
## Obiettivo operativo
Costruire un flusso unico che parta dal file digitale, generi il codice mnemonico/QR, stampi l'etichetta corretta e accompagni il documento dentro la catena fisica fino alla collocazione finale.
## Slice da fare subito
1. Rifinire il builder etichette su `etichette-generiche`.
2. Tenere le impostazioni accanto alla riga o al campo che controllano.
3. Rendere piu esplicita la modifica e la ristampa delle etichette gia create.
4. Portare la gestione di supporto e ubicazione nella tab `movimentazione-codici`.
5. Preparare il flusso `file base -> primo supporto -> contenitori successivi`.
## Convenzioni archivio da fissare
### Codice categoria archivio
- `CONTR-ANNO-###`: contratti di servizio
- `ASSIC-ANNO-###`: polizze assicurative
- `CERT-ANNO-###`: certificati e autorizzazioni
- `PREV-ANNO-###`: preventivi e offerte
- `FATT-ANNO-###`: fatture e documenti fiscali
- `VERB-ANNO-###`: verbali assemblee
- `DOC-ANNO-###`: altri documenti
### Regole comuni
- Il prefisso categoria va salvato anche nella voce archivio su database.
- Il valore deve essere in formato Linux-safe: maiuscolo, senza spazi, uniforme.
- Il codice mnemonico deve diventare sia nome logico del file digitale sia fallback umano dell'etichetta stampata.
- Il QR o TAG deve contenere l'identificatore unico universale del nodo archivistico, leggibile da smartphone o lettore fisico.
- Il passaggio consegne tra amministratori deve riusare lo stesso codice mnemonico/QR senza ricodifiche locali.
### Data nei file
- Inserire la data nel nome file con formato `YYMMGG`.
- Prevedere una futura impostazione generale dell'amministratore per cambiare il formato globale della data.
## Flussi da realizzare
### 1. Gestione file base
Una Blade dedicata deve permettere di registrare il singolo file o una raccolta di fogli con:
- titolo
- descrizione
- note
- tag
- categoria come tag strutturato
- numero totale dei fogli o dell'incartamento
- selezione del layout etichetta da usare
- stampa etichetta con QR e fallback testuale nel titolo e sotto il QR
### 2. Primo contenitore fisico
Dal file base bisogna poter scegliere il primo supporto:
- busta ad anelli
- faldone a tre lembi
- raccoglitore
- altro supporto scelto a catalogo
Il record deve restare collegato al documento digitale per consentire nuove stampe dell'etichetta in qualsiasi momento.
### 3. Contenitori successivi e movimentazione
Serve una gestione separata dove il materiale gia preparato venga caricato nei contenitori successivi:
- cartellina
- busta
- faldone
- scatola
- scaffale
- posto finale
La tab `movimentazione-codici` deve diventare il centro operativo per:
- cambiare il contenitore padre
- aggiornare supporto operativo
- aggiornare magazzino, scaffale, ripiano e dettaglio ubicazione
- leggere codici da scanner, smartphone, QR o plugin locale
## Etichette 11354 e binding DB
- Per il formato `11354` prevedere un campo di collegamento ai dati DB da riversare automaticamente in stampa.
- Ogni riga deve poter leggere un campo DB oppure testo libero.
- Le impostazioni della singola riga devono stare vicino alla riga stessa.
- La ristampa deve essere disponibile anche se fallisce la lettura automatica del QR.
## Catalogo supporti e Amazon
- Ogni supporto fisico scelto dall'utente deve poter avere il pulsante Amazon.
- Il link Amazon deve arrivare dal catalogo prodotti gestito dal super admin.
- L'archivio fisico non deve duplicare il catalogo: deve consumare il dato gia scelto.
## Plugin locale / bridge dispositivi
Serve un plugin HTTP locale o agent Windows per passare dati hardware fuori dal browser:
- TWAIN/WIA scanner
- lettori barcode e QR
- NFC
- seriali
- eventuali periferiche di stampa/lettura
La webapp deve restare il pannello operativo, mentre il bridge locale si occupa della parte hardware.
## Backlog successivo
1. Foto ubicazione finale come nel ticket.
2. Vista modifica/ristampa dalle ultime etichette.
3. Blade separata per file singolo e per raccolta fogli.
4. Conteggio pagine persistente su database.
5. Impostazioni globali amministratore per formato data e convenzioni etichetta.
6. Integrazione completa col bridge locale di comunicazione.

110
public/installers/index.php Normal file
View File

@ -0,0 +1,110 @@
<?php
declare (strict_types = 1);
$scheme = (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? '192.168.0.200:8000';
$baseUrl = $scheme . '://' . $host . '/installers';
header('Content-Type: text/html; charset=UTF-8');
?>
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NetGescon Installers</title>
<style>
body {
font-family: system-ui, sans-serif;
margin: 0;
background: #0f172a;
color: #e2e8f0;
padding: 2rem;
}
.wrap {
max-width: 1100px;
margin: 0 auto;
}
.card {
background: #111827;
border: 1px solid #334155;
border-radius: 16px;
padding: 1.25rem;
margin-top: 1rem;
}
code, pre {
font-family: "Fira Code", monospace;
}
pre {
background: #020617;
border: 1px solid #1e293b;
border-radius: 12px;
padding: 1rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
a {
color: #7dd3fc;
}
.muted {
color: #94a3b8;
}
</style>
</head>
<body>
<div class="wrap">
<h1>NetGescon Installers</h1>
<p class="muted">Da qui puoi lanciare gli script di installazione direttamente con curl, usando staging.netgescon.it oppure il nodo locale 192.168.0.200.</p>
<div class="card">
<h2>1. Macchina base Ubuntu 24.04</h2>
<p>Installa stack Laravel, MySQL, Nginx, Redis, Supervisor e opzionalmente XFCE, XRDP e VS Code.</p>
<pre>curl -fsSL <?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8') ?>/ubuntu2404-laravel-base.php -o /tmp/ubuntu2404-laravel-base.sh
sudo bash /tmp/ubuntu2404-laravel-base.sh</pre>
<p class="muted">Versione server-only:</p>
<pre>curl -fsSL <?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8') ?>/ubuntu2404-laravel-base.php -o /tmp/ubuntu2404-laravel-base.sh
sudo bash /tmp/ubuntu2404-laravel-base.sh --without-xfce --without-vscode --without-xrdp</pre>
</div>
<div class="card">
<h2>2. Bootstrap sito Laravel</h2>
<p>Pubblica un progetto Laravel su una macchina gia pronta senza sovrascrivere directory, database o vhost esistenti se non richiesto esplicitamente.</p>
<pre>curl -fsSL <?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8') ?>/laravel-site-bootstrap.php -o /tmp/laravel-site-bootstrap.sh
sudo bash /tmp/laravel-site-bootstrap.sh \
--site-key demo-app \
--project-name "Demo App" \
--repo-url git@your-gitea:studio/demo-app.git \
--branch main \
--app-url <?php echo htmlspecialchars($scheme . '://' . $host, ENT_QUOTES, 'UTF-8') ?> \
--server-name <?php echo htmlspecialchars($host, ENT_QUOTES, 'UTF-8') ?> \
--db-password 'StrongDbPass123!'</pre>
</div>
<div class="card">
<h2>3. Bootstrap nodo NetGescon</h2>
<p>Clona il repository applicativo, prepara il git-source locale per la sync da browser e lascia il nodo pronto per admin-filament/supporto/aggiornamento-nodo.</p>
<pre>curl -fsSL <?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8') ?>/netgescon-node-bootstrap.php -o /tmp/netgescon-node-bootstrap.sh
sudo bash /tmp/netgescon-node-bootstrap.sh \
--site-key netgescon-prod \
--repo-url git@your-gitea:studio/netgescon.git \
--branch main \
--app-url <?php echo htmlspecialchars($scheme . '://' . $host, ENT_QUOTES, 'UTF-8') ?> \
--server-name <?php echo htmlspecialchars($host, ENT_QUOTES, 'UTF-8') ?> \
--db-password 'StrongDbPass123!' \
--admin-email admin@netgescon.local \
--admin-password 'StrongAdminPass123!'</pre>
</div>
<div class="card">
<h2>Endpoint diretti</h2>
<ul>
<li><a href="<?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8') ?>/ubuntu2404-laravel-base.php"><?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8') ?>/ubuntu2404-laravel-base.php</a></li>
<li><a href="<?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8') ?>/laravel-site-bootstrap.php"><?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8') ?>/laravel-site-bootstrap.php</a></li>
<li><a href="<?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8') ?>/netgescon-node-bootstrap.php"><?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8') ?>/netgescon-node-bootstrap.php</a></li>
</ul>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,19 @@
<?php
declare (strict_types = 1);
$scriptPath = dirname(__DIR__, 2) . '/scripts/install/laravel-site-bootstrap.sh';
if (! is_file($scriptPath) || ! is_readable($scriptPath)) {
http_response_code(404);
header('Content-Type: text/plain; charset=UTF-8');
echo "Installer script not found.\n";
exit;
}
header('Content-Type: text/plain; charset=UTF-8');
header('Content-Disposition: inline; filename="laravel-site-bootstrap.sh"');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
readfile($scriptPath);

View File

@ -0,0 +1,19 @@
<?php
declare (strict_types = 1);
$scriptPath = dirname(__DIR__, 2) . '/scripts/install/netgescon-node-bootstrap.sh';
if (! is_file($scriptPath) || ! is_readable($scriptPath)) {
http_response_code(404);
header('Content-Type: text/plain; charset=UTF-8');
echo "Installer script not found.\n";
exit;
}
header('Content-Type: text/plain; charset=UTF-8');
header('Content-Disposition: inline; filename="netgescon-node-bootstrap.sh"');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
readfile($scriptPath);

View File

@ -0,0 +1,19 @@
<?php
declare (strict_types = 1);
$scriptPath = dirname(__DIR__, 2) . '/scripts/install/ubuntu2404-laravel-base.sh';
if (! is_file($scriptPath) || ! is_readable($scriptPath)) {
http_response_code(404);
header('Content-Type: text/plain; charset=UTF-8');
echo "Installer script not found.\n";
exit;
}
header('Content-Type: text/plain; charset=UTF-8');
header('Content-Disposition: inline; filename="ubuntu2404-laravel-base.sh"');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
readfile($scriptPath);

View File

@ -53,6 +53,52 @@ class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semi
</button>
</dd>
</div>
<div class="pt-2 border-t border-blue-200 dark:border-blue-800">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Presa in carico amministrazione:</dt>
<dd class="mt-2 space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
type="date"
wire:model.defer="presaInCaricoAmministrazione"
class="w-full rounded-lg border-gray-300 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
>
<x-filament::button size="sm" color="primary" wire:click="saveAmministrazioneSetup">
Salva data
</x-filament::button>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Le gestioni operative vengono filtrate dall'anno di presa in carico in avanti.
</div>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Verbale di nomina:</dt>
<dd class="mt-2 space-y-3">
<input
type="file"
wire:model="verbaleNominaUpload"
class="block w-full text-sm text-gray-700 dark:text-gray-200"
>
<div class="flex flex-wrap items-center gap-2">
<x-filament::button size="sm" color="gray" wire:click="uploadVerbaleNomina">
Carica verbale
</x-filament::button>
@if($this->getVerbaleNominaDocumento())
<a
href="{{ $this->getVerbaleNominaDocumento()->url_view ?? $this->getVerbaleNominaDocumento()->url_download }}"
target="_blank"
class="inline-flex items-center rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-1.5 text-xs font-semibold text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-100"
>
Allegato presente
</a>
@else
<span class="inline-flex items-center rounded-lg border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-100">
Nessun allegato caricato
</span>
@endif
</div>
</dd>
</div>
</dl>
</div>

View File

@ -0,0 +1,96 @@
<div
class="rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 via-white to-sky-50 p-4"
x-data="{
scannerActive: false,
scannerError: '',
stream: null,
detector: null,
rafId: null,
baseUrl: '/admin-filament/strumenti/documenti?tab=lettore-codici',
openReader(rawValue) {
const value = (rawValue || this.$refs.archiveCode?.value || '').trim();
if (value === '') {
return;
}
window.location.href = `${this.baseUrl}&code=${encodeURIComponent(value)}`;
},
async startScanner() {
this.scannerError = '';
if (!('BarcodeDetector' in window)) {
this.scannerError = 'BarcodeDetector non disponibile in questo browser. Usa il lettore esterno, il telefono o un tag NFC in input testo.';
return;
}
try {
this.detector = new BarcodeDetector({ formats: ['qr_code'] });
this.stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
this.$refs.video.srcObject = this.stream;
await this.$refs.video.play();
this.scannerActive = true;
this.scanFrame();
} catch (error) {
this.scannerError = 'Impossibile avviare la camera: ' + (error?.message || 'permesso negato');
}
},
stopScanner() {
this.scannerActive = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.stream) {
this.stream.getTracks().forEach((track) => track.stop());
this.stream = null;
}
if (this.$refs.video) {
this.$refs.video.srcObject = null;
}
},
async scanFrame() {
if (!this.scannerActive || !this.detector || !this.$refs.video) {
return;
}
try {
const codes = await this.detector.detect(this.$refs.video);
if (codes.length > 0 && (codes[0]?.rawValue || '') !== '') {
this.$refs.archiveCode.value = codes[0].rawValue;
this.openReader(codes[0].rawValue);
this.stopScanner();
return;
}
} catch (error) {
this.scannerError = 'Errore lettura QR: ' + (error?.message || 'lettura non disponibile');
this.stopScanner();
return;
}
this.rafId = requestAnimationFrame(() => this.scanFrame());
},
}"
>
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div>
<div class="text-sm font-semibold text-slate-900">{{ $title ?? 'Lettore QR archivio' }}</div>
<div class="mt-1 text-xs text-slate-600">{{ $description ?? 'Scansiona un QR archivio e apri subito il contenuto del nodo selezionato.' }}</div>
</div>
<div class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm">Visualizza contenuto senza aprire il contenitore</div>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_auto]">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Codice archivio / payload QR / NFC</label>
<input x-ref="archiveCode" type="text" class="w-full rounded-lg border-slate-200 bg-white text-sm shadow-sm" placeholder="Es. AF-20-0001 oppure NGDOC|AF:AF-20-0001|..." x-on:keydown.enter.prevent="openReader()" />
</div>
<button type="button" class="inline-flex items-center self-end rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50" x-on:click="openReader()">Apri lettore</button>
<button type="button" class="inline-flex items-center self-end rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-slate-800" x-on:click="scannerActive ? stopScanner() : startScanner()">
<span x-text="scannerActive ? 'Ferma camera' : 'Leggi QR'"></span>
</button>
</div>
<div class="mt-3 rounded-lg border border-dashed border-slate-300 bg-white/80 p-3" x-show="scannerActive || scannerError !== ''" x-cloak>
<template x-if="scannerActive">
<video x-ref="video" class="aspect-video w-full rounded-lg border border-slate-300 bg-slate-900" playsinline muted></video>
</template>
<template x-if="scannerError !== ''">
<div class="text-xs text-amber-800" x-text="scannerError"></div>
</template>
</div>
</div>

View File

@ -24,4 +24,30 @@
.fi-sidebar-item {
margin-block: 0 !important;
}
@media (max-width: 63.998rem), (hover: none) and (pointer: coarse) {
.fi-body.fi-body-has-navigation .fi-sidebar {
border-top: 0 !important;
width: 100% !important;
height: auto !important;
position: static !important;
inset: auto !important;
max-height: none !important;
transform: none !important;
}
.fi-body.fi-body-has-navigation .fi-topbar,
.fi-body.fi-body-has-navigation .fi-main,
.fi-body.fi-body-has-navigation .fi-main-ctn {
width: 100% !important;
max-width: 100% !important;
margin-left: 0 !important;
padding-left: 0 !important;
}
.fi-body.fi-body-has-navigation .fi-main-ctn {
padding-top: 0 !important;
overflow-x: visible !important;
}
}
</style>

View File

@ -8,30 +8,9 @@
return false;
}
if ((bool) ($stabile->riscaldamento_centralizzato ?? false)) {
return true;
}
$rateRiscaldamento = $stabile->rate_riscaldamento_mesi ?? [];
if (is_string($rateRiscaldamento)) {
$rateRiscaldamento = json_decode($rateRiscaldamento, true);
}
if (is_array($rateRiscaldamento) && $rateRiscaldamento !== []) {
return true;
}
$config = $stabile->configurazione_avanzata ?? [];
if (is_string($config)) {
$config = json_decode($config, true);
}
if (is_array($config) && array_key_exists('mostra_riscaldamento', $config) && $config['mostra_riscaldamento']) {
return true;
}
return \Illuminate\Support\Facades\DB::table('gestioni_contabili')
->where('stabile_id', $stabile->id)
->where('tipo_gestione', 'riscaldamento')
->exists();
return method_exists($stabile, 'hasOperationalHeating')
? $stabile->hasOperationalHeating()
: false;
};
$stabileLabel = static function (?\App\Models\Stabile $stabile): string {
@ -64,20 +43,5 @@
</div>
@endif
@if($stabili->count() > 1)
<form method="POST" action="{{ route('admin-filament.stabile-attivo') }}" class="flex items-center">
@csrf
<select
name="stabile_id"
class="fi-input block w-full min-w-[220px]"
onchange="this.form.submit()"
>
@foreach($stabili as $stabile)
<option value="{{ $stabile->id }}" @selected($activeId === $stabile->id)>
{{ $stabileLabel($stabile) }} {{ $stabile->denominazione }}
</option>
@endforeach
</select>
</form>
@endif
<div class="text-xs text-gray-500">Selettore stabile temporaneamente disattivato.</div>
</div>

View File

@ -421,6 +421,11 @@
<label class="text-xs text-gray-500">Inizio contratto</label>
<input type="date" wire:model="form.inizio_contratto" class="w-full rounded-md border-gray-300" />
</div>
<div>
<label class="text-xs text-gray-500">Presa in carico operativa dal</label>
<input type="date" wire:model="form.presa_in_carico_operativa_dal" class="w-full rounded-md border-gray-300" />
<div class="mt-1 text-[11px] text-gray-500">I canoni precedenti restano fuori dal gestionale operativo e dalla riconciliazione bancaria.</div>
</div>
<div>
<label class="text-xs text-gray-500">Ultimo rinnovo</label>
<input type="date" wire:model="form.ultimo_rinnovo" class="w-full rounded-md border-gray-300" />

View File

@ -0,0 +1,148 @@
<x-filament-panels::page>
@php($stats = $this->stats)
@php($contratti = $this->contrattiRows)
@php($scadenze = $this->upcomingScadenze)
<div class="space-y-6">
<div class="rounded-2xl border border-slate-200 bg-gradient-to-r from-cyan-50 via-white to-emerald-50 p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div class="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">Stabile</div>
<h2 class="mt-2 text-2xl font-semibold text-slate-900">HUB Contratti {{ $this->stabileAttivo?->denominazione ? '· ' . $this->stabileAttivo->denominazione : '' }}</h2>
<p class="mt-2 max-w-3xl text-sm text-slate-600">Qui registriamo il contratto, i riferimenti tecnici e le scadenze operative da inviare anche al calendario. Il collegamento documentale e contabile parte da qui.</p>
</div>
<div class="rounded-xl border border-white/70 bg-white/80 px-4 py-3 shadow-sm">
<div class="text-xs uppercase tracking-wide text-slate-500">Collegamenti</div>
<div class="mt-2 text-sm text-slate-700">Fornitore, manutentore, servizio, utenza, documento e codici tecnici nello stesso record.</div>
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Contratti</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ $stats['totale'] ?? 0 }}</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Attivi</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ $stats['attivi'] ?? 0 }}</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Scadenze 45 gg</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ $stats['scadenze'] ?? 0 }}</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Documenti contratto</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ $stats['documenti'] ?? 0 }}</div>
</div>
</div>
</div>
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.6fr)]">
<x-filament::section>
<x-slot name="heading">Registro contratti</x-slot>
<x-slot name="description">MVP: contratto, riferimenti essenziali, documento principale e generazione scadenze.</x-slot>
<div class="space-y-4">
@forelse($contratti as $contratto)
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div class="flex flex-wrap items-center gap-2">
<div class="text-lg font-semibold text-slate-900">{{ $contratto->titolo }}</div>
<span class="inline-flex items-center rounded-full bg-{{ $this->contractBadgeColor($contratto->stato) }}-50 px-2.5 py-1 text-xs font-semibold text-{{ $this->contractBadgeColor($contratto->stato) }}-700">
{{ strtoupper($contratto->stato) }}
</span>
<span class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700">
{{ $this->contractTypeLabel($contratto->tipo_contratto) }}
</span>
</div>
<div class="mt-2 text-sm text-slate-600">
{{ $contratto->servizio_label ?: 'Servizio non specificato' }}
@if($contratto->fornitore)
· {{ $contratto->fornitore->ragione_sociale ?: trim(($contratto->fornitore->nome ?? '') . ' ' . ($contratto->fornitore->cognome ?? '')) }}
@endif
</div>
</div>
<div class="text-sm text-slate-500 lg:text-right">
<div>Decorrenza: {{ $contratto->decorrenza_dal?->format('d/m/Y') ?: '—' }}</div>
<div>Fine: {{ $contratto->decorrenza_al?->format('d/m/Y') ?: ($contratto->rinnovo_automatico ? 'rinnovo automatico' : '—') }}</div>
<div>Frequenza: {{ $contratto->frequenza_scadenze ? str_replace('_', ' ', $contratto->frequenza_scadenze) : '—' }}</div>
</div>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4 text-sm">
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">Codice</div>
<div class="mt-1 font-medium text-slate-900">{{ $contratto->codice_contratto ?: '—' }}</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">Riferimento</div>
<div class="mt-1 font-medium text-slate-900">{{ $contratto->riferimento_esterno ?: '—' }}</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">Importo</div>
<div class="mt-1 font-medium text-slate-900">{{ $contratto->importo_periodico !== null ? '€ ' . number_format((float) $contratto->importo_periodico, 2, ',', '.') : '—' }}</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">Documento</div>
<div class="mt-1 font-medium text-slate-900">{{ $contratto->documentoPrincipale?->numero_protocollo ?: ($contratto->documentoPrincipale?->nome ?: '—') }}</div>
</div>
</div>
@if(!empty($contratto->codici_collegati))
<div class="mt-4 flex flex-wrap gap-2">
@foreach($contratto->codici_collegati as $code)
<span class="inline-flex items-center rounded-full bg-cyan-50 px-3 py-1 text-xs font-medium text-cyan-800">
{{ $code['chiave'] ?? 'Dato' }}: {{ $code['valore'] ?? '—' }}
</span>
@endforeach
</div>
@endif
@if($contratto->note)
<div class="mt-4 rounded-lg bg-slate-50 px-3 py-2 text-sm text-slate-600">{{ $contratto->note }}</div>
@endif
</div>
@empty
<div class="rounded-xl border border-dashed bg-white px-4 py-10 text-center text-sm text-slate-500">Nessun contratto registrato per lo stabile attivo.</div>
@endforelse
</div>
</x-filament::section>
<div class="space-y-6">
<x-filament::section>
<x-slot name="heading">Scadenze programmate</x-slot>
<x-slot name="description">Le nuove scadenze vengono registrate sulla tabella calendario operativa.</x-slot>
<div class="space-y-3">
@forelse($scadenze as $scadenza)
<div class="rounded-xl border bg-white px-4 py-3">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">{{ $scadenza->stabileContratto?->titolo ?: $scadenza->descrizione_sintetica }}</div>
<div class="mt-1 text-xs text-slate-500">{{ $scadenza->stabileContratto?->tipo_contratto ? $this->contractTypeLabel($scadenza->stabileContratto->tipo_contratto) : 'Scadenza contratto' }}</div>
</div>
<div class="text-sm font-semibold text-slate-900">{{ $scadenza->data_scadenza?->format('d/m/Y') ?: '—' }}</div>
</div>
@if(!empty($scadenza->legacy_payload['codice_contratto']))
<div class="mt-2 text-xs text-slate-500">Codice: {{ $scadenza->legacy_payload['codice_contratto'] }}</div>
@endif
</div>
@empty
<div class="rounded-xl border border-dashed bg-white px-4 py-6 text-sm text-slate-500">Nessuna scadenza contrattuale registrata.</div>
@endforelse
</div>
</x-filament::section>
<x-filament::section>
<x-slot name="heading">Copertura dati</x-slot>
<div class="space-y-3 text-sm text-slate-600">
<div class="rounded-xl border bg-white px-4 py-3">Per acqua, energia e altri impianti usa i codici collegati per registrare POD, PDR, matricole, codice cliente, numero utenza e riferimenti bolletta.</div>
<div class="rounded-xl border bg-white px-4 py-3">Il SIA resta sul fornitore; qui teniamo i dati specifici del singolo contratto dello stabile.</div>
<div class="rounded-xl border bg-white px-4 py-3">Il documento principale può essere un PDF già registrato nellarchivio documentale con categoria contratto.</div>
</div>
</x-filament::section>
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,90 @@
<x-filament-panels::page>
<div class="space-y-4">
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-base font-semibold text-slate-900">Gestione documentale mobile</div>
<div class="mt-1 text-xs text-slate-600">Pagina dedicata alla lettura QR, al codice mnemonico e all'apertura rapida del contenuto archivistico da telefono o tablet.</div>
</div>
@include('filament.components.archive-code-launcher', [
'title' => 'Lettore documenti mobile',
'description' => 'Scansiona il QR o inserisci il codice mnemonico per aprire il contenuto della cartella, del faldone o della scatola nel lettore archivio.',
])
<div class="rounded-xl border border-slate-200 bg-white p-4"
x-data="{
nfcSupported: 'NDEFReader' in window,
nfcReading: false,
nfcMessage: '',
async startNfc() {
if (!this.nfcSupported) {
this.nfcMessage = 'Web NFC non disponibile in questo browser. Su iPhone Safari al momento non e esposto: usa QR o codice mnemonico.';
return;
}
try {
const reader = new NDEFReader();
await reader.scan();
this.nfcReading = true;
this.nfcMessage = 'Avvicina il tag NFC al telefono.';
reader.onreading = ({ message }) => {
const record = message.records?.[0];
if (!record) {
return;
}
let value = '';
if (record.recordType === 'text') {
value = new TextDecoder(record.encoding || 'utf-8').decode(record.data);
} else {
value = new TextDecoder('utf-8').decode(record.data);
}
window.location.href = `/admin-filament/strumenti/documenti?tab=lettore-codici&code=${encodeURIComponent(value)}`;
};
} catch (error) {
this.nfcReading = false;
this.nfcMessage = 'Lettura NFC non avviata: ' + (error?.message || 'permesso negato');
}
},
}"
>
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div>
<div class="text-sm font-semibold text-slate-900">Prova NFC da browser</div>
<div class="mt-1 text-xs text-slate-600">Facciamo test progressivi: sui browser compatibili puoi avviare la lettura del tag NFC direttamente dalla pagina e aprire il lettore archivio con il codice letto.</div>
</div>
<div class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700" x-text="nfcSupported ? 'Web NFC disponibile' : 'Web NFC non disponibile'"></div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" x-on:click="startNfc()" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Avvia lettura NFC</button>
<a href="{{ \App\Filament\Pages\Strumenti\DocumentiArchivio::getUrl(panel: 'admin-filament') }}?tab=lettore-codici" class="inline-flex items-center rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-slate-800">Apri lettore completo</a>
</div>
<div class="mt-3 rounded-lg border border-dashed border-slate-300 bg-slate-50 px-3 py-3 text-xs text-slate-600" x-text="nfcMessage !== '' ? nfcMessage : 'Sugli iPhone via Safari il Web NFC normalmente non e disponibile: per ora il percorso affidabile resta QR o codice mnemonico.'"></div>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Contenuto archivio</div>
<div class="mt-1 text-xs text-slate-600">Apri il lettore archivio per vedere catena contenitori, documenti interni e ubicazione del nodo scansionato.</div>
<div class="mt-3">
<a href="{{ \App\Filament\Pages\Strumenti\DocumentiArchivio::getUrl(panel: 'admin-filament') }}?tab=lettore-codici" class="inline-flex items-center rounded-md bg-sky-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-600">Apri contenuto</a>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Archivio digitale</div>
<div class="mt-1 text-xs text-slate-600">Apri l'archivio digitale per vedere la lista documenti collegata al contenitore o al protocollo trovato.</div>
<div class="mt-3">
<a href="{{ \App\Filament\Pages\Strumenti\DocumentiArchivio::getUrl(panel: 'admin-filament') }}?tab=archivio-digitale" class="inline-flex items-center rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600">Apri archivio digitale</a>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Movimentazione</div>
<div class="mt-1 text-xs text-slate-600">Dal lettore puoi poi passare alla movimentazione per spostare il contenitore in altro faldone, scaffale o postazione.</div>
<div class="mt-3">
<a href="{{ \App\Filament\Pages\Strumenti\DocumentiArchivio::getUrl(panel: 'admin-filament') }}?tab=movimentazione-codici" class="inline-flex items-center rounded-md bg-amber-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-600">Apri movimentazione</a>
</div>
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -6,6 +6,7 @@
'scansione-documenti' => 'Scansione documenti',
'etichette-generiche' => 'Etichette generiche',
'movimentazione-codici' => 'Movimentazione per codice',
'lettore-codici' => 'Lettore codici',
'template-drive' => 'Template Drive',
'archivio-fisico' => 'Archivio fisico',
'permessi' => 'Permessi e cartelle',
@ -72,7 +73,7 @@
@if($this->hubTab === 'protocollo-comunicazioni')
<x-filament::section>
<x-slot name="heading">Protocollo comunicazioni</x-slot>
<x-slot name="description">Prima tab operativa per l'amministratore: ultimi documenti protocollati, filtri rapidi e vista a box con anteprima PDF o lista compatta.</x-slot>
<x-slot name="description">Prima tab operativa per l'amministratore: ultimi documenti protocollati, filtri rapidi e vista a box universale per PDF, immagini, Office e allegati testuali.</x-slot>
<div class="space-y-4">
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
@ -82,9 +83,9 @@
<div class="mt-1 text-sm text-slate-500">Documenti non fattura nello stabile attivo.</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Con PDF</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ $protocolloStats['con_pdf'] ?? 0 }}</div>
<div class="mt-1 text-sm text-slate-500">Apribili in anteprima o stampa.</div>
<div class="text-xs uppercase tracking-wide text-slate-500">Con allegato</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ $protocolloStats['con_file'] ?? 0 }}</div>
<div class="mt-1 text-sm text-slate-500">Apribili inline o scaricabili secondo il tipo file.</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Questo mese</div>
@ -144,39 +145,47 @@
</div>
@if($this->protocolloView === 'cards')
<div class="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
@forelse($this->protocolloComunicazioniRows as $row)
<article class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div class="space-y-3 p-4">
<div class="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-slate-700">{{ $row['protocollo'] }}</span>
<span>{{ $row['categoria'] }}</span>
<span class="rounded-full bg-sky-50 px-2.5 py-1 text-sky-700">{{ $row['file_badge'] }}</span>
<span>{{ $row['data'] }}</span>
</div>
<div>
<h3 class="text-base font-semibold text-slate-900">{{ $row['titolo'] }}</h3>
<p class="mt-1 text-sm text-slate-600">{{ $row['descrizione'] !== '' ? $row['descrizione'] : 'Nessuna descrizione registrata.' }}</p>
<h3 class="line-clamp-2 text-sm font-semibold text-slate-900">{{ $row['titolo'] }}</h3>
<p class="mt-1 line-clamp-3 text-xs text-slate-600">{{ $row['descrizione'] !== '' ? $row['descrizione'] : 'Nessuna descrizione registrata.' }}</p>
</div>
<div class="space-y-1 text-xs text-slate-500">
<div class="space-y-1 text-[11px] text-slate-500">
<div>Cartella: {{ $row['cartella'] }}</div>
@if($row['fornitore'] !== '')
<div>Ente / referente: {{ $row['fornitore'] }}</div>
@endif
@if($row['archive_reference'] !== '')
@if($row['archive_code'] !== '')
<div>Codice archivio: {{ $row['archive_code'] }}</div>
@endif
@if($row['archive_reference'] !== '' && $row['archive_reference'] !== $row['archive_code'])
<div>Rif. archivio: {{ $row['archive_reference'] }}</div>
@endif
</div>
<div class="flex flex-wrap gap-2 pt-1">
<a href="{{ $row['preview_url'] }}" target="_blank" class="inline-flex items-center rounded-lg bg-slate-900 px-3 py-2 text-xs font-medium text-white hover:bg-slate-800">Apri</a>
<a href="{{ $row['download_url'] }}" target="_blank" class="inline-flex 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">Scarica</a>
@if($row['has_pdf'])
@if($row['has_file'] && $row['preview_url'])
<a href="{{ $row['preview_url'] }}" target="_blank" class="inline-flex items-center rounded-lg bg-slate-900 px-3 py-2 text-xs font-medium text-white hover:bg-slate-800">Apri</a>
<a href="{{ $row['download_url'] }}" target="_blank" class="inline-flex 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">Scarica</a>
@else
<span class="inline-flex items-center rounded-lg border border-dashed border-slate-300 bg-slate-50 px-3 py-2 text-xs font-medium text-slate-500">Solo metadati</span>
@endif
@if($row['can_print'] && $row['print_url'])
<a href="{{ $row['print_url'] }}" target="_blank" class="inline-flex 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">Stampa</a>
@endif
</div>
</div>
</article>
@empty
<div class="rounded-xl border border-dashed border-slate-300 bg-white px-4 py-10 text-sm text-slate-500 md:col-span-2 2xl:col-span-3">
<div class="rounded-xl border border-dashed border-slate-300 bg-white px-4 py-10 text-sm text-slate-500 md:col-span-2 xl:col-span-4">
Nessun documento protocollato trovato con i filtri correnti.
</div>
@endforelse
@ -199,8 +208,11 @@
<div class="min-w-0">
<div class="truncate font-semibold text-slate-900">{{ $row['titolo'] }}</div>
<div class="mt-1 text-xs text-slate-500">{{ $row['descrizione'] !== '' ? $row['descrizione'] : 'Nessuna descrizione registrata.' }}</div>
@if($row['archive_reference'] !== '')
<div class="mt-1 text-xs text-sky-700">{{ $row['archive_reference'] }}</div>
@if($row['archive_code'] !== '')
<div class="mt-1 text-xs text-sky-700">{{ $row['archive_code'] }}</div>
@endif
@if($row['archive_reference'] !== '' && $row['archive_reference'] !== $row['archive_code'])
<div class="mt-1 text-[11px] text-slate-500">{{ $row['archive_reference'] }}</div>
@endif
</div>
<div>
@ -208,8 +220,10 @@
<div class="mt-1 text-xs text-slate-500">{{ $row['cartella'] }}</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ $row['preview_url'] }}" target="_blank" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">Apri</a>
@if($row['has_pdf'])
@if($row['has_file'] && $row['preview_url'])
<a href="{{ $row['preview_url'] }}" target="_blank" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">Apri</a>
@endif
@if($row['can_print'] && $row['print_url'])
<a href="{{ $row['print_url'] }}" target="_blank" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">Stampa</a>
@endif
</div>
@ -437,143 +451,7 @@ class="rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm tran
</div>
</x-filament::section>
@elseif($this->hubTab === 'etichette-generiche')
<x-filament::section>
<x-slot name="heading">Etichette generiche per contenitori</x-slot>
<x-slot name="description">Tab dedicata alla stampa rapida di etichette per faldoni, scatole, raccoglitori e buste. Il QR resta obbligatorio e il codice generato diventa il riferimento stabile per annidamento e scansione.</x-slot>
<div class="grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<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">Nuova etichetta generica</div>
<div class="mt-1 text-xs text-slate-500">Crea il contenitore, stampa subito e poi usalo come nodo della catena QR: fattura, cartella, dock, scatola, magazzino, scaffale.</div>
</div>
<div class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">QR incluso</div>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<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.defer="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.defer="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.defer="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.defer="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.defer="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.defer="genericLabelForm.supporto_fisico" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. Scatola o raccoglitore" />
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Link acquisto / Amazon</label>
<input type="text" wire:model.defer="genericLabelForm.amazon_url" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="URL completo o ASIN Amazon" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Magazzino</label>
<input type="text" wire:model.defer="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.defer="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.defer="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.defer="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.defer="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">
<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 e prepara la stampa</button>
</div>
</div>
<div class="space-y-4">
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Standard operativo</div>
<div class="mt-3 grid gap-3 text-sm text-slate-600 md:grid-cols-2">
<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.</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">Il QR usa lo stesso schema pubblico gia host-agnostic del resto dell'archivio fisico.</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">Il link Amazon puo essere salvato come URL o come ASIN, con tag affiliato aggiunto dal super-admin.</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">Questa tab rende visibile la nuova etichetta senza doverla cercare dentro il registro generale.</div>
</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">Stampa immediata 11354 / 99014</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-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="flex shrink-0 flex-wrap items-center gap-2">
<a href="{{ route('filament.archivio-fisico.etichetta', ['item' => $row['id']]) }}?format=11354&autoprint=1&dymo_fix=0&dymo_rot=0&dymo_dx=0&dymo_dy=-1" target="_blank" class="inline-flex 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">11354</a>
<a href="{{ route('filament.archivio-fisico.etichetta', ['item' => $row['id']]) }}?format=11354&autoprint=1&dymo_fix=1&dymo_rot=90&dymo_dx=0&dymo_dy=0" target="_blank" class="inline-flex 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">11354 verticale</a>
<a href="{{ route('filament.archivio-fisico.etichetta', ['item' => $row['id']]) }}?format=99014&autoprint=1&dymo_fix=0&dymo_rot=0&dymo_dx=3.5&dymo_dy=3.5" target="_blank" class="inline-flex 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">99014</a>
</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>
@include('filament.pages.strumenti.partials.generic-label-builder')
@elseif($this->hubTab === 'movimentazione-codici')
<x-filament::section>
<x-slot name="heading">Movimentazione per codice</x-slot>
@ -583,7 +461,7 @@ class="rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm tran
<div class="space-y-4">
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Sposta per codice</div>
<div class="mt-1 text-xs text-slate-500">Scansiona o inserisci codice sorgente e codice destinazione. Il contenitore padre viene aggiornato senza dover aprire il registro generale.</div>
<div class="mt-1 text-xs text-slate-500">Scansiona o inserisci codice sorgente e codice destinazione. Qui aggiorni anche supporto e ubicazione operativa senza rientrare nella scheda di censimento.</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">Codice sorgente</label>
@ -594,6 +472,42 @@ class="rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm tran
<input type="text" wire:model.defer="physicalArchiveMoveForm.target_code" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. AF-20-0010" />
</div>
</div>
<div class="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">Ubicazione dopo lo spostamento</div>
<div class="mt-1 text-xs text-slate-500">Se lasci i campi vuoti, il sistema eredita la posizione del contenitore destinazione e conserva il supporto attuale.</div>
</div>
<div class="rounded-full bg-white px-3 py-1 text-[11px] font-semibold text-slate-600">Operativo</div>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Supporto operativo</label>
<select wire:model.defer="physicalArchiveMoveForm.supporto_fisico" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
<option value="">Mantieni attuale</option>
@foreach($this->archivioFisicoTypes as $type)
<option value="{{ $type }}">{{ $type }}</option>
@endforeach
</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.defer="physicalArchiveMoveForm.magazzino" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Eredita dal contenitore" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Scaffale</label>
<input type="text" wire:model.defer="physicalArchiveMoveForm.scaffale" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Eredita dal contenitore" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Ripiano</label>
<input type="text" wire:model.defer="physicalArchiveMoveForm.ripiano" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Eredita dal contenitore" />
</div>
<div class="md:col-span-2 xl:col-span-2">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Dettaglio ubicazione</label>
<input type="text" wire:model.defer="physicalArchiveMoveForm.ubicazione_dettaglio" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. lato finestra, mobile sinistro, archivio storico" />
</div>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" wire:click="movePhysicalArchiveItem" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm transition hover:border-slate-400 hover:bg-slate-50">Registra movimentazione</button>
</div>
@ -601,7 +515,7 @@ class="rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm tran
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div class="text-sm font-semibold text-amber-900">Catena QR prevista</div>
<div class="mt-2 text-sm text-amber-800">QR Fattura -> QR Cartella trasparente -> QR Dock -> QR Scatola -> QR Magazzino -> QR Scaffale. Questa tab separa il movimento dal censimento del contenuto.</div>
<div class="mt-2 text-sm text-amber-800">QR Foglio -> QR Cartellina -> QR Busta ad anelli -> QR Faldone -> QR Faldone con lacci -> QR Scatola -> QR Scaffale -> QR Posto. Questa tab separa il movimento dal censimento del contenuto.</div>
</div>
</div>
@ -621,6 +535,229 @@ class="rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm tran
</div>
</div>
</x-filament::section>
@elseif($this->hubTab === 'lettore-codici')
<x-filament::section>
<x-slot name="heading">Lettore codici archivio</x-slot>
<x-slot name="description">Scansiona un QR, passa un tag NFC o inserisci un codice archivio per vedere subito il contenuto di una cartella, di un faldone o di una scatola.</x-slot>
@php
$selectedLookupItem = $this->archiveLookupSelectedItem;
$selectedLookupChain = $this->archiveLookupSelectedChain;
@endphp
<div class="grid gap-4 xl:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
<div class="space-y-4">
<div class="rounded-xl border bg-white p-4"
x-data="{
scannerActive: false,
scannerError: '',
stream: null,
detector: null,
rafId: null,
async startScanner() {
this.scannerError = '';
if (!('BarcodeDetector' in window)) {
this.scannerError = 'Questo browser non espone BarcodeDetector. Usa il lettore esterno, il QR scanner del telefono o l\'NFC come input testo.';
return;
}
try {
this.detector = new BarcodeDetector({ formats: ['qr_code'] });
this.stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
this.$refs.video.srcObject = this.stream;
await this.$refs.video.play();
this.scannerActive = true;
this.scanFrame();
} catch (error) {
this.scannerError = 'Impossibile avviare la camera: ' + (error?.message || 'permesso negato');
}
},
stopScanner() {
this.scannerActive = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.stream) {
this.stream.getTracks().forEach((track) => track.stop());
this.stream = null;
}
if (this.$refs.video) {
this.$refs.video.srcObject = null;
}
},
async scanFrame() {
if (!this.scannerActive || !this.detector || !this.$refs.video) {
return;
}
try {
const codes = await this.detector.detect(this.$refs.video);
if (codes.length > 0) {
const rawValue = codes[0]?.rawValue || '';
if (rawValue !== '') {
this.$refs.codeInput.value = rawValue;
this.$refs.codeInput.dispatchEvent(new Event('input', { bubbles: true }));
this.stopScanner();
return;
}
}
} catch (error) {
this.scannerError = 'Errore lettura QR: ' + (error?.message || 'lettura non disponibile');
this.stopScanner();
return;
}
this.rafId = requestAnimationFrame(() => this.scanFrame());
},
}"
>
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div>
<div class="text-sm font-semibold text-slate-900">Leggi QR / NFC / codice archivio</div>
<div class="mt-1 text-xs text-slate-500">Il sistema estrae il codice archivio dal payload QR e ti mostra subito padre, contenuto interno e documento digitale collegato.</div>
</div>
<div class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">QR e NFC ready</div>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_auto]">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Codice o payload letto</label>
<input x-ref="codeInput" type="text" wire:model.live.debounce.150ms="archiveLookupCode" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. AF-20-0001 oppure NGDOC|AF:AF-20-0001|TIPO:SCATOLA|..." />
</div>
<button type="button" wire:click="openArchiveLookupFromCode" class="inline-flex items-center self-end rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Apri contenuto</button>
<button type="button" x-on:click="scannerActive ? stopScanner() : startScanner()" class="inline-flex items-center self-end rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-slate-800">
<span x-text="scannerActive ? 'Ferma camera' : 'Leggi QR con camera'"></span>
</button>
</div>
<div class="mt-3 rounded-lg border border-dashed border-slate-300 bg-slate-50 p-3" x-show="scannerActive || scannerError !== ''" x-cloak>
<template x-if="scannerActive">
<video x-ref="video" class="aspect-video w-full rounded-lg border border-slate-300 bg-slate-900" playsinline muted></video>
</template>
<template x-if="scannerError !== ''">
<div class="text-xs text-amber-800" x-text="scannerError"></div>
</template>
</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">Ricerca archivio</label>
<input type="text" wire:model.live.debounce.250ms="archiveLookupSearch" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Codice, titolo, nota, scaffale, payload QR..." />
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-xs text-slate-600">
Usa questa pagina anche per i contenitori di trasferimento amministratore: i protocolli si muovono nel contenitore generale senza cambiare i QR già stampati.
</div>
</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">Risultati archivio</div>
<div class="text-xs text-slate-500">{{ count($this->archiveLookupResults) }} risultati</div>
</div>
<div class="mt-4 space-y-3">
@forelse($this->archiveLookupResults as $row)
<button type="button" wire:click="selectArchiveLookupItem({{ $row['id'] }})" class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-left hover:border-sky-300 hover:bg-sky-50/40">
<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>
<span class="text-[11px] text-slate-500">{{ $row['children_count'] }} elementi interni</span>
</div>
<div class="mt-2 text-sm font-semibold text-slate-900">{{ $row['titolo'] }}</div>
<div class="mt-1 text-xs text-slate-500">Padre: {{ $row['parent'] }}</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['documento'])
<div class="mt-1 text-xs text-sky-700">Documento digitale: {{ $row['documento'] }}</div>
@endif
</button>
@empty
<div class="rounded-lg border border-dashed border-slate-300 bg-white px-4 py-6 text-sm text-slate-500">Nessun nodo archivio trovato con i filtri correnti.</div>
@endforelse
</div>
</div>
</div>
<div class="space-y-4">
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Contenuto del nodo selezionato</div>
@if($selectedLookupItem)
<div class="mt-4 space-y-4">
<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">{{ $selectedLookupItem->codice_univoco }}</span>
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-700">{{ $selectedLookupItem->tipo_label }}</span>
@if($selectedLookupItem->nfc_reference)
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[11px] font-medium text-emerald-700">NFC {{ $selectedLookupItem->nfc_reference }}</span>
@endif
</div>
<div>
<div class="text-lg font-semibold text-slate-900">{{ $selectedLookupItem->titolo }}</div>
<div class="mt-1 text-sm text-slate-500">{{ $selectedLookupItem->percorso_fisico }}</div>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-xs text-slate-600">
Payload QR: {{ $selectedLookupItem->qr_code_data }}
</div>
@if($selectedLookupChain !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Catena contenitori</div>
<div class="mt-2 flex flex-wrap gap-2">
@foreach($selectedLookupChain as $node)
<button type="button" wire:click="selectArchiveLookupItem({{ $node['id'] }})" class="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:border-sky-300 hover:text-sky-700">
{{ $node['codice_univoco'] }} · {{ $node['titolo'] }}
</button>
@endforeach
</div>
</div>
@endif
<div class="grid gap-3 md:grid-cols-2">
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-700">
<div class="text-xs uppercase tracking-wide text-slate-500">Ubicazione</div>
<div class="mt-2 font-medium">{{ collect([$selectedLookupItem->magazzino, $selectedLookupItem->scaffale, $selectedLookupItem->ripiano, $selectedLookupItem->ubicazione_dettaglio])->filter()->implode(' · ') ?: 'Non definita' }}</div>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-700">
<div class="text-xs uppercase tracking-wide text-slate-500">Data archiviazione</div>
<div class="mt-2 font-medium">{{ $selectedLookupItem->data_archiviazione?->format('d-m-Y') ?: '—' }}</div>
</div>
</div>
@if($selectedLookupItem->documento)
<div class="rounded-lg border border-slate-200 bg-white px-3 py-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Documento digitale collegato</div>
<div class="mt-2 text-sm font-medium text-slate-900">{{ $selectedLookupItem->documento->nome_file ?: $selectedLookupItem->documento->nome }}</div>
<div class="mt-1 text-xs text-slate-500">{{ $selectedLookupItem->documento->resolveArchiveDisplayReference() }}</div>
</div>
@endif
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Contenuto interno</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
@forelse($selectedLookupItem->children as $child)
<button type="button" wire:click="selectArchiveLookupItem({{ $child->id }})" class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-left hover:border-sky-300 hover:bg-sky-50/40">
<div class="text-sm font-semibold text-slate-900">{{ $child->codice_univoco }}</div>
<div class="mt-1 text-sm text-slate-700">{{ $child->titolo }}</div>
<div class="mt-1 text-xs text-slate-500">{{ $child->tipo_label }}</div>
@if($child->documento)
<div class="mt-1 text-xs text-sky-700">{{ $child->documento->nome_file ?: $child->documento->nome }}</div>
@endif
</button>
@empty
<div class="rounded-lg border border-dashed border-slate-300 bg-white px-4 py-6 text-sm text-slate-500 md:col-span-2">Questo nodo non contiene ancora altri elementi interni.</div>
@endforelse
</div>
</div>
</div>
@else
<div class="mt-4 rounded-lg border border-dashed border-slate-300 bg-slate-50 px-4 py-8 text-sm text-slate-500">Scansiona un QR, passa un tag NFC o seleziona un risultato a sinistra per vedere il contenuto del contenitore.</div>
@endif
</div>
</div>
</div>
</x-filament::section>
@elseif($this->hubTab === 'template-drive')
<x-filament::section>
<x-slot name="heading">Template cartelle Drive</x-slot>
@ -756,21 +893,8 @@ class="rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm tran
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Nota</label>
<textarea wire:model.defer="physicalArchiveForm.note" rows="3" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Elenco fogli, riferimento verbale, contenuto della busta..."></textarea>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Magazzino</label>
<input type="text" wire:model.defer="physicalArchiveForm.magazzino" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. Sede 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.defer="physicalArchiveForm.scaffale" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. A3" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Ripiano</label>
<input type="text" wire:model.defer="physicalArchiveForm.ripiano" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. 2" />
</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.defer="physicalArchiveForm.ubicazione_dettaglio" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. lato finestra" />
<div class="md:col-span-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
Dopo il primo censimento usa la tab <span class="font-semibold text-slate-900">Movimentazione per codice</span> per assegnare supporto operativo, magazzino, scaffale, ripiano e dettaglio ubicazione ai passaggi successivi.
</div>
</div>

View File

@ -18,6 +18,13 @@
'sky' => 'from-sky-600 to-sky-500',
default => 'from-slate-700 to-slate-600',
};
$fontSizeMap = ['xs' => 10, 'sm' => 12, 'md' => 14, 'lg' => 18, 'xl' => 24];
$fontSizes = is_array($preview['font_sizes'] ?? null) ? $preview['font_sizes'] : [];
$mnemonicFontPx = $fontSizeMap[$fontSizes['mnemonic'] ?? 'xl'] ?? 24;
$titleFontPx = $fontSizeMap[$fontSizes['title'] ?? 'xl'] ?? 24;
$subtitleFontPx = $fontSizeMap[$fontSizes['subtitle'] ?? 'md'] ?? 14;
$linesFontPx = $fontSizeMap[$fontSizes['lines'] ?? 'md'] ?? 14;
$noteFontPx = $fontSizeMap[$fontSizes['note'] ?? 'sm'] ?? 12;
@endphp
<div class="space-y-4">
@ -67,18 +74,48 @@
@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 class="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px]">
<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>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Grandezza codice</label>
<select wire:model.live="genericLabelForm.mnemonic_font_size" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->genericLabelFontSizeOptions as $sizeKey => $sizeLabel)
<option value="{{ $sizeKey }}">{{ $sizeLabel }}</option>
@endforeach
</select>
</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 class="md:col-span-2 grid gap-3 md:grid-cols-[minmax(0,1fr)_180px]">
<div>
<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>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Grandezza titolo</label>
<select wire:model.live="genericLabelForm.title_font_size" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->genericLabelFontSizeOptions as $sizeKey => $sizeLabel)
<option value="{{ $sizeKey }}">{{ $sizeLabel }}</option>
@endforeach
</select>
</div>
</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 class="md:col-span-2 grid gap-3 md:grid-cols-[minmax(0,1fr)_180px]">
<div>
<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">Grandezza seconda riga</label>
<select wire:model.live="genericLabelForm.subtitle_font_size" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->genericLabelFontSizeOptions as $sizeKey => $sizeLabel)
<option value="{{ $sizeKey }}">{{ $sizeLabel }}</option>
@endforeach
</select>
</div>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Tipologia</label>
@ -111,31 +148,53 @@
<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 class="md:col-span-2 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<div class="text-sm font-semibold text-slate-900">Quattro righe configurabili</div>
<div class="mt-1 text-xs text-slate-500">Per ogni riga scegli se leggere un campo, scrivere testo libero, lasciare la riga bianca o disattivarla.</div>
</div>
<div class="w-full lg:max-w-[180px]">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Grandezza righe</label>
<select wire:model.live="genericLabelForm.lines_font_size" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->genericLabelFontSizeOptions as $sizeKey => $sizeLabel)
<option value="{{ $sizeKey }}">{{ $sizeLabel }}</option>
@endforeach
</select>
</div>
</div>
<div class="mt-4 grid gap-3">
@for($index = 1; $index <= 4; $index++)
@php
$modeKey = 'line_' . $index . '_mode';
$sourceKey = 'line_' . $index . '_source';
$textKey = 'line_' . $index . '_text';
$currentMode = data_get($this->genericLabelForm, $modeKey, 'none');
@endphp
<div class="grid gap-3 rounded-lg border border-slate-200 bg-white p-3 xl:grid-cols-[160px_minmax(0,1fr)_minmax(0,1fr)]">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Riga {{ $index }}</label>
<select wire:model.live="genericLabelForm.{{ $modeKey }}" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->genericLabelLineModeOptions as $modeValue => $modeLabel)
<option value="{{ $modeValue }}">{{ $modeLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Campo archivio</label>
<select wire:model.live="genericLabelForm.{{ $sourceKey }}" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" {{ $currentMode !== 'source' ? 'disabled' : '' }}>
@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">Testo libero</label>
<input type="text" wire:model.live.debounce.150ms="genericLabelForm.{{ $textKey }}" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Scrivi il testo della riga" {{ $currentMode !== 'text' ? 'disabled' : '' }} />
</div>
</div>
@endfor
</div>
</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>
@ -211,9 +270,19 @@
<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 class="md:col-span-2 grid gap-3 md:grid-cols-[minmax(0,1fr)_180px]">
<div>
<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>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Grandezza note</label>
<select wire:model.live="genericLabelForm.note_font_size" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->genericLabelFontSizeOptions as $sizeKey => $sizeLabel)
<option value="{{ $sizeKey }}">{{ $sizeLabel }}</option>
@endforeach
</select>
</div>
</div>
</div>
@ -240,11 +309,11 @@
<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="border-b-2 border-slate-900 pb-2 font-black uppercase tracking-[0.16em] text-slate-900" style="font-size: {{ $mnemonicFontPx }}px;">{{ $preview['mnemonic_code'] ?? ($this->mnemonicCodeHint ?? 'GER00079') }}</div>
<div class="mt-3 font-bold uppercase leading-tight text-slate-900" style="font-size: {{ $titleFontPx }}px;">{{ data_get($preview, 'slots.title') !== '' ? data_get($preview, 'slots.title') : 'Titolo etichetta' }}</div>
<div class="mt-2 font-medium leading-snug text-slate-700" style="font-size: {{ $subtitleFontPx }}px;">{{ 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">
<div class="mt-4 space-y-2 leading-snug text-slate-700" style="font-size: {{ $linesFontPx }}px;">
@foreach(($preview['stable_lines'] ?? []) as $stableLine)
@if($stableLine === '__BLANK_LINE__')
<div class="h-5 border-b border-slate-400"></div>
@ -261,7 +330,7 @@
</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>
<div class="mt-4 rounded-lg border border-dashed border-slate-300 px-3 py-2 leading-relaxed text-slate-600" style="font-size: {{ $noteFontPx }}px;">{{ data_get($preview, 'slots.note') }}</div>
@endif
</div>
@ -278,16 +347,27 @@
@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>
<div class="font-semibold uppercase tracking-[0.25em] text-slate-500" style="font-size: {{ max(10, $mnemonicFontPx - 10) }}px;">{{ $preview['payload']['codice_univoco'] ?? 'AF-QR-PREVIEW' }}</div>
<div class="mt-2 font-bold leading-tight text-slate-900" style="font-size: {{ $titleFontPx }}px;">{{ 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>
<div class="mt-2 font-medium text-slate-600" style="font-size: {{ $subtitleFontPx }}px;">{{ 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>
<div class="mt-3 leading-relaxed text-slate-500" style="font-size: {{ $linesFontPx }}px;">{{ data_get($preview, 'slots.meta') }}</div>
@endif
@if(($preview['stable_lines'] ?? []) !== [])
<div class="mt-3 space-y-1 leading-relaxed text-slate-600" style="font-size: {{ $linesFontPx }}px;">
@foreach(($preview['stable_lines'] ?? []) as $stableLine)
@if($stableLine === '__BLANK_LINE__')
<div class="h-5 border-b border-slate-300"></div>
@elseif($stableLine !== '')
<div>{{ $stableLine }}</div>
@endif
@endforeach
</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>
<div class="mt-3 rounded-lg border border-dashed border-slate-200 bg-slate-50 px-2 py-2 text-slate-500" style="font-size: {{ $noteFontPx }}px;">{{ 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' }}

View File

@ -10,7 +10,7 @@
<div>
<div class="text-sm font-semibold text-sky-900">Launcher esterno per update e sync Git</div>
<div class="mt-1 text-[11px] text-sky-800">
Usa questa pagina per lanciare aggiornamenti senza restare dentro la pagina completa Modifiche mentre Livewire e i file vengono riallineati.
Usa questa pagina per lanciare aggiornamenti senza restare dentro la pagina completa Modifiche mentre Livewire e i file vengono riallineati. Il flusso principale da Gitea ora blocca nuove richieste, crea il backup pre-update, aggiorna DB e ricostruisce l'app prima di riaprire il nodo.
</div>
</div>
<div class="flex flex-wrap gap-2">
@ -31,8 +31,8 @@
<div class="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Sync Git da Gitea</div>
<div class="mt-1 text-[11px] text-slate-500">Flusso consigliato per staging e nodi interni.</div>
<div class="text-sm font-semibold text-slate-900">Refresh nodo da Gitea</div>
<div class="mt-1 text-[11px] text-slate-500">Flusso consigliato per staging e nodi interni: backup, finestra protetta, sync Git, dipendenze, migrate e cache rebuild.</div>
@if(! $this->ticketAttachmentMetadataReady)
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-[11px] text-amber-900">
@ -59,29 +59,36 @@
<x-filament::button size="sm" color="success" wire:click="runNodeRefresh" :disabled="!$supportCanRunUpdate">
Aggiorna nodo completo da Gitea
</x-filament::button>
<x-filament::button size="sm" wire:click="runGitSync" :disabled="!$supportCanRunUpdate">
<x-filament::button size="sm" color="warning" wire:click="runNodeRefresh" :disabled="!$supportCanRunUpdate">
Aggiorna staging da Gitea
</x-filament::button>
<x-filament::button size="sm" color="info" wire:click="refreshGitSyncProgress" :disabled="!$this->gitSyncInProgress">
Aggiorna avanzamento Git
<x-filament::button size="sm" wire:click="runGitSync" :disabled="!$supportCanRunUpdate">
Solo sync Git avanzata
</x-filament::button>
<x-filament::button size="sm" color="info" wire:click="refreshUpdateProgress" :disabled="!$this->updateInProgress">
Aggiorna avanzamento refresh
</x-filament::button>
</div>
@if($this->gitSyncInProgress)
<div class="mt-3 rounded-lg border bg-slate-50 p-3" wire:poll.2s="refreshGitSyncProgress">
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-[11px] text-amber-900">
Il pulsante <span class="font-semibold">Aggiorna staging da Gitea</span> esegue il refresh completo del nodo: backup pre-update, maintenance mode per bloccare nuove operazioni utente, sync da Gitea, <span class="font-mono">composer install</span>, tentativo di build frontend se disponibile, <span class="font-mono">migrate --force</span>, pulizia cache, rebuild Blade e riapertura automatica del sistema.
</div>
@if($this->updateInProgress)
<div class="mt-3 rounded-lg border bg-slate-50 p-3" wire:poll.2s="refreshUpdateProgress">
@else
<div class="mt-3 rounded-lg border bg-slate-50 p-3">
@endif
<div class="mb-1 flex items-center justify-between text-[11px] text-gray-600">
<span class="font-semibold">Avanzamento sync Git</span>
<span class="font-mono">{{ (int) $this->gitSyncProgressPercent }}%</span>
<span class="font-semibold">Avanzamento refresh nodo</span>
<span class="font-mono">{{ (int) $this->updateProgressPercent }}%</span>
</div>
<div class="h-2 w-full overflow-hidden rounded bg-gray-200">
<div class="h-2 {{ $this->gitSyncProgressStatus === 'failed' ? 'bg-rose-500' : 'bg-emerald-500' }}" style="width: {{ max(0, min(100, (int) $this->gitSyncProgressPercent)) }}%"></div>
<div class="h-2 {{ $this->updateProgressStatus === 'failed' ? 'bg-rose-500' : 'bg-emerald-500' }}" style="width: {{ max(0, min(100, (int) $this->updateProgressPercent)) }}%"></div>
</div>
<div class="mt-1 text-[11px] text-gray-600">{{ $this->gitSyncProgressMessage }}</div>
@if(filled($this->lastGitSyncIssue))
<div class="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-2 text-[11px] text-amber-900">{{ $this->lastGitSyncIssue }}</div>
<div class="mt-1 text-[11px] text-gray-600">{{ $this->updateProgressMessage }}</div>
@if(filled($this->lastUpdateIssue))
<div class="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-2 text-[11px] text-amber-900">{{ $this->lastUpdateIssue }}</div>
@endif
</div>
</div>
@ -108,7 +115,7 @@
</div>
<div class="mt-3 rounded-lg border border-slate-200 bg-slate-50 p-3 text-[11px] text-slate-600">
Il flusso consigliato del nodo e: push su Gitea, poi "Aggiorna nodo completo da Gitea" da questa pagina. Gli archivi caricati manualmente dentro storage o cartelle private non vengono trasferiti da Git, salvo procedure dedicate di backup/sync dati.
Il flusso consigliato del nodo e: push su Gitea, poi "Aggiorna staging da Gitea" da questa pagina. I dati utente non vengono distribuiti via Git: per questo il launcher esegue prima il backup pre-update e poi una finestra di maintenance per evitare scritture concorrenti durante il riallineamento del nodo.
</div>
@if(filled($this->pendingGitPreviewIssue))

View File

@ -1,61 +1,5 @@
<x-filament-panels::page wire:poll.8s="refreshLiveCallBanner">
<x-filament-panels::page>
<div class="space-y-4">
@if($liveIncomingCall)
@php
$rubricaUrl = $this->getLiveRubricaUrl();
$estrattoUrl = $this->getLiveEstrattoContoUrl();
@endphp
<div class="rounded-xl border border-emerald-300 bg-emerald-50 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-emerald-900">Chiamata in arrivo in tempo reale</div>
<div class="mt-1 text-xs text-emerald-800">
Numero: <span class="font-semibold">{{ $liveIncomingCall['phone'] ?? '-' }}</span>
@if(!empty($liveIncomingCall['target_extension']))
· Interno: {{ $liveIncomingCall['target_extension'] }}
@endif
@if(!empty($liveIncomingCall['received_at']))
· Ricevuta: {{ $liveIncomingCall['received_at'] }}
@endif
</div>
@if(!empty($liveIncomingCall['rubrica_nome']))
<div class="mt-1 text-xs text-emerald-800">Contatto riconosciuto: {{ $liveIncomingCall['rubrica_nome'] }}</div>
@endif
@if(!empty($liveIncomingCall['line']))
<div class="mt-1 text-[11px] text-emerald-700">{{ $this->excerpt((string) $liveIncomingCall['line'], 180) }}</div>
@endif
</div>
<div class="flex flex-wrap gap-2">
<x-filament::button size="sm" color="success" wire:click="creaPostItDaChiamataInIngresso">
Crea Post-it (prima valutazione)
</x-filament::button>
<x-filament::button size="sm" color="gray" wire:click="usaChiamataInIngresso">
Precompila ticket (opzionale)
</x-filament::button>
@if($liveCallCanClickToCall)
<x-filament::button size="sm" color="warning" wire:click="richiediClickToCallDaInterno">
Chiama da interno assegnato
</x-filament::button>
@endif
@if($rubricaUrl)
<a href="{{ $rubricaUrl }}" class="inline-flex items-center rounded-md bg-indigo-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-600">
Apri scheda rubrica
</a>
@endif
@if($estrattoUrl)
<a href="{{ $estrattoUrl }}" class="inline-flex items-center rounded-md bg-slate-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-700" target="_blank" rel="noopener noreferrer">
Apri estratto conto
</a>
@endif
</div>
</div>
</div>
@endif
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
Il riepilogo operativo Ticket Mobile ora e nella dashboard principale, cosi i link rapidi e lo stato ticket restano visibili appena entri in Filament.
</div>
@ -124,70 +68,6 @@
</ul>
</div>
@endif
@php
$ticketCallContext = $this->ticketCallContext;
@endphp
@if(!empty($ticketCallContext['phone']) || !empty($ticketCallContext['caller_name']) || !empty($ticketCallContext['target_extension']))
<div class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-900">
<div class="font-semibold uppercase tracking-wide">Contesto chiamata</div>
<div class="mt-2 grid gap-2 md:grid-cols-2">
@if(!empty($ticketCallContext['phone']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Numero chiamante</div>
<div class="text-sm font-semibold">{{ $ticketCallContext['phone'] }}</div>
</div>
@endif
@if(!empty($ticketCallContext['target_extension']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Interno o gruppo chiamato</div>
<div class="text-sm font-semibold">{{ $ticketCallContext['target_extension'] }}</div>
</div>
@endif
@if(!empty($ticketCallContext['received_at']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Ricevuta alle</div>
<div>{{ $ticketCallContext['received_at'] }}</div>
</div>
@endif
@if(!empty($ticketCallContext['caller_name']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Contatto associato</div>
<div>{{ $ticketCallContext['caller_name'] }}</div>
</div>
@endif
@if(!empty($ticketCallContext['caller_profile']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Profilo</div>
<div>{{ $ticketCallContext['caller_profile'] }}</div>
</div>
@endif
@if(!empty($ticketCallContext['riferimento_stabile']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Riferimento stabile</div>
<div>{{ $ticketCallContext['riferimento_stabile'] }}</div>
</div>
@endif
@if(!empty($ticketCallContext['riferimento_unita']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Riferimento unità</div>
<div>{{ $ticketCallContext['riferimento_unita'] }}</div>
</div>
@endif
@if(!empty($ticketCallContext['stabile_label']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Stabile collegato</div>
<div>{{ $ticketCallContext['stabile_label'] }}</div>
</div>
@endif
</div>
@if(!empty($ticketCallContext['call_note']))
<div class="mt-2 rounded-md bg-white/70 px-3 py-2 text-[11px] text-emerald-800">
<div class="font-semibold">Nota operativa importata</div>
<div class="mt-1 whitespace-pre-wrap">{{ $ticketCallContext['call_note'] }}</div>
</div>
@endif
</div>
@endif
<div class="mt-3 grid gap-3 md:grid-cols-2">
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">Titolo operativo</span>

View File

@ -0,0 +1,500 @@
#!/usr/bin/env bash
set -Eeuo pipefail
LOG_FILE="${LOG_FILE:-/tmp/laravel-site-bootstrap.log}"
SITE_KEY="${SITE_KEY:-}"
PROJECT_NAME="${PROJECT_NAME:-Laravel Site}"
APP_DIR="${APP_DIR:-}"
BRANCH="${BRANCH:-main}"
REPO_URL="${REPO_URL:-}"
APP_URL="${APP_URL:-http://127.0.0.1}"
SERVER_NAME="${SERVER_NAME:-_}"
DB_NAME="${DB_NAME:-}"
DB_USER="${DB_USER:-}"
DB_PASSWORD="${DB_PASSWORD:-}"
DB_HOST="${DB_HOST:-127.0.0.1}"
DB_PORT="${DB_PORT:-3306}"
PHP_VERSION="${PHP_VERSION:-8.3}"
RUN_NPM_BUILD=1
RUN_MIGRATIONS=1
CONFIGURE_SCHEDULER=1
CONFIGURE_QUEUE_WORKER=0
ALLOW_EXISTING_APP_DIR=0
ALLOW_EXISTING_DATABASE=0
REPLACE_NGINX_SITE=0
QUEUE_COMMAND="${QUEUE_COMMAND:-php artisan queue:work --sleep=3 --tries=3 --max-time=3600}"
OWNER_USER="${SUDO_USER:-$USER}"
usage() {
cat <<'EOF'
Usage: laravel-site-bootstrap.sh [options]
Required options:
--repo-url <url> Git repository URL to clone
--db-password <password> MySQL password for the application user
Optional options:
--site-key <slug> Unique site key used for nginx/cron/supervisor names
--project-name <name> APP_NAME value (default: Laravel Site)
--branch <name> Git branch to checkout (default: main)
--app-dir <path> Laravel application directory (default: /var/www/<site-key>)
--app-url <url> Public APP_URL value (default: http://127.0.0.1)
--server-name <name> Nginx server_name value (default: _)
--db-name <name> MySQL database name (default derived from site key)
--db-user <user> MySQL app user (default derived from site key)
--db-host <host> MySQL host (default: 127.0.0.1)
--db-port <port> MySQL port (default: 3306)
--php-version <version> PHP FPM version for nginx upstream (default: 8.3)
--skip-build Skip npm install/build
--skip-migrations Skip artisan migrate --force
--without-scheduler Do not create the cron scheduler entry
--with-queue-worker Create a supervisor queue worker service
--queue-command <cmd> Queue worker command when --with-queue-worker is used
--allow-existing-app-dir Reuse an existing git worktree in the target app dir
--allow-existing-database Reuse an existing database/user instead of failing
--replace-nginx-site Replace an existing nginx site file for this site key
--help Show this message
Example:
sudo bash scripts/install/laravel-site-bootstrap.sh \
--site-key demo-netgescon \
--project-name "Demo NetGescon" \
--repo-url git@your-gitea:studio/demo-netgescon.git \
--branch main \
--app-url https://demo.example.it \
--server-name demo.example.it \
--db-password 'StrongDbPass123!'
EOF
}
log() {
local level="$1"
shift
printf '[%s] %s\n' "$level" "$*" >&2
printf '[%s] %s\n' "$level" "$*" >> "$LOG_FILE" 2>/dev/null || true
}
info() { log INFO "$@"; }
warn() { log WARN "$@"; }
fail() { log ERROR "$@"; exit 1; }
on_error() {
local exit_code="$1"
local line_no="$2"
local command_text="$3"
log ERROR "Installer failed at line ${line_no}: ${command_text} (exit ${exit_code})"
log ERROR "See log file: ${LOG_FILE}"
exit "$exit_code"
}
trap 'on_error "$?" "$LINENO" "$BASH_COMMAND"' ERR
run_root() {
if [[ ${EUID} -eq 0 ]]; then
"$@"
else
sudo "$@"
fi
}
run_owner() {
local target_user="$1"
shift
if [[ ${EUID} -eq 0 ]]; then
runuser -u "$target_user" -- "$@"
else
sudo -u "$target_user" "$@"
fi
}
sql_escape() {
printf '%s' "$1" | sed "s/'/''/g"
}
set_env_value() {
local key="$1"
local value="$2"
local file="$3"
local escaped
escaped=$(printf '%s' "$value" | sed -e 's/[\/&]/\\&/g')
if grep -q "^${key}=" "$file"; then
sed -i "s/^${key}=.*/${key}=${escaped}/" "$file"
else
printf '%s=%s\n' "$key" "$value" >> "$file"
fi
}
slugify() {
local value="$1"
value=$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')
value=$(printf '%s' "$value" | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')
if [[ -z "$value" ]]; then
value='laravel-site'
fi
printf '%s' "$value"
}
ensure_site_key() {
if [[ -z "$SITE_KEY" ]]; then
if [[ -n "$APP_DIR" ]]; then
SITE_KEY="$(slugify "$(basename "$APP_DIR")")"
elif [[ "$SERVER_NAME" != '_' ]]; then
SITE_KEY="$(slugify "$SERVER_NAME")"
elif [[ -n "$DB_NAME" ]]; then
SITE_KEY="$(slugify "$DB_NAME")"
else
SITE_KEY='laravel-site'
fi
else
SITE_KEY="$(slugify "$SITE_KEY")"
fi
if [[ -z "$APP_DIR" ]]; then
APP_DIR="/var/www/${SITE_KEY}"
fi
if [[ -z "$DB_NAME" ]]; then
DB_NAME="$(printf 'laravel_%s' "$SITE_KEY" | tr '-' '_' | cut -c1-64)"
fi
if [[ -z "$DB_USER" ]]; then
DB_USER="$(printf '%s_user' "$DB_NAME" | cut -c1-32)"
fi
}
mysql_database_exists() {
run_root mysql -Nse "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '$1'" | grep -qx "$1"
}
mysql_user_exists() {
run_root mysql -Nse "SELECT User FROM mysql.user WHERE User = '$1' AND Host = 'localhost'" | grep -qx "$1"
}
assert_safe_targets() {
if [[ -d "$APP_DIR" ]] && [[ -n "$(find "$APP_DIR" -mindepth 1 -maxdepth 1 2>/dev/null | head -n 1)" ]]; then
if [[ $ALLOW_EXISTING_APP_DIR -ne 1 ]]; then
fail "App directory already exists and is not empty: $APP_DIR. Re-run with --allow-existing-app-dir only if you really want to reuse it."
fi
if [[ ! -d "$APP_DIR/.git" || ! -f "$APP_DIR/artisan" ]]; then
fail "Existing app directory must already be a Laravel git worktree: $APP_DIR"
fi
fi
if [[ -e "$NGINX_SITE_FILE" || -L "$NGINX_SITE_ENABLED" ]]; then
if [[ $REPLACE_NGINX_SITE -ne 1 ]]; then
fail "Nginx site already exists for ${SITE_KEY}. Re-run with --replace-nginx-site only if replacement is intentional."
fi
fi
if mysql_database_exists "$DB_NAME"; then
if [[ $ALLOW_EXISTING_DATABASE -ne 1 ]]; then
fail "Database already exists: $DB_NAME. Re-run with --allow-existing-database only if reuse is intentional."
fi
fi
if mysql_user_exists "$DB_USER"; then
if [[ $ALLOW_EXISTING_DATABASE -ne 1 ]]; then
fail "Database user already exists: $DB_USER. Re-run with --allow-existing-database only if reuse is intentional."
fi
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--site-key)
SITE_KEY="$2"
shift 2
;;
--project-name)
PROJECT_NAME="$2"
shift 2
;;
--repo-url)
REPO_URL="$2"
shift 2
;;
--branch)
BRANCH="$2"
shift 2
;;
--app-dir)
APP_DIR="$2"
shift 2
;;
--app-url)
APP_URL="$2"
shift 2
;;
--server-name)
SERVER_NAME="$2"
shift 2
;;
--db-name)
DB_NAME="$2"
shift 2
;;
--db-user)
DB_USER="$2"
shift 2
;;
--db-password)
DB_PASSWORD="$2"
shift 2
;;
--db-host)
DB_HOST="$2"
shift 2
;;
--db-port)
DB_PORT="$2"
shift 2
;;
--php-version)
PHP_VERSION="$2"
shift 2
;;
--skip-build)
RUN_NPM_BUILD=0
shift
;;
--skip-migrations)
RUN_MIGRATIONS=0
shift
;;
--without-scheduler)
CONFIGURE_SCHEDULER=0
shift
;;
--with-queue-worker)
CONFIGURE_QUEUE_WORKER=1
shift
;;
--queue-command)
QUEUE_COMMAND="$2"
shift 2
;;
--allow-existing-app-dir)
ALLOW_EXISTING_APP_DIR=1
shift
;;
--allow-existing-database)
ALLOW_EXISTING_DATABASE=1
shift
;;
--replace-nginx-site)
REPLACE_NGINX_SITE=1
shift
;;
--help|-h)
usage
exit 0
;;
*)
fail "Unknown option: $1"
;;
esac
done
info "Starting Laravel site bootstrap"
info "Execution user: $(id -un)"
info "Log file: $LOG_FILE"
if [[ -z "$REPO_URL" ]]; then
fail "--repo-url is required"
fi
if [[ -z "$DB_PASSWORD" ]]; then
fail "--db-password is required"
fi
for command in git php composer mysql nginx; do
if ! command -v "$command" >/dev/null 2>&1; then
fail "Missing required command: $command"
fi
done
ensure_site_key
APP_PARENT_DIR="$(dirname "$APP_DIR")"
NGINX_SITE_FILE="/etc/nginx/sites-available/${SITE_KEY}.conf"
NGINX_SITE_ENABLED="/etc/nginx/sites-enabled/${SITE_KEY}.conf"
CRON_FILE="/etc/cron.d/${SITE_KEY}-scheduler"
SUPERVISOR_FILE="/etc/supervisor/conf.d/${SITE_KEY}-worker.conf"
TEMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TEMP_DIR"' EXIT
assert_safe_targets
info "Preparing directories"
run_root install -d -o "$OWNER_USER" -g www-data -m 2775 "$APP_PARENT_DIR"
if [[ -d "$APP_DIR/.git" ]]; then
info "Refreshing application worktree"
run_owner "$OWNER_USER" git -C "$APP_DIR" remote set-url origin "$REPO_URL"
run_owner "$OWNER_USER" git -C "$APP_DIR" fetch --all --prune
run_owner "$OWNER_USER" git -C "$APP_DIR" checkout "$BRANCH"
run_owner "$OWNER_USER" git -C "$APP_DIR" reset --hard "origin/$BRANCH"
else
info "Cloning application worktree"
run_owner "$OWNER_USER" git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR"
fi
if [[ ! -f "$APP_DIR/artisan" ]]; then
fail "Laravel artisan file not found in $APP_DIR"
fi
info "Configuring MySQL database and user"
DB_PASSWORD_ESCAPED="$(sql_escape "$DB_PASSWORD")"
run_root mysql <<SQL
CREATE DATABASE IF NOT EXISTS \`$DB_NAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASSWORD_ESCAPED';
ALTER USER '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASSWORD_ESCAPED';
GRANT ALL PRIVILEGES ON \`$DB_NAME\`.* TO '$DB_USER'@'localhost';
FLUSH PRIVILEGES;
SQL
cd "$APP_DIR"
info "Installing PHP dependencies"
run_owner "$OWNER_USER" composer install --no-dev --optimize-autoloader
if [[ $RUN_NPM_BUILD -eq 1 && -f package.json ]]; then
info "Installing frontend dependencies"
if [[ -f package-lock.json ]]; then
run_owner "$OWNER_USER" npm ci
else
run_owner "$OWNER_USER" npm install
fi
run_owner "$OWNER_USER" npm run build
fi
if [[ ! -f .env ]]; then
if [[ -f .env.example ]]; then
cp .env.example .env
else
fail ".env and .env.example are both missing"
fi
fi
info "Configuring Laravel environment"
set_env_value APP_NAME "$PROJECT_NAME" .env
set_env_value APP_ENV production .env
set_env_value APP_DEBUG false .env
set_env_value APP_URL "$APP_URL" .env
set_env_value DB_CONNECTION mysql .env
set_env_value DB_HOST "$DB_HOST" .env
set_env_value DB_PORT "$DB_PORT" .env
set_env_value DB_DATABASE "$DB_NAME" .env
set_env_value DB_USERNAME "$DB_USER" .env
set_env_value DB_PASSWORD "$DB_PASSWORD" .env
php artisan key:generate --force
php artisan storage:link || true
php artisan optimize:clear
if [[ $RUN_MIGRATIONS -eq 1 ]]; then
info "Running migrations"
php artisan migrate --force
fi
info "Caching Laravel configuration"
php artisan config:cache
php artisan route:cache
php artisan view:cache
info "Setting permissions"
run_root chown -R www-data:www-data "$APP_DIR"
run_root chmod -R 775 "$APP_DIR/storage" "$APP_DIR/bootstrap/cache"
info "Writing nginx virtual host"
cat > "$TEMP_DIR/${SITE_KEY}.conf" <<EOF
server {
listen 80;
listen [::]:80;
server_name $SERVER_NAME;
root $APP_DIR/public;
index index.php index.html;
client_max_body_size 64m;
access_log /var/log/nginx/${SITE_KEY}-access.log;
error_log /var/log/nginx/${SITE_KEY}-error.log;
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php${PHP_VERSION}-fpm.sock;
fastcgi_param SCRIPT_FILENAME \$realpath_root\$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
EOF
run_root install -m 0644 "$TEMP_DIR/${SITE_KEY}.conf" "$NGINX_SITE_FILE"
run_root ln -sfn "$NGINX_SITE_FILE" "$NGINX_SITE_ENABLED"
run_root nginx -t
run_root systemctl reload nginx
if [[ $CONFIGURE_SCHEDULER -eq 1 ]]; then
info "Configuring scheduler cron"
cat > "$TEMP_DIR/${SITE_KEY}-scheduler" <<EOF
* * * * * www-data cd $APP_DIR && php artisan schedule:run >> /var/log/${SITE_KEY}-scheduler.log 2>&1
EOF
run_root install -m 0644 "$TEMP_DIR/${SITE_KEY}-scheduler" "$CRON_FILE"
fi
if [[ $CONFIGURE_QUEUE_WORKER -eq 1 ]]; then
info "Configuring supervisor worker"
cat > "$TEMP_DIR/${SITE_KEY}-worker.conf" <<EOF
[program:${SITE_KEY}-worker]
command=$QUEUE_COMMAND
directory=$APP_DIR
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/${SITE_KEY}-worker.log
stopwaitsecs=3600
EOF
run_root install -m 0644 "$TEMP_DIR/${SITE_KEY}-worker.conf" "$SUPERVISOR_FILE"
run_root supervisorctl reread
run_root supervisorctl update
run_root supervisorctl restart "${SITE_KEY}-worker" || true
fi
SUMMARY_FILE="$APP_DIR/install-site-summary.txt"
cat > "$TEMP_DIR/install-site-summary.txt" <<EOF
Laravel site bootstrap completed
Date: $(date -Is)
Site key: $SITE_KEY
Project name: $PROJECT_NAME
Application dir: $APP_DIR
Branch: $BRANCH
APP_URL: $APP_URL
Server name: $SERVER_NAME
Database: $DB_NAME
Database user: $DB_USER
Scheduler configured: $CONFIGURE_SCHEDULER
Queue worker configured: $CONFIGURE_QUEUE_WORKER
EOF
run_root install -o www-data -g www-data -m 0644 "$TEMP_DIR/install-site-summary.txt" "$SUMMARY_FILE"
info "Laravel site bootstrap completed"
printf '\nSummary file:\n'
printf ' %s\n' "$SUMMARY_FILE"

View File

@ -0,0 +1,466 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP_DIR="${APP_DIR:-/var/www/netgescon}"
GIT_SOURCE_DIR="${GIT_SOURCE_DIR:-/var/www/netgescon-git-source}"
SITE_KEY="${SITE_KEY:-}"
BRANCH="${BRANCH:-main}"
REPO_URL="${REPO_URL:-}"
APP_URL="${APP_URL:-http://127.0.0.1}"
SERVER_NAME="${SERVER_NAME:-_}"
DB_NAME="${DB_NAME:-netgescon}"
DB_USER="${DB_USER:-netgescon_user}"
DB_PASSWORD="${DB_PASSWORD:-}"
ADMIN_NAME="${ADMIN_NAME:-NetGescon Super Admin}"
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@netgescon.local}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
PHP_VERSION="${PHP_VERSION:-8.3}"
RUN_NPM_BUILD=1
RUN_MIGRATIONS=1
ALLOW_EXISTING_APP_DIR=0
ALLOW_EXISTING_DATABASE=0
REPLACE_NGINX_SITE=0
usage() {
cat <<'EOF'
Usage: netgescon-node-bootstrap.sh [options]
Required options:
--repo-url <url> Git repository URL to clone as node source
--db-password <password> MySQL password for the application user
--admin-password <pwd> Password for the initial super-admin user
Optional options:
--site-key <slug> Unique site key used for nginx/cron/supervisor names
--branch <name> Git branch to checkout (default: main)
--app-dir <path> Laravel application directory (default: /var/www/netgescon)
--git-source-dir <path> Local Git source directory for browser sync (default: /var/www/netgescon-git-source)
--app-url <url> Public APP_URL value (default: http://127.0.0.1)
--server-name <name> Nginx server_name value (default: _)
--db-name <name> MySQL database name (default: netgescon)
--db-user <user> MySQL app user (default: netgescon_user)
--admin-email <email> Initial super-admin email (default: admin@netgescon.local)
--admin-name <name> Initial super-admin display name
--php-version <version> PHP FPM version for nginx upstream (default: 8.3)
--skip-build Skip npm install/build
--skip-migrations Skip artisan migrate and admin bootstrap
--allow-existing-app-dir Reuse an existing git worktree in the target app dir
--allow-existing-database Reuse an existing database/user instead of failing
--replace-nginx-site Replace an existing nginx site file for this site key
--help Show this message
Example:
sudo bash scripts/install/netgescon-node-bootstrap.sh \
--repo-url git@your-gitea:studio/netgescon.git \
--app-url https://netgescon.example.it \
--server-name netgescon.example.it \
--db-password 'StrongDbPass123!' \
--admin-password 'StrongAdminPass123!'
EOF
}
log() {
local level="$1"
shift
printf '[%s] %s\n' "$level" "$*"
}
info() { log INFO "$@"; }
warn() { log WARN "$@"; }
fail() { log ERROR "$@"; exit 1; }
slugify() {
local value="$1"
value=$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')
value=$(printf '%s' "$value" | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')
if [[ -z "$value" ]]; then
value='netgescon'
fi
printf '%s' "$value"
}
run_root() {
if [[ ${EUID} -eq 0 ]]; then
"$@"
else
sudo "$@"
fi
}
sql_escape() {
printf '%s' "$1" | sed "s/'/''/g"
}
php_escape() {
printf '%s' "$1" | sed "s/'/\\'/g"
}
mysql_database_exists() {
run_root mysql -Nse "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '$1'" | grep -qx "$1"
}
mysql_user_exists() {
run_root mysql -Nse "SELECT User FROM mysql.user WHERE User = '$1' AND Host = 'localhost'" | grep -qx "$1"
}
set_env_value() {
local key="$1"
local value="$2"
local file="$3"
local escaped
escaped=$(printf '%s' "$value" | sed -e 's/[\/&]/\\&/g')
if grep -q "^${key}=" "$file"; then
sed -i "s/^${key}=.*/${key}=${escaped}/" "$file"
else
printf '%s=%s\n' "$key" "$value" >> "$file"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--site-key)
SITE_KEY="$2"
shift 2
;;
--repo-url)
REPO_URL="$2"
shift 2
;;
--branch)
BRANCH="$2"
shift 2
;;
--app-dir)
APP_DIR="$2"
shift 2
;;
--git-source-dir)
GIT_SOURCE_DIR="$2"
shift 2
;;
--app-url)
APP_URL="$2"
shift 2
;;
--server-name)
SERVER_NAME="$2"
shift 2
;;
--db-name)
DB_NAME="$2"
shift 2
;;
--db-user)
DB_USER="$2"
shift 2
;;
--db-password)
DB_PASSWORD="$2"
shift 2
;;
--admin-email)
ADMIN_EMAIL="$2"
shift 2
;;
--admin-name)
ADMIN_NAME="$2"
shift 2
;;
--admin-password)
ADMIN_PASSWORD="$2"
shift 2
;;
--php-version)
PHP_VERSION="$2"
shift 2
;;
--skip-build)
RUN_NPM_BUILD=0
shift
;;
--skip-migrations)
RUN_MIGRATIONS=0
shift
;;
--allow-existing-app-dir)
ALLOW_EXISTING_APP_DIR=1
shift
;;
--allow-existing-database)
ALLOW_EXISTING_DATABASE=1
shift
;;
--replace-nginx-site)
REPLACE_NGINX_SITE=1
shift
;;
--help|-h)
usage
exit 0
;;
*)
fail "Unknown option: $1"
;;
esac
done
if [[ -z "$REPO_URL" ]]; then
fail "--repo-url is required"
fi
if [[ -z "$DB_PASSWORD" ]]; then
fail "--db-password is required"
fi
if [[ -z "$ADMIN_PASSWORD" && $RUN_MIGRATIONS -eq 1 ]]; then
fail "--admin-password is required unless --skip-migrations is used"
fi
for command in git php composer mysql nginx; do
if ! command -v "$command" >/dev/null 2>&1; then
fail "Missing required command: $command"
fi
done
if [[ -z "$SITE_KEY" ]]; then
SITE_KEY="$(slugify "$(basename "$APP_DIR")")"
else
SITE_KEY="$(slugify "$SITE_KEY")"
fi
APP_PARENT_DIR="$(dirname "$APP_DIR")"
NGINX_SITE_FILE="/etc/nginx/sites-available/${SITE_KEY}.conf"
NGINX_SITE_ENABLED="/etc/nginx/sites-enabled/${SITE_KEY}.conf"
SUPERVISOR_FILE="/etc/supervisor/conf.d/${SITE_KEY}-worker.conf"
CRON_FILE="/etc/cron.d/${SITE_KEY}-scheduler"
TEMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TEMP_DIR"' EXIT
if [[ -d "$APP_DIR" ]] && [[ -n "$(find "$APP_DIR" -mindepth 1 -maxdepth 1 2>/dev/null | head -n 1)" ]]; then
if [[ $ALLOW_EXISTING_APP_DIR -ne 1 ]]; then
fail "App directory already exists and is not empty: $APP_DIR. Re-run with --allow-existing-app-dir only if you really want to reuse it."
fi
if [[ ! -d "$APP_DIR/.git" || ! -f "$APP_DIR/artisan" ]]; then
fail "Existing app directory must already be a Laravel git worktree: $APP_DIR"
fi
fi
if [[ -d "$GIT_SOURCE_DIR" ]] && [[ ! -d "$GIT_SOURCE_DIR/.git" ]] && [[ -n "$(find "$GIT_SOURCE_DIR" -mindepth 1 -maxdepth 1 2>/dev/null | head -n 1)" ]]; then
fail "Git source directory exists and is not a git repository: $GIT_SOURCE_DIR"
fi
if [[ -e "$NGINX_SITE_FILE" || -L "$NGINX_SITE_ENABLED" ]]; then
if [[ $REPLACE_NGINX_SITE -ne 1 ]]; then
fail "Nginx site already exists for ${SITE_KEY}. Re-run with --replace-nginx-site only if replacement is intentional."
fi
fi
if mysql_database_exists "$DB_NAME"; then
if [[ $ALLOW_EXISTING_DATABASE -ne 1 ]]; then
fail "Database already exists: $DB_NAME. Re-run with --allow-existing-database only if reuse is intentional."
fi
fi
if mysql_user_exists "$DB_USER"; then
if [[ $ALLOW_EXISTING_DATABASE -ne 1 ]]; then
fail "Database user already exists: $DB_USER. Re-run with --allow-existing-database only if reuse is intentional."
fi
fi
info "Preparing directories"
run_root install -d -o "${SUDO_USER:-$USER}" -g www-data -m 2775 "$APP_PARENT_DIR"
run_root install -d -o "${SUDO_USER:-$USER}" -g www-data -m 2775 "$(dirname "$GIT_SOURCE_DIR")"
if [[ -d "$GIT_SOURCE_DIR/.git" ]]; then
info "Refreshing local source repository"
git -C "$GIT_SOURCE_DIR" remote set-url origin "$REPO_URL"
git -C "$GIT_SOURCE_DIR" fetch --all --prune
git -C "$GIT_SOURCE_DIR" checkout "$BRANCH"
git -C "$GIT_SOURCE_DIR" reset --hard "origin/$BRANCH"
else
info "Cloning local source repository"
git clone --branch "$BRANCH" "$REPO_URL" "$GIT_SOURCE_DIR"
fi
if [[ -d "$APP_DIR/.git" ]]; then
info "Refreshing application worktree"
git -C "$APP_DIR" remote set-url origin "$REPO_URL"
git -C "$APP_DIR" fetch --all --prune
git -C "$APP_DIR" checkout "$BRANCH"
git -C "$APP_DIR" reset --hard "origin/$BRANCH"
else
info "Cloning application worktree"
git clone --branch "$BRANCH" "$GIT_SOURCE_DIR" "$APP_DIR"
git -C "$APP_DIR" remote set-url origin "$REPO_URL"
fi
run_root git config --global --add safe.directory "$APP_DIR"
run_root git config --global --add safe.directory "$GIT_SOURCE_DIR"
if [[ ! -f "$APP_DIR/artisan" ]]; then
fail "Laravel artisan file not found in $APP_DIR"
fi
info "Configuring MySQL database and user"
DB_PASSWORD_ESCAPED="$(sql_escape "$DB_PASSWORD")"
run_root mysql <<SQL
CREATE DATABASE IF NOT EXISTS \
\\`$DB_NAME\\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASSWORD_ESCAPED';
ALTER USER '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASSWORD_ESCAPED';
GRANT ALL PRIVILEGES ON \\`$DB_NAME\\`.* TO '$DB_USER'@'localhost';
FLUSH PRIVILEGES;
SQL
info "Installing PHP dependencies"
cd "$APP_DIR"
composer install --no-dev --optimize-autoloader
if [[ $RUN_NPM_BUILD -eq 1 && -f package.json ]]; then
info "Installing frontend dependencies"
if [[ -f package-lock.json ]]; then
npm ci
else
npm install
fi
npm run build
fi
if [[ ! -f .env ]]; then
if [[ -f .env.example ]]; then
cp .env.example .env
else
fail ".env and .env.example are both missing"
fi
fi
info "Configuring Laravel environment"
set_env_value APP_NAME NetGescon .env
set_env_value APP_ENV production .env
set_env_value APP_DEBUG false .env
set_env_value APP_URL "$APP_URL" .env
set_env_value DB_CONNECTION mysql .env
set_env_value DB_HOST 127.0.0.1 .env
set_env_value DB_PORT 3306 .env
set_env_value DB_DATABASE "$DB_NAME" .env
set_env_value DB_USERNAME "$DB_USER" .env
set_env_value DB_PASSWORD "$DB_PASSWORD" .env
set_env_value CACHE_STORE file .env
set_env_value SESSION_DRIVER file .env
set_env_value QUEUE_CONNECTION database .env
set_env_value FILESYSTEM_DISK public .env
set_env_value NETGESCON_GIT_SYNC_SOURCE_REPO "$GIT_SOURCE_DIR" .env
mkdir -p storage/app/support
ln -sfn "$GIT_SOURCE_DIR" storage/app/support/git-source
php artisan key:generate --force
php artisan storage:link || true
php artisan optimize:clear
if [[ $RUN_MIGRATIONS -eq 1 ]]; then
info "Running migrations"
php artisan migrate --force
info "Creating initial super-admin user"
ADMIN_NAME_ESCAPED="$(php_escape "$ADMIN_NAME")"
ADMIN_EMAIL_ESCAPED="$(php_escape "$ADMIN_EMAIL")"
ADMIN_PASSWORD_ESCAPED="$(php_escape "$ADMIN_PASSWORD")"
php artisan tinker --execute="use App\\Models\\User; use Illuminate\\Support\\Facades\\Hash; use Spatie\\Permission\\Models\\Role; \\$role = Role::firstOrCreate(['name' => 'super-admin', 'guard_name' => 'web']); \\$adminRole = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']); \\$user = User::updateOrCreate(['email' => '$ADMIN_EMAIL_ESCAPED'], ['name' => '$ADMIN_NAME_ESCAPED', 'email_verified_at' => now(), 'password' => Hash::make('$ADMIN_PASSWORD_ESCAPED'), 'is_active' => true, 'registration_status' => 'approved']); \\$user->syncRoles(['super-admin', 'admin']);"
fi
info "Caching Laravel configuration"
php artisan config:cache
php artisan route:cache
php artisan view:cache
info "Setting permissions"
run_root chown -R www-data:www-data "$APP_DIR"
run_root chmod -R 775 "$APP_DIR/storage" "$APP_DIR/bootstrap/cache"
info "Writing nginx virtual host"
cat > "$TEMP_DIR/${SITE_KEY}.conf" <<EOF
server {
listen 80;
listen [::]:80;
server_name $SERVER_NAME;
root $APP_DIR/public;
index index.php index.html;
client_max_body_size 64m;
access_log /var/log/nginx/${SITE_KEY}-access.log;
error_log /var/log/nginx/${SITE_KEY}-error.log;
location / {
try_files \\$uri \\$uri/ /index.php?\$query_string;
}
location ~ \\.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php${PHP_VERSION}-fpm.sock;
fastcgi_param SCRIPT_FILENAME \\$realpath_root\$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\\.(?!well-known).* {
deny all;
}
}
EOF
run_root install -m 0644 "$TEMP_DIR/${SITE_KEY}.conf" "$NGINX_SITE_FILE"
run_root ln -sfn "$NGINX_SITE_FILE" "$NGINX_SITE_ENABLED"
run_root nginx -t
run_root systemctl reload nginx
info "Configuring scheduler cron"
cat > "$TEMP_DIR/${SITE_KEY}-scheduler" <<EOF
* * * * * www-data cd $APP_DIR && php artisan schedule:run >> /var/log/${SITE_KEY}-scheduler.log 2>&1
EOF
run_root install -m 0644 "$TEMP_DIR/${SITE_KEY}-scheduler" "$CRON_FILE"
info "Configuring supervisor worker"
cat > "$TEMP_DIR/${SITE_KEY}-worker.conf" <<EOF
[program:${SITE_KEY}-worker]
command=php $APP_DIR/artisan queue:work --sleep=3 --tries=3 --max-time=3600
directory=$APP_DIR
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/${SITE_KEY}-worker.log
stopwaitsecs=3600
EOF
run_root install -m 0644 "$TEMP_DIR/${SITE_KEY}-worker.conf" "$SUPERVISOR_FILE"
run_root supervisorctl reread
run_root supervisorctl update
run_root supervisorctl restart "${SITE_KEY}-worker" || true
SUMMARY_FILE="$APP_DIR/install-node-summary.txt"
cat > "$TEMP_DIR/install-node-summary.txt" <<EOF
NetGescon node bootstrap completed
Date: $(date -Is)
Site key: $SITE_KEY
Application dir: $APP_DIR
Git source dir: $GIT_SOURCE_DIR
Branch: $BRANCH
APP_URL: $APP_URL
Server name: $SERVER_NAME
Database: $DB_NAME
Database user: $DB_USER
Admin email: $ADMIN_EMAIL
Update launcher: $APP_URL/admin-filament/supporto/aggiornamento-nodo
EOF
run_root install -o www-data -g www-data -m 0644 "$TEMP_DIR/install-node-summary.txt" "$SUMMARY_FILE"
info "NetGescon node bootstrap completed"
printf '\nOpen after first login:\n'
printf ' %s/admin-filament/supporto/aggiornamento-nodo\n' "$APP_URL"
printf '\nSummary file:\n'
printf ' %s\n' "$SUMMARY_FILE"

View File

@ -0,0 +1,629 @@
#!/usr/bin/env bash
set -Eeuo pipefail
LOG_FILE="${LOG_FILE:-/tmp/ubuntu2404-laravel-base.log}"
PHP_VERSION="${PHP_VERSION:-8.3}"
NODE_MAJOR="${NODE_MAJOR:-20}"
LARAVEL_PROJECTS_DIR="${LARAVEL_PROJECTS_DIR:-/srv/laravel-projects}"
INSTALL_CORE=1
INSTALL_PHP=1
INSTALL_COMPOSER=1
INSTALL_NODE=1
INSTALL_XFCE=1
INSTALL_VSCODE=1
INSTALL_XRDP=1
CONFIGURE_FIREWALL=1
INTERACTIVE=1
declare -a INSTALLED_COMPONENTS=()
declare -a SKIPPED_COMPONENTS=()
if [[ -n "${SUDO_USER:-}" ]]; then
WORKSTATION_USER="$SUDO_USER"
else
WORKSTATION_USER="${USER}"
fi
usage() {
cat <<'EOF'
Usage: ubuntu2404-laravel-base.sh [options]
Options:
--php-version <version> PHP major.minor to install (default: 8.3)
--node-major <version> Node.js major release (default: 20)
--projects-dir <path> Root directory for Laravel projects (default: /srv/laravel-projects)
--without-core Skip core packages (nginx, mysql, redis, supervisor, tools)
--without-php Skip PHP installation and PHP tuning
--without-composer Skip Composer installation
--without-node Skip Node.js installation
--without-xfce Skip XFCE desktop packages
--without-vscode Skip Visual Studio Code installation
--without-xrdp Skip XRDP installation
--without-firewall Skip UFW configuration
--non-interactive Do not ask questions before installation
--workstation-user <user> User that will own desktop/workspace directories
--help Show this message
Example:
sudo bash scripts/install/ubuntu2404-laravel-base.sh --projects-dir /srv/laravel-projects
EOF
}
log() {
local level="$1"
shift
printf '[%s] %s\n' "$level" "$*" >&2
if [[ -n "${LOG_FILE:-}" ]]; then
printf '[%s] %s\n' "$level" "$*" >> "$LOG_FILE" 2>/dev/null || true
fi
}
info() { log INFO "$@"; }
warn() { log WARN "$@"; }
fail() { log ERROR "$@"; exit 1; }
on_error() {
local exit_code="$1"
local line_no="$2"
local command_text="$3"
log ERROR "Installer failed at line ${line_no}: ${command_text} (exit ${exit_code})"
log ERROR "See log file: ${LOG_FILE}"
exit "$exit_code"
}
trap 'on_error "$?" "$LINENO" "$BASH_COMMAND"' ERR
tty_available() {
[[ -r /dev/tty ]]
}
command_version() {
local command_name="$1"
shift || true
local output=''
if ! command -v "$command_name" >/dev/null 2>&1; then
printf 'missing'
return 0
fi
if [[ $# -gt 0 ]]; then
output="$({ "$@" 2>&1 || true; } | head -n 1)"
else
output="$({ "$command_name" --version 2>&1 || true; } | head -n 1)"
fi
if [[ -n "$output" ]]; then
printf '%s' "$output"
else
printf 'installed'
fi
return 0
}
component_state() {
local value="$1"
if [[ "$value" == "missing" || -z "$value" ]]; then
printf 'missing'
else
printf 'present'
fi
}
print_component_line() {
local label="$1"
local state="$2"
local detail="$3"
printf ' - %-18s : %-8s %s\n' "$label" "$state" "$detail"
}
prompt_yes_no() {
local prompt_text="$1"
local default_value="$2"
local answer=''
local suffix='[Y/n]'
if [[ "$default_value" == 'n' ]]; then
suffix='[y/N]'
fi
if ! tty_available; then
[[ "$default_value" == 'y' ]]
return
fi
while true; do
printf '%s %s ' "$prompt_text" "$suffix" > /dev/tty
IFS= read -r answer < /dev/tty || answer=''
answer="${answer,,}"
if [[ -z "$answer" ]]; then
[[ "$default_value" == 'y' ]]
return
fi
case "$answer" in
y|yes)
return 0
;;
n|no)
return 1
;;
esac
done
}
record_component() {
local label="$1"
local status="$2"
if [[ "$status" == 'installed' ]]; then
INSTALLED_COMPONENTS+=("$label")
else
SKIPPED_COMPONENTS+=("$label")
fi
}
normalize_vscode_apt_source_conflicts() {
local source_file=''
local removed_any=0
while IFS= read -r source_file; do
[[ -n "$source_file" ]] || continue
run_root rm -f "$source_file"
removed_any=1
done < <(grep -l "packages.microsoft.com/repos/code" /etc/apt/sources.list.d/*.list 2>/dev/null || true)
if [[ $removed_any -eq 1 ]]; then
info 'Removed conflicting VS Code apt source entries'
fi
}
should_run_apt_work() {
[[ $INSTALL_CORE -eq 1 || \
$INSTALL_PHP -eq 1 || \
$INSTALL_COMPOSER -eq 1 || \
$INSTALL_NODE -eq 1 || \
$INSTALL_VSCODE -eq 1 || \
$INSTALL_XFCE -eq 1 || \
$INSTALL_XRDP -eq 1 ]]
}
run_root() {
if [[ ${EUID} -eq 0 ]]; then
"$@"
else
sudo "$@"
fi
}
run_root_env() {
if [[ ${EUID} -eq 0 ]]; then
env "$@"
else
sudo env "$@"
fi
}
run_as_user() {
local target_user="$1"
shift
if [[ ${EUID} -eq 0 ]]; then
runuser -u "$target_user" -- "$@"
else
sudo -u "$target_user" "$@"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--php-version)
PHP_VERSION="$2"
shift 2
;;
--node-major)
NODE_MAJOR="$2"
shift 2
;;
--projects-dir)
LARAVEL_PROJECTS_DIR="$2"
shift 2
;;
--without-core)
INSTALL_CORE=0
shift
;;
--without-php)
INSTALL_PHP=0
shift
;;
--without-composer)
INSTALL_COMPOSER=0
shift
;;
--without-node)
INSTALL_NODE=0
shift
;;
--without-xfce)
INSTALL_XFCE=0
shift
;;
--without-vscode)
INSTALL_VSCODE=0
shift
;;
--without-xrdp)
INSTALL_XRDP=0
shift
;;
--without-firewall)
CONFIGURE_FIREWALL=0
shift
;;
--non-interactive)
INTERACTIVE=0
shift
;;
--workstation-user)
WORKSTATION_USER="$2"
shift 2
;;
--help|-h)
usage
exit 0
;;
*)
fail "Unknown option: $1"
;;
esac
done
info "Starting Ubuntu 24.04 Laravel base installer"
info "Execution user: $(id -un)"
info "Log file: $LOG_FILE"
if [[ ! -f /etc/os-release ]]; then
fail "Cannot detect operating system"
fi
# shellcheck disable=SC1091
source /etc/os-release
if [[ "${ID:-}" != "ubuntu" || "${VERSION_ID:-}" != "24.04" ]]; then
fail "This script supports Ubuntu 24.04 only"
fi
if ! id -u "$WORKSTATION_USER" >/dev/null 2>&1; then
fail "User not found: $WORKSTATION_USER"
fi
PHP_PACKAGES=(
"php${PHP_VERSION}"
"php${PHP_VERSION}-fpm"
"php${PHP_VERSION}-cli"
"php${PHP_VERSION}-common"
"php${PHP_VERSION}-curl"
"php${PHP_VERSION}-mbstring"
"php${PHP_VERSION}-xml"
"php${PHP_VERSION}-zip"
"php${PHP_VERSION}-mysql"
"php${PHP_VERSION}-sqlite3"
"php${PHP_VERSION}-bcmath"
"php${PHP_VERSION}-intl"
"php${PHP_VERSION}-gd"
"php${PHP_VERSION}-imagick"
"php${PHP_VERSION}-redis"
"php${PHP_VERSION}-opcache"
)
BASE_PACKAGES=(
acl
apt-transport-https
ca-certificates
curl
ffmpeg
git
gnupg
htop
imagemagick
jq
lsb-release
net-tools
nginx
redis-server
software-properties-common
supervisor
tree
unzip
ufw
vim
wget
mysql-server
)
show_preflight_screen() {
local os_label="${PRETTY_NAME:-Ubuntu 24.04}"
local php_detail
local composer_detail
local node_detail
local npm_detail
local mysql_detail
local nginx_detail
local redis_detail
local code_detail
local xfce_detail
local xrdp_detail
php_detail=$(command_version php php -v)
composer_detail=$(command_version composer composer --version)
node_detail=$(command_version node node --version)
npm_detail=$(command_version npm npm --version)
mysql_detail=$(command_version mysql mysql --version)
nginx_detail=$(command_version nginx nginx -v)
redis_detail=$(command_version redis-server redis-server --version)
code_detail=$(if dpkg -s code >/dev/null 2>&1; then dpkg-query -W -f='VS Code package ${Version}' code 2>/dev/null || printf 'VS Code installed'; else printf 'missing'; fi)
xfce_detail=$(if dpkg -s xfce4 >/dev/null 2>&1; then printf 'xfce4 installed'; else printf 'missing'; fi)
xrdp_detail=$(if dpkg -s xrdp >/dev/null 2>&1; then printf 'xrdp installed'; else printf 'missing'; fi)
printf '\n'
printf '============================================================\n'
printf ' Laravel Base Installer - Preflight\n'
printf '============================================================\n'
printf ' Machine : %s\n' "$(hostname)"
printf ' System : %s\n' "$os_label"
printf ' User : %s\n' "$WORKSTATION_USER"
printf ' Projects dir : %s\n' "$LARAVEL_PROJECTS_DIR"
printf '\n'
printf 'Current status:\n'
print_component_line 'PHP' "$(component_state "$php_detail")" "$php_detail"
print_component_line 'Composer' "$(component_state "$composer_detail")" "$composer_detail"
print_component_line 'Node.js' "$(component_state "$node_detail")" "$node_detail"
print_component_line 'npm' "$(component_state "$npm_detail")" "$npm_detail"
print_component_line 'MySQL' "$(component_state "$mysql_detail")" "$mysql_detail"
print_component_line 'Nginx' "$(component_state "$nginx_detail")" "$nginx_detail"
print_component_line 'Redis' "$(component_state "$redis_detail")" "$redis_detail"
print_component_line 'VS Code' "$(component_state "$code_detail")" "$code_detail"
print_component_line 'XFCE' "$(component_state "$xfce_detail")" "$xfce_detail"
print_component_line 'XRDP' "$(component_state "$xrdp_detail")" "$xrdp_detail"
printf '\n'
printf 'Planned install set:\n'
print_component_line 'Core stack' "$([[ $INSTALL_CORE -eq 1 ]] && printf enabled || printf skipped)" 'nginx, mysql, redis, supervisor, common tools'
print_component_line 'PHP stack' "$([[ $INSTALL_PHP -eq 1 ]] && printf enabled || printf skipped)" "php${PHP_VERSION} and extensions"
print_component_line 'Composer' "$([[ $INSTALL_COMPOSER -eq 1 ]] && printf enabled || printf skipped)" 'global composer binary'
print_component_line 'Node.js' "$([[ $INSTALL_NODE -eq 1 ]] && printf enabled || printf skipped)" "Node ${NODE_MAJOR}.x"
print_component_line 'VS Code' "$([[ $INSTALL_VSCODE -eq 1 ]] && printf enabled || printf skipped)" 'desktop IDE'
print_component_line 'XFCE' "$([[ $INSTALL_XFCE -eq 1 ]] && printf enabled || printf skipped)" 'light desktop environment'
print_component_line 'XRDP' "$([[ $INSTALL_XRDP -eq 1 ]] && printf enabled || printf skipped)" 'remote desktop service'
print_component_line 'Firewall' "$([[ $CONFIGURE_FIREWALL -eq 1 ]] && printf enabled || printf skipped)" 'OpenSSH, HTTP, HTTPS, XRDP'
printf '\n'
}
run_interactive_preflight() {
if [[ $INTERACTIVE -ne 1 ]]; then
return
fi
if ! tty_available; then
warn 'No tty available: proceeding with current flags without prompts.'
return
fi
show_preflight_screen
prompt_yes_no 'Install core stack and common tools?' 'y' && INSTALL_CORE=1 || INSTALL_CORE=0
prompt_yes_no "Install PHP ${PHP_VERSION} stack?" 'y' && INSTALL_PHP=1 || INSTALL_PHP=0
prompt_yes_no 'Install Composer?' 'y' && INSTALL_COMPOSER=1 || INSTALL_COMPOSER=0
prompt_yes_no "Install Node.js ${NODE_MAJOR}.x?" 'y' && INSTALL_NODE=1 || INSTALL_NODE=0
prompt_yes_no 'Install Visual Studio Code?' "$([[ $INSTALL_VSCODE -eq 1 ]] && printf y || printf n)" && INSTALL_VSCODE=1 || INSTALL_VSCODE=0
prompt_yes_no 'Install XFCE desktop?' "$([[ $INSTALL_XFCE -eq 1 ]] && printf y || printf n)" && INSTALL_XFCE=1 || INSTALL_XFCE=0
prompt_yes_no 'Install XRDP remote desktop?' "$([[ $INSTALL_XRDP -eq 1 ]] && printf y || printf n)" && INSTALL_XRDP=1 || INSTALL_XRDP=0
prompt_yes_no 'Configure firewall rules?' "$([[ $CONFIGURE_FIREWALL -eq 1 ]] && printf y || printf n)" && CONFIGURE_FIREWALL=1 || CONFIGURE_FIREWALL=0
printf '\nSelection summary:\n'
print_component_line 'Core stack' "$([[ $INSTALL_CORE -eq 1 ]] && printf install || printf skip)" ''
print_component_line 'PHP stack' "$([[ $INSTALL_PHP -eq 1 ]] && printf install || printf skip)" ''
print_component_line 'Composer' "$([[ $INSTALL_COMPOSER -eq 1 ]] && printf install || printf skip)" ''
print_component_line 'Node.js' "$([[ $INSTALL_NODE -eq 1 ]] && printf install || printf skip)" ''
print_component_line 'VS Code' "$([[ $INSTALL_VSCODE -eq 1 ]] && printf install || printf skip)" ''
print_component_line 'XFCE' "$([[ $INSTALL_XFCE -eq 1 ]] && printf install || printf skip)" ''
print_component_line 'XRDP' "$([[ $INSTALL_XRDP -eq 1 ]] && printf install || printf skip)" ''
print_component_line 'Firewall' "$([[ $CONFIGURE_FIREWALL -eq 1 ]] && printf configure || printf skip)" ''
printf '\n'
if ! prompt_yes_no 'Proceed with installation?' 'y'; then
fail 'Installation aborted by user'
fi
}
run_interactive_preflight
if should_run_apt_work; then
normalize_vscode_apt_source_conflicts
info "Updating apt metadata"
run_root apt-get update
run_root_env DEBIAN_FRONTEND=noninteractive apt-get -y upgrade
else
info "No package installation selected: skipping apt metadata refresh"
fi
if [[ $INSTALL_CORE -eq 1 ]]; then
info "Installing base packages"
run_root_env DEBIAN_FRONTEND=noninteractive apt-get install -y "${BASE_PACKAGES[@]}"
record_component 'Core stack' 'installed'
else
record_component 'Core stack' 'skipped'
fi
if [[ $INSTALL_PHP -eq 1 ]]; then
info "Installing PHP ${PHP_VERSION} stack"
run_root_env DEBIAN_FRONTEND=noninteractive apt-get install -y "${PHP_PACKAGES[@]}"
record_component "PHP ${PHP_VERSION} stack" 'installed'
else
record_component "PHP ${PHP_VERSION} stack" 'skipped'
fi
if [[ $INSTALL_PHP -eq 1 ]]; then
info "Configuring PHP"
run_root mkdir -p "/etc/php/${PHP_VERSION}/fpm/conf.d" "/etc/php/${PHP_VERSION}/cli/conf.d"
run_root tee "/etc/php/${PHP_VERSION}/fpm/conf.d/99-laravel-base.ini" >/dev/null <<EOF
memory_limit=512M
upload_max_filesize=64M
post_max_size=64M
max_execution_time=300
max_input_vars=3000
opcache.enable=1
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=2
realpath_cache_size=4096K
realpath_cache_ttl=600
EOF
run_root cp "/etc/php/${PHP_VERSION}/fpm/conf.d/99-laravel-base.ini" "/etc/php/${PHP_VERSION}/cli/conf.d/99-laravel-base.ini"
fi
TMP_COMPOSER_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_COMPOSER_DIR"' EXIT
if [[ $INSTALL_COMPOSER -eq 1 ]]; then
info "Installing Composer"
curl -fsSL https://getcomposer.org/installer -o "$TMP_COMPOSER_DIR/composer-setup.php"
php "$TMP_COMPOSER_DIR/composer-setup.php" --install-dir="$TMP_COMPOSER_DIR" --filename=composer >/dev/null
run_root install -m 0755 "$TMP_COMPOSER_DIR/composer" /usr/local/bin/composer
record_component 'Composer' 'installed'
else
record_component 'Composer' 'skipped'
fi
if [[ $INSTALL_NODE -eq 1 ]]; then
info "Installing Node.js ${NODE_MAJOR}.x"
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | run_root bash
run_root_env DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
record_component "Node.js ${NODE_MAJOR}.x" 'installed'
else
record_component "Node.js ${NODE_MAJOR}.x" 'skipped'
fi
if [[ $INSTALL_VSCODE -eq 1 ]]; then
info "Installing Visual Studio Code"
curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > "$TMP_COMPOSER_DIR/packages.microsoft.gpg"
run_root install -D -o root -g root -m 0644 "$TMP_COMPOSER_DIR/packages.microsoft.gpg" /usr/share/keyrings/packages.microsoft.gpg
normalize_vscode_apt_source_conflicts
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/packages.microsoft.gpg] https://packages.microsoft.com/repos/code stable main" \
| run_root tee /etc/apt/sources.list.d/vscode.list >/dev/null
run_root apt-get update
run_root_env DEBIAN_FRONTEND=noninteractive apt-get install -y code
record_component 'VS Code' 'installed'
else
record_component 'VS Code' 'skipped'
fi
if [[ $INSTALL_XFCE -eq 1 ]]; then
info "Installing XFCE desktop"
run_root_env DEBIAN_FRONTEND=noninteractive apt-get install -y xfce4 xfce4-goodies xorg dbus-x11
run_as_user "$WORKSTATION_USER" mkdir -p "/home/$WORKSTATION_USER"
run_as_user "$WORKSTATION_USER" bash -lc "printf 'xfce4-session\n' > ~/.xsession"
record_component 'XFCE desktop' 'installed'
else
record_component 'XFCE desktop' 'skipped'
fi
if [[ $INSTALL_XRDP -eq 1 ]]; then
info "Installing XRDP"
run_root_env DEBIAN_FRONTEND=noninteractive apt-get install -y xrdp
run_root systemctl enable xrdp
run_root systemctl restart xrdp
record_component 'XRDP' 'installed'
else
record_component 'XRDP' 'skipped'
fi
info "Creating Laravel workspace directories"
run_root mkdir -p "$LARAVEL_PROJECTS_DIR" "$LARAVEL_PROJECTS_DIR/shared" "$LARAVEL_PROJECTS_DIR/backups"
run_root chown -R "$WORKSTATION_USER":www-data "$LARAVEL_PROJECTS_DIR"
run_root chmod -R 2775 "$LARAVEL_PROJECTS_DIR"
info "Configuring services"
SERVICE_NAMES=()
if [[ $INSTALL_PHP -eq 1 ]]; then
SERVICE_NAMES+=("php${PHP_VERSION}-fpm")
fi
if [[ $INSTALL_CORE -eq 1 ]]; then
SERVICE_NAMES+=(nginx mysql redis-server supervisor)
fi
if [[ ${#SERVICE_NAMES[@]} -gt 0 ]]; then
run_root systemctl enable "${SERVICE_NAMES[@]}"
run_root systemctl restart "${SERVICE_NAMES[@]}"
fi
if [[ $CONFIGURE_FIREWALL -eq 1 ]]; then
info "Configuring firewall"
run_root ufw allow OpenSSH
run_root ufw allow 80/tcp
run_root ufw allow 443/tcp
if [[ $INSTALL_XRDP -eq 1 ]]; then
run_root ufw allow 3389/tcp
fi
run_root ufw --force enable
record_component 'Firewall rules' 'installed'
else
record_component 'Firewall rules' 'skipped'
fi
SUMMARY_FILE="/home/$WORKSTATION_USER/laravel-base-setup-summary.txt"
info "Writing summary to $SUMMARY_FILE"
SUMMARY_PHP="$(command_version php php -v)"
SUMMARY_COMPOSER="$(command_version composer composer --version)"
SUMMARY_NODE="$(command_version node node --version)"
SUMMARY_NPM="$(command_version npm npm --version)"
SUMMARY_MYSQL="$(command_version mysql mysql --version)"
SUMMARY_NGINX="$(command_version nginx nginx -v)"
SUMMARY_REDIS="$(command_version redis-server redis-server --version)"
cat > "$TMP_COMPOSER_DIR/summary.txt" <<EOF
Laravel Base Setup Summary
Date: $(date -Is)
Host: $(hostname)
Ubuntu: ${VERSION:-Ubuntu 24.04}
PHP: $SUMMARY_PHP
Composer: $SUMMARY_COMPOSER
Node: $SUMMARY_NODE
NPM: $SUMMARY_NPM
MySQL: $SUMMARY_MYSQL
Nginx: $SUMMARY_NGINX
Redis: $SUMMARY_REDIS
Projects dir: $LARAVEL_PROJECTS_DIR
Workstation user: $WORKSTATION_USER
XFCE installed: $INSTALL_XFCE
VS Code installed: $INSTALL_VSCODE
XRDP installed: $INSTALL_XRDP
Firewall configured: $CONFIGURE_FIREWALL
Installed components: ${INSTALLED_COMPONENTS[*]:-none}
Skipped components: ${SKIPPED_COMPONENTS[*]:-none}
EOF
run_root install -o "$WORKSTATION_USER" -g "$WORKSTATION_USER" -m 0644 "$TMP_COMPOSER_DIR/summary.txt" "$SUMMARY_FILE"
info "Base machine ready"
printf '\nInstalled components:\n'
for component in "${INSTALLED_COMPONENTS[@]}"; do
printf ' - %s\n' "$component"
done
printf '\nSkipped components:\n'
for component in "${SKIPPED_COMPONENTS[@]}"; do
printf ' - %s\n' "$component"
done
printf '\nNext steps:\n'
printf ' 1. Copy or clone your Laravel project under %s\n' "$LARAVEL_PROJECTS_DIR"
printf ' 2. Configure Nginx site and database for the specific project\n'
printf ' 3. For NetGescon, run scripts/install/netgescon-node-bootstrap.sh\n'