From 4fa8045d70ae735e6f2f36e68f48d7e1ba27793d Mon Sep 17 00:00:00 2001 From: Sajo <75281007+sajotrei@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:36:39 +0200 Subject: [PATCH 1/2] feat: aggiungi arrotondamento totale nei documenti di vendita --- include/document-rounding.php | 244 ++++++++++++++++++++++++++ modules/contratti/buttons.php | 9 + modules/fatture/buttons.php | 9 +- modules/preventivi/buttons.php | 8 + src/Common/DocumentRounding.php | 236 +++++++++++++++++++++++++ tests/Common/DocumentRoundingTest.php | 29 +++ update/2_11_3.sql | 7 + 7 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 include/document-rounding.php create mode 100644 src/Common/DocumentRounding.php create mode 100644 tests/Common/DocumentRoundingTest.php create mode 100644 update/2_11_3.sql diff --git a/include/document-rounding.php b/include/document-rounding.php new file mode 100644 index 0000000000..51a0525cf7 --- /dev/null +++ b/include/document-rounding.php @@ -0,0 +1,244 @@ + '.tr('Arrotondamento non disponibile.').''; + return; +} + +$document = null; +$isInvoice = false; +$blockedReasons = []; +$vatLocked = false; +$accountId = null; + +switch ((string) $module->directory) { + case 'preventivi': + $document = Preventivo::find($id_record); + if (!$document || !empty($document->stato->is_bloccato)) { + echo '
'.tr('Preventivo non modificabile.').'
'; + return; + } + if (DocumentRounding::hasSections($document)) { + $blockedReasons[] = 'sectioned_quote'; + } + break; + + case 'contratti': + $document = Contratto::find($id_record); + if (!$document || !empty($document->stato->is_bloccato)) { + echo '
'.tr('Contratto non modificabile.').'
'; + return; + } + break; + + case 'fatture': + if ($module->name !== 'Fatture di vendita') { + break; + } + $document = Fattura::find($id_record); + $draftId = (int) StatoFattura::where('name', 'Bozza')->value('id'); + if (!$document || $document->direzione !== 'entrata' || (int) $document->id_stato !== $draftId) { + echo '
'.tr('Disponibile solo sulle fatture di vendita in Bozza.').'
'; + return; + } + $isInvoice = true; + $blockedReasons = array_merge($blockedReasons, DocumentRounding::invoiceGuard($document)); + $vatLocked = !empty($document->id_dichiarazione_intento); + break; +} + +if (!$document) { + echo '
'.tr('Arrotondamento non disponibile per questo documento.').'
'; + return; +} + +$dir = (string) $document->direzione; +$fallbackVat = $vatLocked + ? (int) setting("Iva per lettere d'intento") + : (int) ($document->anagrafica?->id_iva_vendite ?: setting('Iva predefinita')); +$context = DocumentRounding::context($document, $fallbackVat ?: null); +$existing = $context['existing']; + +if ($context['duplicate_count'] > 1) { + $blockedReasons[] = 'duplicate_rounding'; +} + +$selectedVatId = $vatLocked ? $fallbackVat : (int) ($context['default_vat_id'] ?: 0); +if ($isInvoice) { + $accountId = $existing && !empty($existing->id_conto) + ? (int) $existing->id_conto + : (int) setting('Conto predefinito fatture di vendita'); + if (!$accountId) { + $blockedReasons[] = 'missing_account'; + } +} + +$vatIds = array_values(array_unique(array_filter(array_map('intval', $context['vat_ids'])))); +if ($selectedVatId && !in_array($selectedVatId, $vatIds, true)) { + $vatIds[] = $selectedVatId; +} +if ($vatLocked) { + $vatIds = $selectedVatId ? [$selectedVatId] : []; +} + +$vatChoices = []; +$plans = []; +$pricesIncludeVat = (bool) setting('Utilizza prezzi di vendita comprensivi di IVA'); +foreach ($vatIds as $vatId) { + $vat = Aliquota::find($vatId); + if (!$vat) { + continue; + } + + $title = trim((string) $vat->getTranslation('title')); + $rate = (float) $vat->percentuale; + $rateText = rtrim(rtrim(number_format($rate, 3, ',', '.'), '0'), ',').'%'; + $vatChoices[$vatId] = [ + 'id' => $vatId, + 'text' => $title === '' ? $rateText : (str_contains($title, '%') ? $title : $title.' ('.$rateText.')'), + ]; + + foreach (['down', 'nearest', 'up'] as $mode) { + try { + $plan = DocumentRounding::solve($context, $rate, $pricesIncludeVat, $mode); + $plans[$vatId][$mode] = [ + 'input' => number_format((float) $plan['input'], DocumentRounding::INPUT_DECIMALS, '.', ''), + 'target' => (float) $plan['target'], + 'difference' => (float) $plan['difference'], + 'adjustment' => (float) $plan['adjustment'], + 'requires_adjustment' => (bool) $plan['requires_adjustment'], + ]; + } catch (Throwable) { + $plans[$vatId][$mode] = null; + } + } +} + +if (!$vatChoices) { + $blockedReasons[] = 'missing_vat'; +} +if (count($vatChoices) === 1 && !$selectedVatId) { + $selectedVatId = (int) array_key_first($vatChoices); +} + +$reasonLabels = [ + 'sectioned_quote' => tr('Il preventivo contiene sezioni: il totale generale non viene arrotondato per non alterare il subtotale dell’ultima sezione.'), + 'credit_note' => tr('Le note di credito non sono supportate.'), + 'electronic_invoice_locked' => tr('Lo stato della fattura elettronica non consente modifiche.'), + 'percentage_collection_fee' => tr('Sono presenti spese d’incasso percentuali.'), + 'automatic_stamp_duty' => tr('È attivo il bollo automatico.'), + 'final_discount' => tr('È presente uno sconto finale sul documento.'), + 'withholdings_or_social_security' => tr('Sono presenti ritenute o cassa previdenziale.'), + 'duplicate_rounding' => tr('Sono presenti più righe di arrotondamento: rimuovere i duplicati prima di procedere.'), + 'missing_account' => tr('Imposta il conto predefinito delle fatture di vendita.'), + 'missing_vat' => tr('Nessuna aliquota IVA disponibile.'), +]; + +$format = static function (float $value, int $decimals): string { + return Translator::numberToLocale(abs($value) < (0.5 / (10 ** $decimals)) ? 0 : $value, $decimals).' '.currency(); +}; + +$initial = $selectedVatId ? ($plans[$selectedVatId][$defaultMode] ?? null) : null; +$currentText = $format((float) $context['source_total'], 3); +$targetText = $initial ? $format((float) $initial['target'], 2) : '—'; +$differenceText = $initial ? $format((float) $initial['difference'], 3) : '—'; +$canApply = empty($blockedReasons) && $initial && $initial['requires_adjustment']; +$action = base_path_osm().'/editor.php?id_module='.$id_module.'&id_record='.$id_record; + +if ($blockedReasons) { + echo '
'; +} + +echo '
+ + + + + + +'; + +if ($isInvoice) { + echo ' + + + +'; +} + +$fixedVat = count($vatChoices) === 1 || $vatLocked; +if ($fixedVat && $selectedVatId) { + echo ''; +} + +echo '
+
'; +foreach (['down' => ['fa-arrow-down', tr('Euro inferiore')], 'nearest' => ['fa-bullseye', tr('Euro più vicino')], 'up' => ['fa-arrow-up', tr('Euro superiore')]] as $mode => $data) { + $active = $defaultMode === $mode ? 'btn-primary active' : 'btn-default'; + echo ''; +} +echo '
'; + +if ($fixedVat) { + echo ''; +} else { + echo '{[ "type": "select", "name": "id_iva", "id": "rounding-vat", "values": '.json_encode(array_values($vatChoices), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).', "value": "'.($selectedVatId ?: '').'", "required": 1 ]}'; +} + +echo '
+
+
'.tr('Totale attuale').'
'.$currentText.'
+
'.tr('Totale arrotondato').'
'.$targetText.'
+
'.tr('Rettifica').'
'.$differenceText.'
+
+
'; +if ($existing) { + echo ''; +} +echo '
+
'; + +if ($existing) { + echo '
'; +} + +$plansJson = json_encode($plans, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION); +$symbol = json_encode((string) currency(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +$staticCanApply = empty($blockedReasons) ? 'true' : 'false'; +$fixedVatJs = $fixedVat ? 'true' : 'false'; + +echo ''; diff --git a/modules/contratti/buttons.php b/modules/contratti/buttons.php index b85e9cd5e0..4d7d1015d2 100755 --- a/modules/contratti/buttons.php +++ b/modules/contratti/buttons.php @@ -20,6 +20,8 @@ include_once __DIR__.'/../../core.php'; +use Common\DocumentRounding; + if (!$is_anagrafica_deleted) { $is_fatturabile = $record['is_fatturabile']; $stati_fatturabili = $dbo->fetchOne('SELECT GROUP_CONCAT(`title` SEPARATOR ", ") AS stati_abilitati FROM `co_stati_contratti` LEFT JOIN `co_stati_contratti_lang` ON (`co_stati_contratti`.`id` = `co_stati_contratti_lang`.`id_record` AND `co_stati_contratti_lang`.`id_lang` = '.prepare(Models\Locale::getDefault()->id).') WHERE `is_fatturabile` = 1')['stati_abilitati']; @@ -48,6 +50,13 @@ '; } +if (DocumentRounding::defaultMode() !== null && empty($record['is_bloccato'])) { + echo ' +'; +} + // Duplica contratto echo ' '; +} + // Duplica fattura echo ' '; +} + // Duplica preventivo echo '