netgescon-day0/app/Filament/Pages/Fornitore/Contabilita.php

707 lines
27 KiB
PHP

<?php
namespace App\Filament\Pages\Fornitore;
use App\Filament\Pages\Contabilita\FattureElettronicheP7mRicevute;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Models\Amministratore;
use App\Models\Fornitore;
use App\Models\Stabile;
use App\Models\TicketIntervento;
use App\Models\User;
use App\Modules\Contabilita\Models\FatturaFornitore;
use App\Services\FattureElettroniche\FatturaElettronicaImporter;
use App\Services\FattureElettroniche\FatturaElettronicaXmlParser;
use App\Services\FattureElettroniche\P7mExtractor;
use App\Support\ArchivioPaths;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use UnitEnum;
use ZipArchive;
class Contabilita extends Page
{
use ResolvesOperatoreContext;
protected static ?string $navigationLabel = 'Contabilita';
protected static ?string $title = 'Contabilita fornitore';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-banknotes';
protected static UnitEnum|string|null $navigationGroup = 'Fornitore';
protected static ?int $navigationSort = 3;
protected static ?string $slug = 'fornitore/contabilita';
protected string $view = 'filament.pages.fornitore.contabilita';
public ?Fornitore $fornitore = null;
public ?string $fornitoreLabel = null;
public bool $missingAdminContext = false;
public bool $contabilitaReady = false;
/** @var array{aperte:int,pagate:int,registrate:int,totale_aperto:float,totale_registrato:float} */
public array $stats = [
'aperte' => 0,
'pagate' => 0,
'registrate' => 0,
'totale_aperto' => 0.0,
'totale_registrato' => 0.0,
];
/** @var array<int,array<string,mixed>> */
public array $fattureRows = [];
/** @var array<int,array<string,mixed>> */
public array $queueRows = [];
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'fornitore']);
}
public function mount(): void
{
[$fornitore] = $this->resolveOperatoreContext(allowAdminWithoutSupplier: true);
if (! $fornitore instanceof Fornitore) {
$this->missingAdminContext = true;
return;
}
$this->fornitore = $fornitore;
$this->fornitoreLabel = $this->getFornitoreLabel($fornitore);
$this->loadData();
}
public function getTicketsUrl(): string
{
if ($this->fornitore instanceof Fornitore) {
return TicketOperativi::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament');
}
return TicketOperativi::getUrl(panel: 'admin-filament');
}
public function getCollaboratoriUrl(): string
{
if ($this->fornitore instanceof Fornitore) {
return Collaboratori::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament');
}
return Collaboratori::getUrl(panel: 'admin-filament');
}
public function getFattureRicevuteUrl(): string
{
return FattureElettronicheP7mRicevute::getUrl(panel: 'admin-filament');
}
protected function getHeaderActions(): array
{
if (! $this->fornitore instanceof Fornitore) {
return [];
}
return [
Action::make('importa_fe_manuale')
->label('Importa FE manuale')
->icon('heroicon-o-arrow-up-tray')
->modalWidth('4xl')
->form([
FileUpload::make('fe_files')
->label('File FE (XML/P7M/ZIP)')
->directory(fn(): string => $this->getSupplierFeUploadDirectory())
->disk('local')
->acceptedFileTypes([
'.p7m',
'.xml',
'.zip',
'application/xml',
'text/xml',
'application/pkcs7-mime',
'application/x-pkcs7-mime',
'application/pkcs7-signature',
'application/x-pkcs7-signature',
'application/octet-stream',
'application/zip',
'application/x-zip-compressed',
])
->multiple()
->required(),
Select::make('importa_righe')
->label('Import righe')
->options([
'auto' => 'Auto (segue impostazione fornitore)',
'si' => 'Si (forza import righe)',
'no' => 'No (non importare righe)',
])
->default('auto')
->required(),
Toggle::make('force_update')
->label('Reimport / completa se gia presente')
->default(false),
])
->action(function (array $data): void {
if (! $this->fornitore instanceof Fornitore) {
Notification::make()->title('Fornitore non disponibile')->danger()->send();
return;
}
if (! $this->supplierHasTaxIdentity()) {
Notification::make()
->title('Import FE bloccato')
->body('Il fornitore corrente non ha P.IVA o codice fiscale valorizzati: non posso verificare che l XML appartenga davvero a questa anagrafica.')
->warning()
->send();
return;
}
$paths = $data['fe_files'] ?? [];
$paths = is_array($paths) ? $paths : [$paths];
$parser = new FatturaElettronicaXmlParser();
$importer = new FatturaElettronicaImporter($parser);
$extractor = app(P7mExtractor::class);
$summary = [
'imported' => 0,
'duplicates' => 0,
'skipped' => 0,
'errors' => 0,
'messages' => [],
];
foreach ($paths as $path) {
$result = $this->importSupplierUploadedFeFile(
(string) $path,
$importer,
$parser,
$extractor,
[
'force_update' => (bool) ($data['force_update'] ?? false),
'importa_righe' => (string) ($data['importa_righe'] ?? 'auto'),
]
);
$summary['imported'] += (int) ($result['imported'] ?? 0);
$summary['duplicates'] += (int) ($result['duplicates'] ?? 0);
$summary['skipped'] += (int) ($result['skipped'] ?? 0);
$summary['errors'] += (int) ($result['errors'] ?? 0);
$summary['messages'] = array_merge($summary['messages'], $result['messages'] ?? []);
}
$this->loadData();
$body = 'Importate: ' . $summary['imported'] . ' · Duplicate: ' . $summary['duplicates'] . ' · Saltate: ' . $summary['skipped'] . ' · Errori: ' . $summary['errors'];
if ($summary['messages'] !== []) {
$body .= "\n" . implode("\n", array_slice($summary['messages'], 0, 3));
}
Notification::make()
->title('Import FE manuale completato')
->body($body)
->{$summary['errors'] > 0 ? 'warning' : 'success'}()
->send();
}),
];
}
protected function loadData(): void
{
if (! $this->fornitore instanceof Fornitore) {
return;
}
$this->queueRows = TicketIntervento::query()
->with(['ticket.stabile.amministratore'])
->where('fornitore_id', (int) $this->fornitore->id)
->whereIn('stato', ['in_verifica', 'fatturabile', 'fatturato'])
->orderByDesc('updated_at')
->limit(40)
->get()
->map(fn(TicketIntervento $intervento) => [
'ticket_id' => (int) $intervento->ticket_id,
'intervento_id' => (int) $intervento->id,
'stato' => (string) $intervento->stato,
'stabile' => (string) ($intervento->ticket?->stabile?->denominazione ?? '-'),
'amministratore' => (string) ($intervento->ticket?->stabile?->amministratore?->denominazione_studio ?? '-'),
'titolo' => (string) ($intervento->ticket?->titolo ?? '-'),
'aggiornato' => optional($intervento->updated_at)->format('d/m/Y H:i') ?: '-',
'url' => TicketInterventoScheda::getUrl(['record' => (int) $intervento->id], panel: 'admin-filament'),
])
->all();
if (! Schema::hasTable('contabilita_fatture_fornitori')) {
$this->contabilitaReady = false;
return;
}
$this->contabilitaReady = true;
$fatture = FatturaFornitore::query()
->with(['stabile.amministratore'])
->where('fornitore_id', (int) $this->fornitore->id)
->orderByDesc('data_documento')
->orderByDesc('id')
->limit(100)
->get();
$this->stats = [
'aperte' => $fatture->whereIn('stato', ['inserito', 'contabilizzato'])->count(),
'pagate' => $fatture->where('stato', 'pagato')->count(),
'registrate' => $fatture->count(),
'totale_aperto' => (float) $fatture->whereIn('stato', ['inserito', 'contabilizzato'])->sum('netto_da_pagare'),
'totale_registrato' => (float) $fatture->sum('totale'),
];
$this->fattureRows = $fatture
->map(fn(FatturaFornitore $fattura) => [
'id' => (int) $fattura->id,
'data_documento' => optional($fattura->data_documento)->format('d/m/Y') ?: '-',
'numero_documento' => (string) ($fattura->numero_documento ?? '-'),
'stato' => (string) ($fattura->stato ?? 'inserito'),
'stabile' => (string) ($fattura->stabile?->denominazione ?? '-'),
'amministratore' => (string) ($fattura->stabile?->amministratore?->denominazione_studio ?? '-'),
'descrizione' => (string) ($fattura->descrizione ?? ''),
'totale' => (float) ($fattura->totale ?? 0),
'netto' => (float) ($fattura->netto_da_pagare ?? 0),
])
->all();
}
/**
* @param array<string, mixed> $options
* @return array{imported:int,duplicates:int,skipped:int,errors:int,messages:array<int,string>}
*/
private function importSupplierUploadedFeFile(
string $path,
FatturaElettronicaImporter $importer,
FatturaElettronicaXmlParser $parser,
P7mExtractor $extractor,
array $options,
): array {
$summary = [
'imported' => 0,
'duplicates' => 0,
'skipped' => 0,
'errors' => 0,
'messages' => [],
];
$path = trim($path);
if ($path === '') {
return $summary;
}
try {
$filename = basename($path);
$lower = Str::lower($filename);
if (Str::endsWith($lower, '.zip')) {
$absZip = Storage::disk('local')->path($path);
if (! is_file($absZip)) {
throw new \RuntimeException('ZIP non trovato');
}
$tmpDir = 'tmp/fornitori-fe/' . now()->format('Y/m') . '/' . (string) Str::uuid();
Storage::disk('local')->makeDirectory($tmpDir);
$absTmp = Storage::disk('local')->path($tmpDir);
$zip = new ZipArchive();
$open = $zip->open($absZip);
if ($open !== true) {
throw new \RuntimeException('ZIP non valido');
}
$zip->extractTo($absTmp);
$zip->close();
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($absTmp));
foreach ($it as $fileInfo) {
if (! $fileInfo instanceof \SplFileInfo || ! $fileInfo->isFile()) {
continue;
}
$innerName = (string) $fileInfo->getFilename();
$innerLower = Str::lower($innerName);
if (! (Str::endsWith($innerLower, '.xml') || Str::endsWith($innerLower, '.p7m'))) {
continue;
}
$bytes = file_get_contents($fileInfo->getPathname());
if (! is_string($bytes) || $bytes === '') {
$summary['errors']++;
$summary['messages'][] = $filename . '::' . $innerName . ' - file vuoto';
continue;
}
$isP7m = Str::endsWith($innerLower, '.p7m');
$xml = $isP7m ? $extractor->extractXmlFromP7m($fileInfo->getPathname()) : $bytes;
$this->mergeSupplierImportResult(
$summary,
$this->importSupplierParsedXml(
is_string($xml) ? $xml : '',
$isP7m ? (pathinfo($innerName, PATHINFO_FILENAME) . '.xml') : $innerName,
$importer,
$parser,
$options,
$isP7m ? $innerName : null,
$isP7m ? $bytes : null
),
$filename . '::' . $innerName
);
}
Storage::disk('local')->deleteDirectory($tmpDir);
return $summary;
}
$bytes = Storage::disk('local')->get($path);
if (! is_string($bytes) || $bytes === '') {
throw new \RuntimeException('File vuoto');
}
$isP7m = Str::endsWith($lower, '.p7m');
$xml = $isP7m ? $extractor->extractXmlFromP7m(Storage::disk('local')->path($path)) : $bytes;
$this->mergeSupplierImportResult(
$summary,
$this->importSupplierParsedXml(
is_string($xml) ? $xml : '',
$isP7m ? (pathinfo($filename, PATHINFO_FILENAME) . '.xml') : $filename,
$importer,
$parser,
$options,
$isP7m ? $filename : null,
$isP7m ? $bytes : null
),
$filename
);
} catch (\Throwable $e) {
$summary['errors']++;
$summary['messages'][] = basename($path) . ' - ' . $e->getMessage();
} finally {
try {
Storage::disk('local')->delete($path);
} catch (\Throwable) {
// ignore
}
}
return $summary;
}
/**
* @param array<string, mixed> $options
* @return array{status:string,message:string}
*/
private function importSupplierParsedXml(
string $xml,
string $xmlFilename,
FatturaElettronicaImporter $importer,
FatturaElettronicaXmlParser $parser,
array $options,
?string $p7mFilename = null,
?string $p7mBytes = null,
): array {
$xml = trim($xml);
if ($xml === '') {
return ['status' => 'error', 'message' => 'XML non valido o assente'];
}
if ($this->isSdINotificationDocument($xmlFilename, $xml)) {
return [
'status' => 'skipped',
'message' => 'Ricevuta/notifica SdI: non viene importata nella contabilita fornitore',
];
}
$parsed = $parser->parse($xml);
if (! $this->supplierMatchesParsedIdentity($parsed)) {
return ['status' => 'error', 'message' => 'Il file non appartiene al fornitore corrente'];
}
$stabileId = $this->resolveSupplierStabileIdFromParsed($parsed);
if ($stabileId <= 0) {
return ['status' => 'error', 'message' => 'Impossibile determinare uno stabile univoco per questa FE'];
}
$unique = (string) Str::uuid();
$ym = now()->format('Y/m');
$inboxBase = $this->supplierInboxBaseForStabile($stabileId, $ym);
$permanentP7mPath = null;
if ($p7mFilename !== null && is_string($p7mBytes) && $p7mBytes !== '') {
$permanentP7mPath = $inboxBase . '/' . $unique . '-' . $p7mFilename;
Storage::disk('local')->put($permanentP7mPath, $p7mBytes);
}
$permanentXmlPath = $inboxBase . '/' . $unique . '-' . $xmlFilename;
Storage::disk('local')->put($permanentXmlPath, $xml);
$result = $importer->importXml($xml, 0, $xmlFilename, [
'xml_path' => $permanentXmlPath,
'p7m_path' => $permanentP7mPath,
'nome_file_p7m' => $p7mFilename,
'force_update' => (bool) ($options['force_update'] ?? false),
'auto_repair_duplicate' => true,
'importa_righe' => (string) ($options['importa_righe'] ?? 'auto'),
'create_fornitore_if_missing' => false,
'allow_fallback_stabile' => false,
'force_stabile_id' => $stabileId,
'force_fornitore_id' => (int) $this->fornitore->id,
'skip_fornitore_sync' => true,
'user_id' => (int) Auth::id(),
]);
$status = (string) ($result['status'] ?? 'error');
$message = (string) ($result['message'] ?? 'Import FE non riuscito');
if ($status === 'duplicate' || $status === 'error') {
try {
Storage::disk('local')->delete($permanentXmlPath);
if ($permanentP7mPath !== null) {
Storage::disk('local')->delete($permanentP7mPath);
}
} catch (\Throwable) {
// ignore
}
}
return [
'status' => $status,
'message' => $message,
];
}
/**
* @param array<string, mixed> $summary
* @param array<string, string> $result
*/
private function mergeSupplierImportResult(array &$summary, array $result, string $label): void
{
$status = (string) ($result['status'] ?? 'error');
$message = trim((string) ($result['message'] ?? ''));
if ($status === 'imported') {
$summary['imported']++;
return;
}
if ($status === 'duplicate') {
$summary['duplicates']++;
$summary['messages'][] = $label . ' - ' . ($message !== '' ? $message : 'duplicato');
return;
}
if ($status === 'skipped') {
$summary['skipped']++;
$summary['messages'][] = $label . ' - ' . ($message !== '' ? $message : 'saltato');
return;
}
$summary['errors']++;
$summary['messages'][] = $label . ' - ' . ($message !== '' ? $message : 'errore');
}
private function isSdINotificationDocument(string $xmlFilename, string $xml): bool
{
$filename = strtoupper(trim($xmlFilename));
if ($filename !== '' && preg_match('/_(RC|NS|MC|NE|EC|AT|DT|MT|SE|SM)_\d+\.XML$/', $filename) === 1) {
return true;
}
foreach ([
'<RicevutaConsegna',
'<NotificaScarto',
'<NotificaMancataConsegna',
'<NotificaEsito',
'<MetadatiInvioFile',
'<AttestazioneTrasmissioneFattura',
'<EsitoCommittente',
] as $needle) {
if (str_contains($xml, $needle)) {
return true;
}
}
return false;
}
private function getSupplierFeUploadDirectory(): string
{
if (! $this->fornitore instanceof Fornitore) {
return 'tmp/fornitori-fe';
}
$adminCode = (string) ($this->fornitore->amministratore?->codice_amministratore ?: $this->fornitore->amministratore?->codice_univoco ?: 'ID-' . (int) $this->fornitore->amministratore_id);
$fornitoreCode = (string) ($this->fornitore->codice_fornitore ?: $this->fornitore->codice_univoco ?: 'ID-' . (int) $this->fornitore->id);
return 'amministratori/' . $adminCode . '/fornitori/' . $fornitoreCode . '/temp/upload/fatture-fe';
}
private function supplierHasTaxIdentity(): bool
{
if (! $this->fornitore instanceof Fornitore) {
return false;
}
return $this->normalizeTaxId((string) ($this->fornitore->partita_iva ?? '')) !== ''
|| $this->normalizeTaxId((string) ($this->fornitore->codice_fiscale ?? '')) !== ''
|| $this->normalizeTaxId((string) ($this->fornitore->rubrica?->partita_iva ?? '')) !== ''
|| $this->normalizeTaxId((string) ($this->fornitore->rubrica?->codice_fiscale ?? '')) !== '';
}
/**
* @param array<string, mixed> $parsed
*/
private function supplierMatchesParsedIdentity(array $parsed): bool
{
if (! $this->fornitore instanceof Fornitore) {
return false;
}
$invoiceIds = array_values(array_unique(array_filter([
$this->normalizeTaxId((string) ($parsed['fornitore_piva'] ?? '')),
$this->normalizeTaxId((string) ($parsed['fornitore_cf'] ?? '')),
$this->normalizeTaxId((string) ($parsed['destinatario_piva'] ?? '')),
$this->normalizeTaxId((string) ($parsed['destinatario_cf'] ?? '')),
])));
if ($invoiceIds === []) {
return false;
}
$supplierIds = array_values(array_filter([
$this->normalizeTaxId((string) ($this->fornitore->partita_iva ?? '')),
$this->normalizeTaxId((string) ($this->fornitore->codice_fiscale ?? '')),
$this->normalizeTaxId((string) ($this->fornitore->rubrica?->partita_iva ?? '')),
$this->normalizeTaxId((string) ($this->fornitore->rubrica?->codice_fiscale ?? '')),
]));
foreach ($invoiceIds as $invoiceId) {
if (in_array($invoiceId, $supplierIds, true)) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $parsed
*/
private function resolveSupplierStabileIdFromParsed(array $parsed): int
{
$adminId = (int) ($this->fornitore?->amministratore_id ?? 0);
if ($adminId <= 0) {
return 0;
}
$cf = $this->normalizeTaxId((string) ($parsed['destinatario_cf'] ?? ''));
if ($cf !== '') {
$match = Stabile::query()
->where('amministratore_id', $adminId)
->whereNotNull('codice_fiscale')
->whereRaw("REPLACE(UPPER(codice_fiscale), ' ', '') = ?", [$cf])
->where('attivo', true)
->get();
if ($match->count() === 1) {
return (int) $match->first()->id;
}
}
$codice = $this->normalizeTaxId((string) ($parsed['codice_destinatario'] ?? ''));
if ($codice !== '') {
$match = Stabile::query()
->where('amministratore_id', $adminId)
->whereNotNull('codice_destinatario_sdi')
->whereRaw("REPLACE(UPPER(codice_destinatario_sdi), ' ', '') = ?", [$codice])
->where('attivo', true)
->get();
if ($match->count() === 1) {
return (int) $match->first()->id;
}
}
$pec = trim(strtolower((string) ($parsed['pec_destinatario'] ?? '')));
if ($pec !== '') {
$match = Stabile::query()
->where('amministratore_id', $adminId)
->where(function ($query) use ($pec): void {
$query->whereRaw('LOWER(pec_condominio) = ?', [$pec]);
if (Schema::hasColumn('stabili', 'pec_amministratore')) {
$query->orWhereRaw('LOWER(pec_amministratore) = ?', [$pec]);
}
})
->where('attivo', true)
->get();
if ($match->count() === 1) {
return (int) $match->first()->id;
}
}
$destPiva = $this->normalizeTaxId((string) ($parsed['destinatario_piva'] ?? ''));
if ($destPiva !== '') {
$match = Stabile::query()
->select('stabili.id')
->join('amministratori', 'amministratori.id', '=', 'stabili.amministratore_id')
->where('stabili.amministratore_id', $adminId)
->where('stabili.attivo', true)
->whereRaw("REPLACE(UPPER(amministratori.partita_iva), ' ', '') = ?", [$destPiva])
->get();
if ($match->count() === 1) {
return (int) $match->first()->id;
}
}
return 0;
}
private function supplierInboxBaseForStabile(int $stabileId, string $ym): string
{
$stabile = Stabile::query()
->with(['amministratore:id,codice_amministratore,codice_univoco'])
->select(['id', 'amministratore_id', 'codice_stabile', 'codice_univoco'])
->find($stabileId);
if ($stabile instanceof Stabile) {
$base = ArchivioPaths::stabileBase($stabile, $stabile->amministratore instanceof Amministratore ? $stabile->amministratore : null);
if (is_string($base) && $base !== '') {
return $base . '/fatture-ricevute/inbox/' . $ym;
}
}
$adminId = (int) ($stabile?->amministratore_id ?? 0);
return 'amministratori/ID-' . $adminId . '/stabili/ID-' . $stabileId . '/fatture-ricevute/inbox/' . $ym;
}
private function normalizeTaxId(string $value): string
{
$normalized = strtoupper(str_replace(' ', '', trim($value)));
if (str_starts_with($normalized, 'IT') && strlen($normalized) > 11) {
$normalized = substr($normalized, 2);
}
return preg_replace('/[^A-Z0-9]/', '', $normalized) ?? '';
}
}