netgescon-day0/app/Console/Commands/GesconSyncOperazioniPrimaNota.php

645 lines
25 KiB
PHP
Executable File

<?php
namespace App\Console\Commands;
use App\Models\Fornitore;
use App\Models\GestioneContabile;
use App\Models\Stabile;
use App\Models\User;
use App\Models\VoceSpesa;
use App\Modules\Contabilita\Models\Movimento;
use App\Modules\Contabilita\Models\Registrazione;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class GesconSyncOperazioniPrimaNota extends Command
{
private array $legacyAnnualMetaCache = [];
protected $signature = 'gescon:sync-operazioni-prima-nota
{--stabile-id= : ID stabile NetGescon}
{--year= : Anno (YYYY)}
{--tipo=all : all|ordinaria|straordinaria|riscaldamento}
{--dry : Simula senza scrivere}';
protected $description = 'Sincronizza operazioni legacy in contabilita_registrazioni (Prima Nota), con upsert idempotente per external_reference.';
public function handle(): int
{
$stabileId = (int) ($this->option('stabile-id') ?? 0);
if ($stabileId <= 0) {
$this->error('Serve --stabile-id valido.');
return 1;
}
$yearOpt = $this->option('year');
$year = is_numeric($yearOpt) ? (int) $yearOpt : null;
$tipo = strtolower((string) ($this->option('tipo') ?? 'all'));
if (! in_array($tipo, ['all', 'ordinaria', 'straordinaria', 'riscaldamento'], true)) {
$this->error('Valore --tipo non valido. Usa all|ordinaria|straordinaria|riscaldamento');
return 1;
}
$dry = (bool) $this->option('dry');
$systemUserId = (int) (User::query()->orderBy('id')->value('id') ?? 0);
$stabile = Stabile::query()->find($stabileId);
$amministratoreId = (int) ($stabile?->amministratore_id ?? 0);
$legacyStableCodes = array_values(array_unique(array_filter(array_map(
static fn($value): string => trim((string) $value),
[$stabile?->codice_stabile ?? null, $stabile?->cod_stabile ?? null]
))));
$query = DB::connection('gescon_import')->table('operazioni');
if (Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile') && $legacyStableCodes !== []) {
$scopedExists = DB::connection('gescon_import')
->table('operazioni')
->whereIn('cod_stabile', $legacyStableCodes)
->exists();
if ($scopedExists) {
$query->whereIn('cod_stabile', $legacyStableCodes);
} else {
$this->warn('Nessuna operazione legacy con cod_stabile in [' . implode(', ', $legacyStableCodes) . ']. Non importo righe non scoperte per evitare contaminazioni tra stabili.');
return 0;
}
}
if ($year) {
$query->whereYear('dt_spe', $year);
}
if ($tipo !== 'all') {
$gestioneCode = match ($tipo) {
'ordinaria' => 'O',
'straordinaria' => 'S',
'riscaldamento' => 'R',
default => null,
};
if ($gestioneCode) {
$query->where('gestione', $gestioneCode);
}
}
$rows = $query
->orderBy('dt_spe')
->orderBy('id_operaz')
->get([
'id_operaz',
'dt_spe',
'benef',
'num_fat',
'note',
'natura',
'gestione',
'n_stra',
'cod_spe',
'tabella',
'cod_for',
'importo',
'importo_euro',
'importo_spese',
'importo_entrate',
'compet',
'cod_stabile',
'legacy_year',
]);
$created = 0;
$updated = 0;
$unchanged = 0;
$movimentiSynced = 0;
$fornitoriLinked = 0;
$vociLinked = 0;
foreach ($rows as $row) {
$legacyId = (int) ($row->id_operaz ?? 0);
if ($legacyId <= 0) {
continue;
}
$dataReg = ! empty($row->dt_spe) ? (string) $row->dt_spe : now()->toDateString();
$tipoGestione = match (strtoupper((string) ($row->gestione ?? 'O'))) {
'S' => 'straordinaria',
'R' => 'riscaldamento',
default => 'ordinaria',
};
$legacyMeta = $this->resolveLegacyAnnualMeta(
(string) ($row->cod_stabile ?? ($legacyStableCodes[0] ?? '')),
trim((string) ($row->legacy_year ?? '')),
$tipoGestione
);
$anno = $legacyMeta['anno_gestione'] ?? (int) substr($dataReg, 0, 4);
$legacyArchiveCode = $legacyMeta['codice_archivio_legacy'] ?? null;
$periodStart = $legacyMeta['data_inizio'] ?? null;
$periodEnd = $legacyMeta['data_fine'] ?? null;
$legacyLabel = $legacyMeta['label'] ?? null;
$gestioneQuery = GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('anno_gestione', $anno)
->where('tipo_gestione', $tipoGestione);
if ($legacyArchiveCode && Schema::hasColumn('gestioni_contabili', 'codice_archivio_legacy')) {
$gestioneQuery->where(function (Builder $q) use ($legacyArchiveCode): void {
$q->where('codice_archivio_legacy', $legacyArchiveCode)
->orWhereNull('codice_archivio_legacy');
});
}
if ($tipoGestione === 'straordinaria') {
$nStra = is_numeric($row->n_stra ?? null) ? (int) $row->n_stra : null;
if ($nStra && $nStra > 0) {
$gestioneQuery->where('numero_straordinaria', $nStra);
}
}
$gestione = $gestioneQuery
->orderByRaw("CASE WHEN codice_archivio_legacy = ? THEN 0 WHEN codice_archivio_legacy IS NULL THEN 1 ELSE 2 END", [$legacyArchiveCode])
->orderByDesc('id')
->first();
if (! $gestione instanceof GestioneContabile) {
$gestione = $this->ensureLegacyGestione(
$stabileId,
$anno,
$tipoGestione,
$dataReg,
is_numeric($row->n_stra ?? null) ? (int) $row->n_stra : null,
$legacyArchiveCode,
$periodStart,
$periodEnd,
$legacyLabel
);
} elseif ($legacyArchiveCode && Schema::hasColumn('gestioni_contabili', 'codice_archivio_legacy')) {
$updates = [];
if (blank($gestione->codice_archivio_legacy) || str_starts_with((string) $gestione->codice_archivio_legacy, 'operazioni:')) {
$updates['codice_archivio_legacy'] = $legacyArchiveCode;
}
if ($periodStart && empty($gestione->data_inizio)) {
$updates['data_inizio'] = $periodStart;
}
if ($periodEnd && empty($gestione->data_fine)) {
$updates['data_fine'] = $periodEnd;
}
if ($legacyLabel && empty($gestione->denominazione)) {
$updates['denominazione'] = $legacyLabel;
}
if ($updates !== []) {
$gestione->fill($updates);
if (! $dry) {
$gestione->save();
}
}
}
$resolvedGestioneId = $gestione
? Registrazione::resolveGestioneIdOrDefault((int) $gestione->id, $stabileId, $dataReg)
: Registrazione::resolveGestioneIdOrDefault(0, $stabileId, $dataReg);
if (! $resolvedGestioneId) {
$this->warn('Gestione non risolta per operazione legacy #' . $legacyId . ' anno ' . $anno . ' tipo ' . $tipoGestione . ': riga saltata.');
continue;
}
$legacyClassification = $this->classifyLegacyOperation($row);
$voceSpesaId = $this->resolveVoceSpesaId($stabileId, $resolvedGestioneId, (string) ($row->cod_spe ?? ''));
$fornitoreId = $this->resolveFornitoreId($amministratoreId, $row->cod_for ?? null);
if ($voceSpesaId > 0) {
$vociLinked++;
}
if ($fornitoreId > 0) {
$fornitoriLinked++;
}
$payload = [
'stabile_id' => $stabileId,
'gestione_id' => $resolvedGestioneId,
'data_registrazione' => $dataReg,
'entry_date' => $dataReg,
'description' => trim((string) ($row->benef ?: $row->note ?: $row->natura ?: 'Operazione legacy #' . $legacyId)),
'document_type' => 'LEGACY_GESCON',
'document_number' => trim((string) ($row->num_fat ?? '')) !== ''
? trim((string) $row->num_fat)
: ('LEG-' . $legacyId),
'external_reference' => 'OPERAZIONE_LEGACY:' . $legacyId,
'protocol' => 'LG' . $anno . '-' . str_pad((string) $legacyId, 6, '0', STR_PAD_LEFT),
'protocollo_prefix' => 'LG' . $anno,
'protocollo_numero' => $legacyId,
'protocollo_completo' => 'LG' . $anno . '-' . $legacyId,
'status' => 'confirmed',
];
if (Schema::hasColumn('contabilita_registrazioni', 'user_id')) {
$payload['user_id'] = $systemUserId > 0 ? $systemUserId : null;
}
if (Schema::hasColumn('contabilita_registrazioni', 'created_by')) {
$payload['created_by'] = $systemUserId > 0 ? $systemUserId : null;
}
if (Schema::hasColumn('contabilita_registrazioni', 'updated_by')) {
$payload['updated_by'] = $systemUserId > 0 ? $systemUserId : null;
}
$existing = Registrazione::query()
->where('stabile_id', $stabileId)
->where('external_reference', $payload['external_reference'])
->first();
if (! $existing) {
if (! $dry) {
$existing = Registrazione::query()->create($payload);
$this->syncMovimentiLegacy($existing, $row, $voceSpesaId, $fornitoreId, $legacyClassification);
$movimentiSynced++;
}
$created++;
continue;
}
$dirty = false;
foreach ($payload as $field => $value) {
if ((string) ($existing->{$field} ?? '') !== (string) ($value ?? '')) {
$existing->{$field} = $value;
$dirty = true;
}
}
if (! $dry && $dirty) {
if (empty($existing->protocol) || empty($existing->protocollo_completo)) {
$existing->applyAutoProtocols();
}
$existing->save();
}
if (! $dry) {
$this->syncMovimentiLegacy($existing, $row, $voceSpesaId, $fornitoreId, $legacyClassification);
$movimentiSynced++;
}
if ($dirty) {
$updated++;
} else {
$unchanged++;
}
}
$this->info('Sync completato: creati=' . $created . ' aggiornati=' . $updated . ' invariati=' . $unchanged . ' movimenti=' . $movimentiSynced . ' voci_link=' . $vociLinked . ' fornitori_link=' . $fornitoriLinked . ($dry ? ' (DRY-RUN)' : ''));
return 0;
}
private function resolveVoceSpesaId(int $stabileId, ?int $gestioneId, string $codSpe): int
{
$codSpe = trim($codSpe);
if ($codSpe === '' || ! Schema::hasTable('voci_spesa')) {
return 0;
}
$query = VoceSpesa::query()
->where('stabile_id', $stabileId)
->where(function (Builder $q) use ($codSpe): void {
$q->where('legacy_codice', $codSpe)
->orWhere('codice', $codSpe);
});
if ($gestioneId && Schema::hasColumn('voci_spesa', 'gestione_contabile_id')) {
$clone = (clone $query)->where('gestione_contabile_id', $gestioneId)->orderBy('id')->first();
if ($clone instanceof VoceSpesa) {
return (int) $clone->id;
}
}
$voce = $query->orderByDesc('attiva')->orderBy('id')->first();
return $voce instanceof VoceSpesa ? (int) $voce->id : 0;
}
private function ensureLegacyGestione(
int $stabileId,
int $anno,
string $tipoGestione,
string $dataReg,
?int $numeroStraordinaria = null,
?string $legacyArchiveCode = null,
?string $periodStart = null,
?string $periodEnd = null,
?string $legacyLabel = null
): ?GestioneContabile {
if ($stabileId <= 0 || $anno <= 0) {
return null;
}
$query = GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('anno_gestione', $anno)
->where('tipo_gestione', $tipoGestione);
if ($tipoGestione === 'straordinaria' && $numeroStraordinaria && $numeroStraordinaria > 0) {
$query->where('numero_straordinaria', $numeroStraordinaria);
}
$existing = $query->orderByDesc('id')->first();
if ($existing instanceof GestioneContabile) {
return $existing;
}
$start = $periodStart ?: sprintf('%04d-01-01', $anno);
$end = $periodEnd ?: sprintf('%04d-12-31', $anno);
if ($tipoGestione === 'riscaldamento') {
$start = $periodStart ?: sprintf('%04d-01-01', $anno);
$end = $periodEnd ?: sprintf('%04d-12-31', $anno);
}
$denominazione = $legacyLabel ?: ('Legacy GESCON ' . $anno . ' - ' . ucfirst($tipoGestione));
return GestioneContabile::query()->create([
'tenant_id' => $this->resolveTenantIdForStabile($stabileId),
'stabile_id' => $stabileId,
'anno_gestione' => $anno,
'tipo_gestione' => $tipoGestione,
'numero_straordinaria' => $tipoGestione === 'straordinaria' && $numeroStraordinaria && $numeroStraordinaria > 0 ? $numeroStraordinaria : null,
'denominazione' => $denominazione,
'data_inizio' => $start,
'data_fine' => $end,
'stato' => 'chiusa',
'protocollo_prefix' => 'LEG' . $anno,
'ultimo_protocollo' => 0,
'gestione_attiva' => false,
'codice_archivio_legacy' => $legacyArchiveCode ?: ('operazioni:' . $anno),
'note_gestione' => 'Creata automaticamente per import operazioni legacy GESCON da Prima Nota. Data riferimento: ' . $dataReg,
]);
}
private function resolveLegacyAnnualMeta(string $codStabile, string $legacyYear, string $tipoGestione): array
{
$codStabile = trim($codStabile);
$legacyYear = trim($legacyYear);
$cacheKey = implode('|', [$codStabile, $legacyYear, $tipoGestione]);
if (array_key_exists($cacheKey, $this->legacyAnnualMetaCache)) {
return $this->legacyAnnualMetaCache[$cacheKey];
}
if ($codStabile === '' || $legacyYear === '' || ! Schema::connection('gescon_import')->hasTable('gestioni_annuali')) {
return $this->legacyAnnualMetaCache[$cacheKey] = [];
}
$row = DB::connection('gescon_import')
->table('gestioni_annuali')
->where('cod_stabile', $codStabile)
->where('cartella', $legacyYear)
->first([
'cartella',
'anno_ordinario',
'anno_riscaldamento',
'descrizione',
'ordinaria_dal',
'ordinaria_al',
'riscaldamento_dal',
'riscaldamento_al',
]);
if (! $row) {
return $this->legacyAnnualMetaCache[$cacheKey] = [];
}
$isRiscaldamento = $tipoGestione === 'riscaldamento';
$annoLabel = trim((string) ($isRiscaldamento ? ($row->anno_riscaldamento ?? '') : ($row->anno_ordinario ?? '')));
$anno = $this->extractLegacyYearInt($annoLabel)
?: $this->extractLegacyYearInt((string) ($row->descrizione ?? ''))
?: $this->extractLegacyYearInt($legacyYear);
$start = $isRiscaldamento
? ($row->riscaldamento_dal ?: $row->ordinaria_dal)
: $row->ordinaria_dal;
$end = $isRiscaldamento
? ($row->riscaldamento_al ?: $row->ordinaria_al)
: $row->ordinaria_al;
$label = trim((string) ($row->descrizione ?? ''));
if ($label === '') {
$label = ucfirst($tipoGestione) . ' ' . ($annoLabel !== '' ? $annoLabel : $legacyYear);
}
return $this->legacyAnnualMetaCache[$cacheKey] = [
'cartella' => $legacyYear,
'anno_gestione' => $anno,
'anno_label' => $annoLabel !== '' ? $annoLabel : null,
'data_inizio' => $start ? (string) $start : null,
'data_fine' => $end ? (string) $end : null,
'label' => $label,
'codice_archivio_legacy' => 'legacy:gestioni_annuali:' . $legacyYear,
];
}
private function extractLegacyYearInt(?string $value): ?int
{
$value = trim((string) ($value ?? ''));
if ($value === '') {
return null;
}
if (preg_match('/(19|20)\d{2}/', $value, $match) === 1) {
return (int) $match[0];
}
return is_numeric($value) && strlen($value) === 4 ? (int) $value : null;
}
private function resolveTenantIdForStabile(int $stabileId): string
{
$tenant = GestioneContabile::query()
->where('stabile_id', $stabileId)
->whereNotNull('tenant_id')
->orderByDesc('id')
->value('tenant_id');
$tenant = trim((string) ($tenant ?? ''));
return $tenant !== '' ? $tenant : 'T1';
}
private function resolveFornitoreId(int $amministratoreId, mixed $codFor): int
{
$codFor = trim((string) ($codFor ?? ''));
if ($amministratoreId <= 0 || $codFor === '' || ! Schema::hasTable('fornitori')) {
return 0;
}
$query = Fornitore::query()->where('amministratore_id', $amministratoreId);
if (is_numeric($codFor)) {
$fornitore = (clone $query)->where('cod_forn', (int) $codFor)->first();
if ($fornitore instanceof Fornitore) {
return (int) $fornitore->id;
}
$fornitore = (clone $query)->where('old_id', (int) $codFor)->first();
if ($fornitore instanceof Fornitore) {
return (int) $fornitore->id;
}
}
return 0;
}
private function syncMovimentiLegacy(Registrazione $registrazione, object $row, int $voceSpesaId, int $fornitoreId, array $legacyClassification): void
{
if (! Schema::hasTable('contabilita_movimenti')) {
return;
}
Movimento::query()->where('registrazione_id', (int) $registrazione->id)->delete();
$amount = $this->resolveOperationAmount($row);
if (abs($amount) < 0.005) {
return;
}
$metadata = [
'source' => 'gescon_operazioni',
'legacy_id_operaz' => (int) ($row->id_operaz ?? 0),
'legacy_gestione' => trim((string) ($row->gestione ?? '')),
'legacy_compet' => $legacyClassification['compet'],
'legacy_natura2' => $legacyClassification['natura2'],
'legacy_posting_mode' => $legacyClassification['posting_mode'],
'legacy_cod_spe' => trim((string) ($row->cod_spe ?? '')),
'legacy_cod_for' => trim((string) ($row->cod_for ?? '')),
'legacy_tabella' => trim((string) ($row->tabella ?? '')),
'legacy_year' => trim((string) ($row->legacy_year ?? '')),
'fornitore_id' => $fornitoreId > 0 ? $fornitoreId : null,
];
$contoId = $this->resolveLegacyContoId((int) $registrazione->stabile_id, $legacyClassification);
$payload = [
'registrazione_id' => (int) $registrazione->id,
'conto_id' => $contoId,
'voce_spesa_id' => ($legacyClassification['allow_cost_voice'] ?? true) && $voceSpesaId > 0 ? $voceSpesaId : null,
'tipo' => $amount >= 0 ? 'dare' : 'avere',
'importo' => abs($amount),
'movement_description' => trim((string) ($row->benef ?: $row->note ?: $row->natura ?: 'Operazione legacy')),
'sequence' => 1,
];
if (Schema::hasColumn('contabilita_movimenti', 'metadata')) {
$payload['metadata'] = json_encode($metadata, JSON_UNESCAPED_SLASHES);
}
if (Schema::hasColumn('contabilita_movimenti', 'entry_id')) {
$payload['entry_id'] = (int) $registrazione->id;
}
if (Schema::hasColumn('contabilita_movimenti', 'debit_amount')) {
$payload['debit_amount'] = $amount >= 0 ? abs($amount) : 0;
}
if (Schema::hasColumn('contabilita_movimenti', 'credit_amount')) {
$payload['credit_amount'] = $amount < 0 ? abs($amount) : 0;
}
Movimento::query()->create($payload);
}
private function classifyLegacyOperation(object $row): array
{
$compet = strtoupper(trim((string) ($row->compet ?? 'C')));
if (! in_array($compet, ['C', 'P'], true)) {
$compet = 'C';
}
$natura2 = trim((string) ($row->natura2 ?? ''));
$naturaNorm = strtoupper($natura2);
$postingMode = 'cost';
$allowCostVoice = true;
if ($compet === 'P') {
$postingMode = 'supplier_debt_payment';
$allowCostVoice = false;
} elseif (in_array($naturaNorm, ['DEBITO', 'D'], true)) {
$postingMode = 'supplier_debt_open';
} elseif (in_array($naturaNorm, ['CREDITO', 'C'], true)) {
$postingMode = 'credit';
$allowCostVoice = false;
} elseif (in_array($naturaNorm, ['ENTRATA', 'ENTRATE', 'E'], true)) {
$postingMode = 'income';
$allowCostVoice = false;
}
return [
'compet' => $compet,
'natura2' => $natura2,
'posting_mode' => $postingMode,
'allow_cost_voice' => $allowCostVoice,
];
}
private function resolveLegacyContoId(int $stabileId, array $legacyClassification): int
{
$codice = match ($legacyClassification['posting_mode'] ?? 'cost') {
'supplier_debt_payment', 'supplier_debt_open' => (string) config('contabilita.conti_speciali.debiti_fornitori_codice', '2000'),
'credit', 'income' => (string) config('contabilita.conti_speciali.crediti_condomini_codice', '1100'),
default => '5100',
};
return $this->resolveOrCreateSpecialPianoConto($codice);
}
private function resolveOrCreateSpecialPianoConto(string $codice): int
{
if (! Schema::hasTable('contabilita_piano_conti')) {
return 0;
}
$existing = DB::table('contabilita_piano_conti')
->where('codice', $codice)
->value('id');
if (is_numeric($existing) && (int) $existing > 0) {
return (int) $existing;
}
$tipoConto = match (substr($codice, 0, 1)) {
'1' => 'ATTIVO',
'2' => 'PASSIVO',
'3' => 'PATRIMONIO',
'4' => 'RICAVO',
default => 'COSTO',
};
$descrizione = match ($codice) {
'2000' => (string) config('contabilita.conti_speciali.debiti_fornitori_descrizione', 'Debiti verso fornitori'),
'1100' => (string) config('contabilita.conti_speciali.crediti_condomini_descrizione', 'Crediti verso condomini'),
default => 'Servizi condominiali',
};
return (int) DB::table('contabilita_piano_conti')->insertGetId([
'codice' => $codice,
'denominazione' => $descrizione,
'tipo_conto' => $tipoConto,
'attivo' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function resolveOperationAmount(object $row): float
{
foreach (['importo_euro', 'importo', 'importo_spese', 'importo_entrate'] as $field) {
if (isset($row->{$field}) && is_numeric($row->{$field}) && (float) $row->{$field} != 0.0) {
$value = (float) $row->{$field};
if ($field === 'importo_entrate') {
return -abs($value);
}
return $value;
}
}
return 0.0;
}
}