Aggiorna flussi operativi e refresh staging sicuro
This commit is contained in:
parent
5f248f825c
commit
d7e9a1c39d
480
app/Console/Commands/GesconFornitoriDedupCommand.php
Normal file
480
app/Console/Commands/GesconFornitoriDedupCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2828,7 +2828,7 @@ private function stepOperazioni(?int $limit): int
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'tenant_id' => null,
|
'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,
|
'legacy_id' => $legacy,
|
||||||
'descrizione' => $o->note ?? ($o->natura ?? 'Operazione'),
|
'descrizione' => $o->note ?? ($o->natura ?? 'Operazione'),
|
||||||
'data_operazione' => $o->dt_spe ?? now()->toDateString(),
|
'data_operazione' => $o->dt_spe ?? now()->toDateString(),
|
||||||
|
|
@ -5782,46 +5782,21 @@ private function resolveGestioneContabileIdForStabile(?int $stabileId, ?string $
|
||||||
return $match?->id;
|
return $match?->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveGestioneId($anno, $compet, $tipo): ?int
|
private function resolveGestioneId($anno, $compet, $tipo, $numeroStraordinaria = null): ?int
|
||||||
{
|
{
|
||||||
if (! Schema::hasTable('gestioni_contabili')) {
|
if (! Schema::hasTable('gestioni_contabili')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize tipo from O/R/S to descriptive if needed
|
$legacyYear = $anno !== null && $anno !== '' ? (string) $anno : null;
|
||||||
$map = ['O' => 'ordinaria', 'R' => 'riscaldamento', 'S' => 'straordinaria'];
|
$stabileId = $this->stabileId ?: $this->resolveStabileId();
|
||||||
$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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In dry-run, avoid creating missing gestione
|
return $this->resolveGestioneContabileIdForStabile(
|
||||||
if ($this->option('dry-run')) {
|
$stabileId,
|
||||||
return null;
|
$legacyYear,
|
||||||
}
|
$tipo !== null ? (string) $tipo : null,
|
||||||
|
is_numeric($numeroStraordinaria) ? (int) $numeroStraordinaria : 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function dynamicInsert(string $table, array $data): void
|
private function dynamicInsert(string $table, array $data): void
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,21 @@
|
||||||
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Process;
|
||||||
|
|
||||||
class NetgesconNodeRefreshCommand extends Command
|
class NetgesconNodeRefreshCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'netgescon:node-refresh
|
protected $signature = 'netgescon:node-refresh
|
||||||
{--remote=origin : Remote Git da usare}
|
{--remote=origin : Remote Git da usare}
|
||||||
{--branch=main : Branch da allineare}
|
{--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}
|
{--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-migrate : Salta php artisan migrate --force}
|
||||||
|
{--skip-maintenance : Salta php artisan down/up (solo test/debug)}
|
||||||
{--skip-view-cache : Salta rebuild view cache}
|
{--skip-view-cache : Salta rebuild view cache}
|
||||||
{--progress-file= : File JSON dove scrivere lo stato avanzamento}';
|
{--progress-file= : File JSON dove scrivere lo stato avanzamento}';
|
||||||
|
|
||||||
|
|
@ -20,12 +27,73 @@ public function handle(): int
|
||||||
{
|
{
|
||||||
$remote = trim((string) $this->option('remote')) ?: 'origin';
|
$remote = trim((string) $this->option('remote')) ?: 'origin';
|
||||||
$branch = trim((string) $this->option('branch')) ?: 'main';
|
$branch = trim((string) $this->option('branch')) ?: 'main';
|
||||||
|
$adminId = (int) $this->option('admin-id');
|
||||||
$force = (bool) $this->option('force');
|
$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');
|
$skipMigrate = (bool) $this->option('skip-migrate');
|
||||||
|
$skipMaintenance = (bool) $this->option('skip-maintenance');
|
||||||
$skipViewCache = (bool) $this->option('skip-view-cache');
|
$skipViewCache = (bool) $this->option('skip-view-cache');
|
||||||
|
$maintenanceOn = false;
|
||||||
|
$maintenanceError = null;
|
||||||
|
|
||||||
$this->writeProgress(2, 'Avvio refresh nodo da Gitea', 'running');
|
$this->writeProgress(2, 'Avvio refresh nodo da Gitea', 'running');
|
||||||
|
|
||||||
|
$fullOutput = [];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ($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 = [
|
$gitParams = [
|
||||||
'--remote' => $remote,
|
'--remote' => $remote,
|
||||||
'--branch' => $branch,
|
'--branch' => $branch,
|
||||||
|
|
@ -36,7 +104,7 @@ public function handle(): int
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("Sync Git da {$remote}/{$branch}...");
|
$this->info("Sync Git da {$remote}/{$branch}...");
|
||||||
$this->writeProgress(15, 'Sync Git da Gitea in corso', 'running');
|
$this->writeProgress(28, 'Sync Git da Gitea in corso', 'running');
|
||||||
$gitExit = Artisan::call('netgescon:git-sync', $gitParams);
|
$gitExit = Artisan::call('netgescon:git-sync', $gitParams);
|
||||||
$gitOutput = trim((string) Artisan::output());
|
$gitOutput = trim((string) Artisan::output());
|
||||||
|
|
||||||
|
|
@ -48,14 +116,54 @@ public function handle(): int
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$fullOutput = [
|
$fullOutput[] = 'NODE REFRESH: netgescon:git-sync';
|
||||||
'NODE REFRESH: netgescon:git-sync',
|
$fullOutput[] = $gitOutput;
|
||||||
$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) {
|
if (! $skipMigrate) {
|
||||||
$this->info('Esecuzione migrate --force...');
|
$this->info('Esecuzione migrate --force...');
|
||||||
$this->writeProgress(55, 'Applicazione migration', 'running');
|
$this->writeProgress(76, 'Applicazione migration', 'running');
|
||||||
$migrateExit = Artisan::call('migrate', ['--force' => true]);
|
$migrateExit = Artisan::call('migrate', ['--force' => true]);
|
||||||
$migrateOutput = trim((string) Artisan::output());
|
$migrateOutput = trim((string) Artisan::output());
|
||||||
$fullOutput[] = 'NODE REFRESH: php artisan migrate --force';
|
$fullOutput[] = 'NODE REFRESH: php artisan migrate --force';
|
||||||
|
|
@ -70,24 +178,9 @@ public function handle(): int
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$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) {
|
if (! $skipViewCache) {
|
||||||
$this->info('Ricostruzione view cache...');
|
$this->info('Ricostruzione view cache...');
|
||||||
$this->writeProgress(88, 'Ricostruzione view cache', 'running');
|
$this->writeProgress(84, 'Ricostruzione view cache', 'running');
|
||||||
|
|
||||||
$viewClearExit = Artisan::call('view:clear');
|
$viewClearExit = Artisan::call('view:clear');
|
||||||
$viewClearOutput = trim((string) Artisan::output());
|
$viewClearOutput = trim((string) Artisan::output());
|
||||||
|
|
@ -116,12 +209,108 @@ public function handle(): int
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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->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;
|
||||||
|
}
|
||||||
|
|
||||||
$this->writeProgress(100, 'Refresh nodo completato', 'completed', 0);
|
$this->writeProgress(100, 'Refresh nodo completato', 'completed', 0);
|
||||||
|
|
||||||
return self::SUCCESS;
|
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
|
private function writeProgress(int $percent, string $message, string $status, ?int $exitCode = null): void
|
||||||
{
|
{
|
||||||
$path = trim((string) $this->option('progress-file'));
|
$path = trim((string) $this->option('progress-file'));
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Fornitore;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
@ -70,24 +71,7 @@ public function handle(): int
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
$fornitoreMap = [];
|
$fornitoreMap = $this->buildFornitoreMapByLegacyCode($codes);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$created = 0;
|
$created = 0;
|
||||||
$updated = 0;
|
$updated = 0;
|
||||||
|
|
@ -415,13 +399,15 @@ private function resolveFatturaElettronicaId(int $stabileId, int $fornitoreId, s
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$fornitoreIds = $this->resolveEquivalentFornitoreIds($fornitoreId);
|
||||||
|
|
||||||
$base = DB::table('fatture_elettroniche')
|
$base = DB::table('fatture_elettroniche')
|
||||||
->where('stabile_id', $stabileId)
|
->where('stabile_id', $stabileId)
|
||||||
|
->whereIn('fornitore_id', $fornitoreIds)
|
||||||
->whereDate('data_fattura', $dataDocumento)
|
->whereDate('data_fattura', $dataDocumento)
|
||||||
->whereRaw('LOWER(TRIM(numero_fattura)) = ?', [$numeroNorm]);
|
->whereRaw('LOWER(TRIM(numero_fattura)) = ?', [$numeroNorm]);
|
||||||
|
|
||||||
$strict = (clone $base)
|
$strict = (clone $base)
|
||||||
->where('fornitore_id', $fornitoreId)
|
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->value('id');
|
->value('id');
|
||||||
|
|
||||||
|
|
@ -444,4 +430,118 @@ private function resolveFatturaElettronicaId(int $stabileId, int $fornitoreId, s
|
||||||
|
|
||||||
return null;
|
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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
470
app/Filament/Pages/Condomini/ContrattiHub.php
Normal file
470
app/Filament/Pages/Condomini/ContrattiHub.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -195,13 +195,7 @@ public function ensureAcquaAceaContract(): void
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$fornitoreAcea = Fornitore::query()
|
$fornitoreAcea = $this->resolvePreferredAcquaSupplier($stabileId);
|
||||||
->whereRaw('LOWER(ragione_sociale) like ?', ['%acea%ato2%'])
|
|
||||||
->orderBy('ragione_sociale')
|
|
||||||
->first() ?? Fornitore::query()
|
|
||||||
->whereRaw('LOWER(ragione_sociale) like ?', ['%acea%'])
|
|
||||||
->orderBy('ragione_sociale')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $fornitoreAcea) {
|
if (! $fornitoreAcea) {
|
||||||
Notification::make()->title('Fornitore Acea Ato2 non trovato in anagrafica fornitori.')->warning()->send();
|
Notification::make()->title('Fornitore Acea Ato2 non trovato in anagrafica fornitori.')->warning()->send();
|
||||||
|
|
@ -1241,11 +1235,7 @@ public function getAcquaContrattoStabileProperty(): array
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$fornitoreSuggerito = Fornitore::query()
|
$fornitoreSuggerito = $this->resolvePreferredAcquaSupplier($stabileId);
|
||||||
->whereRaw('LOWER(ragione_sociale) like ?', ['%acea%ato2%'])
|
|
||||||
->value('ragione_sociale') ?? Fornitore::query()
|
|
||||||
->whereRaw('LOWER(ragione_sociale) like ?', ['%acea%'])
|
|
||||||
->value('ragione_sociale');
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'servizio_id' => $servizio?->id,
|
'servizio_id' => $servizio?->id,
|
||||||
|
|
@ -1256,7 +1246,7 @@ public function getAcquaContrattoStabileProperty(): array
|
||||||
'codice_cliente' => $servizio?->codice_cliente,
|
'codice_cliente' => $servizio?->codice_cliente,
|
||||||
'codice_contratto' => $servizio?->codice_contratto,
|
'codice_contratto' => $servizio?->codice_contratto,
|
||||||
'matricola' => $servizio?->contatore_matricola,
|
'matricola' => $servizio?->contatore_matricola,
|
||||||
'suggerito_fornitore' => $fornitoreSuggerito ? (string) $fornitoreSuggerito : null,
|
'suggerito_fornitore' => $fornitoreSuggerito?->ragione_sociale,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1429,6 +1419,8 @@ public function getAcquaFatturePerGestioneProperty(): array
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
|
$fornitoreIds = $this->resolveEquivalentFornitoreIds($fornitoreIds);
|
||||||
|
|
||||||
if (Schema::hasTable('fornitori') && Schema::hasColumn('fornitori', 'fe_features')) {
|
if (Schema::hasTable('fornitori') && Schema::hasColumn('fornitori', 'fe_features')) {
|
||||||
$waterEnabledSupplierIds = Fornitore::query()
|
$waterEnabledSupplierIds = Fornitore::query()
|
||||||
->where(function (Builder $query) use ($stabileId): Builder {
|
->where(function (Builder $query) use ($stabileId): Builder {
|
||||||
|
|
@ -1452,7 +1444,17 @@ public function getAcquaFatturePerGestioneProperty(): array
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->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 !== []) {
|
if ($fornitoreIds !== []) {
|
||||||
|
|
@ -1557,6 +1559,192 @@ public function getAcquaFatturePerGestioneProperty(): array
|
||||||
return $out;
|
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>> */
|
/** @return array<int, array<string,mixed>> */
|
||||||
public function getAcquaLettureCondominiProperty(): array
|
public function getAcquaLettureCondominiProperty(): array
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
use App\Models\CommunicationMessage;
|
use App\Models\CommunicationMessage;
|
||||||
use App\Models\Documento;
|
use App\Models\Documento;
|
||||||
use App\Models\DocumentoCollegato;
|
use App\Models\DocumentoCollegato;
|
||||||
|
use App\Models\DocumentoStabile;
|
||||||
use App\Models\InsuranceClaim;
|
use App\Models\InsuranceClaim;
|
||||||
use App\Models\InsurancePolicy;
|
use App\Models\InsurancePolicy;
|
||||||
use App\Models\RubricaUniversale;
|
use App\Models\RubricaUniversale;
|
||||||
|
|
@ -68,6 +69,10 @@ class StabilePage extends Page
|
||||||
|
|
||||||
public ?string $gestioneDataInizio = null;
|
public ?string $gestioneDataInizio = null;
|
||||||
|
|
||||||
|
public ?string $presaInCaricoAmministrazione = null;
|
||||||
|
|
||||||
|
public $verbaleNominaUpload = null;
|
||||||
|
|
||||||
public ?string $gestioneDataFine = null;
|
public ?string $gestioneDataFine = null;
|
||||||
|
|
||||||
/** @var array<int> */
|
/** @var array<int> */
|
||||||
|
|
@ -274,6 +279,97 @@ public function toggleMostraRiscaldamento(): void
|
||||||
$this->dispatch('refresh-gestioni-stabile');
|
$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
|
public function savePeriodiEmissione(): void
|
||||||
{
|
{
|
||||||
if (! $this->stabile) {
|
if (! $this->stabile) {
|
||||||
|
|
@ -1037,6 +1133,9 @@ private function initializeOfficialMailSettings(): void
|
||||||
if ($this->officialMailboxes === []) {
|
if ($this->officialMailboxes === []) {
|
||||||
$this->officialMailboxes = $this->buildDefaultOfficialMailboxes();
|
$this->officialMailboxes = $this->buildDefaultOfficialMailboxes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->presaInCaricoAmministrazione = data_get($config, 'amministrazione.presa_in_carico_dal')
|
||||||
|
?: ($this->stabile->data_nomina?->format('Y-m-d'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,16 @@
|
||||||
use App\Modules\Contabilita\Models\MovimentoBanca;
|
use App\Modules\Contabilita\Models\MovimentoBanca;
|
||||||
use App\Modules\Contabilita\Models\PianoConti;
|
use App\Modules\Contabilita\Models\PianoConti;
|
||||||
use App\Modules\Contabilita\Models\SaldoConto;
|
use App\Modules\Contabilita\Models\SaldoConto;
|
||||||
use App\Modules\Contabilita\Services\MovimentoBancaRiconciliazioneService;
|
|
||||||
use App\Modules\Contabilita\Services\MovimentoBancaPrimaNotaService;
|
use App\Modules\Contabilita\Services\MovimentoBancaPrimaNotaService;
|
||||||
|
use App\Modules\Contabilita\Services\MovimentoBancaRiconciliazioneService;
|
||||||
use App\Modules\Contabilita\Services\PagamentoFornitorePrimaNotaService;
|
use App\Modules\Contabilita\Services\PagamentoFornitorePrimaNotaService;
|
||||||
use App\Services\Contabilita\ExcelMovimentiParser;
|
use App\Services\Contabilita\ExcelMovimentiParser;
|
||||||
use App\Services\Contabilita\IntesaCsvParser;
|
use App\Services\Contabilita\IntesaCsvParser;
|
||||||
use App\Services\Contabilita\MovimentiBancaImporter;
|
use App\Services\Contabilita\MovimentiBancaImporter;
|
||||||
use App\Services\Contabilita\MpsQifParser;
|
use App\Services\Contabilita\MpsQifParser;
|
||||||
use App\Services\Contabilita\UnicreditWriParser;
|
use App\Services\Contabilita\UnicreditWriParser;
|
||||||
use App\Support\ArchivioPaths;
|
|
||||||
use App\Support\AnnoGestioneContext;
|
use App\Support\AnnoGestioneContext;
|
||||||
|
use App\Support\ArchivioPaths;
|
||||||
use App\Support\GestioneContext;
|
use App\Support\GestioneContext;
|
||||||
use App\Support\StabileContext;
|
use App\Support\StabileContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\BadgeColumn;
|
use Filament\Tables\Columns\IconColumn;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
|
@ -795,16 +795,7 @@ protected function getHeaderActions(): array
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return GestioneContabile::query()
|
return $this->getVisibleGestioneOptions($stabileId);
|
||||||
->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();
|
|
||||||
})
|
})
|
||||||
->default(function (): ?string {
|
->default(function (): ?string {
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
@ -1277,16 +1268,7 @@ public function table(Table $table): Table
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return GestioneContabile::query()
|
return $this->getVisibleGestioneOptions($stabileId);
|
||||||
->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();
|
|
||||||
})
|
})
|
||||||
->searchable(),
|
->searchable(),
|
||||||
])
|
])
|
||||||
|
|
@ -1310,6 +1292,13 @@ public function table(Table $table): Table
|
||||||
->date('d/m/Y')
|
->date('d/m/Y')
|
||||||
->sortable(),
|
->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')
|
TextColumn::make('valuta')
|
||||||
->label('Valuta')
|
->label('Valuta')
|
||||||
->date('d/m/Y'),
|
->date('d/m/Y'),
|
||||||
|
|
@ -1359,8 +1348,9 @@ public function table(Table $table): Table
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('dettaglio_movimento')
|
Action::make('dettaglio_movimento')
|
||||||
->label('Apri dati')
|
->hiddenLabel()
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
|
->tooltip('Apri dati')
|
||||||
->modalWidth('7xl')
|
->modalWidth('7xl')
|
||||||
->action(function (MovimentoBanca $record): void {
|
->action(function (MovimentoBanca $record): void {
|
||||||
$this->detailMovementId = $record->id;
|
$this->detailMovementId = $record->id;
|
||||||
|
|
@ -1375,8 +1365,9 @@ public function table(Table $table): Table
|
||||||
]);
|
]);
|
||||||
}),
|
}),
|
||||||
Action::make('conferma_movimento')
|
Action::make('conferma_movimento')
|
||||||
->label('Conferma')
|
->hiddenLabel()
|
||||||
->icon('heroicon-o-check-circle')
|
->icon('heroicon-o-check-circle')
|
||||||
|
->tooltip('Conferma')
|
||||||
->visible(fn(MovimentoBanca $record): bool => (bool) ($record->da_confermare ?? false))
|
->visible(fn(MovimentoBanca $record): bool => (bool) ($record->da_confermare ?? false))
|
||||||
->action(function (MovimentoBanca $record): void {
|
->action(function (MovimentoBanca $record): void {
|
||||||
if (! Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) {
|
if (! Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) {
|
||||||
|
|
@ -1408,16 +1399,7 @@ public function table(Table $table): Table
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return GestioneContabile::query()
|
return $this->getVisibleGestioneOptions($stabileId);
|
||||||
->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();
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
DatePicker::make('data')
|
DatePicker::make('data')
|
||||||
|
|
@ -1531,8 +1513,9 @@ public function table(Table $table): Table
|
||||||
}),
|
}),
|
||||||
|
|
||||||
Action::make('genera_prima_nota')
|
Action::make('genera_prima_nota')
|
||||||
->label('Genera prima nota')
|
->hiddenLabel()
|
||||||
->icon('heroicon-o-book-open')
|
->icon('heroicon-o-book-open')
|
||||||
|
->tooltip('Genera prima nota')
|
||||||
->visible(function (MovimentoBanca $record): bool {
|
->visible(function (MovimentoBanca $record): bool {
|
||||||
if (! Schema::hasColumn('contabilita_movimenti_banca', 'registrazione_id')) {
|
if (! Schema::hasColumn('contabilita_movimenti_banca', 'registrazione_id')) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -1557,19 +1540,7 @@ public function table(Table $table): Table
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return GestioneContabile::query()
|
return $this->getVisibleGestioneOptions($stabileId);
|
||||||
->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();
|
|
||||||
})
|
})
|
||||||
->searchable()
|
->searchable()
|
||||||
->visible(fn(MovimentoBanca $record) => empty($record->gestione_id))
|
->visible(fn(MovimentoBanca $record) => empty($record->gestione_id))
|
||||||
|
|
@ -1633,19 +1604,7 @@ public function table(Table $table): Table
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return GestioneContabile::query()
|
return $this->getVisibleGestioneOptions($stabileId);
|
||||||
->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();
|
|
||||||
})
|
})
|
||||||
->searchable()
|
->searchable()
|
||||||
->visible(fn(MovimentoBanca $record) => empty($record->gestione_id))
|
->visible(fn(MovimentoBanca $record) => empty($record->gestione_id))
|
||||||
|
|
@ -2076,6 +2035,7 @@ protected function resolveDefaultGestioneId(int $stabileId): ?string
|
||||||
|
|
||||||
$anno = AnnoGestioneContext::resolveActiveAnno();
|
$anno = AnnoGestioneContext::resolveActiveAnno();
|
||||||
$tipo = GestioneContext::resolveActiveGestione();
|
$tipo = GestioneContext::resolveActiveGestione();
|
||||||
|
$stabile = Stabile::query()->find($stabileId);
|
||||||
|
|
||||||
$match = GestioneContabile::query()
|
$match = GestioneContabile::query()
|
||||||
->where('stabile_id', $stabileId)
|
->where('stabile_id', $stabileId)
|
||||||
|
|
@ -2086,6 +2046,10 @@ protected function resolveDefaultGestioneId(int $stabileId): ?string
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
if ($match && ! $this->isGestioneVisibleForStabile($match, $stabile)) {
|
||||||
|
$match = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $match) {
|
if (! $match) {
|
||||||
$match = GestioneContabile::query()
|
$match = GestioneContabile::query()
|
||||||
->where('stabile_id', $stabileId)
|
->where('stabile_id', $stabileId)
|
||||||
|
|
@ -2093,11 +2057,96 @@ protected function resolveDefaultGestioneId(int $stabileId): ?string
|
||||||
->orderByDesc('gestione_attiva')
|
->orderByDesc('gestione_attiva')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->first();
|
->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 $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
|
protected function resolveGestioneForYear(int $stabileId, int $year, ?string $preferredType = null): ?GestioneContabile
|
||||||
{
|
{
|
||||||
if (! DB::getSchemaBuilder()->hasTable('gestioni_contabili')) {
|
if (! DB::getSchemaBuilder()->hasTable('gestioni_contabili')) {
|
||||||
|
|
@ -3129,6 +3178,10 @@ protected function extractFatturaPaymentHints(FatturaFornitore $fattura): array
|
||||||
$hints['sia'] = trim((string) $payment['sia']);
|
$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']) !== '') {
|
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']));
|
$hints['voce_spesa_code'] = strtoupper(trim((string) $payload['voce_spesa_code']));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Filament\Pages\Contabilita;
|
namespace App\Filament\Pages\Contabilita;
|
||||||
|
|
||||||
use App\Models\DatiBancari;
|
use App\Models\DatiBancari;
|
||||||
|
|
@ -9,10 +8,10 @@
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Contabilita\Models\MovimentoBanca;
|
use App\Modules\Contabilita\Models\MovimentoBanca;
|
||||||
use App\Modules\Contabilita\Models\SaldoConto;
|
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\MovimentiBancaImporter;
|
||||||
use App\Services\Contabilita\UnicreditWriParser;
|
use App\Services\Contabilita\UnicreditWriParser;
|
||||||
use App\Services\Contabilita\IntesaCsvParser;
|
|
||||||
use App\Services\Contabilita\ExcelMovimentiParser;
|
|
||||||
use App\Support\AnnoGestioneContext;
|
use App\Support\AnnoGestioneContext;
|
||||||
use App\Support\GestioneContext;
|
use App\Support\GestioneContext;
|
||||||
use App\Support\StabileContext;
|
use App\Support\StabileContext;
|
||||||
|
|
|
||||||
|
|
@ -748,35 +748,18 @@ public function getGestioniStraordinarieList(): array
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$stabile = Stabile::query()->find($stabileId);
|
||||||
|
|
||||||
return GestioneContabile::query()
|
return GestioneContabile::query()
|
||||||
->where('stabile_id', $stabileId)
|
->where('stabile_id', $stabileId)
|
||||||
->where('tipo_gestione', 'straordinaria')
|
->where('tipo_gestione', 'straordinaria')
|
||||||
|
->where('stato', 'aperta')
|
||||||
->orderByDesc('anno_gestione')
|
->orderByDesc('anno_gestione')
|
||||||
->orderByDesc('numero_straordinaria')
|
->orderByDesc('numero_straordinaria')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->get(['id', 'anno_gestione', 'tipo_gestione', 'denominazione', 'protocollo_prefix', 'numero_straordinaria', 'stato'])
|
->get(['id', 'anno_gestione', 'tipo_gestione', 'denominazione', 'protocollo_prefix', 'numero_straordinaria', 'stato'])
|
||||||
->map(function (GestioneContabile $g): array {
|
->filter(fn(GestioneContabile $g): bool => $this->isGestioneVisibleForStabile($g, $stabile))
|
||||||
$label = trim((string) ($g->denominazione ?? ''));
|
->map(fn(GestioneContabile $g): array=> ['id' => (int) $g->id, 'label' => $this->formatGestioneLabel($g, $stabile)])
|
||||||
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];
|
|
||||||
})
|
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1104,47 +1087,10 @@ public function header(Schema $schema): Schema
|
||||||
->schema([
|
->schema([
|
||||||
Select::make('gestione_id')
|
Select::make('gestione_id')
|
||||||
->label('Gestione')
|
->label('Gestione')
|
||||||
->options(function (): array {
|
->options(fn(): array=> $this->getVisibleGestioneOptions())
|
||||||
$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();
|
|
||||||
})
|
|
||||||
->searchable()
|
->searchable()
|
||||||
->required()
|
->required()
|
||||||
->helperText('Ordinaria / riscaldamento / straordinaria (per anno).'),
|
->helperText('Solo gestioni aperte e coerenti con lo stabile attivo.'),
|
||||||
|
|
||||||
Select::make('causale_contabile')
|
Select::make('causale_contabile')
|
||||||
->label('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
|
private function resolveRiscaldamentoGestioneByDate(int $stabileId, string $date): int
|
||||||
{
|
{
|
||||||
|
$stabile = Stabile::query()->find($stabileId);
|
||||||
|
if ($stabile instanceof Stabile && ! $stabile->hasOperationalHeating()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$dt = Carbon::parse($date);
|
$dt = Carbon::parse($date);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
|
|
@ -2561,7 +2512,8 @@ private function resolveRiscaldamentoGestioneByDate(int $stabileId, string $date
|
||||||
|
|
||||||
$q = GestioneContabile::query()
|
$q = GestioneContabile::query()
|
||||||
->where('stabile_id', $stabileId)
|
->where('stabile_id', $stabileId)
|
||||||
->where('tipo_gestione', 'riscaldamento');
|
->where('tipo_gestione', 'riscaldamento')
|
||||||
|
->where('stato', 'aperta');
|
||||||
|
|
||||||
$match = (clone $q)
|
$match = (clone $q)
|
||||||
->whereNotNull('data_inizio')
|
->whereNotNull('data_inizio')
|
||||||
|
|
@ -2595,6 +2547,8 @@ private function resolveDefaultGestioneId(int $stabileId, ?int $year = null): in
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$stabile = Stabile::query()->find($stabileId);
|
||||||
|
|
||||||
$baseQuery = GestioneContabile::query()
|
$baseQuery = GestioneContabile::query()
|
||||||
->where('stabile_id', $stabileId)
|
->where('stabile_id', $stabileId)
|
||||||
->where('stato', 'aperta');
|
->where('stato', 'aperta');
|
||||||
|
|
@ -2603,6 +2557,16 @@ private function resolveDefaultGestioneId(int $stabileId, ?int $year = null): in
|
||||||
$baseQuery->where('anno_gestione', $year);
|
$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)
|
$ordinariaId = (clone $baseQuery)
|
||||||
->where('tipo_gestione', 'ordinaria')
|
->where('tipo_gestione', 'ordinaria')
|
||||||
->orderByDesc('anno_gestione')
|
->orderByDesc('anno_gestione')
|
||||||
|
|
@ -2621,6 +2585,80 @@ private function resolveDefaultGestioneId(int $stabileId, ?int $year = null): in
|
||||||
return is_numeric($fallbackId) ? (int) $fallbackId : 0;
|
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
|
public function getLinkedFatturaElettronica(): ?FatturaElettronica
|
||||||
{
|
{
|
||||||
if ($this->linkedFeCache !== null) {
|
if ($this->linkedFeCache !== null) {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Filament\Pages\Contabilita;
|
namespace App\Filament\Pages\Contabilita;
|
||||||
|
|
||||||
use App\Models\DatiBancari;
|
use App\Models\DatiBancari;
|
||||||
use App\Models\DocumentoStabile;
|
use App\Models\DocumentoStabile;
|
||||||
use App\Models\FatturaElettronica;
|
use App\Models\FatturaElettronica;
|
||||||
use App\Models\GestioneContabile;
|
|
||||||
use App\Models\PersonaUnitaRelazione;
|
use App\Models\PersonaUnitaRelazione;
|
||||||
use App\Models\RegistroRitenuteAcconto;
|
use App\Models\RegistroRitenuteAcconto;
|
||||||
use App\Models\Stabile;
|
use App\Models\Stabile;
|
||||||
|
|
@ -15,6 +13,7 @@
|
||||||
use App\Modules\Contabilita\Models\MovimentoBanca;
|
use App\Modules\Contabilita\Models\MovimentoBanca;
|
||||||
use App\Modules\Contabilita\Models\SaldoConto;
|
use App\Modules\Contabilita\Models\SaldoConto;
|
||||||
use App\Support\StabileContext;
|
use App\Support\StabileContext;
|
||||||
|
use BackedEnum;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
@ -25,7 +24,6 @@
|
||||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
use BackedEnum;
|
|
||||||
|
|
||||||
class SituazioneIniziale extends Page
|
class SituazioneIniziale extends Page
|
||||||
{
|
{
|
||||||
|
|
@ -1631,7 +1629,6 @@ private function loadLegacyYears(?string $codStabileLegacy): array
|
||||||
return $years;
|
return $years;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private function emptyBilancio(): array
|
private function emptyBilancio(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
@ -2709,7 +2706,6 @@ private function computeConguagliExcel(string $codStabileLegacy, ?string $legacy
|
||||||
$matrix[$idCond]['cells'][$key] = (float) ($r['totale'] ?? 0.0);
|
$matrix[$idCond]['cells'][$key] = (float) ($r['totale'] ?? 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
$out = array_values($matrix);
|
$out = array_values($matrix);
|
||||||
usort($out, fn($a, $b) => (int) ($a['id_cond'] ?? 0) <=> (int) ($b['id_cond'] ?? 0));
|
usort($out, fn($a, $b) => (int) ($a['id_cond'] ?? 0) <=> (int) ($b['id_cond'] ?? 0));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Filament\Pages\Contabilita;
|
namespace App\Filament\Pages\Contabilita;
|
||||||
|
|
||||||
use App\Filament\Pages\Condomini\TabelleMillesimaliProspetto;
|
use App\Filament\Pages\Condomini\TabelleMillesimaliProspetto;
|
||||||
use App\Filament\Pages\Contabilita\VoceSpesaMastrino;
|
use App\Filament\Pages\Contabilita\VoceSpesaMastrino;
|
||||||
|
use App\Models\GestioneContabile;
|
||||||
use App\Models\RipartizioneSpeseInquilini;
|
use App\Models\RipartizioneSpeseInquilini;
|
||||||
use App\Models\TabellaMillesimale;
|
use App\Models\TabellaMillesimale;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\VoceSpesa;
|
use App\Models\VoceSpesa;
|
||||||
use App\Models\GestioneContabile;
|
|
||||||
use App\Support\StabileContext;
|
|
||||||
use App\Support\AnnoGestioneContext;
|
use App\Support\AnnoGestioneContext;
|
||||||
|
use App\Support\StabileContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@
|
||||||
use App\Modules\Contabilita\Models\FatturaFornitore as ContabilitaFatturaFornitore;
|
use App\Modules\Contabilita\Models\FatturaFornitore as ContabilitaFatturaFornitore;
|
||||||
use App\Modules\Contabilita\Models\PianoConti;
|
use App\Modules\Contabilita\Models\PianoConti;
|
||||||
use App\Services\Catalog\FornitoreProductCatalogService;
|
use App\Services\Catalog\FornitoreProductCatalogService;
|
||||||
use App\Services\Catalog\ProductHubViewService;
|
|
||||||
use App\Services\Catalog\ProductAssetIngestionService;
|
use App\Services\Catalog\ProductAssetIngestionService;
|
||||||
|
use App\Services\Catalog\ProductHubViewService;
|
||||||
use App\Services\Catalog\ProductOfferService;
|
use App\Services\Catalog\ProductOfferService;
|
||||||
use App\Services\Consumi\AcquaPdfTextParser;
|
use App\Services\Consumi\AcquaPdfTextParser;
|
||||||
use App\Services\Consumi\ConsumiAcquaIngestionService;
|
use App\Services\Consumi\ConsumiAcquaIngestionService;
|
||||||
|
|
|
||||||
33
app/Filament/Pages/Mobile/DocumentiMobile.php
Normal file
33
app/Filament/Pages/Mobile/DocumentiMobile.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -862,11 +862,33 @@ public function getGenericLabelStableFieldOptionsProperty(): array
|
||||||
'stabile_code' => 'Codice stabile',
|
'stabile_code' => 'Codice stabile',
|
||||||
'stabile_address' => 'Indirizzo stabile',
|
'stabile_address' => 'Indirizzo stabile',
|
||||||
'stabile_summary' => 'Stabile compatto',
|
'stabile_summary' => 'Stabile compatto',
|
||||||
|
'none' => 'Nessuna riga',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
public function getGenericLabelLineModeOptionsProperty(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'source' => 'Campo archivio',
|
||||||
|
'text' => 'Testo libero',
|
||||||
'blank_line' => 'Riga bianca scrivibile',
|
'blank_line' => 'Riga bianca scrivibile',
|
||||||
'none' => 'Nessuna riga',
|
'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> */
|
/** @return array<string, mixed> */
|
||||||
public function getGenericLabelPreviewProperty(): array
|
public function getGenericLabelPreviewProperty(): array
|
||||||
{
|
{
|
||||||
|
|
@ -925,14 +947,7 @@ public function getGenericLabelPreviewProperty(): array
|
||||||
'amazon_url' => $amazonUrl,
|
'amazon_url' => $amazonUrl,
|
||||||
];
|
];
|
||||||
|
|
||||||
$stableLineOneSource = (string) ($this->genericLabelForm['stable_line_one_source'] ?? 'stabile_summary');
|
$stableLines = $this->buildGenericLabelLineRows($payload);
|
||||||
$stableLineTwoSource = (string) ($this->genericLabelForm['stable_line_two_source'] ?? 'blank_line');
|
|
||||||
$blankLinesCount = max(0, min(4, (int) ($this->genericLabelForm['blank_lines_count'] ?? 2)));
|
|
||||||
|
|
||||||
$stableLines = [
|
|
||||||
$this->resolveGenericLabelStableLineValue($stableLineOneSource, $payload),
|
|
||||||
$this->resolveGenericLabelStableLineValue($stableLineTwoSource, $payload),
|
|
||||||
];
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'template_name' => $templateName,
|
'template_name' => $templateName,
|
||||||
|
|
@ -948,8 +963,15 @@ public function getGenericLabelPreviewProperty(): array
|
||||||
],
|
],
|
||||||
'payload' => $payload,
|
'payload' => $payload,
|
||||||
'mnemonic_code' => $mnemonicCode,
|
'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,
|
'stable_lines' => $stableLines,
|
||||||
'blank_lines' => array_fill(0, $blankLinesCount, ''),
|
'blank_lines' => [],
|
||||||
'amazon_url' => $amazonUrl,
|
'amazon_url' => $amazonUrl,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -1296,6 +1318,12 @@ public function saveGenericArchiveLabel(): void
|
||||||
'blank_lines_count' => max(0, min(4, (int) ($this->genericLabelForm['blank_lines_count'] ?? 2))),
|
'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_position' => $this->normalizeNullableText($this->genericLabelForm['qr_position'] ?? null) ?: 'right-center',
|
||||||
'qr_scale' => $this->normalizeNullableText($this->genericLabelForm['qr_scale'] ?? null) ?: 'md',
|
'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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$source->forceFill([
|
$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,
|
'parent_id' => (int) $target->id,
|
||||||
'updated_by' => Auth::id(),
|
'updated_by' => Auth::id(),
|
||||||
])->save();
|
'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 = [
|
$this->physicalArchiveMoveForm = [
|
||||||
'source_code' => '',
|
'source_code' => '',
|
||||||
'target_code' => '',
|
'target_code' => '',
|
||||||
|
'supporto_fisico' => '',
|
||||||
|
'magazzino' => '',
|
||||||
|
'scaffale' => '',
|
||||||
|
'ripiano' => '',
|
||||||
|
'ubicazione_dettaglio' => '',
|
||||||
];
|
];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Movimentazione registrata')
|
->title('Movimentazione registrata')
|
||||||
->body($source->codice_univoco . ' → ' . $target->codice_univoco)
|
->body(trim($source->codice_univoco . ' → ' . $target->codice_univoco . ($ubicazione !== '' ? ' · ' . $ubicazione : '')))
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
@ -1856,6 +1907,11 @@ private function initializePhysicalArchiveForms(): void
|
||||||
$this->physicalArchiveMoveForm = [
|
$this->physicalArchiveMoveForm = [
|
||||||
'source_code' => '',
|
'source_code' => '',
|
||||||
'target_code' => '',
|
'target_code' => '',
|
||||||
|
'supporto_fisico' => '',
|
||||||
|
'magazzino' => '',
|
||||||
|
'scaffale' => '',
|
||||||
|
'ripiano' => '',
|
||||||
|
'ubicazione_dettaglio' => '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1886,6 +1942,23 @@ private function initializeGenericLabelForm(): void
|
||||||
'blank_lines_count' => 2,
|
'blank_lines_count' => 2,
|
||||||
'qr_position' => 'bottom-right',
|
'qr_position' => 'bottom-right',
|
||||||
'qr_scale' => 'lg',
|
'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 = '';
|
$this->genericLabelTemplateLabel = '';
|
||||||
|
|
@ -2062,7 +2135,22 @@ public function applyGenericLabelPreset(string $presetKey): void
|
||||||
'blank_lines_count' => max(0, min(4, (int) ($preset['blank_lines_count'] ?? 2))),
|
'blank_lines_count' => max(0, min(4, (int) ($preset['blank_lines_count'] ?? 2))),
|
||||||
'qr_position' => (string) ($preset['qr_position'] ?? 'right-center'),
|
'qr_position' => (string) ($preset['qr_position'] ?? 'right-center'),
|
||||||
'qr_scale' => (string) ($preset['qr_scale'] ?? 'md'),
|
'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
|
public function applySelectedGenericLabelPreset(): void
|
||||||
|
|
@ -2329,6 +2417,12 @@ private function buildGenericLabelTemplatePayload(string $label): array
|
||||||
'blank_lines_count' => max(0, min(4, (int) ($this->genericLabelForm['blank_lines_count'] ?? 2))),
|
'blank_lines_count' => max(0, min(4, (int) ($this->genericLabelForm['blank_lines_count'] ?? 2))),
|
||||||
'qr_position' => (string) ($this->genericLabelForm['qr_position'] ?? 'right-center'),
|
'qr_position' => (string) ($this->genericLabelForm['qr_position'] ?? 'right-center'),
|
||||||
'qr_scale' => (string) ($this->genericLabelForm['qr_scale'] ?? 'md'),
|
'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' => [
|
'sources' => [
|
||||||
'title' => (string) ($this->genericLabelForm['title_source'] ?? 'titolo'),
|
'title' => (string) ($this->genericLabelForm['title_source'] ?? 'titolo'),
|
||||||
'subtitle' => (string) ($this->genericLabelForm['subtitle_source'] ?? 'linea_secondaria'),
|
'subtitle' => (string) ($this->genericLabelForm['subtitle_source'] ?? 'linea_secondaria'),
|
||||||
|
|
@ -2376,6 +2470,92 @@ private function resolveGenericLabelStableLineValue(string $fieldKey, array $pay
|
||||||
return trim((string) ($payload[$fieldKey] ?? ''));
|
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
|
private function getDefaultStabileId(): ?int
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Anagrafiche\RubricaPhoneLookupService;
|
use App\Services\Anagrafiche\RubricaPhoneLookupService;
|
||||||
use App\Services\Cti\PbxRoutingService;
|
use App\Services\Cti\PbxRoutingService;
|
||||||
|
use App\Support\StabileContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
|
@ -18,7 +19,6 @@
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use App\Support\StabileContext;
|
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class PostItGestione extends Page
|
class PostItGestione extends Page
|
||||||
|
|
|
||||||
|
|
@ -1783,10 +1783,27 @@ private function startNodeRefreshJob(): void
|
||||||
'--progress-file=' . $progressPath,
|
'--progress-file=' . $progressPath,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$adminId = $this->resolveAmministratoreId();
|
||||||
|
$requireDrive = $this->shouldRequireDrivePreupdateBackup();
|
||||||
|
$driveEnabled = $this->shouldAttemptDrivePreupdateBackup();
|
||||||
|
|
||||||
if ($this->gitForce) {
|
if ($this->gitForce) {
|
||||||
$args[] = '--force';
|
$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));
|
$escaped = implode(' ', array_map(static fn(string $arg): string => escapeshellarg($arg), $args));
|
||||||
$shell = 'nohup ' . $escaped . ' > ' . escapeshellarg($logPath) . ' 2>&1 & echo $!';
|
$shell = 'nohup ' . $escaped . ' > ' . escapeshellarg($logPath) . ' 2>&1 & echo $!';
|
||||||
$started = Process::path(base_path())->run(['bash', '-lc', $shell]);
|
$started = Process::path(base_path())->run(['bash', '-lc', $shell]);
|
||||||
|
|
|
||||||
|
|
@ -133,12 +133,7 @@ public function etichetta(Request $request, Documento $documento, DocumentoArchi
|
||||||
|
|
||||||
$anno = $documento->data_documento?->format('Y') ?: now()->format('Y');
|
$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)));
|
$protocollo = (string) ($documento->numero_protocollo ?: ('DOC-' . $anno . '-' . str_pad((string) $documento->id, 3, '0', STR_PAD_LEFT)));
|
||||||
|
$codiceUnico = $documento->resolveArchiveCode();
|
||||||
$stabileId = (int) ($documento->stabile_id ?: 0);
|
|
||||||
$codiceUnico = $stabileId > 0 ? ('STB-' . $stabileId . '-' . $protocollo) : $protocollo;
|
|
||||||
if ($faldone !== '') {
|
|
||||||
$codiceUnico .= '-F' . Str::upper($faldone);
|
|
||||||
}
|
|
||||||
|
|
||||||
$openUrl = route('filament.documenti.download', $documento);
|
$openUrl = route('filament.documenti.download', $documento);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Documento extends Model
|
class Documento extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -236,6 +237,53 @@ public function getStatoScadenzaAttribute()
|
||||||
return 'valido';
|
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à
|
* Metodi di utilità
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class DocumentoArchivioFisicoItem extends Model
|
class DocumentoArchivioFisicoItem extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -49,11 +50,15 @@ class DocumentoArchivioFisicoItem extends Model
|
||||||
|
|
||||||
public const TIPI = [
|
public const TIPI = [
|
||||||
'documento' => 'Documento cartaceo',
|
'documento' => 'Documento cartaceo',
|
||||||
|
'foglio' => 'Foglio / documento singolo',
|
||||||
|
'cartellina' => 'Cartellina',
|
||||||
'busta_trasparente' => 'Busta trasparente con anelli da archiviare',
|
'busta_trasparente' => 'Busta trasparente con anelli da archiviare',
|
||||||
'cartella_tre_lembi' => 'Cartella a tre lembi',
|
'cartella_tre_lembi' => 'Cartella a tre lembi',
|
||||||
'faldone_anelli' => 'Faldone con anelli',
|
'faldone_anelli' => 'Faldone con anelli',
|
||||||
'faldone_lacci' => 'Faldone con lacci',
|
'faldone_lacci' => 'Faldone con lacci',
|
||||||
'scatola' => 'Scatola',
|
'scatola' => 'Scatola',
|
||||||
|
'scaffale' => 'Scaffale',
|
||||||
|
'posto' => 'Posto / ubicazione finale',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function stabile(): BelongsTo
|
public function stabile(): BelongsTo
|
||||||
|
|
@ -106,7 +111,7 @@ public function getPercorsoFisicoAttribute(): string
|
||||||
|
|
||||||
public function getQrCodeImageAttribute(): 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)) {
|
if ($data !== '' && class_exists(\chillerlan\QRCode\QROptions::class) && class_exists(\chillerlan\QRCode\QRCode::class)) {
|
||||||
$options = new \chillerlan\QRCode\QROptions([
|
$options = new \chillerlan\QRCode\QROptions([
|
||||||
|
|
@ -156,10 +161,34 @@ public static function generateCodice(int $stabileId): string
|
||||||
public function refreshQrCodeData(): void
|
public function refreshQrCodeData(): void
|
||||||
{
|
{
|
||||||
$this->forceFill([
|
$this->forceFill([
|
||||||
'qr_code_data' => $this->buildPublicQrUrl(),
|
'qr_code_data' => $this->buildQrPayload(),
|
||||||
])->saveQuietly();
|
])->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
|
public function buildPublicQrUrl(): string
|
||||||
{
|
{
|
||||||
$relativeSignedPath = URL::temporarySignedRoute(
|
$relativeSignedPath = URL::temporarySignedRoute(
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ class Fornitore extends Model
|
||||||
'iban',
|
'iban',
|
||||||
'intestazione_cc_esatta',
|
'intestazione_cc_esatta',
|
||||||
'bic',
|
'bic',
|
||||||
|
'sia',
|
||||||
'modalita_pagamento_predefinita',
|
'modalita_pagamento_predefinita',
|
||||||
'escludi_righe_fe',
|
'escludi_righe_fe',
|
||||||
'fe_features',
|
'fe_features',
|
||||||
|
|
@ -97,6 +98,18 @@ public function getOperationalConfigSafeAttribute(): array
|
||||||
return is_array($this->operational_config ?? null) ? $this->operational_config : [];
|
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
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::creating(function (Fornitore $fornitore) {
|
static::creating(function (Fornitore $fornitore) {
|
||||||
|
|
|
||||||
15
app/Models/Impostazione.php
Normal file
15
app/Models/Impostazione.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
@ -14,6 +13,7 @@ class Scadenza extends Model
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'legacy_id',
|
'legacy_id',
|
||||||
'stabile_id',
|
'stabile_id',
|
||||||
|
'stabile_contratto_id',
|
||||||
'codice_stabile_legacy',
|
'codice_stabile_legacy',
|
||||||
'descrizione_sintetica',
|
'descrizione_sintetica',
|
||||||
'data_scadenza',
|
'data_scadenza',
|
||||||
|
|
@ -53,6 +53,11 @@ public function stabile()
|
||||||
return $this->belongsTo(Stabile::class, 'stabile_id');
|
return $this->belongsTo(Stabile::class, 'stabile_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function stabileContratto()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(StabileContratto::class, 'stabile_contratto_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function getTitoloCalendarioAttribute(): string
|
public function getTitoloCalendarioAttribute(): string
|
||||||
{
|
{
|
||||||
$parts = array_filter([
|
$parts = array_filter([
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class Stabile extends Model
|
class Stabile extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -156,6 +157,69 @@ class Stabile extends Model
|
||||||
'ultima_generazione_unita' => 'datetime',
|
'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
|
* Relazione con Amministratore
|
||||||
*/
|
*/
|
||||||
|
|
@ -241,6 +305,16 @@ public function gestioniContabili()
|
||||||
->orderBy('numero_straordinaria');
|
->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
|
* Relazione con PianoRateizzazione
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
75
app/Models/StabileContratto.php
Normal file
75
app/Models/StabileContratto.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Modules\Contabilita\Models;
|
namespace App\Modules\Contabilita\Models;
|
||||||
|
|
||||||
use App\Models\DatiBancari;
|
use App\Models\DatiBancari;
|
||||||
|
|
@ -18,6 +17,7 @@ class SaldoConto extends Model
|
||||||
'periodo_da',
|
'periodo_da',
|
||||||
'periodo_a',
|
'periodo_a',
|
||||||
'saldo',
|
'saldo',
|
||||||
|
'is_partenza_contabile',
|
||||||
'tipo_estratto',
|
'tipo_estratto',
|
||||||
'note',
|
'note',
|
||||||
'documento_stabile_id',
|
'documento_stabile_id',
|
||||||
|
|
@ -30,6 +30,7 @@ class SaldoConto extends Model
|
||||||
'periodo_da' => 'date',
|
'periodo_da' => 'date',
|
||||||
'periodo_a' => 'date',
|
'periodo_a' => 'date',
|
||||||
'saldo' => 'decimal:2',
|
'saldo' => 'decimal:2',
|
||||||
|
'is_partenza_contabile' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function conto()
|
public function conto()
|
||||||
|
|
|
||||||
54
app/Modules/Contabilita/Services/ContiFinanziariService.php
Normal file
54
app/Modules/Contabilita/Services/ContiFinanziariService.php
Normal 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à');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Modules\Contabilita\Services;
|
namespace App\Modules\Contabilita\Services;
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Fornitore;
|
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\Movimento;
|
||||||
use App\Modules\Contabilita\Models\MovimentoBanca;
|
use App\Modules\Contabilita\Models\MovimentoBanca;
|
||||||
use App\Modules\Contabilita\Models\PianoConti;
|
use App\Modules\Contabilita\Models\PianoConti;
|
||||||
use App\Modules\Contabilita\Models\Registrazione;
|
use App\Modules\Contabilita\Models\Registrazione;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
@ -15,7 +18,7 @@
|
||||||
class PagamentoFornitorePrimaNotaService
|
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
|
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);
|
$contoFinanziarioId = (int) (($override['conto_finanziario_id'] ?? null) ?: 0);
|
||||||
if ($contoFinanziarioId <= 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);
|
$contoFinanziario = PianoConti::query()->find($contoFinanziarioId);
|
||||||
|
|
@ -73,6 +78,21 @@ public function generaPagamentoDaMovimento(User $user, MovimentoBanca $movimento
|
||||||
? trim((string) $fornitore->codice_univoco)
|
? trim((string) $fornitore->codice_univoco)
|
||||||
: null;
|
: 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(
|
$contoDebitoFornitore = app(ContiSoggettiService::class)->resolveContoDebitoFornitore(
|
||||||
$stabileId,
|
$stabileId,
|
||||||
$fornitoreId,
|
$fornitoreId,
|
||||||
|
|
@ -94,7 +114,7 @@ public function generaPagamentoDaMovimento(User $user, MovimentoBanca $movimento
|
||||||
$descr = $base !== '' ? $base : 'Pagamento fornitore';
|
$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 = [
|
$regPayload = [
|
||||||
'stabile_id' => $stabileId,
|
'stabile_id' => $stabileId,
|
||||||
'gestione_id' => (Schema::hasColumn('contabilita_registrazioni', 'gestione_id')
|
'gestione_id' => (Schema::hasColumn('contabilita_registrazioni', 'gestione_id')
|
||||||
|
|
@ -133,9 +153,91 @@ public function generaPagamentoDaMovimento(User $user, MovimentoBanca $movimento
|
||||||
|
|
||||||
$movimento->registrazione_id = (int) $reg->id;
|
$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();
|
$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;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ public function panel(Panel $panel): Panel
|
||||||
->group('NetGescon')
|
->group('NetGescon')
|
||||||
->icon('heroicon-o-users')
|
->icon('heroicon-o-users')
|
||||||
->url(fn() => RubricaUniversaleArchivio::getUrl(panel: 'admin-filament'))
|
->url(fn() => RubricaUniversaleArchivio::getUrl(panel: 'admin-filament'))
|
||||||
->visible(function () {
|
->visible(function (): bool {
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
return $user instanceof User
|
return $user instanceof User
|
||||||
|
|
@ -66,7 +66,7 @@ public function panel(Panel $panel): Panel
|
||||||
->group('NetGescon')
|
->group('NetGescon')
|
||||||
->icon('heroicon-o-phone')
|
->icon('heroicon-o-phone')
|
||||||
->url(fn() => route('admin.mobile'))
|
->url(fn() => route('admin.mobile'))
|
||||||
->visible(function () {
|
->visible(function (): bool {
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
return $user instanceof User
|
return $user instanceof User
|
||||||
|
|
@ -76,7 +76,7 @@ public function panel(Panel $panel): Panel
|
||||||
->group('NetGescon')
|
->group('NetGescon')
|
||||||
->icon('heroicon-o-device-phone-mobile')
|
->icon('heroicon-o-device-phone-mobile')
|
||||||
->url(fn() => TicketMobile::getUrl(panel: 'admin-filament'))
|
->url(fn() => TicketMobile::getUrl(panel: 'admin-filament'))
|
||||||
->visible(function () {
|
->visible(function (): bool {
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
return $user instanceof User
|
return $user instanceof User
|
||||||
|
|
@ -86,7 +86,7 @@ public function panel(Panel $panel): Panel
|
||||||
->group('NetGescon')
|
->group('NetGescon')
|
||||||
->icon('heroicon-o-building-office-2')
|
->icon('heroicon-o-building-office-2')
|
||||||
->url(fn() => StabilePage::getUrl(panel: 'admin-filament'))
|
->url(fn() => StabilePage::getUrl(panel: 'admin-filament'))
|
||||||
->visible(function () {
|
->visible(function (): bool {
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
return $user instanceof User
|
return $user instanceof User
|
||||||
|
|
@ -171,5 +171,4 @@ protected function resolveSupplierCatalogUrl(): string
|
||||||
|
|
||||||
return TicketOperativi::getUrl(panel: 'admin-filament');
|
return TicketOperativi::getUrl(panel: 'admin-filament');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
use App\Models\ProductIdentifier;
|
use App\Models\ProductIdentifier;
|
||||||
use App\Models\ProductMedia;
|
use App\Models\ProductMedia;
|
||||||
use App\Models\ProductOffer;
|
use App\Models\ProductOffer;
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
use App\Models\ProductOffer;
|
use App\Models\ProductOffer;
|
||||||
use App\Models\TicketIntervento;
|
use App\Models\TicketIntervento;
|
||||||
use App\Modules\Contabilita\Models\FatturaFornitoreRiga;
|
use App\Modules\Contabilita\Models\FatturaFornitoreRiga;
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
class ProductHubViewService
|
class ProductHubViewService
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Documenti;
|
namespace App\Services\Documenti;
|
||||||
|
|
||||||
use App\Models\Documento;
|
use App\Models\Documento;
|
||||||
use App\Models\DocumentoArchivioCartella;
|
|
||||||
use App\Models\DocumentoStabile;
|
use App\Models\DocumentoStabile;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\StabileContext;
|
use App\Support\StabileContext;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\FattureElettroniche;
|
namespace App\Services\FattureElettroniche;
|
||||||
|
|
||||||
use App\Models\Documento;
|
use App\Models\Documento;
|
||||||
|
|
@ -351,10 +350,21 @@ private function generateFallbackPdfForFattura(FatturaElettronica $fattura, stri
|
||||||
$piva = trim((string) ($fattura->fornitore_piva ?? ''));
|
$piva = trim((string) ($fattura->fornitore_piva ?? ''));
|
||||||
$cf = trim((string) ($fattura->fornitore_cf ?? ''));
|
$cf = trim((string) ($fattura->fornitore_cf ?? ''));
|
||||||
|
|
||||||
if ($fornitore !== '') $lines[] = 'Fornitore: ' . $fornitore;
|
if ($fornitore !== '') {
|
||||||
if ($piva !== '' || $cf !== '') $lines[] = 'P.IVA/CF: ' . ($piva !== '' ? $piva : '—') . ' / ' . ($cf !== '' ? $cf : '—');
|
$lines[] = 'Fornitore: ' . $fornitore;
|
||||||
if ($num !== '' || $data !== '') $lines[] = 'Fattura: ' . ($num !== '' ? $num : '—') . ' - ' . ($data !== '' ? $data : '—');
|
}
|
||||||
if ($tot !== '') $lines[] = 'Totale: EUR ' . $tot;
|
|
||||||
|
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[] = '';
|
||||||
$lines[] = 'Nota: il PDF originale non era disponibile; questo documento serve solo per archiviazione e protocollazione.';
|
$lines[] = 'Nota: il PDF originale non era disponibile; questo documento serve solo per archiviazione e protocollazione.';
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
|
|
||||||
use App\Models\Amministratore;
|
use App\Models\Amministratore;
|
||||||
use App\Models\DatiBancari;
|
use App\Models\DatiBancari;
|
||||||
use App\Models\GestioneContabile;
|
|
||||||
use App\Models\Fornitore;
|
use App\Models\Fornitore;
|
||||||
|
use App\Models\GestioneContabile;
|
||||||
use App\Models\Stabile;
|
use App\Models\Stabile;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Facade;
|
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 [
|
return [
|
||||||
|
|
||||||
|
|
@ -101,7 +102,6 @@
|
||||||
|
|
||||||
])->toArray(),
|
])->toArray(),
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Class Aliases
|
| Class Aliases
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
81
docker/docker-compose.node-local.yml
Normal file
81
docker/docker-compose.node-local.yml
Normal 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
|
||||||
82
docker/install-node-local.sh
Normal file
82
docker/install-node-local.sh
Normal 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."
|
||||||
103
docker/restore-node-backup.sh
Normal file
103
docker/restore-node-backup.sh
Normal 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."
|
||||||
|
|
@ -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.
|
||||||
177
docs/INSTALLAZIONE-NODO-LOCALE-0.9.md
Normal file
177
docs/INSTALLAZIONE-NODO-LOCALE-0.9.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
116
docs/roadmaps/DOCUMENTI-ARCHIVIO-DAFARE.md
Normal file
116
docs/roadmaps/DOCUMENTI-ARCHIVIO-DAFARE.md
Normal 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
110
public/installers/index.php
Normal 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>
|
||||||
19
public/installers/laravel-site-bootstrap.php
Normal file
19
public/installers/laravel-site-bootstrap.php
Normal 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);
|
||||||
19
public/installers/netgescon-node-bootstrap.php
Normal file
19
public/installers/netgescon-node-bootstrap.php
Normal 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);
|
||||||
19
public/installers/ubuntu2404-laravel-base.php
Normal file
19
public/installers/ubuntu2404-laravel-base.php
Normal 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);
|
||||||
|
|
@ -53,6 +53,52 @@ class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semi
|
||||||
</button>
|
</button>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -24,4 +24,30 @@
|
||||||
.fi-sidebar-item {
|
.fi-sidebar-item {
|
||||||
margin-block: 0 !important;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -8,30 +8,9 @@
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((bool) ($stabile->riscaldamento_centralizzato ?? false)) {
|
return method_exists($stabile, 'hasOperationalHeating')
|
||||||
return true;
|
? $stabile->hasOperationalHeating()
|
||||||
}
|
: false;
|
||||||
|
|
||||||
$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();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$stabileLabel = static function (?\App\Models\Stabile $stabile): string {
|
$stabileLabel = static function (?\App\Models\Stabile $stabile): string {
|
||||||
|
|
@ -64,20 +43,5 @@
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if($stabili->count() > 1)
|
<div class="text-xs text-gray-500">Selettore stabile temporaneamente disattivato.</div>
|
||||||
<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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -421,6 +421,11 @@
|
||||||
<label class="text-xs text-gray-500">Inizio contratto</label>
|
<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" />
|
<input type="date" wire:model="form.inizio_contratto" class="w-full rounded-md border-gray-300" />
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="text-xs text-gray-500">Ultimo rinnovo</label>
|
<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" />
|
<input type="date" wire:model="form.ultimo_rinnovo" class="w-full rounded-md border-gray-300" />
|
||||||
|
|
|
||||||
148
resources/views/filament/pages/condomini/contratti-hub.blade.php
Normal file
148
resources/views/filament/pages/condomini/contratti-hub.blade.php
Normal 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 nell’archivio documentale con categoria contratto.</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament-panels::page>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
'scansione-documenti' => 'Scansione documenti',
|
'scansione-documenti' => 'Scansione documenti',
|
||||||
'etichette-generiche' => 'Etichette generiche',
|
'etichette-generiche' => 'Etichette generiche',
|
||||||
'movimentazione-codici' => 'Movimentazione per codice',
|
'movimentazione-codici' => 'Movimentazione per codice',
|
||||||
|
'lettore-codici' => 'Lettore codici',
|
||||||
'template-drive' => 'Template Drive',
|
'template-drive' => 'Template Drive',
|
||||||
'archivio-fisico' => 'Archivio fisico',
|
'archivio-fisico' => 'Archivio fisico',
|
||||||
'permessi' => 'Permessi e cartelle',
|
'permessi' => 'Permessi e cartelle',
|
||||||
|
|
@ -72,7 +73,7 @@
|
||||||
@if($this->hubTab === 'protocollo-comunicazioni')
|
@if($this->hubTab === 'protocollo-comunicazioni')
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<x-slot name="heading">Protocollo comunicazioni</x-slot>
|
<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="space-y-4">
|
||||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-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 class="mt-1 text-sm text-slate-500">Documenti non fattura nello stabile attivo.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl border bg-white p-4">
|
<div class="rounded-xl border bg-white p-4">
|
||||||
<div class="text-xs uppercase tracking-wide text-slate-500">Con PDF</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_pdf'] ?? 0 }}</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 in anteprima o stampa.</div>
|
<div class="mt-1 text-sm text-slate-500">Apribili inline o scaricabili secondo il tipo file.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl border bg-white p-4">
|
<div class="rounded-xl border bg-white p-4">
|
||||||
<div class="text-xs uppercase tracking-wide text-slate-500">Questo mese</div>
|
<div class="text-xs uppercase tracking-wide text-slate-500">Questo mese</div>
|
||||||
|
|
@ -144,39 +145,47 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($this->protocolloView === 'cards')
|
@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)
|
@forelse($this->protocolloComunicazioniRows as $row)
|
||||||
<article class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
<article class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
<div class="space-y-3 p-4">
|
<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">
|
<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 class="rounded-full bg-slate-100 px-2.5 py-1 text-slate-700">{{ $row['protocollo'] }}</span>
|
||||||
<span>{{ $row['categoria'] }}</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>
|
<span>{{ $row['data'] }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-base font-semibold text-slate-900">{{ $row['titolo'] }}</h3>
|
<h3 class="line-clamp-2 text-sm 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>
|
<p class="mt-1 line-clamp-3 text-xs text-slate-600">{{ $row['descrizione'] !== '' ? $row['descrizione'] : 'Nessuna descrizione registrata.' }}</p>
|
||||||
</div>
|
</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>
|
<div>Cartella: {{ $row['cartella'] }}</div>
|
||||||
@if($row['fornitore'] !== '')
|
@if($row['fornitore'] !== '')
|
||||||
<div>Ente / referente: {{ $row['fornitore'] }}</div>
|
<div>Ente / referente: {{ $row['fornitore'] }}</div>
|
||||||
@endif
|
@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>
|
<div>Rif. archivio: {{ $row['archive_reference'] }}</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2 pt-1">
|
<div class="flex flex-wrap gap-2 pt-1">
|
||||||
|
@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['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>
|
<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'])
|
@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>
|
<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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@empty
|
@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.
|
Nessun documento protocollato trovato con i filtri correnti.
|
||||||
</div>
|
</div>
|
||||||
@endforelse
|
@endforelse
|
||||||
|
|
@ -199,8 +208,11 @@
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="truncate font-semibold text-slate-900">{{ $row['titolo'] }}</div>
|
<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>
|
<div class="mt-1 text-xs text-slate-500">{{ $row['descrizione'] !== '' ? $row['descrizione'] : 'Nessuna descrizione registrata.' }}</div>
|
||||||
@if($row['archive_reference'] !== '')
|
@if($row['archive_code'] !== '')
|
||||||
<div class="mt-1 text-xs text-sky-700">{{ $row['archive_reference'] }}</div>
|
<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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -208,8 +220,10 @@
|
||||||
<div class="mt-1 text-xs text-slate-500">{{ $row['cartella'] }}</div>
|
<div class="mt-1 text-xs text-slate-500">{{ $row['cartella'] }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@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>
|
<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'])
|
@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>
|
<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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -437,143 +451,7 @@ class="rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm tran
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@elseif($this->hubTab === 'etichette-generiche')
|
@elseif($this->hubTab === 'etichette-generiche')
|
||||||
<x-filament::section>
|
@include('filament.pages.strumenti.partials.generic-label-builder')
|
||||||
<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>
|
|
||||||
@elseif($this->hubTab === 'movimentazione-codici')
|
@elseif($this->hubTab === 'movimentazione-codici')
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<x-slot name="heading">Movimentazione per codice</x-slot>
|
<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="space-y-4">
|
||||||
<div class="rounded-xl border bg-white p-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="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 class="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Codice sorgente</label>
|
<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" />
|
<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>
|
</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">
|
<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>
|
<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>
|
</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="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="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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -621,6 +535,229 @@ class="rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm tran
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</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')
|
@elseif($this->hubTab === 'template-drive')
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<x-slot name="heading">Template cartelle Drive</x-slot>
|
<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>
|
<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>
|
<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>
|
||||||
<div>
|
<div class="md:col-span-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Magazzino</label>
|
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.
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@
|
||||||
'sky' => 'from-sky-600 to-sky-500',
|
'sky' => 'from-sky-600 to-sky-500',
|
||||||
default => 'from-slate-700 to-slate-600',
|
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
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|
@ -67,19 +74,49 @@
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px]">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Codice mnemonico</label>
|
<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" />
|
<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="mt-1 text-[11px] text-slate-500">Suggerito: {{ $this->mnemonicCodeHint ?? 'GER00079' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-2">
|
<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 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>
|
<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" />
|
<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>
|
||||||
<div class="md:col-span-2">
|
<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 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>
|
<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" />
|
<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>
|
||||||
|
<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>
|
<div>
|
||||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Tipologia</label>
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Tipologia</label>
|
||||||
<select wire:model.live="genericLabelForm.tipo_item" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
|
<select wire:model.live="genericLabelForm.tipo_item" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
|
||||||
|
|
@ -111,31 +148,53 @@
|
||||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Supporto</label>
|
<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" />
|
<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>
|
||||||
|
<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>
|
||||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Riga stabile 1</label>
|
<div class="text-sm font-semibold text-slate-900">Quattro righe configurabili</div>
|
||||||
<select wire:model.live="genericLabelForm.stable_line_one_source" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
|
<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)
|
@foreach($this->genericLabelStableFieldOptions as $fieldKey => $fieldLabel)
|
||||||
<option value="{{ $fieldKey }}">{{ $fieldLabel }}</option>
|
<option value="{{ $fieldKey }}">{{ $fieldLabel }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Riga stabile 2</label>
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Testo libero</label>
|
||||||
<select wire:model.live="genericLabelForm.stable_line_two_source" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
|
<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' : '' }} />
|
||||||
@foreach($this->genericLabelStableFieldOptions as $fieldKey => $fieldLabel)
|
</div>
|
||||||
<option value="{{ $fieldKey }}">{{ $fieldLabel }}</option>
|
</div>
|
||||||
@endforeach
|
@endfor
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Righe bianche scrivibili</label>
|
|
||||||
<select wire:model.live="genericLabelForm.blank_lines_count" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
|
|
||||||
<option value="0">Nessuna</option>
|
|
||||||
<option value="1">1 riga</option>
|
|
||||||
<option value="2">2 righe</option>
|
|
||||||
<option value="3">3 righe</option>
|
|
||||||
<option value="4">4 righe</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Prodotto Amazon</label>
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Prodotto Amazon</label>
|
||||||
|
|
@ -211,10 +270,20 @@
|
||||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Dettaglio ubicazione</label>
|
<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" />
|
<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>
|
||||||
<div class="md:col-span-2">
|
<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>
|
<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>
|
<textarea wire:model.live.debounce.200ms="genericLabelForm.note" rows="3" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Contenuto, periodo, riferimenti operativi..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap gap-2 border-t border-slate-200 pt-4">
|
<div class="mt-4 flex flex-wrap gap-2 border-t border-slate-200 pt-4">
|
||||||
|
|
@ -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="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="grid min-h-[260px] grid-cols-[1fr_82px] gap-4 p-4">
|
||||||
<div class="min-w-0">
|
<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="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 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-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 text-[13px] font-medium leading-snug text-slate-700">{{ data_get($preview, 'slots.subtitle') !== '' ? data_get($preview, 'slots.subtitle') : 'Seconda riga descrittiva' }}</div>
|
<div class="mt-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)
|
@foreach(($preview['stable_lines'] ?? []) as $stableLine)
|
||||||
@if($stableLine === '__BLANK_LINE__')
|
@if($stableLine === '__BLANK_LINE__')
|
||||||
<div class="h-5 border-b border-slate-400"></div>
|
<div class="h-5 border-b border-slate-400"></div>
|
||||||
|
|
@ -261,7 +330,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(data_get($preview, 'slots.note') !== '')
|
@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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -278,16 +347,27 @@
|
||||||
@endif
|
@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="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="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="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 text-lg font-bold leading-tight text-slate-900">{{ data_get($preview, 'slots.title') !== '' ? data_get($preview, 'slots.title') : 'Titolo etichetta' }}</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') !== '')
|
@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
|
@endif
|
||||||
@if(data_get($preview, 'slots.meta') !== '')
|
@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
|
@endif
|
||||||
@if(data_get($preview, 'slots.note') !== '')
|
@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
|
@endif
|
||||||
<div class="mt-3 rounded-lg border border-slate-200 bg-white px-2 py-2 text-[11px] text-slate-500">
|
<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' }}
|
QR payload: {{ $preview['payload']['qr_payload'] ?? 'NGDOC|AF:AF-QR-PREVIEW|TIPO:SCATOLA|STB:0|PARENT:RADICE' }}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-semibold text-sky-900">Launcher esterno per update e sync Git</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">
|
<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>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<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="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
|
||||||
<div class="rounded-xl border bg-white p-4">
|
<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="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.</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)
|
@if(! $this->ticketAttachmentMetadataReady)
|
||||||
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-[11px] text-amber-900">
|
<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">
|
<x-filament::button size="sm" color="success" wire:click="runNodeRefresh" :disabled="!$supportCanRunUpdate">
|
||||||
Aggiorna nodo completo da Gitea
|
Aggiorna nodo completo da Gitea
|
||||||
</x-filament::button>
|
</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
|
Aggiorna staging da Gitea
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
<x-filament::button size="sm" color="info" wire:click="refreshGitSyncProgress" :disabled="!$this->gitSyncInProgress">
|
<x-filament::button size="sm" wire:click="runGitSync" :disabled="!$supportCanRunUpdate">
|
||||||
Aggiorna avanzamento Git
|
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>
|
</x-filament::button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($this->gitSyncInProgress)
|
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-[11px] text-amber-900">
|
||||||
<div class="mt-3 rounded-lg border bg-slate-50 p-3" wire:poll.2s="refreshGitSyncProgress">
|
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
|
@else
|
||||||
<div class="mt-3 rounded-lg border bg-slate-50 p-3">
|
<div class="mt-3 rounded-lg border bg-slate-50 p-3">
|
||||||
@endif
|
@endif
|
||||||
<div class="mb-1 flex items-center justify-between text-[11px] text-gray-600">
|
<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-semibold">Avanzamento refresh nodo</span>
|
||||||
<span class="font-mono">{{ (int) $this->gitSyncProgressPercent }}%</span>
|
<span class="font-mono">{{ (int) $this->updateProgressPercent }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-2 w-full overflow-hidden rounded bg-gray-200">
|
<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>
|
||||||
<div class="mt-1 text-[11px] text-gray-600">{{ $this->gitSyncProgressMessage }}</div>
|
<div class="mt-1 text-[11px] text-gray-600">{{ $this->updateProgressMessage }}</div>
|
||||||
@if(filled($this->lastGitSyncIssue))
|
@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->lastGitSyncIssue }}</div>
|
<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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -108,7 +115,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 rounded-lg border border-slate-200 bg-slate-50 p-3 text-[11px] text-slate-600">
|
<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>
|
</div>
|
||||||
|
|
||||||
@if(filled($this->pendingGitPreviewIssue))
|
@if(filled($this->pendingGitPreviewIssue))
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,5 @@
|
||||||
<x-filament-panels::page wire:poll.8s="refreshLiveCallBanner">
|
<x-filament-panels::page>
|
||||||
<div class="space-y-4">
|
<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">
|
<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.
|
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>
|
</div>
|
||||||
|
|
@ -124,70 +68,6 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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">
|
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||||
<label class="block text-sm md:col-span-2">
|
<label class="block text-sm md:col-span-2">
|
||||||
<span class="mb-1 block font-medium">Titolo operativo</span>
|
<span class="mb-1 block font-medium">Titolo operativo</span>
|
||||||
|
|
|
||||||
500
scripts/install/laravel-site-bootstrap.sh
Normal file
500
scripts/install/laravel-site-bootstrap.sh
Normal 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"
|
||||||
466
scripts/install/netgescon-node-bootstrap.sh
Executable file
466
scripts/install/netgescon-node-bootstrap.sh
Executable 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"
|
||||||
629
scripts/install/ubuntu2404-laravel-base.sh
Executable file
629
scripts/install/ubuntu2404-laravel-base.sh
Executable 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'
|
||||||
Loading…
Reference in New Issue
Block a user