Stabilize supplier ops and strict legacy sync

This commit is contained in:
michele 2026-04-16 08:12:41 +00:00
parent f1836763ae
commit 09022d5753
17 changed files with 1006 additions and 300 deletions

View File

@ -0,0 +1,584 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class GesconSyncOrdinariaPreventivoCommand extends Command
{
protected $signature = 'gescon:sync-ordinaria-preventivo
{stabile_code : Codice stabile legacy, es. 0023}
{year : Anno gestione, es. 2025}
{--stabile-id= : ID stabile NetGescon se gia noto}
{--gestione-id= : ID gestione contabile ordinaria se gia noto}';
protected $description = 'Sincronizza in modo rigoroso preventivo ordinario, tabelle millesimali e dettagli da gescon_import per uno stabile/anno specifici';
public function handle(): int
{
$stableCode = trim((string) $this->argument('stabile_code'));
$year = (int) $this->argument('year');
if ($stableCode === '' || $year < 1900) {
$this->error('Parametri non validi.');
return self::FAILURE;
}
$stableId = $this->resolveStableId($stableCode);
$gestioneId = $this->resolveGestioneId($stableId, $year);
$legacyVoci = $this->loadLegacyVoci($stableCode, $year);
if ($legacyVoci->isEmpty()) {
$this->error('Nessuna voce legacy ordinaria trovata per stabile ' . $stableCode . ' anno ' . $year . '.');
return self::FAILURE;
}
$tableCodes = $legacyVoci
->pluck('tabella')
->filter(fn ($value): bool => is_string($value) && trim($value) !== '')
->map(fn (string $value): string => trim($value))
->unique()
->values();
$legacyDetails = $this->loadLegacyDettagli($stableCode, $year, $tableCodes);
$legacyHeaders = $this->loadLegacyTableHeaders($stableCode, $tableCodes);
DB::transaction(function () use ($stableCode, $stableId, $year, $gestioneId, $legacyVoci, $legacyDetails, $tableCodes, $legacyHeaders): void {
$this->ensureLegacyUnitCoverage($stableCode, $stableId, $legacyDetails);
$tableMap = $this->syncTabelleMillesimali($stableId, $year, $tableCodes, $legacyDetails, $legacyHeaders);
$this->syncDettagliMillesimali($stableId, $year, $legacyDetails, $tableMap);
$this->syncVociSpesa($stableId, $gestioneId, $year, $legacyVoci, $tableMap);
});
$totalBudget = (float) DB::table('voci_spesa')
->where('stabile_id', $stableId)
->where('gestione_contabile_id', $gestioneId)
->sum('importo_default');
$this->info('Sync completato per stabile ' . $stableCode . ' anno ' . $year . '.');
$this->line('Gestione ordinaria: #' . $gestioneId);
$this->line('Voci spesa sincronizzate: ' . DB::table('voci_spesa')->where('stabile_id', $stableId)->where('gestione_contabile_id', $gestioneId)->count());
$this->line('Tabelle millesimali sincronizzate: ' . DB::table('tabelle_millesimali')->where('stabile_id', $stableId)->where('anno_gestione', $year)->count());
$this->line('Totale preventivo importato: ' . number_format($totalBudget, 2, '.', ''));
return self::SUCCESS;
}
private function resolveStableId(string $stableCode): int
{
$optionStableId = $this->option('stabile-id');
if (is_numeric($optionStableId) && (int) $optionStableId > 0) {
return (int) $optionStableId;
}
$query = DB::table('stabili');
$stable = $query
->where('codice_stabile', $stableCode)
->orWhere('codice_stabile', ltrim($stableCode, '0'))
->orderBy('id')
->first(['id']);
if (! $stable) {
throw new \RuntimeException('Stabile NetGescon non trovato per codice ' . $stableCode . '.');
}
return (int) $stable->id;
}
private function resolveGestioneId(int $stableId, int $year): int
{
$optionGestioneId = $this->option('gestione-id');
if (is_numeric($optionGestioneId) && (int) $optionGestioneId > 0) {
return (int) $optionGestioneId;
}
$gestione = DB::table('gestioni_contabili')
->where('stabile_id', $stableId)
->where('anno_gestione', $year)
->where('tipo_gestione', 'ordinaria')
->orderByDesc('gestione_attiva')
->orderByDesc('id')
->first(['id']);
if (! $gestione) {
throw new \RuntimeException('Gestione ordinaria non trovata per stabile #' . $stableId . ' anno ' . $year . '.');
}
return (int) $gestione->id;
}
private function loadLegacyVoci(string $stableCode, int $year): Collection
{
return DB::connection('gescon_import')
->table('voc_spe')
->where('cod_stabile', $stableCode)
->where('legacy_year', $year)
->where(function ($query): void {
$query->where('v_ors', 'O')->orWhereNull('v_ors');
})
->orderBy('tabella')
->orderBy('cod')
->get([
'cod',
'descriz',
'tabella',
'preventivo_euro',
'consuntivo_euro',
'perc_proprietario',
'perc_inquilino',
'note',
'v_ors',
]);
}
private function loadLegacyDettagli(string $stableCode, int $year, Collection $tableCodes): Collection
{
return DB::connection('gescon_import')
->table('dett_tab')
->where('cod_stabile', $stableCode)
->where('legacy_year', $year)
->whereIn('cod_tab', $tableCodes->all())
->orderBy('cod_tab')
->orderBy('id_cond')
->orderBy('cond_inquil')
->get([
'cod_tab',
'id_cond',
'cond_inquil',
'mm',
'prev_euro',
'cons_euro',
]);
}
/**
* @return array<string, object>
*/
private function loadLegacyTableHeaders(string $stableCode, Collection $tableCodes): array
{
if ($tableCodes->isEmpty() || ! Schema::connection('gescon_import')->hasTable('tabelle_millesimali')) {
return [];
}
$columns = Schema::connection('gescon_import')->getColumnListing('tabelle_millesimali');
$codeColumn = in_array('cod_tabella', $columns, true)
? 'cod_tabella'
: (in_array('cod_tab', $columns, true) ? 'cod_tab' : null);
if (! $codeColumn) {
return [];
}
$query = DB::connection('gescon_import')->table('tabelle_millesimali');
if (in_array('cod_stabile', $columns, true)) {
$query->where('cod_stabile', $stableCode);
}
$rows = $query
->whereIn($codeColumn, $tableCodes->all())
->orderBy($codeColumn)
->get();
$headers = [];
foreach ($rows as $row) {
$code = strtoupper(trim((string) ($row->{$codeColumn} ?? '')));
if ($code === '' || isset($headers[$code])) {
continue;
}
$headers[$code] = $row;
}
return $headers;
}
private function ensureLegacyUnitCoverage(string $stableCode, int $stableId, Collection $legacyDetails): void
{
$legacyIds = $legacyDetails
->pluck('id_cond')
->filter(fn ($value): bool => is_string($value) && trim($value) !== '')
->map(fn (string $value): string => trim($value))
->unique()
->values();
$existingIds = DB::table('unita_immobiliari')
->where('stabile_id', $stableId)
->whereNull('deleted_at')
->whereIn('legacy_cond_id', $legacyIds->all())
->pluck('legacy_cond_id')
->map(fn ($value): string => trim((string) $value))
->all();
$existingSet = array_fill_keys($existingIds, true);
foreach ($legacyIds as $legacyId) {
if (isset($existingSet[$legacyId])) {
continue;
}
$sourceRow = $this->resolveCondominSourceRow($stableCode, $legacyId);
$this->createMissingLegacyUnit($stableCode, $stableId, $legacyId, $sourceRow);
}
}
private function resolveCondominSourceRow(string $stableCode, string $legacyId): object
{
$idCondRows = DB::connection('gescon_import')
->table('condomin')
->where('cod_stabile', $stableCode)
->where('id_cond', $legacyId)
->orderByDesc('legacy_year')
->orderByDesc('id')
->get();
if ($idCondRows->isNotEmpty()) {
return $idCondRows->first();
}
$codCondRows = DB::connection('gescon_import')
->table('condomin')
->where('cod_stabile', $stableCode)
->where('cod_cond', $legacyId)
->orderByDesc('legacy_year')
->orderByDesc('id')
->get();
if ($codCondRows->isNotEmpty()) {
return $codCondRows->first();
}
throw new \RuntimeException('Impossibile risolvere la fonte condomin legacy per id_cond ' . $legacyId . '.');
}
private function createMissingLegacyUnit(string $stableCode, int $stableId, string $legacyId, object $sourceRow): void
{
$internal = trim((string) ($sourceRow->interno ?? ''));
if ($internal === '') {
throw new \RuntimeException('Interno mancante per il legacy id ' . $legacyId . '.');
}
$scale = trim((string) ($sourceRow->scala ?? ''));
$scale = $scale !== '' ? strtoupper($scale) : 'A';
$floor = $this->normalizeFloor($sourceRow->piano ?? null);
$displayName = trim((string) (($sourceRow->cognome ?? '') . ' ' . ($sourceRow->nome ?? '')));
if ($displayName === '') {
$displayName = trim((string) ($sourceRow->nom_cond ?? ''));
}
$code = $this->buildUnitCode($stableCode, $scale, $internal, $legacyId);
if (DB::table('unita_immobiliari')->where('codice_unita', $code)->exists()) {
throw new \RuntimeException('Codice unita gia presente durante il backfill rigoroso: ' . $code . '.');
}
DB::table('unita_immobiliari')->insert([
'stabile_id' => $stableId,
'codice_unita' => $code,
'denominazione' => $displayName !== '' ? $displayName : ('Unita legacy ' . $legacyId),
'scala' => $scale,
'piano' => $floor,
'interno' => $internal,
'legacy_cond_id' => $legacyId,
'tipo_unita' => 'abitazione',
'stato_occupazione' => 'occupata_proprietario',
'stato_conservazione' => 'buono',
'millesimi_generali' => 0,
'attiva' => true,
'unita_demo' => false,
'created_at' => now(),
'updated_at' => now(),
]);
}
/**
* @return array<string, int>
*/
private function syncTabelleMillesimali(int $stableId, int $year, Collection $tableCodes, Collection $legacyDetails, array $legacyHeaders): array
{
$detailSummary = $legacyDetails
->groupBy('cod_tab')
->map(function (Collection $rows): array {
return [
'total_mm' => (float) $rows->sum(fn ($row): float => is_numeric($row->mm ?? null) ? (float) $row->mm : 0.0),
'rows' => $rows->count(),
];
});
$tableMap = [];
$sortOrder = 1;
foreach ($tableCodes as $tableCode) {
$normalizedCode = strtoupper(trim((string) $tableCode));
$summary = $detailSummary->get($tableCode, ['total_mm' => 0.0, 'rows' => 0]);
$header = $legacyHeaders[$normalizedCode] ?? null;
$rawCalcolo = $header->tipo_calcolo ?? $header->calcolo ?? null;
$calcolo = $this->normalizeLegacyCalcolo($rawCalcolo);
$tipoLegacy = $this->normalizeLegacyTipo($header->tipologia ?? $header->tipo ?? null) ?? 'O';
$tipoTabella = $this->resolveTipoTabellaFromLegacy($tipoLegacy, $calcolo, $normalizedCode);
$tipoCalcoloDb = in_array($calcolo, ['millesimi', 'parti', 'fisso'], true)
? $calcolo
: ($normalizedCode === 'ACQUA' ? 'parti' : 'millesimi');
$denominazione = trim((string) ($header->denominazione ?? $header->descrizione ?? $tableCode));
$ordine = null;
foreach (['nord', 'ordinamento'] as $orderKey) {
if (isset($header->{$orderKey}) && is_numeric($header->{$orderKey})) {
$ordine = (int) $header->{$orderKey};
break;
}
}
$sortValue = $ordine ?? $sortOrder;
$payload = [
'stabile_id' => $stableId,
'anno_gestione' => $year,
'nome_tabella' => $tableCode,
'denominazione' => $denominazione !== '' ? $denominazione : $tableCode,
'codice_tabella' => $tableCode,
'legacy_codice' => $tableCode,
'ordine_visualizzazione' => $sortValue,
'tipo_tabella' => $tipoTabella,
'tipo_calcolo' => $tipoCalcoloDb,
'attiva' => true,
'ordinamento' => $sortValue,
'nord' => $sortValue,
'totale_millesimi' => $summary['total_mm'],
'meta_legacy' => json_encode([
'strict_source' => 'gescon_import',
'legacy_year' => $year,
'cod_tab' => $normalizedCode,
'tipo' => $tipoLegacy,
'calcolo' => is_string($rawCalcolo) && trim($rawCalcolo) !== '' ? strtoupper(trim($rawCalcolo)) : null,
'detail_rows' => $summary['rows'],
]),
'updated_at' => now(),
];
$existing = DB::table('tabelle_millesimali')
->where('stabile_id', $stableId)
->where('codice_tabella', $tableCode)
->first(['id']);
if ($existing) {
DB::table('tabelle_millesimali')->where('id', (int) $existing->id)->update($payload);
$tableMap[$tableCode] = (int) $existing->id;
} else {
$payload['created_at'] = now();
DB::table('tabelle_millesimali')->insert($payload);
$tableMap[$tableCode] = (int) DB::table('tabelle_millesimali')
->where('stabile_id', $stableId)
->where('codice_tabella', $tableCode)
->value('id');
}
$sortOrder++;
}
return $tableMap;
}
/**
* @param array<string, int> $tableMap
*/
private function syncDettagliMillesimali(int $stableId, int $year, Collection $legacyDetails, array $tableMap): void
{
$tableIds = array_values($tableMap);
if ($tableIds === []) {
return;
}
DB::table('dettaglio_millesimi')
->whereIn('tabella_millesimale_id', $tableIds)
->delete();
$groupedRows = $legacyDetails
->groupBy(function ($row): string {
return implode('|', [
trim((string) ($row->cod_tab ?? '')),
trim((string) ($row->id_cond ?? '')),
strtoupper(trim((string) ($row->cond_inquil ?? ''))),
]);
});
foreach ($groupedRows as $groupKey => $rows) {
[$tableCode, $legacyId, $role] = explode('|', $groupKey);
$tableId = $tableMap[$tableCode] ?? null;
if (! $tableId) {
throw new \RuntimeException('Tabella millesimale non risolta per codice ' . $tableCode . '.');
}
$unitId = DB::table('unita_immobiliari')
->where('stabile_id', $stableId)
->whereNull('deleted_at')
->where('legacy_cond_id', $legacyId)
->value('id');
if (! $unitId) {
throw new \RuntimeException('Unita NetGescon non risolta per legacy_cond_id ' . $legacyId . '.');
}
$millesimi = (float) $rows->sum(fn ($row): float => is_numeric($row->mm ?? null) ? (float) $row->mm : 0.0);
$valorePrev = (float) $rows->sum(fn ($row): float => is_numeric($row->prev_euro ?? null) ? (float) $row->prev_euro : 0.0);
$valoreCons = (float) $rows->sum(fn ($row): float => is_numeric($row->cons_euro ?? null) ? (float) $row->cons_euro : 0.0);
DB::table('dettaglio_millesimi')->insert([
'tabella_millesimale_id' => $tableId,
'unita_immobiliare_id' => (int) $unitId,
'millesimi' => $millesimi,
'valore_prev' => $valorePrev,
'valore_cons' => $valoreCons,
'ruolo_legacy' => $role !== '' ? $role : null,
'legacy_interno' => $legacyId,
'partecipa' => true,
'note' => null,
'legacy_payload' => json_encode([
'strict_source' => 'gescon_import',
'legacy_year' => $year,
'cod_tab' => $tableCode,
'id_cond' => $legacyId,
'cond_inquil' => $role !== '' ? $role : null,
]),
'created_at' => now(),
'updated_at' => now(),
]);
}
}
/**
* @param array<string, int> $tableMap
*/
private function syncVociSpesa(int $stableId, int $gestioneId, int $year, Collection $legacyVoci, array $tableMap): void
{
$sortOrder = 1;
foreach ($legacyVoci as $voce) {
$code = trim((string) ($voce->cod ?? ''));
if ($code === '') {
continue;
}
$tableCode = trim((string) ($voce->tabella ?? ''));
$tableId = $tableCode !== '' ? ($tableMap[$tableCode] ?? null) : null;
if ($tableCode !== '' && ! $tableId) {
throw new \RuntimeException('Tabella legacy non risolta per la voce ' . $code . ': ' . $tableCode . '.');
}
$payload = [
'stabile_id' => $stableId,
'gestione_contabile_id' => $gestioneId,
'codice' => $code,
'legacy_codice' => $code,
'descrizione' => trim((string) ($voce->descriz ?? '')),
'categoria' => 'ordinaria',
'tipo_gestione' => 'ordinaria',
'tabella_millesimale_default_id' => $tableId,
'importo_default' => is_numeric($voce->preventivo_euro ?? null) ? (float) $voce->preventivo_euro : 0.0,
'importo_consuntivo' => is_numeric($voce->consuntivo_euro ?? null) ? (float) $voce->consuntivo_euro : 0.0,
'percentuale_condomino' => is_numeric($voce->perc_proprietario ?? null) ? (float) $voce->perc_proprietario : 0.0,
'percentuale_inquilino' => is_numeric($voce->perc_inquilino ?? null) ? (float) $voce->perc_inquilino : 0.0,
'attiva' => true,
'ordinamento' => $sortOrder,
'nord' => $sortOrder,
'tipo' => 'ordinaria',
'legacy_tipo' => trim((string) ($voce->v_ors ?? 'O')) ?: 'O',
'note' => trim((string) ($voce->note ?? '')) !== '' ? trim((string) ($voce->note ?? '')) : null,
'meta_legacy' => json_encode([
'strict_source' => 'gescon_import',
'legacy_year' => $year,
'cod_tab' => $tableCode !== '' ? $tableCode : null,
]),
'updated_at' => now(),
];
$existing = DB::table('voci_spesa')
->where('stabile_id', $stableId)
->where('gestione_contabile_id', $gestioneId)
->where('codice', $code)
->first(['id']);
if ($existing) {
DB::table('voci_spesa')->where('id', (int) $existing->id)->update($payload);
} else {
$payload['created_at'] = now();
DB::table('voci_spesa')->insert($payload);
}
$sortOrder++;
}
}
private function normalizeFloor(mixed $rawFloor): int
{
$value = strtoupper(trim((string) $rawFloor));
return match ($value) {
'', 'T', 'PT', 'R', 'PR' => 0,
'S', 'PS', '-1' => -1,
default => is_numeric($value) ? (int) $value : 0,
};
}
private function buildUnitCode(string $stableCode, string $scale, string $internal, string $legacyId): string
{
$normalizedStable = trim($stableCode);
$normalizedScale = trim($scale) !== '' ? trim($scale) : 'A';
$normalizedInternal = strtoupper(trim(preg_replace('/\s+/', '', $internal) ?? $internal));
return $normalizedStable . '-' . $normalizedScale . '-' . $normalizedInternal . '-C' . $legacyId;
}
private function normalizeLegacyCalcolo(?string $raw): ?string
{
$value = strtolower(trim((string) ($raw ?? '')));
if ($value === '') {
return null;
}
return match ($value) {
'm' => 'millesimi',
'a', 'c' => 'a_consumo',
'x' => 'conguagli',
'p' => 'personali',
default => $value,
};
}
private function normalizeLegacyTipo(?string $raw): ?string
{
$value = strtoupper(trim((string) ($raw ?? '')));
if ($value === '') {
return null;
}
return match ($value) {
'O', 'ORD', 'ORDINARIA' => 'O',
'R', 'RISC', 'RISCALDAMENTO' => 'R',
'S', 'STRA', 'STRAORDINARIA' => 'S',
default => $value,
};
}
private function resolveTipoTabellaFromLegacy(?string $tipoLegacy, ?string $calcolo, ?string $codice): string
{
$normalizedCalcolo = strtolower(trim((string) ($calcolo ?? '')));
$normalizedCode = strtoupper(trim((string) ($codice ?? '')));
if ($normalizedCode === 'ACQUA' || in_array($normalizedCalcolo, ['a_consumo', 'acqua', 'consumo', 'c', 'a'], true)) {
return 'custom';
}
return match ($tipoLegacy) {
'R' => 'riscaldamento',
'S' => 'straordinaria',
default => 'custom',
};
}
}

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Fornitore; use App\Models\Fornitore;

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Amministratore; use App\Models\Amministratore;

View File

@ -1,24 +1,23 @@
<?php <?php
namespace App\Filament\Pages\Condomini; namespace App\Filament\Pages\Condomini;
use App\Models\DettaglioMillesimi; use App\Models\DettaglioMillesimi;
use App\Models\RipartizionePreset;
use App\Models\RipartizioneSpeseInquilini;
use App\Models\Stabile; use App\Models\Stabile;
use App\Models\TabellaMillesimale; use App\Models\TabellaMillesimale;
use App\Models\UnitaImmobiliare; use App\Models\UnitaImmobiliare;
use App\Models\User; use App\Models\User;
use App\Models\VoceSpesa; use App\Models\VoceSpesa;
use App\Models\RipartizioneSpeseInquilini;
use App\Models\RipartizionePreset;
use App\Support\AnnoGestioneContext; use App\Support\AnnoGestioneContext;
use App\Support\StabileContext; use App\Support\StabileContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
@ -26,10 +25,10 @@
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use UnitEnum; use UnitEnum;
class TabelleMillesimaliArchivio extends Page implements HasTable class TabelleMillesimaliArchivio extends Page implements HasTable
@ -357,7 +356,10 @@ public function table(Table $table): Table
if (! $v) { if (! $v) {
$v = $record->tipo_calcolo; $v = $record->tipo_calcolo;
} }
if (!$v) return '—'; if (! $v) {
return '—';
}
$v = strtolower((string) $v); $v = strtolower((string) $v);
return match ($v) { return match ($v) {
'acqua', 'a', 'consumo', 'c', 'a_consumo' => 'A CONSUMO', 'acqua', 'a', 'consumo', 'c', 'a_consumo' => 'A CONSUMO',
@ -406,8 +408,14 @@ public function table(Table $table): Table
} }
$t = strtolower((string) ($record->tipo_tabella ?? '')); $t = strtolower((string) ($record->tipo_tabella ?? ''));
if ($t === 'riscaldamento') return 'R (Riscaldamento)'; if ($t === 'riscaldamento') {
if ($t === '') return '—'; return 'R (Riscaldamento)';
}
if ($t === '') {
return '—';
}
return 'O (Ordinaria)'; return 'O (Ordinaria)';
}) })
->sortable() ->sortable()
@ -1333,9 +1341,17 @@ private function resolveTipoLabel(TabellaMillesimale $t, ?string $calcoloRaw): ?
} }
$tipo = strtolower((string) ($t->tipo_tabella ?? '')); $tipo = strtolower((string) ($t->tipo_tabella ?? ''));
if ($tipo === 'riscaldamento') return 'R (Riscaldamento)'; if ($tipo === 'riscaldamento') {
if ($tipo === 'straordinaria') return 'S (Straordinaria)'; return 'R (Riscaldamento)';
if ($tipo === 'ordinaria') return 'O (Ordinaria)'; }
if ($tipo === 'straordinaria') {
return 'S (Straordinaria)';
}
if ($tipo === 'ordinaria') {
return 'O (Ordinaria)';
}
return $tipo !== '' ? strtoupper($tipo) : null; return $tipo !== '' ? strtoupper($tipo) : null;
} }

View File

@ -8,7 +8,6 @@
use App\Models\Stabile; use App\Models\Stabile;
use App\Models\User; use App\Models\User;
use App\Services\TenantArchivePathService; use App\Services\TenantArchivePathService;
use App\Support\ArchivioPaths;
use App\Support\GoogleAccountStore; use App\Support\GoogleAccountStore;
use BackedEnum; use BackedEnum;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@ -4,8 +4,8 @@
use App\Models\Amministratore; use App\Models\Amministratore;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\Stabile; use App\Models\Stabile;
use InvalidArgumentException;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
class TenantArchivePathService class TenantArchivePathService
{ {

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Support; namespace App\Support;
use App\Models\Amministratore; use App\Models\Amministratore;

View File

@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
if (! Schema::hasTable('dettaglio_millesimi') || ! Schema::hasColumn('dettaglio_millesimi', 'ruolo_legacy')) {
return;
}
Schema::table('dettaglio_millesimi', function (Blueprint $table): void {
$table->unique(
['tabella_millesimale_id', 'unita_immobiliare_id', 'ruolo_legacy'],
'unique_tabella_unita_ruolo_legacy'
);
});
Schema::table('dettaglio_millesimi', function (Blueprint $table): void {
try {
DB::statement('ALTER TABLE `dettaglio_millesimi` DROP INDEX `unique_tabella_unita`');
} catch (\Throwable) {
// Already dropped or not present.
}
});
}
public function down(): void
{
if (! Schema::hasTable('dettaglio_millesimi')) {
return;
}
Schema::table('dettaglio_millesimi', function (Blueprint $table): void {
$table->unique(['tabella_millesimale_id', 'unita_immobiliare_id'], 'unique_tabella_unita');
});
Schema::table('dettaglio_millesimi', function (Blueprint $table): void {
try {
DB::statement('ALTER TABLE `dettaglio_millesimi` DROP INDEX `unique_tabella_unita_ruolo_legacy`');
} catch (\Throwable) {
// Already dropped or not present.
}
});
}
};

View File

@ -0,0 +1,51 @@
# Operativita fornitore TecnoRepair
Questa nota riassume i punti introdotti nel flusso fornitore per archivio, rubrica clienti, Google e fatture elettroniche manuali.
## Archivio fornitore
- Il root reale del disco locale applicativo e `storage/app/private`.
- I percorsi mostrati nelle pagine fornitore devono essere risolti tramite `Storage::disk('local')->path(...)`, non costruiti a mano con `storage/app/...`.
- Per riallineare le cartelle fisiche dei fornitori esistenti usare:
```bash
php artisan netgescon:provision-fornitori-archives
```
## Rubrica clienti fornitore
- La rubrica clienti fornitore e materializzata in `fornitore_clienti`.
- La sorgente attuale non e una tabella MDB separata a runtime: il dato cliente e ricostruito da `AssistenzaTecnorepairScheda.metadata['cliente']` gia importato dal legacy TecnoRepair.
- Il comando operativo e:
```bash
php artisan tecnorepair:import-rubrica-clienti {amministratore_id} --fornitore-id={fornitore_id}
```
- Durante l import vengono normalizzati:
- province legacy troppo lunghe
- campi telefono contaminati da note testuali
## Google fornitore
- Gli account Google fornitore usano chiavi dedicate `fornitore-{id}`.
- Regola importante: un account fornitore non deve diventare il default Google dell amministratore.
- Il blocco legacy `google.oauth` va aggiornato solo dal default amministratore, non dagli account scoped del fornitore.
- In caso di collegamento/scollegamento fornitore, la UI amministratore deve continuare a riflettere solo l account default amministratore.
## FE manuale lato fornitore
- La pagina `admin-filament/fornitore/contabilita` espone un import manuale FE dal menu azioni in alto.
- File ammessi: XML, P7M, ZIP.
- Il file viene accettato solo se il cedente della FE coincide con P.IVA/CF del fornitore corrente.
- Lo stabile viene risolto in modo automatico dai dati destinatario della FE, ma ristretto agli stabili dell amministratore del fornitore.
- L import riusa `FatturaElettronicaImporter`; non crea fornitori nuovi e non introduce acquisizioni automatiche.
## Scheda intervento
- La Blade fornitore e stata resa piu compatta con tab operative:
- `Segnalazione`
- `Operativo`
- `Allegati`
- `Storico`
- L obiettivo e avvicinare la navigazione al flusso desktop TecnoRepair senza riscrivere la logica Livewire esistente.

View File

@ -5,6 +5,7 @@
<div class="space-y-4" <div class="space-y-4"
x-data="{ x-data="{
activeTab: 'scheda',
localFotoPreviews: [], localFotoPreviews: [],
localDocumentoPreviews: [], localDocumentoPreviews: [],
geoBusy: false, geoBusy: false,
@ -78,9 +79,19 @@
</div> </div>
</div> </div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="flex flex-wrap items-center gap-2">
<button type="button" x-on:click="activeTab = 'scheda'" x-bind:class="activeTab === 'scheda' ? 'bg-slate-900 text-white ring-slate-900' : 'bg-white text-slate-700 ring-slate-300'" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold ring-1 ring-inset transition">Segnalazione</button>
<button type="button" x-on:click="activeTab = 'operativo'" x-bind:class="activeTab === 'operativo' ? 'bg-slate-900 text-white ring-slate-900' : 'bg-white text-slate-700 ring-slate-300'" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold ring-1 ring-inset transition">Operativo</button>
<button type="button" x-on:click="activeTab = 'allegati'" x-bind:class="activeTab === 'allegati' ? 'bg-slate-900 text-white ring-slate-900' : 'bg-white text-slate-700 ring-slate-300'" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold ring-1 ring-inset transition">Allegati</button>
<button type="button" x-on:click="activeTab = 'storico'" x-bind:class="activeTab === 'storico' ? 'bg-slate-900 text-white ring-slate-900' : 'bg-white text-slate-700 ring-slate-300'" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold ring-1 ring-inset transition">Storico</button>
<div class="ml-auto text-[11px] uppercase tracking-wide text-slate-500">Vista compatta TecnoRepair-like</div>
</div>
</div>
<div class="grid gap-4 xl:grid-cols-3"> <div class="grid gap-4 xl:grid-cols-3">
<div class="space-y-4 xl:col-span-2"> <div class="space-y-4 xl:col-span-2">
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4" x-show="activeTab === 'scheda'" x-cloak>
<div class="text-sm font-semibold">Scheda segnalazione</div> <div class="text-sm font-semibold">Scheda segnalazione</div>
<div class="mt-3 grid gap-3 md:grid-cols-2"> <div class="mt-3 grid gap-3 md:grid-cols-2">
<div> <div>
@ -158,7 +169,7 @@
</div> </div>
@if(count($accessSummaryRows) > 0 || filled($dispatchBoardNote)) @if(count($accessSummaryRows) > 0 || filled($dispatchBoardNote))
<div class="rounded-xl border border-amber-200 bg-amber-50/60 p-4"> <div class="rounded-xl border border-amber-200 bg-amber-50/60 p-4" x-show="activeTab === 'scheda'" x-cloak>
<div class="text-sm font-semibold text-amber-900">Accesso, chiavi e reperibilità</div> <div class="text-sm font-semibold text-amber-900">Accesso, chiavi e reperibilità</div>
@if(filled($dispatchBoardNote)) @if(filled($dispatchBoardNote))
<div class="mt-2 rounded-lg border border-amber-200 bg-white/80 p-3 text-xs text-amber-900">{{ $dispatchBoardNote }}</div> <div class="mt-2 rounded-lg border border-amber-200 bg-white/80 p-3 text-xs text-amber-900">{{ $dispatchBoardNote }}</div>
@ -211,7 +222,7 @@
</div> </div>
@endif @endif
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4" x-show="activeTab === 'operativo'" x-cloak>
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-semibold">Operativo e rapportino</div> <div class="text-sm font-semibold">Operativo e rapportino</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@ -571,7 +582,7 @@
</div> </div>
</div> </div>
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4" x-show="activeTab === 'operativo'" x-cloak>
<div class="text-sm font-semibold">Comunicazioni ticket</div> <div class="text-sm font-semibold">Comunicazioni ticket</div>
<div class="mt-3 space-y-2"> <div class="mt-3 space-y-2">
@forelse($ticket->messages as $msg) @forelse($ticket->messages as $msg)
@ -588,7 +599,7 @@
</div> </div>
<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" x-show="activeTab === 'operativo'" x-cloak>
<div class="text-sm font-semibold">Stato operativo</div> <div class="text-sm font-semibold">Stato operativo</div>
<div class="mt-3 space-y-2 text-sm"> <div class="mt-3 space-y-2 text-sm">
<div><span class="font-medium">Stato:</span> {{ $this->intervento->stato }}</div> <div><span class="font-medium">Stato:</span> {{ $this->intervento->stato }}</div>
@ -599,7 +610,7 @@
</div> </div>
</div> </div>
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4" x-show="activeTab === 'operativo'" x-cloak>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="text-sm font-semibold">Sessioni e presenza</div> <div class="text-sm font-semibold">Sessioni e presenza</div>
<div class="text-xs text-gray-500">{{ count($sessionRows) }} eventi</div> <div class="text-xs text-gray-500">{{ count($sessionRows) }} eventi</div>
@ -631,7 +642,7 @@
</div> </div>
</div> </div>
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4" x-show="activeTab === 'operativo'" x-cloak>
<div class="text-sm font-semibold">Google workspace</div> <div class="text-sm font-semibold">Google workspace</div>
@if($googleWorkspaceStatus) @if($googleWorkspaceStatus)
<div class="mt-3 space-y-2 text-sm"> <div class="mt-3 space-y-2 text-sm">
@ -655,7 +666,7 @@
@endif @endif
</div> </div>
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4" x-show="activeTab === 'allegati'" x-cloak>
<div class="text-sm font-semibold">Allegati ticket</div> <div class="text-sm font-semibold">Allegati ticket</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2"> <div class="mt-3 grid gap-3 sm:grid-cols-2">
@forelse($ticket->attachments as $attachment) @forelse($ticket->attachments as $attachment)
@ -686,7 +697,7 @@
</div> </div>
</div> </div>
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4" x-show="activeTab === 'storico'" x-cloak>
<div class="text-sm font-semibold">Storico correlato</div> <div class="text-sm font-semibold">Storico correlato</div>
<div class="mt-3 space-y-2"> <div class="mt-3 space-y-2">
@forelse($storicoRows as $row) @forelse($storicoRows as $row)