Improve bank movements and e-invoice import workflow

This commit is contained in:
michele 2026-04-21 17:16:50 +00:00
parent 1c09aae635
commit 6ac70ce38e
12 changed files with 2460 additions and 187 deletions

View File

@ -4,6 +4,7 @@ # Changelog
## [Unreleased] ## [Unreleased]
- Reworked the banking operations hub so the topbar stays full-width, the sidebar stays fixed below it, the movements tab remains focused on imported/manual bank movements, and archive or gestione structure moved into a dedicated tab. The same slice also added first-pass QIF cleanup metadata for CBILL, SIA, commissions and provider hints, plus stronger bank-to-supplier matching and ACQ assignment for water invoices extracted from FE/PDF data.
- Fixed document archive visibility queries to work safely even when single-user ACL columns are not yet present in older databases, and added the missing `documenti.allowed_user_ids` schema field for per-document access control. - Fixed document archive visibility queries to work safely even when single-user ACL columns are not yet present in older databases, and added the missing `documenti.allowed_user_ids` schema field for per-document access control.
- Restored a cleaner two-column layout in Post-it Gestione boards and hid visual noise from calls that terminate on internal response group `601` while keeping the underlying records in the system. - Restored a cleaner two-column layout in Post-it Gestione boards and hid visual noise from calls that terminate on internal response group `601` while keeping the underlying records in the system.
- Added actionable banking reconciliation for tenant rents and supplier payments directly from the MPS movement queue, with automatic operational matching metadata and supplier RA register synchronization when a withholding-bearing supplier invoice is paid. - Added actionable banking reconciliation for tenant rents and supplier payments directly from the MPS movement queue, with automatic operational matching metadata and supplier RA register synchronization when a withholding-bearing supplier invoice is paid.

View File

@ -1 +1 @@
0.8.1 0.8.2

File diff suppressed because it is too large Load Diff

View File

@ -994,6 +994,54 @@ private function parseDescrizioneEstesa(string $text): array
$res['periodo_rate'] = trim($m[1]); $res['periodo_rate'] = trim($m[1]);
} }
if (preg_match('/\b(Altri\s+pagamenti|Bonifici?|Addebiti?|Prelievi?|Commissioni?)\b/i', $text, $m)) {
$res['payment_group'] = strtoupper(trim((string) $m[1]));
}
if (preg_match('/\bFav\.?\s+([^\n]+?)(?=\s+(?:Cbill|CBILL|SIA\b|Da\s+Digital\s+Banking|Di\s+Cui\s+Commissioni|T[\-\+0-9\.,]|$))/i', $text, $m)) {
$provider = trim((string) $m[1]);
if ($provider !== '') {
$res['payment_provider'] = $provider;
if (empty($res['beneficiario'])) {
$res['beneficiario'] = $provider;
}
}
}
if (preg_match('/\bCbill\b\s*(?:N\.)?\s*\.?\s*([0-9]{9,})/i', $text, $m)) {
$res['cbill'] = trim((string) $m[1]);
}
if (preg_match('/\bSIA\b[:\s]*([A-Z0-9]{3,10})/i', $text, $m)) {
$res['sia'] = strtoupper(trim((string) $m[1]));
} elseif (preg_match('/\b([A-Z][A-Z0-9]{3,6})\b(?=\s+Cbill\b)/i', $text, $m)) {
$candidate = strtoupper(trim((string) $m[1]));
if (! in_array($candidate, ['CBILL', 'FAV'], true)) {
$res['sia'] = $candidate;
}
}
if (preg_match('/Di\s+Cui\s+Commissioni:\s*([0-9\.,]+)\s*Euro/i', $text, $m)) {
$commissione = $this->parseItalianDecimal($m[1] ?? null);
if ($commissione !== null) {
$res['commissioni_euro'] = $commissione;
}
}
if (preg_match('/\bDa\s+Digital\s+Banking\b/i', $text)) {
$res['payment_channel'] = 'digital_banking';
}
$providerUpper = strtoupper(trim((string) ($res['payment_provider'] ?? $res['beneficiario'] ?? '')));
if ($providerUpper !== '') {
if (str_contains($providerUpper, 'ACEA ATO2') || str_contains($providerUpper, 'ACEA ACQUA')) {
$res['utility_type'] = 'acqua';
$res['suggested_voce_spesa_code'] = 'ACQ';
} elseif (str_contains($providerUpper, 'A2A')) {
$res['utility_type'] = 'utenza';
}
}
if (empty($res) && str_contains($text, 'Bonifico a Vostro favore')) { if (empty($res) && str_contains($text, 'Bonifico a Vostro favore')) {
$res['descrizione_raw'] = $text; $res['descrizione_raw'] = $text;
} }
@ -1040,4 +1088,17 @@ private function matchFornitoreId(string $name): ?int
return $rows->count() === 1 ? (int) $rows->first()->id : null; return $rows->count() === 1 ? (int) $rows->first()->id : null;
} }
private function parseItalianDecimal(mixed $value): ?float
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
$value = str_replace(['.', ' '], ['', ''], $value);
$value = str_replace(',', '.', $value);
return is_numeric($value) ? (float) $value : null;
}
} }

View File

@ -256,8 +256,8 @@ public function downloadAndImport(
Storage::disk('local')->makeDirectory($errorsDir); Storage::disk('local')->makeDirectory($errorsDir);
$errorsLogPath = $errorsDir . '/' . $uuid . '-download-errors.jsonl'; $errorsLogPath = $errorsDir . '/' . $uuid . '-download-errors.jsonl';
// Salva i file scaricati in una cartella stabile XML dentro lo stabile. // Salva gli XML in una struttura leggibile e annuale: fatture_xml/ANNO.
$xmlBase = $stabileBase . '/XML'; $xmlBase = ArchivioPaths::fattureXmlYearPath($stabile, $year, $amministratore) ?: ($stabileBase . '/fatture_xml/' . $year);
Storage::disk('local')->makeDirectory($xmlBase); Storage::disk('local')->makeDirectory($xmlBase);
foreach ($files as $filePath) { foreach ($files as $filePath) {

View File

@ -11,6 +11,7 @@
use App\Modules\Contabilita\Models\FatturaFornitore; use App\Modules\Contabilita\Models\FatturaFornitore;
use App\Modules\Contabilita\Models\RegolaPrimaNota; use App\Modules\Contabilita\Models\RegolaPrimaNota;
use App\Services\Catalog\FornitoreProductCatalogService; use App\Services\Catalog\FornitoreProductCatalogService;
use App\Services\Consumi\AcquaPdfTextParser;
use App\Support\ArchivioPaths; use App\Support\ArchivioPaths;
use DOMDocument; use DOMDocument;
use DOMXPath; use DOMXPath;
@ -531,9 +532,6 @@ private function updateContabilitaFornituraDaPdf(FatturaElettronica $fattura): v
} }
$existing = is_array($fatturaContabile->dati_fornitura) ? $fatturaContabile->dati_fornitura : []; $existing = is_array($fatturaContabile->dati_fornitura) ? $fatturaContabile->dati_fornitura : [];
if (count($existing) > 0) {
return;
}
$pdfPath = is_string($fattura->allegato_pdf_path ?? null) ? trim((string) $fattura->allegato_pdf_path) : ''; $pdfPath = is_string($fattura->allegato_pdf_path ?? null) ? trim((string) $fattura->allegato_pdf_path) : '';
if ($pdfPath === '') { if ($pdfPath === '') {
@ -550,26 +548,56 @@ private function updateContabilitaFornituraDaPdf(FatturaElettronica $fattura): v
} }
$fullPath = $disk->path($resolvedPath); $fullPath = $disk->path($resolvedPath);
$data = $this->extractGasFornituraDataFromPdf($fullPath); $text = $this->extractPdfText($fullPath);
if (! is_array($data) || $data === []) { if (! is_string($text) || trim($text) === '') {
return; return;
} }
$fatturaContabile->dati_fornitura = $data; $gasData = $this->extractGasFornituraDataFromPdfText($text);
$waterData = $this->extractAcquaFornituraDataFromPdfText($text);
$paymentData = $this->extractPaymentReferenceDataFromPdfText($text, $fattura);
$data = is_array($waterData) && $waterData !== [] ? $waterData : $gasData;
if (! is_array($data)) {
$data = [];
}
if ($paymentData !== []) {
$data['pagamento'] = array_merge(is_array($data['pagamento'] ?? null) ? $data['pagamento'] : [], $paymentData);
}
$merged = $this->mergeFornituraPayload($existing, $data);
if ($merged === []) {
return;
}
if (($merged['type'] ?? $merged['tipo'] ?? null) === 'acqua') {
$this->enrichWaterInvoiceFromParsedData($fattura, $merged);
$this->assignWaterExpenseCode($fatturaContabile, $merged);
}
if ($merged !== $existing) {
$fatturaContabile->dati_fornitura = $merged;
$fatturaContabile->save(); $fatturaContabile->save();
} }
}
/** @return array<string, mixed>|null */ /** @return array<string, mixed>|null */
private function extractGasFornituraDataFromPdf(string $fullPath): ?array private function extractGasFornituraDataFromPdf(string $fullPath): ?array
{
$text = $this->extractPdfText($fullPath);
return is_string($text) ? $this->extractGasFornituraDataFromPdfText($text) : null;
}
/** @return array<string, mixed>|null */
private function extractGasFornituraDataFromPdfText(string $text): ?array
{ {
try { try {
$parser = new Parser(); $text = trim($text);
$text = $parser->parseFile($fullPath)->getText();
} catch (\Throwable) { } catch (\Throwable) {
return null; return null;
} }
if (! is_string($text) || trim($text) === '') { if ($text === '') {
return null; return null;
} }
@ -636,6 +664,183 @@ private function extractGasFornituraDataFromPdf(string $fullPath): ?array
return $payload !== [] ? $payload : null; return $payload !== [] ? $payload : null;
} }
private function extractPdfText(string $fullPath): ?string
{
try {
$parser = new Parser();
$text = $parser->parseFile($fullPath)->getText();
} catch (\Throwable) {
return null;
}
$text = is_string($text) ? trim($text) : '';
return $text !== '' ? $text : null;
}
/** @return array<string, mixed>|null */
private function extractAcquaFornituraDataFromPdfText(string $text): ?array
{
try {
$parsed = app(AcquaPdfTextParser::class)->parse($text);
} catch (\Throwable) {
return null;
}
$codici = is_array($parsed['codici'] ?? null) ? $parsed['codici'] : [];
$contatore = is_array($parsed['contatore'] ?? null) ? $parsed['contatore'] : [];
$consumi = is_array($parsed['consumi'] ?? null) ? $parsed['consumi'] : [];
$generale = is_array($parsed['generale'] ?? null) ? $parsed['generale'] : [];
$quadro = is_array($parsed['quadro_dettaglio'] ?? null) ? $parsed['quadro_dettaglio'] : [];
$hasIdentifiers = count(array_filter([
$codici['utenza'] ?? null,
$codici['cliente'] ?? null,
$codici['contratto'] ?? null,
$contatore['matricola'] ?? null,
$generale['numero_fattura_pdf'] ?? null,
], static fn($value): bool => is_string($value) ? trim($value) !== '' : $value !== null)) > 0;
if (! $hasIdentifiers && $consumi === []) {
return null;
}
$payload = [
'type' => 'acqua',
'voce_spesa_code' => 'ACQ',
'codici' => [
'utenza' => $codici['utenza'] ?? null,
'cliente' => $codici['cliente'] ?? null,
'contratto' => $codici['contratto'] ?? null,
],
'contatore' => [
'matricola' => $contatore['matricola'] ?? null,
],
'generale' => $generale,
'quadro_dettaglio' => $quadro,
'consumi' => array_values($consumi),
];
return array_filter($payload, fn($value) => $value !== null && $value !== '');
}
/** @return array<string, mixed> */
private function extractPaymentReferenceDataFromPdfText(string $text, FatturaElettronica $fattura): array
{
$provider = trim((string) ($fattura->fornitore_denominazione ?? ''));
$cbill = null;
$sia = null;
if (preg_match('/\bCBILL\b(?:\s*N\.)?\s*\.?\s*([0-9]{9,})/i', $text, $m)) {
$cbill = trim((string) ($m[1] ?? ''));
}
if (preg_match('/\bSIA\b[:\s]*([A-Z0-9]{3,10})/i', $text, $m)) {
$sia = strtoupper(trim((string) ($m[1] ?? '')));
} elseif (preg_match('/\b([A-Z][A-Z0-9]{3,6})\b(?=\s+CBILL\b)/i', $text, $m)) {
$candidate = strtoupper(trim((string) ($m[1] ?? '')));
if (! in_array($candidate, ['CBILL', 'FAV'], true)) {
$sia = $candidate;
}
}
$payload = [
'provider' => $provider !== '' ? $provider : null,
'cbill' => $cbill,
'sia' => $sia,
];
return array_filter($payload, fn($value) => $value !== null && $value !== '');
}
/** @param array<string, mixed> $existing @param array<string, mixed> $incoming @return array<string, mixed> */
private function mergeFornituraPayload(array $existing, array $incoming): array
{
foreach ($incoming as $key => $value) {
if (is_array($value)) {
$existing[$key] = $this->mergeFornituraPayload(is_array($existing[$key] ?? null) ? $existing[$key] : [], $value);
continue;
}
if ($value !== null && $value !== '') {
$existing[$key] = $value;
}
}
return $existing;
}
/** @param array<string, mixed> $payload */
private function enrichWaterInvoiceFromParsedData(FatturaElettronica $fattura, array $payload): void
{
$consumi = is_array($payload['consumi'] ?? null) ? $payload['consumi'] : [];
if ($consumi === []) {
return;
}
$sum = 0.0;
$hasNumeric = false;
foreach ($consumi as $consumo) {
if (! is_array($consumo) || ! is_numeric($consumo['valore'] ?? null)) {
continue;
}
$sum += (float) $consumo['valore'];
$hasNumeric = true;
}
$reference = null;
$first = is_array($consumi[0] ?? null) ? $consumi[0] : [];
if (is_string($first['dal'] ?? null) && is_string($first['al'] ?? null) && $first['dal'] !== '' && $first['al'] !== '') {
$reference = $first['dal'] . ' - ' . $first['al'];
}
$dirty = false;
if ($hasNumeric && (! is_numeric($fattura->consumo_valore) || (float) $fattura->consumo_valore <= 0)) {
$fattura->consumo_valore = $sum;
$dirty = true;
}
if ((! is_string($fattura->consumo_unita ?? null) || trim((string) $fattura->consumo_unita) === '') && $hasNumeric) {
$fattura->consumo_unita = 'mc';
$dirty = true;
}
if ($reference !== null && (! is_string($fattura->consumo_riferimento ?? null) || trim((string) $fattura->consumo_riferimento) === '')) {
$fattura->consumo_riferimento = $reference;
$dirty = true;
}
if (! is_string($fattura->consumo_raw ?? null) || trim((string) $fattura->consumo_raw) === '') {
$fattura->consumo_raw = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$dirty = true;
}
if ($dirty) {
$fattura->save();
}
}
/** @param array<string, mixed> $payload */
private function assignWaterExpenseCode(FatturaFornitore $fatturaContabile, array $payload): void
{
if (($payload['voce_spesa_code'] ?? null) !== 'ACQ') {
return;
}
if (! Schema::hasTable('voci_spesa') || ! Schema::hasTable('contabilita_fatture_fornitori_righe') || ! Schema::hasColumn('contabilita_fatture_fornitori_righe', 'voce_spesa_id')) {
return;
}
$voceId = DB::table('voci_spesa')
->where('stabile_id', (int) $fatturaContabile->stabile_id)
->where('codice', 'ACQ')
->orderBy('id')
->value('id');
if (! $voceId) {
return;
}
$fatturaContabile->righe()
->whereNull('voce_spesa_id')
->where('descrizione', '!=', 'Riepilogo FE / arrotondamenti')
->update(['voce_spesa_id' => (int) $voceId]);
}
/** @param array<int, string> $lines */ /** @param array<int, string> $lines */
private function findValueAfterLabel(array $lines, string $labelPattern, int $maxLookahead = 6, ?string $valuePattern = null): ?string private function findValueAfterLabel(array $lines, string $labelPattern, int $maxLookahead = 6, ?string $valuePattern = null): ?string
{ {
@ -1140,21 +1345,28 @@ private function postProcess(FatturaElettronica $fattura, string $xml, array $ex
private function archiveBaseForStabile(int $stabileId, string $ym): string private function archiveBaseForStabile(int $stabileId, string $ym): string
{ {
$year = null;
try {
$year = (int) Carbon::parse($ym . '/01')->format('Y');
} catch (\Throwable) {
$year = (int) now()->format('Y');
}
$stabile = Stabile::query() $stabile = Stabile::query()
->with(['amministratore:id,codice_amministratore,codice_univoco']) ->with(['amministratore:id,codice_amministratore,codice_univoco'])
->select(['id', 'amministratore_id', 'codice_stabile', 'codice_univoco']) ->select(['id', 'amministratore_id', 'codice_stabile', 'codice_univoco'])
->find($stabileId); ->find($stabileId);
if ($stabile) { if ($stabile) {
$base = ArchivioPaths::stabileBase($stabile, $stabile->amministratore); $base = ArchivioPaths::fattureXmlYearPath($stabile, $year, $stabile->amministratore);
if (is_string($base) && $base !== '') { if (is_string($base) && $base !== '') {
return $base . '/fatture-ricevute/' . $ym; return $base;
} }
} }
// fallback ultra-conservativo // fallback ultra-conservativo
$adminId = (int) ($stabile?->amministratore_id ?: 0); $adminId = (int) ($stabile?->amministratore_id ?: 0);
return 'amministratori/ID-' . $adminId . '/stabili/ID-' . (int) $stabileId . '/fatture-ricevute/' . $ym; return 'amministratori/ID-' . $adminId . '/stabili/ID-' . (int) $stabileId . '/fatture_xml/' . $year;
} }
/** /**

View File

@ -4,6 +4,7 @@
use App\Models\Documento; use App\Models\Documento;
use App\Models\FatturaElettronica; use App\Models\FatturaElettronica;
use App\Models\GestioneContabile;
use App\Models\Stabile; use App\Models\Stabile;
use App\Support\ArchivioPaths; use App\Support\ArchivioPaths;
use Dompdf\Dompdf; use Dompdf\Dompdf;
@ -40,7 +41,12 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u
$stabileFolder = $stabileCode ?: ('ID-' . (int) $fattura->stabile_id); $stabileFolder = $stabileCode ?: ('ID-' . (int) $fattura->stabile_id);
$stabileBase = ArchivioPaths::stabileBase($stabile, $stabile->amministratore); $stabileBase = ArchivioPaths::stabileBase($stabile, $stabile->amministratore);
$base = (is_string($stabileBase) && $stabileBase !== '') $gestione = $this->resolveGestioneForYear($stabile, $anno);
$pdfBase = ArchivioPaths::fatturePdfGestionePath($stabile, $gestione, $anno, 'ordinaria', $stabile->amministratore)
?: $this->basePath($adminFolder, $stabileFolder, $anno);
$xmlBase = ArchivioPaths::fattureXmlYearPath($stabile, $anno, $stabile->amministratore)
?: $this->basePath($adminFolder, $stabileFolder, $anno);
$legacyBase = (is_string($stabileBase) && $stabileBase !== '')
? ($stabileBase . '/documenti/' . $anno . '/fatture-ricevute') ? ($stabileBase . '/documenti/' . $anno . '/fatture-ricevute')
: $this->basePath($adminFolder, $stabileFolder, $anno); : $this->basePath($adminFolder, $stabileFolder, $anno);
@ -50,7 +56,9 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u
} }
// Recupero soft dal base path (file già spostati manualmente). // Recupero soft dal base path (file già spostati manualmente).
$this->recoverMissingPathsFromBase($fattura, $base); $this->recoverMissingPathsFromBase($fattura, $legacyBase);
$this->recoverMissingPathsFromBase($fattura, $pdfBase);
$this->recoverMissingPathsFromBase($fattura, $xmlBase);
// Ripara PDF se salvato in vecchio schema con ID numerici. // Ripara PDF se salvato in vecchio schema con ID numerici.
$newBase = (is_string($stabileBase) && $stabileBase !== '') $newBase = (is_string($stabileBase) && $stabileBase !== '')
@ -170,8 +178,8 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u
|| (is_string($fattura->xml_path) && $fattura->xml_path !== '' && Storage::disk('local')->exists($fattura->xml_path)); || (is_string($fattura->xml_path) && $fattura->xml_path !== '' && Storage::disk('local')->exists($fattura->xml_path));
if (! $hasPdfNow && $hasXmlNow) { if (! $hasPdfNow && $hasXmlNow) {
$targetName = $protocollo . ' ' . $dateForName . ' ' . $sanFornitore . ' ' . $sanNumero . '.pdf'; $targetName = $protocollo . ' ' . $dateForName . ' ' . $sanFornitore . ' ' . $sanNumero . '.pdf';
$targetPath = $base . '/' . $targetName; $targetPath = $pdfBase . '/' . $targetName;
$this->ensureDir($base); $this->ensureDir($pdfBase);
// Evita overwrite: se esiste già, lo riusiamo. // Evita overwrite: se esiste già, lo riusiamo.
if (! Storage::disk('local')->exists($targetPath)) { if (! Storage::disk('local')->exists($targetPath)) {
@ -188,8 +196,8 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u
if (is_string($fattura->allegato_pdf_path) && $fattura->allegato_pdf_path !== '' && Storage::disk('local')->exists($fattura->allegato_pdf_path)) { if (is_string($fattura->allegato_pdf_path) && $fattura->allegato_pdf_path !== '' && Storage::disk('local')->exists($fattura->allegato_pdf_path)) {
$targetName = $protocollo . ' ' . $dateForName . ' ' . $sanFornitore . ' ' . $sanNumero . '.pdf'; $targetName = $protocollo . ' ' . $dateForName . ' ' . $sanFornitore . ' ' . $sanNumero . '.pdf';
$targetPath = $base . '/' . $targetName; $targetPath = $pdfBase . '/' . $targetName;
$this->ensureDir($base); $this->ensureDir($pdfBase);
if ($fattura->allegato_pdf_path !== $targetPath) { if ($fattura->allegato_pdf_path !== $targetPath) {
Storage::disk('local')->move($fattura->allegato_pdf_path, $targetPath); Storage::disk('local')->move($fattura->allegato_pdf_path, $targetPath);
$fattura->allegato_pdf_path = $targetPath; $fattura->allegato_pdf_path = $targetPath;
@ -199,8 +207,8 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u
if (is_string($fattura->xml_path) && $fattura->xml_path !== '' && Storage::disk('local')->exists($fattura->xml_path)) { if (is_string($fattura->xml_path) && $fattura->xml_path !== '' && Storage::disk('local')->exists($fattura->xml_path)) {
$targetName = $protocollo . ' ' . $dateForName . ' ' . $sanFornitore . ' ' . $sanNumero . '.xml'; $targetName = $protocollo . ' ' . $dateForName . ' ' . $sanFornitore . ' ' . $sanNumero . '.xml';
$targetPath = $base . '/' . $targetName; $targetPath = $xmlBase . '/' . $targetName;
$this->ensureDir($base); $this->ensureDir($xmlBase);
if ($fattura->xml_path !== $targetPath) { if ($fattura->xml_path !== $targetPath) {
Storage::disk('local')->move($fattura->xml_path, $targetPath); Storage::disk('local')->move($fattura->xml_path, $targetPath);
$fattura->xml_path = $targetPath; $fattura->xml_path = $targetPath;
@ -271,6 +279,35 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u
}); });
} }
private function resolveGestioneForYear(Stabile $stabile, int $year): ?GestioneContabile
{
if (! Schema::hasTable('gestioni_contabili')) {
return null;
}
$query = GestioneContabile::query()
->where('stabile_id', (int) $stabile->id)
->where('anno_gestione', $year);
$ordinaria = (clone $query)
->where('tipo_gestione', 'ordinaria')
->orderByDesc('gestione_attiva')
->orderByRaw("CASE WHEN stato = 'aperta' THEN 0 ELSE 1 END")
->orderByDesc('id')
->first();
if ($ordinaria) {
return $ordinaria;
}
return $query
->orderByRaw("CASE WHEN tipo_gestione = 'straordinaria' THEN 0 ELSE 1 END")
->orderByDesc('gestione_attiva')
->orderByRaw("CASE WHEN stato = 'aperta' THEN 0 ELSE 1 END")
->orderByDesc('id')
->first();
}
private function getDocumentoProtocolColumn(): ?string private function getDocumentoProtocolColumn(): ?string
{ {
static $cached = null; static $cached = null;

View File

@ -2,6 +2,8 @@
namespace App\Support; namespace App\Support;
use App\Models\Amministratore; use App\Models\Amministratore;
use App\Models\DatiBancari;
use App\Models\GestioneContabile;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\Stabile; use App\Models\Stabile;
use App\Models\User; use App\Models\User;
@ -66,6 +68,91 @@ public static function stabileBase(Stabile $stabile, ?Amministratore $admin = nu
return self::adminBase($admin) . '/stabili/' . $folder; return self::adminBase($admin) . '/stabili/' . $folder;
} }
public static function gestioneFolderName(?GestioneContabile $gestione, ?int $fallbackYear = null, ?string $fallbackType = 'ordinaria'): string
{
$year = $fallbackYear ?: (int) now()->format('Y');
$type = $fallbackType ?: 'ordinaria';
if ($gestione instanceof GestioneContabile) {
$year = (int) ($gestione->anno_gestione ?: $year);
$type = (string) ($gestione->tipo_gestione ?: $type);
}
$prefix = match (strtolower(trim($type))) {
'straordinaria', 'straordinario', 's' => 'S',
'riscaldamento', 'r', 'acqua', 'a' => 'R',
default => 'O',
};
return $prefix . $year;
}
public static function fattureXmlYearPath(Stabile $stabile, int|string $year, ?Amministratore $admin = null): ?string
{
$base = self::stabileBase($stabile, $admin);
if (! is_string($base) || $base === '') {
return null;
}
return $base . '/fatture_xml/' . (int) $year;
}
public static function fatturePdfGestionePath(Stabile $stabile, ?GestioneContabile $gestione = null, ?int $fallbackYear = null, ?string $fallbackType = 'ordinaria', ?Amministratore $admin = null): ?string
{
$base = self::stabileBase($stabile, $admin);
if (! is_string($base) || $base === '') {
return null;
}
return $base . '/fatture/' . self::gestioneFolderName($gestione, $fallbackYear, $fallbackType);
}
public static function bankAccountFolderName(DatiBancari $conto): string
{
$bankLabel = strtolower(trim((string) ($conto->denominazione_banca ?? '')));
$bankCode = match (true) {
str_contains($bankLabel, 'monte') || str_contains($bankLabel, 'paschi') || str_contains($bankLabel, 'mps') => 'MPS',
str_contains($bankLabel, 'intesa') => 'INTESA',
str_contains($bankLabel, 'unicredit') => 'UNICREDIT',
str_contains($bankLabel, 'bper') => 'BPER',
str_contains($bankLabel, 'bcc') => 'BCC',
default => strtoupper(self::sanitizeFolder((string) ($conto->denominazione_banca ?: 'BANCA'))),
};
if ($bankCode === '') {
$bankCode = 'BANCA';
}
$typeCode = strtolower(trim((string) ($conto->tipo_conto ?? 'corrente'))) === 'cassa'
? 'CA'
: 'CC';
$identifier = preg_replace('/\D+/', '', (string) ($conto->numero_conto ?? '')) ?? '';
if ($identifier === '') {
$identifier = self::sanitizeFolder((string) ($conto->legacy_cod_cassa ?? ''));
}
if ($identifier === '') {
$iban = strtoupper(preg_replace('/[^A-Z0-9]+/', '', (string) ($conto->iban ?? '')) ?? '');
$identifier = $iban !== '' ? substr($iban, -8) : '';
}
if ($identifier === '') {
$identifier = 'ID' . (int) $conto->id;
}
return $bankCode . '-' . $typeCode . '-' . $identifier;
}
public static function bankYearPath(Stabile $stabile, DatiBancari $conto, int|string $year, ?Amministratore $admin = null): ?string
{
$base = self::stabileBase($stabile, $admin);
if (! is_string($base) || $base === '') {
return null;
}
return $base . '/banca_cc/' . self::bankAccountFolderName($conto) . '/' . (int) $year;
}
public static function fornitoreBase(Fornitore $fornitore, ?Amministratore $admin = null): ?string public static function fornitoreBase(Fornitore $fornitore, ?Amministratore $admin = null): ?string
{ {
$code = (string) ($fornitore->codice_univoco ?: ''); $code = (string) ($fornitore->codice_univoco ?: '');

View File

@ -29,6 +29,23 @@
'ritenute_da_versare_codice' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE', '2010'), 'ritenute_da_versare_codice' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE', '2010'),
'ritenute_da_versare_descrizione' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE_DESC', 'Erario c/ritenute da versare'), 'ritenute_da_versare_descrizione' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE_DESC', 'Erario c/ritenute da versare'),
'ritenute_da_versare_tipo' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE_TIPO', 'Passività'), 'ritenute_da_versare_tipo' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE_TIPO', 'Passività'),
// Bucket patrimoniali e di quadratura da usare nella riconciliazione operativa.
'fondo_riserva_codice' => env('CONTABILITA_CONTO_FONDO_RISERVA', '3100'),
'fondo_riserva_descrizione' => env('CONTABILITA_CONTO_FONDO_RISERVA_DESC', 'Fondo di riserva'),
'fondo_riserva_tipo' => env('CONTABILITA_CONTO_FONDO_RISERVA_TIPO', 'Patrimonio Netto'),
'accantonamenti_codice' => env('CONTABILITA_CONTO_ACCANTONAMENTI', '3110'),
'accantonamenti_descrizione' => env('CONTABILITA_CONTO_ACCANTONAMENTI_DESC', 'Accantonamenti da destinare'),
'accantonamenti_tipo' => env('CONTABILITA_CONTO_ACCANTONAMENTI_TIPO', 'Patrimonio Netto'),
'rimborsi_da_distribuire_codice' => env('CONTABILITA_CONTO_RIMBORSI_DISTRIBUIRE', '2105'),
'rimborsi_da_distribuire_descrizione' => env('CONTABILITA_CONTO_RIMBORSI_DISTRIBUIRE_DESC', 'Rimborsi da distribuire'),
'rimborsi_da_distribuire_tipo' => env('CONTABILITA_CONTO_RIMBORSI_DISTRIBUIRE_TIPO', 'Passività'),
'rimborsi_da_utilizzare_codice' => env('CONTABILITA_CONTO_RIMBORSI_UTILIZZARE', '1115'),
'rimborsi_da_utilizzare_descrizione' => env('CONTABILITA_CONTO_RIMBORSI_UTILIZZARE_DESC', 'Rimborsi da utilizzare'),
'rimborsi_da_utilizzare_tipo' => env('CONTABILITA_CONTO_RIMBORSI_UTILIZZARE_TIPO', 'Attività'),
], ],
'fatture_elettroniche' => [ 'fatture_elettroniche' => [

View File

@ -23,6 +23,21 @@ .fi-main-ctn>main {
min-height: 0; min-height: 0;
} }
.fi-sidebar {
position: sticky !important;
top: 0 !important;
align-self: flex-start !important;
height: 100vh !important;
height: 100dvh !important;
overflow: hidden !important;
}
.fi-sidebar-header {
position: sticky !important;
top: 0 !important;
z-index: 20 !important;
}
.fi-main { .fi-main {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
@ -40,6 +55,52 @@ .netgescon-footer {
.fi-sidebar-nav, .fi-sidebar-nav,
.fi-sidebar-nav-groups { .fi-sidebar-nav-groups {
row-gap: calc(var(--spacing) * 1.5) !important; row-gap: calc(var(--spacing) * 1.5) !important;
overflow-y: auto !important;
max-height: calc(100vh - 5rem) !important;
max-height: calc(100dvh - 5rem) !important;
}
@media (min-width: 64rem) {
.fi-body.fi-body-has-navigation {
overflow: hidden !important;
}
.fi-body.fi-body-has-navigation .fi-sidebar {
position: fixed !important;
top: 4.5rem !important;
left: 0 !important;
bottom: 0 !important;
width: 20rem !important;
height: calc(100vh - 4.5rem) !important;
height: calc(100dvh - 4.5rem) !important;
z-index: 40 !important;
border-top: 1px solid rgba(148, 163, 184, 0.2) !important;
background: inherit !important;
}
.fi-body.fi-body-has-navigation .fi-topbar {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
margin-left: 0 !important;
z-index: 50 !important;
}
.fi-body.fi-body-has-navigation .fi-main-ctn {
margin-left: 0 !important;
width: 100% !important;
padding-top: 4.5rem !important;
padding-left: 20rem !important;
box-sizing: border-box !important;
}
.fi-body.fi-body-has-navigation .fi-main-ctn > main {
height: calc(100vh - 4.5rem) !important;
height: calc(100dvh - 4.5rem) !important;
overflow-y: auto !important;
}
} }
.fi-sidebar-group-btn, .fi-sidebar-group-btn,

View File

@ -3,9 +3,70 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css">
<link rel="stylesheet" href="{{ asset('css/netgescon-admin.css') }}"> <link rel="stylesheet" href="{{ asset('css/netgescon-admin.css') }}">
<style> <style>
.fi-sidebar {
position: sticky !important;
top: 0 !important;
align-self: flex-start !important;
height: 100vh !important;
height: 100dvh !important;
overflow: hidden !important;
}
.fi-sidebar-header {
position: sticky !important;
top: 0 !important;
z-index: 20 !important;
}
.fi-sidebar-nav, .fi-sidebar-nav,
.fi-sidebar-nav-groups { .fi-sidebar-nav-groups {
row-gap: calc(var(--spacing) * 1.5) !important; row-gap: calc(var(--spacing) * 1.5) !important;
overflow-y: auto !important;
max-height: calc(100vh - 5rem) !important;
max-height: calc(100dvh - 5rem) !important;
}
@media (min-width: 64rem) {
.fi-body.fi-body-has-navigation {
overflow: hidden !important;
}
.fi-body.fi-body-has-navigation .fi-sidebar {
position: fixed !important;
top: 4.5rem !important;
left: 0 !important;
bottom: 0 !important;
width: 20rem !important;
height: calc(100vh - 4.5rem) !important;
height: calc(100dvh - 4.5rem) !important;
z-index: 40 !important;
border-top: 1px solid rgba(148, 163, 184, 0.2) !important;
background: inherit !important;
}
.fi-body.fi-body-has-navigation .fi-topbar {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
margin-left: 0 !important;
z-index: 50 !important;
}
.fi-body.fi-body-has-navigation .fi-main-ctn {
margin-left: 0 !important;
width: 100% !important;
padding-top: 4.5rem !important;
padding-left: 20rem !important;
box-sizing: border-box !important;
}
.fi-body.fi-body-has-navigation .fi-main-ctn > main {
height: calc(100vh - 4.5rem) !important;
height: calc(100dvh - 4.5rem) !important;
overflow-y: auto !important;
}
} }
.fi-sidebar-group-btn, .fi-sidebar-group-btn,

View File

@ -23,6 +23,15 @@
$ricQueue = $this->getRiconciliazioneQueue(); $ricQueue = $this->getRiconciliazioneQueue();
$ricCurrent = $this->getRiconciliazioneCurrent(); $ricCurrent = $this->getRiconciliazioneCurrent();
$ricCandidates = $this->getRiconciliazioneCandidates(); $ricCandidates = $this->getRiconciliazioneCandidates();
$archiveSummary = $this->getArchivioOperativoSummary();
$gestioniRilevate = $this->getGestioniContabiliRilevate();
$ricBuckets = $this->getRiconciliazioneBucketRows();
$tipoEstrattoOptions = [
'estratto_conto' => 'Estratto conto',
'saldo_banca' => 'Saldo banca',
'riconciliazione' => 'Riconciliazione',
'altro' => 'Altro',
];
@endphp @endphp
@if(! $stabile) @if(! $stabile)
@ -73,6 +82,7 @@
<x-filament::tabs.item :active="$this->hubTab === 'conti'" wire:click="goToHubTab('conti')">Conti</x-filament::tabs.item> <x-filament::tabs.item :active="$this->hubTab === 'conti'" wire:click="goToHubTab('conti')">Conti</x-filament::tabs.item>
<x-filament::tabs.item :active="$this->hubTab === 'saldi'" wire:click="goToHubTab('saldi')">Saldi e estratti</x-filament::tabs.item> <x-filament::tabs.item :active="$this->hubTab === 'saldi'" wire:click="goToHubTab('saldi')">Saldi e estratti</x-filament::tabs.item>
<x-filament::tabs.item :active="$this->hubTab === 'movimenti'" wire:click="goToHubTab('movimenti')">Movimenti</x-filament::tabs.item> <x-filament::tabs.item :active="$this->hubTab === 'movimenti'" wire:click="goToHubTab('movimenti')">Movimenti</x-filament::tabs.item>
<x-filament::tabs.item :active="$this->hubTab === 'struttura'" wire:click="goToHubTab('struttura')">Struttura e gestioni</x-filament::tabs.item>
<x-filament::tabs.item :active="$this->hubTab === 'riconciliazione'" wire:click="goToHubTab('riconciliazione')">Riconciliazione</x-filament::tabs.item> <x-filament::tabs.item :active="$this->hubTab === 'riconciliazione'" wire:click="goToHubTab('riconciliazione')">Riconciliazione</x-filament::tabs.item>
</x-filament::tabs> </x-filament::tabs>
@ -159,6 +169,7 @@
<tr> <tr>
<th class="py-2 pr-4">Data saldo</th> <th class="py-2 pr-4">Data saldo</th>
<th class="py-2 pr-4">Periodo</th> <th class="py-2 pr-4">Periodo</th>
<th class="py-2 pr-4">Esito</th>
<th class="py-2 pr-4">Tipo</th> <th class="py-2 pr-4">Tipo</th>
<th class="py-2 pr-4 text-right">Saldo</th> <th class="py-2 pr-4 text-right">Saldo</th>
<th class="py-2 pr-4">Documento</th> <th class="py-2 pr-4">Documento</th>
@ -174,20 +185,109 @@
<div>{{ $row['periodo_breve'] }}</div> <div>{{ $row['periodo_breve'] }}</div>
<div class="text-xs text-slate-500">{{ $row['periodo'] }}</div> <div class="text-xs text-slate-500">{{ $row['periodo'] }}</div>
</td> </td>
<td class="py-3 pr-4 align-top">
@if(($row['quadratura_status'] ?? 'neutral') === 'ok')
<div class="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-700">
<x-heroicon-o-check-circle class="h-4 w-4" />
OK
</div>
@elseif(($row['quadratura_status'] ?? 'neutral') === 'anchor')
<div class="inline-flex items-center gap-1 rounded-full bg-sky-100 px-2.5 py-1 text-xs font-semibold text-sky-700">
<x-heroicon-o-flag class="h-4 w-4" />
Partenza
</div>
@elseif(($row['quadratura_status'] ?? 'neutral') === 'mismatch')
<div class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">
<x-heroicon-o-exclamation-triangle class="h-4 w-4" />
Verifica
</div>
@else
<div class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600">
<x-heroicon-o-minus-circle class="h-4 w-4" />
N/D
</div>
@endif
<div class="mt-1 text-xs text-slate-500">{{ $row['quadratura_label'] ?? '—' }}</div>
@if(($row['quadratura_status'] ?? null) === 'mismatch' && array_key_exists('quadratura_delta', $row))
<div class="mt-1 text-xs font-medium text-rose-700">
Scarto: {{ number_format((float) ($row['quadratura_delta'] ?? 0), 2, ',', '.') }}
</div>
@endif
</td>
<td class="py-3 pr-4">{{ $row['tipo'] }}</td> <td class="py-3 pr-4">{{ $row['tipo'] }}</td>
<td class="py-3 pr-4 text-right font-semibold"> {{ number_format((float) $row['saldo'], 2, ',', '.') }}</td> <td class="py-3 pr-4 text-right font-semibold"> {{ number_format((float) $row['saldo'], 2, ',', '.') }}</td>
<td class="py-3 pr-4"> <td class="py-3 pr-4">
@if($row['documento_url'] && $row['documento_label']) @if($row['documento_url'] && $row['documento_label'])
<a href="{{ $row['documento_url'] }}" target="_blank" class="text-primary-600 hover:underline">{{ $row['documento_label'] }}</a> <button type="button" wire:click="openSaldoDocumento({{ $row['id'] }})" class="text-primary-600 hover:underline">{{ $row['documento_label'] }}</button>
@else @else
<span class="text-slate-400"></span> <span class="text-slate-400"></span>
@endif @endif
</td> </td>
<td class="py-3 pr-4 text-slate-600">{{ $row['note'] !== '' ? $row['note'] : '—' }}</td> <td class="py-3 pr-4 text-slate-600">{{ $row['note'] !== '' ? $row['note'] : '—' }}</td>
<td class="py-3 pr-0 text-right"> <td class="py-3 pr-0 text-right">
<div class="flex flex-wrap justify-end gap-2">
<x-filament::button size="sm" color="gray" wire:click="openSaldoPeriodo({{ $row['id'] }})">Apri movimenti</x-filament::button> <x-filament::button size="sm" color="gray" wire:click="openSaldoPeriodo({{ $row['id'] }})">Apri movimenti</x-filament::button>
<x-filament::button size="sm" color="warning" wire:click="startEditingSaldo({{ $row['id'] }})">Modifica</x-filament::button>
@if(($this->confirmingDeleteSaldoId ?? null) === $row['id'])
<x-filament::button size="sm" color="danger" wire:click="deleteSaldo({{ $row['id'] }})">Conferma elimina</x-filament::button>
<x-filament::button size="sm" color="gray" wire:click="cancelDeleteSaldo">Annulla</x-filament::button>
@else
<x-filament::button size="sm" color="danger" wire:click="requestDeleteSaldo({{ $row['id'] }})">Elimina</x-filament::button>
@endif
</div>
</td> </td>
</tr> </tr>
@if(($this->editingSaldoId ?? null) === $row['id'])
<tr class="bg-amber-50/60">
<td colspan="8" class="py-4 pr-0">
<div class="rounded-xl border border-amber-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Modifica saldo / estratto</div>
<div class="mt-1 text-xs text-slate-600">Puoi correggere data saldo, periodo, tipo e note del record gia memorizzato.</div>
<div class="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-3">
<label class="text-xs text-slate-600">
<span class="mb-1 block">Data saldo</span>
<input type="date" wire:model.defer="editingSaldoData.data_saldo" class="w-full rounded-lg border-gray-300 text-sm" />
</label>
<label class="text-xs text-slate-600">
<span class="mb-1 block">Periodo dal</span>
<input type="date" wire:model.defer="editingSaldoData.periodo_da" class="w-full rounded-lg border-gray-300 text-sm" />
</label>
<label class="text-xs text-slate-600">
<span class="mb-1 block">Periodo al</span>
<input type="date" wire:model.defer="editingSaldoData.periodo_a" class="w-full rounded-lg border-gray-300 text-sm" />
</label>
<label class="text-xs text-slate-600">
<span class="mb-1 block">Saldo ufficiale</span>
<input type="text" wire:model.defer="editingSaldoData.saldo" class="w-full rounded-lg border-gray-300 text-sm" />
</label>
<label class="text-xs text-slate-600">
<span class="mb-1 block">Tipo riferimento</span>
<select wire:model.defer="editingSaldoData.tipo_estratto" class="w-full rounded-lg border-gray-300 text-sm">
<option value="">Saldo registrato</option>
@foreach($tipoEstrattoOptions as $tipoValue => $tipoLabel)
<option value="{{ $tipoValue }}">{{ $tipoLabel }}</option>
@endforeach
</select>
</label>
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-700">
<input type="checkbox" wire:model.defer="editingSaldoData.is_partenza_contabile" class="rounded border-gray-300 text-primary-600" />
<span>Usa questo saldo come partenza contabile</span>
</label>
<label class="text-xs text-slate-600 lg:col-span-2">
<span class="mb-1 block">Note</span>
<input type="text" wire:model.defer="editingSaldoData.note" class="w-full rounded-lg border-gray-300 text-sm" />
</label>
</div>
<div class="mt-4 flex flex-wrap justify-end gap-2">
<x-filament::button size="sm" color="gray" wire:click="cancelEditingSaldo">Annulla</x-filament::button>
<x-filament::button size="sm" color="primary" wire:click="saveEditingSaldo">Salva modifiche</x-filament::button>
</div>
</div>
</td>
</tr>
@endif
@endforeach @endforeach
</tbody> </tbody>
</table> </table>
@ -198,6 +298,80 @@
@endforelse @endforelse
</div> </div>
</x-filament::section> </x-filament::section>
@elseif($this->hubTab === 'struttura')
<x-filament::section>
<x-slot name="heading">Struttura operativa banca / FE</x-slot>
<x-slot name="description">Separiamo la struttura documentale e le gestioni dal tab movimenti, così loperatore vede subito il mastrino e gli import.</x-slot>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div class="rounded-xl border bg-slate-50 p-4">
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Struttura archivio banca</div>
<div class="mt-2 text-sm font-semibold text-slate-900">{{ $archiveSummary['banca_folder'] ?? 'banca_cc/<conto>/<anno>' }}</div>
<div class="mt-2 text-xs text-slate-600">Qui finiscono estratti, saldi, file importati e documenti conto in modo uniforme su tutti gli stabili.</div>
<div class="mt-3 rounded-lg border bg-white p-3 text-xs text-slate-700">
<div><span class="font-semibold">Base stabile:</span> {{ $archiveSummary['base_folder'] ?? '—' }}</div>
<div class="mt-1"><span class="font-semibold">Anno operativo:</span> {{ $archiveSummary['reference_year'] ?? '—' }}</div>
</div>
</div>
<div class="rounded-xl border bg-slate-50 p-4">
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Gestioni contabili</div>
<div class="mt-2 text-sm font-semibold text-slate-900">{{ $archiveSummary['gestione_label'] ?? 'Gestione da associare' }}</div>
<div class="mt-1 text-xs text-slate-600">Le gestioni storiche restano qui, fuori dal mastrino, così il tab movimenti resta leggibile anche con molte annualità.</div>
<div class="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
@forelse($gestioniRilevate as $gestione)
<div class="rounded-lg border bg-white px-3 py-2 text-xs text-slate-700">
<div class="flex items-center justify-between gap-2">
<span class="font-semibold">{{ $gestione['folder'] }}</span>
<span class="rounded-full px-2 py-0.5 {{ $gestione['active'] ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600' }}">{{ $gestione['stato'] !== '' ? $gestione['stato'] : 'attiva' }}</span>
</div>
<div class="mt-1">{{ $gestione['label'] }}</div>
</div>
@empty
<div class="rounded-lg border border-dashed bg-white px-3 py-3 text-xs text-slate-500">Nessuna gestione ordinaria/straordinaria rilevata.</div>
@endforelse
</div>
</div>
<div class="rounded-xl border bg-slate-50 p-4">
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Struttura FE pronta</div>
<div class="mt-2 space-y-2 text-xs text-slate-700">
<div class="rounded-lg border bg-white px-3 py-2">
<div class="font-semibold text-slate-900">{{ $archiveSummary['fatture_xml_folder'] ?? 'fatture_xml/<anno>' }}</div>
<div class="mt-1">XML originali per anno, indipendenti dal PDF.</div>
</div>
<div class="rounded-lg border bg-white px-3 py-2">
<div class="font-semibold text-slate-900">{{ $archiveSummary['fatture_pdf_folder'] ?? 'fatture/<gestione>' }}</div>
<div class="mt-1">PDF protocollati per gestione, pronti per ACL atomici su anno e tipo gestione.</div>
</div>
</div>
</div>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)]">
<div class="rounded-lg border bg-slate-50 p-3">
<div class="text-sm font-semibold text-slate-900">Criteri di riconciliazione e quadratura</div>
<div class="mt-1 text-xs text-slate-600">Questi bucket restano visibili come riferimento operativo, ma fuori dal tab movimenti.</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
@foreach($ricBuckets as $bucket)
<div class="rounded-lg border bg-white p-3 text-sm">
<div class="font-semibold text-slate-900">{{ $bucket['title'] }}</div>
<div class="mt-1 text-xs text-slate-600">{{ $bucket['detail'] }}</div>
</div>
@endforeach
</div>
</div>
<div class="rounded-lg border bg-slate-50 p-3">
<div class="text-sm font-semibold text-slate-900">Regole operative di struttura</div>
<div class="mt-3 space-y-2 text-xs text-slate-700">
<div class="rounded-lg border bg-white px-3 py-2">I movimenti banca restano sempre sul <span class="font-semibold">conto selezionato</span> e vanno associati a una <span class="font-semibold">gestione</span>.</div>
<div class="rounded-lg border bg-white px-3 py-2">Per questo stabile trattiamo solo <span class="font-semibold">ordinaria</span> e <span class="font-semibold">straordinaria</span>; il riscaldamento non entra nel flusso.</div>
<div class="rounded-lg border bg-white px-3 py-2">Le FE acqua possono confluire sulla voce <span class="font-semibold">ACQ</span> quando il PDF e il pagamento banca danno un aggancio coerente.</div>
</div>
</div>
</div>
</x-filament::section>
@elseif($this->hubTab === 'riconciliazione') @elseif($this->hubTab === 'riconciliazione')
<x-filament::section> <x-filament::section>
<x-slot name="heading">Riconciliazione guidata</x-slot> <x-slot name="heading">Riconciliazione guidata</x-slot>
@ -255,6 +429,8 @@
<x-filament::button size="sm" color="primary" wire:click="goToHubTab('movimenti')">Apri nel mastrino</x-filament::button> <x-filament::button size="sm" color="primary" wire:click="goToHubTab('movimenti')">Apri nel mastrino</x-filament::button>
@if($ricCurrent['has_registrazione']) @if($ricCurrent['has_registrazione'])
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700">Ha già una prima nota</span> <span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700">Ha già una prima nota</span>
@elseif(! empty($ricCurrent['match_label']))
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700">{{ $ricCurrent['match_label'] }}</span>
@else @else
<span class="rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700">Da collegare</span> <span class="rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700">Da collegare</span>
@endif @endif
@ -266,9 +442,9 @@
<div class="text-sm font-semibold text-slate-900">Candidati automatici</div> <div class="text-sm font-semibold text-slate-900">Candidati automatici</div>
<div class="mt-1 text-xs text-slate-600"> <div class="mt-1 text-xs text-slate-600">
@if($ricCurrent['importo'] >= 0) @if($ricCurrent['importo'] >= 0)
Match su incassi registrati per importo, data e testo della causale. Match su canoni affitto e incassi registrati per importo, data e testo della causale.
@else @else
Match su fatture fornitore per importo, data e denominazione del fornitore. Match su fatture fornitore contabili per importo netto, data e denominazione del fornitore.
@endif @endif
</div> </div>
@ -288,6 +464,19 @@
<span>Importo: {{ number_format((float) $candidate['importo'], 2, ',', '.') }}</span> <span>Importo: {{ number_format((float) $candidate['importo'], 2, ',', '.') }}</span>
</div> </div>
<div class="mt-2 text-xs text-slate-600">{{ $candidate['motivo'] }}</div> <div class="mt-2 text-xs text-slate-600">{{ $candidate['motivo'] }}</div>
@if(($candidate['azione'] ?? null) === 'riconcilia_affitto')
<div class="mt-3">
<x-filament::button size="sm" color="success" wire:click="riconciliaCanoneAffitto({{ (int) $candidate['canone_dovuto_id'] }})">
Chiudi canone affitto
</x-filament::button>
</div>
@elseif(($candidate['azione'] ?? null) === 'riconcilia_pagamento_fornitore')
<div class="mt-3">
<x-filament::button size="sm" color="success" wire:click="riconciliaPagamentoFornitore({{ (int) $candidate['fattura_fornitore_id'] }})">
Registra pagamento fornitore
</x-filament::button>
</div>
@endif
</div> </div>
@empty @empty
<div class="rounded-lg border border-dashed bg-slate-50 p-4 text-sm text-slate-500">Nessun candidato convincente. In questo caso loperatore lavora dal mastrino e conferma manualmente il collegamento.</div> <div class="rounded-lg border border-dashed bg-slate-50 p-4 text-sm text-slate-500">Nessun candidato convincente. In questo caso loperatore lavora dal mastrino e conferma manualmente il collegamento.</div>
@ -297,7 +486,7 @@
</div> </div>
@endif @endif
</x-filament::section> </x-filament::section>
@else @elseif($this->hubTab === 'movimenti')
<x-filament::section> <x-filament::section>
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div> <div>
@ -305,16 +494,23 @@
<div class="mt-1 text-xs text-gray-600"> <div class="mt-1 text-xs text-gray-600">
Seleziona il conto o la cassa corretta e importa il file nel formato relativo. Il collegamento al conto resta esplicito, senza inferenze dal file. Seleziona il conto o la cassa corretta e importa il file nel formato relativo. Il collegamento al conto resta esplicito, senza inferenze dal file.
</div> </div>
<div class="mt-2 text-xs text-slate-700">Per i file <span class="font-semibold">.qif</span> puliamo i movimenti nei gruppi operativi e leggiamo <span class="font-semibold">CBILL</span>, <span class="font-semibold">SIA</span>, commissioni e fornitore favorito per proporre subito il collegamento con fatture e prima nota.</div>
@if($contoImport) @if($contoImport)
<div class="mt-2 text-xs text-gray-700"> <div class="mt-2 text-xs text-gray-700">
Conto selezionato: <span class="font-medium">{{ $contoImport['label'] }}</span> Conto selezionato: <span class="font-medium">{{ $contoImport['label'] }}</span>
</div> </div>
@endif @endif
</div> </div>
<div class="flex flex-wrap gap-2">
<x-filament::button type="button" color="gray" wire:click="mountAction('aggiungi_movimento_manuale')">
Nuovo movimento manuale
</x-filament::button>
<x-filament::button type="button" color="primary" wire:click="mountAction('importa_estratto_unificato')"> <x-filament::button type="button" color="primary" wire:click="mountAction('importa_estratto_unificato')">
Importa estratto Importa estratto
</x-filament::button> </x-filament::button>
</div> </div>
</div>
</x-filament::section> </x-filament::section>
<x-filament::section> <x-filament::section>
@ -404,10 +600,16 @@
<div class="font-medium text-slate-900">{{ $card['tipo'] }}</div> <div class="font-medium text-slate-900">{{ $card['tipo'] }}</div>
<div class="text-xs text-slate-500">{{ $card['data'] ?? '—' }}</div> <div class="text-xs text-slate-500">{{ $card['data'] ?? '—' }}</div>
</div> </div>
@if(!empty($card['is_partenza_contabile']))
<div class="mt-2 inline-flex items-center gap-1 rounded-full bg-sky-100 px-2.5 py-1 text-[11px] font-semibold text-sky-700">
<x-heroicon-o-flag class="h-3.5 w-3.5" />
Partenza contabile
</div>
@endif
<div class="mt-2 text-lg font-semibold text-slate-900"> {{ number_format((float) ($card['saldo'] ?? 0), 2, ',', '.') }}</div> <div class="mt-2 text-lg font-semibold text-slate-900"> {{ number_format((float) ($card['saldo'] ?? 0), 2, ',', '.') }}</div>
<div class="mt-1 text-xs text-slate-600">{{ $card['periodo'] }}</div> <div class="mt-1 text-xs text-slate-600">{{ $card['periodo'] }}</div>
@if(!empty($card['documento_url']) && !empty($card['documento_label'])) @if(!empty($card['documento_url']) && !empty($card['documento_label']))
<a href="{{ $card['documento_url'] }}" target="_blank" class="mt-2 inline-flex text-xs font-medium text-indigo-700 hover:underline">{{ $card['documento_label'] }}</a> <button type="button" wire:click="openSaldoDocumento({{ $card['id'] }})" class="mt-2 inline-flex text-xs font-medium text-indigo-700 hover:underline">{{ $card['documento_label'] }}</button>
@endif @endif
</div> </div>
@empty @empty
@ -423,4 +625,26 @@
@endif @endif
@endif @endif
</div> </div>
@if($this->pdfViewerUrl)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4">
<div class="flex h-[92vh] w-full max-w-7xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl">
<div class="flex items-center justify-between border-b px-5 py-3">
<div>
<div class="text-sm font-semibold text-slate-900">{{ $this->pdfViewerTitle ?? 'Documento PDF' }}</div>
<div class="text-xs text-slate-500">Viewer PDF inline standard per saldi, estratti e documenti collegati.</div>
</div>
<div class="flex items-center gap-2">
@if($this->pdfViewerOpenUrl)
<a href="{{ $this->pdfViewerOpenUrl }}" target="_blank" rel="noopener" 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">Apri in nuova scheda</a>
@endif
<x-filament::button type="button" color="gray" size="sm" wire:click="closePdfViewer">Chiudi</x-filament::button>
</div>
</div>
<div class="min-h-0 flex-1 bg-slate-100 p-4">
@include('filament.modals.pdf-viewer', ['url' => $this->pdfViewerUrl, 'openUrl' => $this->pdfViewerOpenUrl, 'title' => $this->pdfViewerTitle])
</div>
</div>
</div>
@endif
</x-filament-panels::page> </x-filament-panels::page>