From 0b04cf2592fff2779787a93982401260931839a8 Mon Sep 17 00:00:00 2001 From: Sajo <75281007+sajotrei@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:00:04 +0200 Subject: [PATCH 1/4] Ripulisce PR Contratti fornitori --- modules/contratti_fornitori/actions.php | 89 +++ modules/contratti_fornitori/add.php | 67 +++ modules/contratti_fornitori/bulk.php | 197 ++++++ modules/contratti_fornitori/buttons.php | 32 + modules/contratti_fornitori/edit.php | 187 ++++++ modules/contratti_fornitori/init.php | 32 + modules/contratti_fornitori/src/Categoria.php | 40 ++ .../src/ContrattoFornitore.php | 80 +++ .../src/ContrattoFornitoreService.php | 566 ++++++++++++++++++ modules/contratti_fornitori/src/Stato.php | 40 ++ .../ContrattoFornitoreServiceTest.php | 80 +++ update/2_12_contratti_fornitori.sql | 165 +++++ 12 files changed, 1575 insertions(+) create mode 100644 modules/contratti_fornitori/actions.php create mode 100644 modules/contratti_fornitori/add.php create mode 100644 modules/contratti_fornitori/bulk.php create mode 100644 modules/contratti_fornitori/buttons.php create mode 100644 modules/contratti_fornitori/edit.php create mode 100644 modules/contratti_fornitori/init.php create mode 100644 modules/contratti_fornitori/src/Categoria.php create mode 100644 modules/contratti_fornitori/src/ContrattoFornitore.php create mode 100644 modules/contratti_fornitori/src/ContrattoFornitoreService.php create mode 100644 modules/contratti_fornitori/src/Stato.php create mode 100644 tests/Modules/ContrattiFornitori/ContrattoFornitoreServiceTest.php create mode 100644 update/2_12_contratti_fornitori.sql diff --git a/modules/contratti_fornitori/actions.php b/modules/contratti_fornitori/actions.php new file mode 100644 index 000000000..ce6f73adc --- /dev/null +++ b/modules/contratti_fornitori/actions.php @@ -0,0 +1,89 @@ +. + */ + +include_once __DIR__.'/../../core.php'; +require_once __DIR__.'/src/ContrattoFornitoreService.php'; + +use Modules\ContrattiFornitori\ContrattoFornitoreService; + +$service = new ContrattoFornitoreService($dbo, (int) $id_module); + +try { + switch (post('op')) { + case 'add': + $id_record = $service->create([ + 'id_segment' => post('id_segment'), + 'nome' => post('nome'), + 'id_fornitore' => post('id_fornitore'), + 'id_categoria' => post('id_categoria'), + 'data_inizio' => post('data_inizio'), + 'validita' => post('validita'), + 'tipo_validita' => post('tipo_validita'), + ]); + + flash()->info(tr('Contratto fornitore aggiunto.')); + break; + + case 'update': + $service->update((int) $id_record, [ + 'numero' => post('numero'), + 'nome' => post('nome'), + 'id_fornitore' => post('id_fornitore'), + 'id_referente' => post('id_referente'), + 'idagente' => post('idagente'), + 'id_stato' => post('id_stato'), + 'id_categoria' => post('id_categoria'), + 'numero_fornitore' => post('numero_fornitore'), + 'data_stipula' => post('data_stipula'), + 'data_inizio' => post('data_inizio'), + 'validita' => post('validita'), + 'tipo_validita' => post('tipo_validita'), + 'data_scadenza' => post('data_scadenza'), + 'giorni_preavviso' => post('giorni_preavviso'), + 'rinnovo_automatico' => post('rinnovo_automatico'), + 'mesi_rinnovo' => post('mesi_rinnovo'), + 'condizioni_rinnovo' => post('condizioni_rinnovo'), + 'importo' => post('importo'), + 'periodicita' => post('periodicita'), + 'note_economiche' => post('note_economiche'), + 'note' => post('note'), + ], !empty(post('force_state_transition'))); + + flash()->info(tr('Contratto fornitore aggiornato.')); + break; + + case 'copy': + $id_record = $service->duplicate((int) $id_record); + flash()->info(tr('Contratto duplicato correttamente.')); + break; + + case 'renew': + $id_record = $service->renew((int) $id_record); + flash()->info(tr('Contratto rinnovato correttamente.')); + break; + + case 'delete': + $service->deleteDraft((int) $id_record); + flash()->info(tr('Contratto fornitore eliminato.')); + break; + } +} catch (Throwable $e) { + flash()->error($e->getMessage()); +} diff --git a/modules/contratti_fornitori/add.php b/modules/contratti_fornitori/add.php new file mode 100644 index 000000000..77d4db476 --- /dev/null +++ b/modules/contratti_fornitori/add.php @@ -0,0 +1,67 @@ +. + */ + +include_once __DIR__.'/../../core.php'; + +$id_segment = getSegmentPredefined($id_module); + +if (empty($id_segment)) { + echo '
'.tr('Non è configurato un sezionale predefinito per Contratti fornitori. Configurarlo dal modulo Segmenti prima di creare il contratto.').'
'; + + return; +} + +echo ' +
+ + + + + +
+
+ {[ "type": "text", "label": "'.tr('Descrizione contratto').'", "name": "nome", "required": 1 ]} +
+ +
+ {[ "type": "select", "label": "'.tr('Fornitore').'", "name": "id_fornitore", "required": 1, "ajax-source": "fornitori" ]} +
+
+ +
+
+ {[ "type": "select", "label": "'.tr('Categoria').'", "name": "id_categoria", "values": "query=SELECT `id`, `nome` AS descrizione FROM `ac_categorie_contratti_fornitori` WHERE `enabled` = 1 ORDER BY `nome`" ]} +
+ +
+ {[ "type": "date", "label": "'.tr('Data inizio').'", "name": "data_inizio", "value": "'.date('Y-m-d').'" ]} +
+ +
+ {[ "type": "number", "label": "'.tr('Validità contratto').'", "name": "validita", "decimals": "0", "min-value": "0", "icon-after": "choice|period|manual", "help": "'.tr('Il campo Validità contratto viene utilizzato per il calcolo della Data di scadenza del contratto.').'" ]} +
+
+ + +
'; diff --git a/modules/contratti_fornitori/bulk.php b/modules/contratti_fornitori/bulk.php new file mode 100644 index 000000000..ee8567e9b --- /dev/null +++ b/modules/contratti_fornitori/bulk.php @@ -0,0 +1,197 @@ +. + */ + +include_once __DIR__.'/../../core.php'; + +require_once __DIR__.'/src/ContrattoFornitoreService.php'; + +use Modules\ContrattiFornitori\ContrattoFornitoreService; + +$service = new ContrattoFornitoreService($dbo, (int) $id_module); +$operations = []; + +function contrattiFornitoriBulkErrors(array $errors): string +{ + $total = count($errors); + $visible = array_slice($errors, 0, 5); + $message = implode(' | ', $visible); + + if ($total > count($visible)) { + $message .= ' | '.tr('altri _NUM_ errori', [ + '_NUM_' => $total - count($visible), + ]); + } + + return $message; +} + +$selectedCount = count($id_records ?? []); + +if (!empty(post('op')) && $selectedCount > ContrattoFornitoreService::MAX_BULK_RECORDS) { + flash()->error(tr('È possibile elaborare al massimo _MAX_ contratti per volta.', [ + '_MAX_' => ContrattoFornitoreService::MAX_BULK_RECORDS, + ])); + + return []; +} + +switch (post('op')) { + case 'change_status': + $updated = 0; + $errors = []; + + foreach ($id_records as $id) { + try { + $service->changeState((int) $id, (int) post('id_stato'), !empty(post('force_state_transition'))); + ++$updated; + } catch (Throwable $e) { + $errors[] = '#'.(int) $id.': '.$e->getMessage(); + } + } + + if ($updated > 0) { + flash()->info(tr('Stato aggiornato per _NUM_ contratti fornitori.', ['_NUM_' => $updated])); + } + + if (!empty($errors)) { + flash()->warning(tr('Alcuni contratti non sono stati aggiornati: _ERRORS_', [ + '_ERRORS_' => contrattiFornitoriBulkErrors($errors), + ])); + } + break; + + case 'copy_bulk': + $copied = 0; + $errors = []; + + foreach ($id_records as $id) { + try { + $service->duplicate((int) $id); + ++$copied; + } catch (Throwable $e) { + $errors[] = '#'.(int) $id.': '.$e->getMessage(); + } + } + + if ($copied > 0) { + flash()->info(tr('_NUM_ contratti fornitori duplicati.', ['_NUM_' => $copied])); + } + + if (!empty($errors)) { + flash()->warning(tr('Alcuni contratti non sono stati duplicati: _ERRORS_', [ + '_ERRORS_' => contrattiFornitoriBulkErrors($errors), + ])); + } + break; + + case 'renew_bulk': + $renewed = 0; + $errors = []; + + foreach ($id_records as $id) { + try { + $service->renew((int) $id); + ++$renewed; + } catch (Throwable $e) { + $errors[] = '#'.(int) $id.': '.$e->getMessage(); + } + } + + if ($renewed > 0) { + flash()->info(tr('_NUM_ contratti fornitori rinnovati.', ['_NUM_' => $renewed])); + } + + if (!empty($errors)) { + flash()->warning(tr('Alcuni contratti non sono stati rinnovati: _ERRORS_', [ + '_ERRORS_' => contrattiFornitoriBulkErrors($errors), + ])); + } + break; + + case 'delete_drafts_bulk': + $deleted = 0; + $errors = []; + + foreach ($id_records as $id) { + try { + $service->deleteDraft((int) $id); + ++$deleted; + } catch (Throwable $e) { + $errors[] = '#'.(int) $id.': '.$e->getMessage(); + } + } + + if ($deleted > 0) { + flash()->info(tr('_NUM_ bozze eliminate.', ['_NUM_' => $deleted])); + } + + if (!empty($errors)) { + flash()->warning(tr('Alcune bozze non sono state eliminate: _ERRORS_', [ + '_ERRORS_' => contrattiFornitoriBulkErrors($errors), + ])); + } + break; +} + +$operations['change_status'] = [ + 'text' => ' '.tr('Cambia stato').'', + 'data' => [ + 'title' => tr('Aggiornare lo stato dei contratti selezionati?'), + 'msg' => '
'.tr('Alcuni passaggi di stato possono riaprire o modificare contratti già attivi o conclusi. Verificare attentamente i record selezionati.').'

{[ "type": "select", "label": "'.tr('Stato').'", "name": "id_stato", "required": 1, "values": "query=SELECT `id`, `nome` AS descrizione, `colore` AS _bgcolor_ FROM `ac_stati_contratti_fornitori` WHERE `enabled` = 1 AND `nome` != \"In scadenza\" ORDER BY `ordine`, `nome`" ]}
{[ "type": "checkbox", "label": "'.tr('Confermo anche eventuali transizioni di stato non standard').'", "name": "force_state_transition", "value": "1" ]}', + 'button' => tr('Procedi'), + 'class' => 'btn btn-lg btn-warning', + 'blank' => false, + ], +]; + +$operations['copy_bulk'] = [ + 'text' => ' '.tr('Duplica contratti').'', + 'data' => [ + 'title' => tr('Duplicare i contratti selezionati?'), + 'msg' => tr('Ogni copia sarà creata in stato Bozza con una nuova numerazione. Gli allegati non saranno copiati.'), + 'button' => tr('Procedi'), + 'class' => 'btn btn-lg btn-warning', + 'blank' => false, + ], +]; + +$operations['renew_bulk'] = [ + 'text' => ' '.tr('Rinnova contratti').'', + 'data' => [ + 'title' => tr('Rinnovare i contratti selezionati?'), + 'msg' => tr('Saranno rinnovati soltanto i contratti Attivi, non già rinnovati e con una data di scadenza. Gli allegati non saranno copiati.'), + 'button' => tr('Procedi'), + 'class' => 'btn btn-lg btn-warning', + 'blank' => false, + ], +]; + +$operations['delete_drafts_bulk'] = [ + 'text' => ' '.tr('Elimina bozze').'', + 'data' => [ + 'title' => tr('Eliminare le bozze selezionate?'), + 'msg' => tr('Saranno eliminati soltanto i contratti in stato Bozza senza allegati.'), + 'button' => tr('Elimina'), + 'class' => 'btn btn-lg btn-danger', + 'blank' => false, + ], +]; + +return $operations; diff --git a/modules/contratti_fornitori/buttons.php b/modules/contratti_fornitori/buttons.php new file mode 100644 index 000000000..b64fed9ff --- /dev/null +++ b/modules/contratti_fornitori/buttons.php @@ -0,0 +1,32 @@ +. + */ + +include_once __DIR__.'/../../core.php'; + +if (!empty($id_record)) { + $manualWithoutDuration = ($record['tipo_validita'] ?? null) === 'manual' && empty($record['mesi_rinnovo']); + $canRenew = !empty($record['data_scadenza']) && empty($record['id_contratto_successivo']) && !$manualWithoutDuration; + + echo '
'; + echo ''; + echo '
'; + + echo ''; +} diff --git a/modules/contratti_fornitori/edit.php b/modules/contratti_fornitori/edit.php new file mode 100644 index 000000000..7908ff126 --- /dev/null +++ b/modules/contratti_fornitori/edit.php @@ -0,0 +1,187 @@ +. + */ + +include_once __DIR__.'/../../core.php'; + +if (empty($record)) { + echo '
'.tr('Contratto fornitore non trovato.').'
'; + + return; +} + +$collegamenti_rinnovo = []; + +if (!empty($record['id_contratto_precedente'])) { + $precedente = $dbo->fetchOne( + 'SELECT `id`, `numero` FROM `ac_contratti_fornitori` WHERE `id` = '.prepare($record['id_contratto_precedente']).' LIMIT 1' + ); + + if (!empty($precedente)) { + $collegamenti_rinnovo[] = '
'.tr('Contratto precedente').'
'.htmlentities((string) $precedente['numero']).'
'; + } +} + +if (!empty($record['id_contratto_successivo'])) { + $successivo = $dbo->fetchOne( + 'SELECT `id`, `numero` FROM `ac_contratti_fornitori` WHERE `id` = '.prepare($record['id_contratto_successivo']).' LIMIT 1' + ); + + if (!empty($successivo)) { + $collegamenti_rinnovo[] = '
'.tr('Contratto successivo').'
'.htmlentities((string) $successivo['numero']).'
'; + } +} + +if (!empty($collegamenti_rinnovo)) { + echo '

'.tr('Collegamenti rinnovo').'

'.implode('', $collegamenti_rinnovo).'
'; +} + +$stato_corrente = $dbo->fetchOne( + 'SELECT `id`, `nome` FROM `ac_stati_contratti_fornitori` WHERE `id` = '.prepare($record['id_stato']).' LIMIT 1' +); + +$stati_disponibili = $dbo->fetchArray( + 'SELECT `id`, `nome` FROM `ac_stati_contratti_fornitori` WHERE (`enabled` = 1 OR `id` = '.prepare($record['id_stato']).') AND `nome` != \'In scadenza\' ORDER BY `ordine`, `nome`' +); + +$nomi_stati = []; +foreach ($stati_disponibili as $stato_disponibile) { + $nomi_stati[(int) $stato_disponibile['id']] = $stato_disponibile['nome']; +} + +$contratto_in_scadenza = ($stato_corrente['nome'] ?? null) === 'Attivo' + && !empty($record['data_scadenza']) + && $record['data_scadenza'] >= date('Y-m-d') + && $record['data_scadenza'] <= date('Y-m-d', strtotime('+60 days')); + +if ($contratto_in_scadenza) { + echo '
'.tr('Contratto in scadenza').': '.tr('Scadenza prevista il _DATE_.', [ + '_DATE_' => dateFormat($record['data_scadenza']), + ]).'
'; +} + +$transizioni_standard = [ + 'Bozza' => ['Attivo', 'Disdetto'], + 'Attivo' => ['Disdetto', 'Terminato'], + 'Disdetto' => ['Attivo', 'Terminato'], + 'Terminato' => [], +]; + +echo ' +
+{[ "type": "select", "label": "'.tr('Stato').'", "name": "id_stato", "required": 1, "values": "query=SELECT id, nome AS descrizione, colore AS _bgcolor_ FROM ac_stati_contratti_fornitori WHERE (enabled=1 OR id=$id_stato$) AND nome!=\"In scadenza\" ORDER BY ordine,nome", "value": "$id_stato$", "form": "edit-form" ]} +
+
+ + + + + +

'.tr('Contratto fornitore').'

+
{[ "type":"text","label":"'.tr('Numero').'","name":"numero","required":1,"value":"$numero$" ]}
{[ "type":"text","label":"'.tr('Descrizione contratto').'","name":"nome","required":1,"value":"$nome$" ]}
{[ "type":"select","label":"'.tr('Sezionale').'","name":"id_segment","ajax-source":"segmenti","select-options":'.json_encode(['id_module' => $id_module, 'is_sezionale' => 1]).',"value":"$id_segment$","disabled":1 ]}
+
{[ "type":"select","label":"'.tr('Fornitore').'","name":"id_fornitore","required":1,"ajax-source":"fornitori","value":"$id_fornitore$" ]}
{[ "type":"select","label":"'.tr('Referente fornitore').'","name":"id_referente","ajax-source":"referenti","select-options":{"idanagrafica":"$id_fornitore$"},"value":"$id_referente$" ]}
{[ "type":"select","label":"'.tr('Referente interno').'","name":"idagente","ajax-source":"agenti","value":"$idagente$" ]}
+
{[ "type":"select","label":"'.tr('Categoria').'","name":"id_categoria","values":"query=SELECT id,nome AS descrizione FROM ac_categorie_contratti_fornitori WHERE enabled=1 ORDER BY nome","value":"$id_categoria$" ]}
{[ "type":"text","label":"'.tr('Numero contratto fornitore').'","name":"numero_fornitore","value":"$numero_fornitore$" ]}
{[ "type":"date","label":"'.tr('Data stipula').'","name":"data_stipula","value":"$data_stipula$" ]}
+
+ +

'.tr('Durata, rinnovo e disdetta').'

+
'.tr('Durata del contratto').'
'.tr('Imposta la decorrenza e la modalità di calcolo della scadenza.').'
+
{[ "type":"date","label":"'.tr('Data inizio').'","name":"data_inizio","value":"$data_inizio$","help":"'.tr('Data dalla quale il contratto produce effetti.').'" ]}
{[ "type":"number","label":"'.tr('Validità contratto').'","name":"validita","decimals":0,"value":"$validita$","icon-after":"choice|period|$tipo_validita$","help":"'.tr('Durata usata per calcolare automaticamente la scadenza. Selezionare Manuale per inserire direttamente la data finale.').'" ]}
{[ "type":"date","label":"'.tr('Data scadenza').'","name":"data_scadenza","value":"$data_scadenza$","help":"'.tr('Data finale del contratto. È calcolata automaticamente, salvo validità manuale.').'" ]}
{[ "type":"number","label":"'.tr('Preavviso disdetta').'","name":"giorni_preavviso","decimals":0,"value":"$giorni_preavviso$","icon-after":"'.tr('giorni').'","help":"'.tr('Giorni di anticipo richiesti per comunicare la disdetta.').'" ]}
+
+
'.tr('Disdetta').'
'.tr('Il termine viene calcolato sottraendo il preavviso dalla data di scadenza.').'
{[ "type":"date","label":"'.tr('Termine ultimo per la disdetta').'","name":"data_limite_disdetta","value":"$data_limite_disdetta$","disabled":1,"help":"'.tr('Data entro cui inviare la comunicazione di disdetta.').'" ]}
'.tr('Rinnovo').'
'.tr('Configura l’eventuale rinnovo automatico e le relative condizioni.').'
{[ "type":"checkbox","label":"'.tr('Rinnovo automatico').'","name":"rinnovo_automatico","value":"$rinnovo_automatico$","help":"'.tr('Indica che il contratto si rinnova automaticamente alla scadenza.').'" ]}
{[ "type":"number","label":"'.tr('Durata rinnovo').'","name":"mesi_rinnovo","decimals":0,"value":"$mesi_rinnovo$","icon-after":"'.tr('mesi').'","help":"'.tr('Durata del nuovo periodo contrattuale.').'" ]}
{[ "type":"text","label":"'.tr('Condizioni di rinnovo').'","name":"condizioni_rinnovo","value":"$condizioni_rinnovo$","help":"'.tr('Eventuali variazioni di prezzo, durata o condizioni.').'" ]}
+
+ +

'.tr('Informazioni economiche indicative').'

{[ "type":"number","label":"'.tr('Importo indicativo').'","name":"importo","decimals":2,"value":"$importo$","icon-after":"€" ]}
{[ "type":"select","label":"'.tr('Periodicità').'","name":"periodicita","values":"list=\"mensile\":\"Mensile\",\"trimestrale\":\"Trimestrale\",\"semestrale\":\"Semestrale\",\"annuale\":\"Annuale\",\"una_tantum\":\"Una tantum\",\"variabile\":\"Variabile\"","value":"$periodicita$" ]}
{[ "type":"text","label":"'.tr('Note economiche').'","name":"note_economiche","value":"$note_economiche$" ]}
+

'.tr('Note').'

{[ "type":"textarea","label":"'.tr('Note operative').'","name":"note","value":"$note$","rows":6 ]}
+
+{( "name": "filelist_and_upload", "id": "'.random_int(1, 999).'", "id_record": "'.$id_record.'", "id_module": "'.$id_module.'", "id_plugin": "" )} +
'.tr('Elimina').'
'; +?> + diff --git a/modules/contratti_fornitori/init.php b/modules/contratti_fornitori/init.php new file mode 100644 index 000000000..ed8b6ef9b --- /dev/null +++ b/modules/contratti_fornitori/init.php @@ -0,0 +1,32 @@ +. + */ + +include_once __DIR__.'/../../core.php'; + +if (!empty($id_record)) { + $record = $dbo->fetchOne( + 'SELECT c.*, a.ragione_sociale AS fornitore, s.nome AS stato_nome, s.colore AS stato_colore, cat.nome AS categoria_nome + FROM ac_contratti_fornitori c + INNER JOIN an_anagrafiche a ON a.idanagrafica = c.id_fornitore + INNER JOIN ac_stati_contratti_fornitori s ON s.id = c.id_stato + LEFT JOIN ac_categorie_contratti_fornitori cat ON cat.id = c.id_categoria + WHERE c.id = '.prepare($id_record) + ); +} diff --git a/modules/contratti_fornitori/src/Categoria.php b/modules/contratti_fornitori/src/Categoria.php new file mode 100644 index 000000000..e8a913690 --- /dev/null +++ b/modules/contratti_fornitori/src/Categoria.php @@ -0,0 +1,40 @@ +. + */ + +namespace Modules\ContrattiFornitori; + +use Common\SimpleModelTrait; +use Illuminate\Database\Eloquent\Model; + +class Categoria extends Model +{ + use SimpleModelTrait; + + public $timestamps = false; + + protected $table = 'ac_categorie_contratti_fornitori'; + + protected $guarded = []; + + public function contratti() + { + return $this->hasMany(ContrattoFornitore::class, 'id_categoria'); + } +} diff --git a/modules/contratti_fornitori/src/ContrattoFornitore.php b/modules/contratti_fornitori/src/ContrattoFornitore.php new file mode 100644 index 000000000..8f298a333 --- /dev/null +++ b/modules/contratti_fornitori/src/ContrattoFornitore.php @@ -0,0 +1,80 @@ +. + */ + +namespace Modules\ContrattiFornitori; + +use Common\SimpleModelTrait; +use Illuminate\Database\Eloquent\Model; +use Modules\Anagrafiche\Anagrafica; +use Traits\RecordTrait; + +class ContrattoFornitore extends Model +{ + use RecordTrait; + use SimpleModelTrait; + + protected $table = 'ac_contratti_fornitori'; + + protected $casts = [ + 'data_stipula' => 'date', + 'data_inizio' => 'date', + 'data_scadenza' => 'date', + 'data_limite_disdetta' => 'date', + 'rinnovo_automatico' => 'boolean', + 'importo' => 'decimal:2', + ]; + + protected $guarded = []; + + public function getModuleAttribute(): string + { + return 'Contratti fornitori'; + } + + public function fornitore() + { + return $this->belongsTo(Anagrafica::class, 'id_fornitore'); + } + + public function referenteInterno() + { + return $this->belongsTo(Anagrafica::class, 'idagente'); + } + + public function stato() + { + return $this->belongsTo(Stato::class, 'id_stato'); + } + + public function categoria() + { + return $this->belongsTo(Categoria::class, 'id_categoria'); + } + + public function precedente() + { + return $this->belongsTo(self::class, 'id_contratto_precedente'); + } + + public function successivo() + { + return $this->belongsTo(self::class, 'id_contratto_successivo'); + } +} diff --git a/modules/contratti_fornitori/src/ContrattoFornitoreService.php b/modules/contratti_fornitori/src/ContrattoFornitoreService.php new file mode 100644 index 000000000..9e13d8038 --- /dev/null +++ b/modules/contratti_fornitori/src/ContrattoFornitoreService.php @@ -0,0 +1,566 @@ + [self::STATO_ATTIVO, self::STATO_DISDETTO], + self::STATO_ATTIVO => [self::STATO_DISDETTO, self::STATO_TERMINATO], + self::STATO_DISDETTO => [self::STATO_ATTIVO, self::STATO_TERMINATO], + self::STATO_TERMINATO => [], + ]; + + private const PERIODICITA_AMMESSE = [ + 'una_tantum', 'mensile', 'bimestrale', 'trimestrale', 'semestrale', 'annuale', + ]; + + public function __construct(private \Database $dbo, private int $idModulo) + { + } + + public function validateText(?string $value, int $maxLength, string $label, bool $required = false): ?string + { + $value = trim((string) $value); + if ($value === '') { + if ($required) { + throw new RuntimeException(tr('Il campo _FIELD_ è obbligatorio.', ['_FIELD_' => $label])); + } + + return null; + } + + if (mb_strlen($value) > $maxLength) { + throw new RuntimeException(tr('Il campo _FIELD_ non può superare _MAX_ caratteri.', [ + '_FIELD_' => $label, + '_MAX_' => $maxLength, + ])); + } + + return $value; + } + + public function validateName(?string $name): string + { + return (string) $this->validateText($name, 255, tr('Descrizione contratto'), true); + } + + public function validateDate(?string $date, bool $required = false): ?string + { + $date = trim((string) $date); + if ($date === '') { + if ($required) { + throw new RuntimeException(tr('La data richiesta non è stata indicata.')); + } + + return null; + } + + $parsed = DateTime::createFromFormat('Y-m-d', $date); + $errors = DateTime::getLastErrors(); + if (!$parsed || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) || $parsed->format('Y-m-d') !== $date) { + throw new RuntimeException(tr('Formato data non valido.')); + } + + return $date; + } + + public function normalizeAmount($amount): float + { + if ($amount === null || $amount === '') { + return 0.0; + } + + if (is_int($amount) || is_float($amount)) { + $normalized = round((float) $amount, 2); + } else { + $value = str_replace(["\xc2\xa0", ' ', '€'], '', trim((string) $amount)); + if (!preg_match('/^-?[0-9.,]+$/', $value)) { + throw new RuntimeException(tr('Importo non valido.')); + } + + $comma = strrpos($value, ','); + $dot = strrpos($value, '.'); + if ($comma !== false && $dot !== false) { + $value = $comma > $dot + ? str_replace(',', '.', str_replace('.', '', $value)) + : str_replace(',', '', $value); + } elseif ($comma !== false) { + $value = str_replace(',', '.', str_replace('.', '', $value)); + } elseif (substr_count($value, '.') > 1) { + $value = str_replace('.', '', $value); + } + + if (!is_numeric($value)) { + throw new RuntimeException(tr('Importo non valido.')); + } + + $normalized = round((float) $value, 2); + } + + if ($normalized < 0) { + throw new RuntimeException(tr('L\'importo non può essere negativo.')); + } + + return $normalized; + } + + public function normalizeTipoValidita(?string $tipo): ?string + { + $tipo = trim((string) $tipo); + $map = ['manuale' => 'manual', 'giorni' => 'days', 'mesi' => 'months', 'anni' => 'years']; + + return $map[$tipo] ?? ($tipo !== '' ? $tipo : null); + } + + public function calculateExpiry(?string $inizio, $validita, ?string $tipo): ?string + { + $tipo = $this->normalizeTipoValidita($tipo); + $validita = (int) $validita; + if (empty($inizio) || $validita <= 0 || empty($tipo) || $tipo === 'manual') { + return null; + } + + if (!in_array($tipo, ['days', 'months', 'years'], true)) { + throw new RuntimeException(tr('Tipo di validità non valido.')); + } + + $start = new DateTimeImmutable($this->validateDate($inizio, true)); + if ($tipo === 'days') { + return $start->modify('+'.($validita - 1).' days')->format('Y-m-d'); + } + + $months = $tipo === 'years' ? $validita * 12 : $validita; + $startDay = (int) $start->format('d'); + $targetMonth = $start->modify('first day of this month')->modify('+'.$months.' months'); + $daysInTargetMonth = (int) $targetMonth->format('t'); + + if ($startDay > $daysInTargetMonth) { + $expiry = $targetMonth->setDate((int) $targetMonth->format('Y'), (int) $targetMonth->format('m'), $daysInTargetMonth); + } else { + $expiry = $targetMonth + ->setDate((int) $targetMonth->format('Y'), (int) $targetMonth->format('m'), $startDay) + ->modify('-1 day'); + } + + return $expiry->format('Y-m-d'); + } + + public function calculateCancellationDeadline(?string $scadenza, $giorni): ?string + { + if (empty($scadenza)) { + return null; + } + + return (new DateTimeImmutable($this->validateDate($scadenza, true))) + ->modify('-'.max(0, (int) $giorni).' days') + ->format('Y-m-d'); + } + + public function getStateId(string $name): int + { + $row = $this->dbo->fetchOne('SELECT `id` FROM `ac_stati_contratti_fornitori` WHERE `nome` = '.prepare($name).' LIMIT 1'); + if (empty($row['id'])) { + throw new RuntimeException(tr('Stato contratto non configurato: _STATE_.', ['_STATE_' => $name])); + } + + return (int) $row['id']; + } + + public function getStateName(int $idState): string + { + $row = $this->dbo->fetchOne('SELECT `nome` FROM `ac_stati_contratti_fornitori` WHERE `id` = '.prepare($idState).' LIMIT 1'); + if (empty($row['nome'])) { + throw new RuntimeException(tr('Stato contratto non valido.')); + } + + return (string) $row['nome']; + } + + public function validateSupplier($idSupplier): int + { + $idSupplier = (int) $idSupplier; + $row = $this->dbo->fetchOne( + 'SELECT `an_anagrafiche`.`idanagrafica` + FROM `an_anagrafiche` + INNER JOIN `an_tipianagrafiche_anagrafiche` + ON `an_tipianagrafiche_anagrafiche`.`idanagrafica` = `an_anagrafiche`.`idanagrafica` + INNER JOIN `an_tipianagrafiche` + ON `an_tipianagrafiche`.`idtipoanagrafica` = `an_tipianagrafiche_anagrafiche`.`idtipoanagrafica` + WHERE `an_anagrafiche`.`idanagrafica` = '.prepare($idSupplier).' + AND `an_tipianagrafiche`.`descrizione` = '.prepare('Fornitore').' + LIMIT 1' + ); + if (empty($row)) { + throw new RuntimeException(tr('Selezionare un\'anagrafica di tipo Fornitore.')); + } + + return $idSupplier; + } + + public function validateCategory($idCategory): ?int + { + $idCategory = (int) $idCategory; + if ($idCategory <= 0) { + return null; + } + + $row = $this->dbo->fetchOne('SELECT `id` FROM `ac_categorie_contratti_fornitori` WHERE `id` = '.prepare($idCategory).' LIMIT 1'); + if (empty($row)) { + throw new RuntimeException(tr('Categoria non valida.')); + } + + return $idCategory; + } + + public function validateReferent($idReferent, int $idSupplier): ?int + { + $idReferent = (int) $idReferent; + if ($idReferent <= 0) { + return null; + } + + $row = $this->dbo->fetchOne('SELECT `id` FROM `an_referenti` WHERE `id` = '.prepare($idReferent).' AND `idanagrafica` = '.prepare($idSupplier).' LIMIT 1'); + if (empty($row)) { + throw new RuntimeException(tr('Referente del fornitore non valido.')); + } + + return $idReferent; + } + + public function validateInternalReferent($idAgent): ?int + { + $idAgent = (int) $idAgent; + if ($idAgent <= 0) { + return null; + } + + $row = $this->dbo->fetchOne('SELECT `idanagrafica` FROM `an_anagrafiche` WHERE `idanagrafica` = '.prepare($idAgent).' LIMIT 1'); + if (empty($row)) { + throw new RuntimeException(tr('Referente interno non valido.')); + } + + return $idAgent; + } + + public function validatePeriodicity(?string $periodicity): ?string + { + $periodicity = trim((string) $periodicity); + if ($periodicity === '') { + return null; + } + if (!in_array($periodicity, self::PERIODICITA_AMMESSE, true)) { + throw new RuntimeException(tr('Periodicità non valida.')); + } + + return $periodicity; + } + + public function nextNumber(int $idSegment, ?string $creationDate = null): string + { + $creationDate = $this->validateDate($creationDate ?: date('Y-m-d'), true); + $year = substr($creationDate, 2, 2); + $rows = $this->dbo->fetchArray('SELECT `numero` FROM `ac_contratti_fornitori` WHERE `id_segment` = '.prepare($idSegment).' FOR UPDATE'); + $max = 0; + foreach ($rows as $row) { + if (preg_match('/^(\d+)\/'.$year.'$/', (string) $row['numero'], $matches)) { + $max = max($max, (int) $matches[1]); + } + } + + return str_pad((string) ($max + 1), 4, '0', STR_PAD_LEFT).'/'.$year; + } + + public function validateNumber(string $number, int $idSegment, ?int $excludeId = null): string + { + $number = trim($number); + if ($number === '') { + throw new RuntimeException(tr('Numero contratto obbligatorio.')); + } + + $query = 'SELECT `id` FROM `ac_contratti_fornitori` WHERE `id_segment` = '.prepare($idSegment).' AND `numero` = '.prepare($number); + if ($excludeId) { + $query .= ' AND `id` != '.prepare($excludeId); + } + if (!empty($this->dbo->fetchOne($query.' LIMIT 1'))) { + throw new RuntimeException(tr('Numero contratto già utilizzato nel sezionale selezionato.')); + } + + return $number; + } + + public function create(array $input): int + { + $scope = $this->beginTransaction(); + try { + $idSegment = (int) ($input['id_segment'] ?? 0); + if ($idSegment <= 0) { + throw new RuntimeException(tr('Sezionale non valido.')); + } + $start = $this->validateDate($input['data_inizio'] ?? null, true); + $type = $this->normalizeTipoValidita($input['tipo_validita'] ?? null); + $validity = max(0, (int) ($input['validita'] ?? 0)); + $data = [ + 'numero' => $this->nextNumber($idSegment), + 'id_segment' => $idSegment, + 'id_fornitore' => $this->validateSupplier($input['id_fornitore'] ?? 0), + 'id_stato' => $this->getStateId(self::STATO_BOZZA), + 'id_categoria' => $this->validateCategory($input['id_categoria'] ?? null), + 'nome' => $this->validateName($input['nome'] ?? null), + 'data_inizio' => $start, + 'validita' => $validity ?: null, + 'tipo_validita' => $type, + 'data_scadenza' => $this->calculateExpiry($start, $validity, $type), + 'giorni_preavviso' => 0, + 'rinnovo_automatico' => 0, + 'mesi_rinnovo' => 0, + 'importo' => 0, + 'note' => '', + ]; + $this->dbo->insert('ac_contratti_fornitori', $data); + $id = (int) $this->dbo->lastInsertedID(); + $this->commitTransaction($scope); + + return $id; + } catch (Throwable $e) { + $this->rollbackTransaction($scope); + throw $e; + } + } + + public function update(int $id, array $input, bool $forceStateTransition = false): void + { + $scope = $this->beginTransaction(); + try { + $old = $this->findForUpdate($id); + $supplier = $this->validateSupplier($input['id_fornitore'] ?? $old['id_fornitore']); + $state = (int) ($input['id_stato'] ?? $old['id_stato']); + $oldState = $this->getStateName((int) $old['id_stato']); + $newState = $this->getStateName($state); + if (!$forceStateTransition && $oldState !== $newState && !in_array($newState, self::STATE_TRANSITIONS[$oldState] ?? [], true)) { + throw new RuntimeException(tr('Passaggio di stato non standard. Confermare per procedere.')); + } + + $start = $this->validateDate($input['data_inizio'] ?? $old['data_inizio'], true); + $type = $this->normalizeTipoValidita($input['tipo_validita'] ?? $old['tipo_validita']); + $validity = max(0, (int) ($input['validita'] ?? $old['validita'])); + $expiry = $type === 'manual' + ? $this->validateDate($input['data_scadenza'] ?? $old['data_scadenza']) + : $this->calculateExpiry($start, $validity, $type); + $notice = max(0, (int) ($input['giorni_preavviso'] ?? $old['giorni_preavviso'])); + + $data = [ + 'numero' => $this->validateNumber((string) ($input['numero'] ?? $old['numero']), (int) $old['id_segment'], $id), + 'nome' => $this->validateName($input['nome'] ?? $old['nome']), + 'id_fornitore' => $supplier, + 'id_referente' => $this->validateReferent($input['id_referente'] ?? null, $supplier), + 'idagente' => $this->validateInternalReferent($input['idagente'] ?? null), + 'id_stato' => $state, + 'id_categoria' => $this->validateCategory($input['id_categoria'] ?? null), + 'numero_fornitore' => $this->validateText($input['numero_fornitore'] ?? null, 100, tr('Numero fornitore')), + 'data_stipula' => $this->validateDate($input['data_stipula'] ?? null), + 'data_inizio' => $start, + 'validita' => $validity ?: null, + 'tipo_validita' => $type, + 'data_scadenza' => $expiry, + 'giorni_preavviso' => $notice, + 'data_limite_disdetta' => $this->calculateCancellationDeadline($expiry, $notice), + 'rinnovo_automatico' => empty($input['rinnovo_automatico']) ? 0 : 1, + 'mesi_rinnovo' => max(0, (int) ($input['mesi_rinnovo'] ?? 0)), + 'condizioni_rinnovo' => $this->validateText($input['condizioni_rinnovo'] ?? null, 255, tr('Condizioni di rinnovo')), + 'importo' => $this->normalizeAmount($input['importo'] ?? 0), + 'periodicita' => $this->validatePeriodicity($input['periodicita'] ?? null), + 'note_economiche' => $this->validateText($input['note_economiche'] ?? null, 255, tr('Note economiche')), + 'note' => trim((string) ($input['note'] ?? '')), + ]; + $this->dbo->update('ac_contratti_fornitori', $data, ['id' => $id]); + $this->commitTransaction($scope); + } catch (Throwable $e) { + $this->rollbackTransaction($scope); + throw $e; + } + } + + public function changeState(int $id, int $idState, bool $force = false): void + { + $old = $this->find($id); + $from = $this->getStateName((int) $old['id_stato']); + $to = $this->getStateName($idState); + if (!$force && $from !== $to && !in_array($to, self::STATE_TRANSITIONS[$from] ?? [], true)) { + throw new RuntimeException(tr('Passaggio di stato non standard. Confermare per procedere.')); + } + $this->dbo->update('ac_contratti_fornitori', ['id_stato' => $idState], ['id' => $id]); + } + + public function duplicate(int $id): int + { + return $this->copyRecord($id, false); + } + + public function renew(int $id): int + { + return $this->copyRecord($id, true); + } + + public function deleteDraft(int $id): void + { + $scope = $this->beginTransaction(); + try { + $record = $this->findForUpdate($id); + if ($this->getStateName((int) $record['id_stato']) !== self::STATO_BOZZA) { + throw new RuntimeException(tr('È possibile eliminare soltanto i contratti in stato Bozza.')); + } + if (Upload::where('id_module', $this->idModulo)->where('id_record', $id)->exists()) { + throw new RuntimeException(tr('Eliminare prima gli allegati associati al contratto.')); + } + if (!empty($record['id_contratto_precedente'])) { + $this->dbo->update('ac_contratti_fornitori', ['id_contratto_successivo' => null], ['id' => $record['id_contratto_precedente']]); + } + if (!empty($record['id_contratto_successivo'])) { + $this->dbo->update('ac_contratti_fornitori', ['id_contratto_precedente' => null], ['id' => $record['id_contratto_successivo']]); + } + $this->dbo->delete('ac_contratti_fornitori', ['id' => $id]); + $this->commitTransaction($scope); + } catch (Throwable $e) { + $this->rollbackTransaction($scope); + throw $e; + } + } + + private function copyRecord(int $id, bool $renew): int + { + $scope = $this->beginTransaction(); + try { + $old = $this->findForUpdate($id); + if ($renew) { + if ($this->getStateName((int) $old['id_stato']) !== self::STATO_ATTIVO) { + throw new RuntimeException(tr('È possibile rinnovare soltanto i contratti Attivi.')); + } + if (!empty($old['id_contratto_successivo']) || empty($old['data_scadenza'])) { + throw new RuntimeException(tr('Il contratto non può essere rinnovato.')); + } + } + + $data = $old; + unset($data['id'], $data['created_at'], $data['updated_at']); + $data['numero'] = $this->nextNumber((int) $old['id_segment']); + $data['id_stato'] = $this->getStateId(self::STATO_BOZZA); + $data['data_stipula'] = null; + $data['id_contratto_successivo'] = null; + $data['id_contratto_precedente'] = $renew ? $id : null; + + if ($renew) { + $start = date('Y-m-d', strtotime($old['data_scadenza'].' +1 day')); + $type = $this->normalizeTipoValidita($old['tipo_validita']); + $validity = $type === 'manual' ? max(1, (int) $old['mesi_rinnovo']) : (int) $old['validita']; + $newType = $type === 'manual' ? 'months' : $type; + $data['data_inizio'] = $start; + $data['validita'] = $validity; + $data['tipo_validita'] = $newType; + $data['data_scadenza'] = $this->calculateExpiry($start, $validity, $newType); + $data['data_limite_disdetta'] = $this->calculateCancellationDeadline($data['data_scadenza'], $old['giorni_preavviso']); + } + + $this->dbo->insert('ac_contratti_fornitori', $data); + $newId = (int) $this->dbo->lastInsertedID(); + if ($renew) { + $update = ['id_contratto_successivo' => $newId]; + if ($old['data_scadenza'] <= date('Y-m-d')) { + $update['id_stato'] = $this->getStateId(self::STATO_TERMINATO); + } + $this->dbo->update('ac_contratti_fornitori', $update, ['id' => $id]); + } + $this->commitTransaction($scope); + + return $newId; + } catch (Throwable $e) { + $this->rollbackTransaction($scope); + throw $e; + } + } + + private function beginTransaction(): array + { + $pdo = $this->dbo->getPDO(); + if (!$pdo->inTransaction()) { + $pdo->beginTransaction(); + + return ['own' => true, 'savepoint' => null]; + } + + $savepoint = 'cf_'.(++$this->savepointCounter); + $pdo->exec('SAVEPOINT '.$savepoint); + + return ['own' => false, 'savepoint' => $savepoint]; + } + + private function commitTransaction(array $scope): void + { + $pdo = $this->dbo->getPDO(); + if ($scope['own']) { + if ($pdo->inTransaction()) { + $pdo->commit(); + } + } elseif ($pdo->inTransaction()) { + $pdo->exec('RELEASE SAVEPOINT '.$scope['savepoint']); + } + } + + private function rollbackTransaction(array $scope): void + { + $pdo = $this->dbo->getPDO(); + if (!$pdo->inTransaction()) { + return; + } + if ($scope['own']) { + $pdo->rollBack(); + } else { + $pdo->exec('ROLLBACK TO SAVEPOINT '.$scope['savepoint']); + $pdo->exec('RELEASE SAVEPOINT '.$scope['savepoint']); + } + } + + private function find(int $id): array + { + $row = $this->dbo->fetchOne('SELECT * FROM `ac_contratti_fornitori` WHERE `id` = '.prepare($id).' LIMIT 1'); + if (empty($row)) { + throw new RuntimeException(tr('Contratto fornitore non trovato.')); + } + + return $row; + } + + private function findForUpdate(int $id): array + { + $row = $this->dbo->fetchOne('SELECT * FROM `ac_contratti_fornitori` WHERE `id` = '.prepare($id).' LIMIT 1 FOR UPDATE'); + if (empty($row)) { + throw new RuntimeException(tr('Contratto fornitore non trovato.')); + } + + return $row; + } +} diff --git a/modules/contratti_fornitori/src/Stato.php b/modules/contratti_fornitori/src/Stato.php new file mode 100644 index 000000000..3a0735ebc --- /dev/null +++ b/modules/contratti_fornitori/src/Stato.php @@ -0,0 +1,40 @@ +. + */ + +namespace Modules\ContrattiFornitori; + +use Common\SimpleModelTrait; +use Illuminate\Database\Eloquent\Model; + +class Stato extends Model +{ + use SimpleModelTrait; + + public $timestamps = false; + + protected $table = 'ac_stati_contratti_fornitori'; + + protected $guarded = []; + + public function contratti() + { + return $this->hasMany(ContrattoFornitore::class, 'id_stato'); + } +} diff --git a/tests/Modules/ContrattiFornitori/ContrattoFornitoreServiceTest.php b/tests/Modules/ContrattiFornitori/ContrattoFornitoreServiceTest.php new file mode 100644 index 000000000..0dca0ecd7 --- /dev/null +++ b/tests/Modules/ContrattiFornitori/ContrattoFornitoreServiceTest.php @@ -0,0 +1,80 @@ +. + */ + +namespace Tests\Modules\ContrattiFornitori; + +use Modules\ContrattiFornitori\ContrattoFornitoreService; +use PHPUnit\Framework\TestCase; +use ReflectionClass; +use RuntimeException; + +class ContrattoFornitoreServiceTest extends TestCase +{ + private ContrattoFornitoreService $service; + + protected function setUp(): void + { + $reflection = new ReflectionClass(ContrattoFornitoreService::class); + $this->service = $reflection->newInstanceWithoutConstructor(); + } + + public function testNormalizzaImportiItalianiENormalizzati(): void + { + self::assertSame(2000.50, $this->service->normalizeAmount('2.000,50')); + self::assertSame(2000.50, $this->service->normalizeAmount('2000,50')); + self::assertSame(2000.50, $this->service->normalizeAmount('2000.50')); + self::assertSame(2000.00, $this->service->normalizeAmount('2000')); + } + + public function testRifiutaImportoNegativo(): void + { + $this->expectException(RuntimeException::class); + $this->service->normalizeAmount('-1'); + } + + public function testCalcoloScadenzaFineMese(): void + { + self::assertSame( + '2026-02-28', + $this->service->calculateExpiry('2026-01-31', 1, 'months') + ); + + self::assertSame( + '2028-02-29', + $this->service->calculateExpiry('2028-01-31', 1, 'months') + ); + } + + public function testCalcoloScadenzaGiorni(): void + { + self::assertSame( + '2026-01-30', + $this->service->calculateExpiry('2026-01-01', 30, 'days') + ); + } + + public function testCalcoloTermineDisdetta(): void + { + self::assertSame( + '2026-08-26', + $this->service->calculateCancellationDeadline('2026-09-25', 30) + ); + } +} diff --git a/update/2_12_contratti_fornitori.sql b/update/2_12_contratti_fornitori.sql new file mode 100644 index 000000000..9e405b958 --- /dev/null +++ b/update/2_12_contratti_fornitori.sql @@ -0,0 +1,165 @@ +-- Migrazione del modulo Contratti fornitori. +-- Da integrare in update/2_12.sql prima della revisione finale. + +INSERT INTO `zz_modules` +(`name`, `directory`, `options`, `options2`, `icon`, `version`, `compatibility`, `order`, `parent`, `default`, `enabled`, `use_notes`, `use_checklists`, `attachments_directory`) +VALUES +( + 'Contratti fornitori', + 'contratti_fornitori', + 'SELECT |select| FROM `ac_contratti_fornitori` INNER JOIN `an_anagrafiche` ON `an_anagrafiche`.`idanagrafica` = `ac_contratti_fornitori`.`id_fornitore` INNER JOIN `ac_stati_contratti_fornitori` ON `ac_stati_contratti_fornitori`.`id` = `ac_contratti_fornitori`.`id_stato` LEFT JOIN `ac_categorie_contratti_fornitori` ON `ac_categorie_contratti_fornitori`.`id` = `ac_contratti_fornitori`.`id_categoria` WHERE 1=1 HAVING 2=2', + '', + 'fa fa-file-text-o', + '2.12', + '2.12', + 30, + (SELECT `id` FROM `zz_modules` AS `parent_module` WHERE `name` = 'Acquisti'), + 1, + 1, + 1, + 0, + 'contratti_fornitori' +); + +SET @id_modulo_cf := LAST_INSERT_ID(); + +INSERT INTO `zz_modules_lang` (`id_lang`, `id_record`, `title`) +SELECT `id`, @id_modulo_cf, + CASE WHEN `id` = 2 THEN 'Supplier contracts' ELSE 'Contratti fornitori' END +FROM `zz_langs`; + +CREATE TABLE `ac_stati_contratti_fornitori` ( + `id` int NOT NULL AUTO_INCREMENT, + `nome` varchar(100) NOT NULL, + `colore` varchar(20) NOT NULL DEFAULT '#6c757d', + `ordine` int NOT NULL DEFAULT 0, + `enabled` tinyint(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + UNIQUE KEY `nome` (`nome`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `ac_categorie_contratti_fornitori` ( + `id` int NOT NULL AUTO_INCREMENT, + `nome` varchar(100) NOT NULL, + `enabled` tinyint(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + UNIQUE KEY `nome` (`nome`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `ac_contratti_fornitori` ( + `id` int NOT NULL AUTO_INCREMENT, + `numero` varchar(50) NOT NULL, + `id_segment` int NOT NULL, + `id_fornitore` int NOT NULL, + `id_referente` int DEFAULT NULL, + `idagente` int DEFAULT NULL, + `id_stato` int NOT NULL, + `id_categoria` int DEFAULT NULL, + `nome` varchar(255) NOT NULL, + `numero_fornitore` varchar(100) DEFAULT NULL, + `data_stipula` date DEFAULT NULL, + `data_inizio` date NOT NULL, + `validita` int DEFAULT NULL, + `tipo_validita` varchar(20) DEFAULT NULL, + `data_scadenza` date DEFAULT NULL, + `giorni_preavviso` int NOT NULL DEFAULT 0, + `data_limite_disdetta` date DEFAULT NULL, + `rinnovo_automatico` tinyint(1) NOT NULL DEFAULT 0, + `mesi_rinnovo` int NOT NULL DEFAULT 0, + `condizioni_rinnovo` varchar(255) DEFAULT NULL, + `importo` decimal(15,2) NOT NULL DEFAULT 0.00, + `periodicita` varchar(30) DEFAULT NULL, + `note_economiche` varchar(255) DEFAULT NULL, + `note` text NOT NULL, + `id_contratto_precedente` int DEFAULT NULL, + `id_contratto_successivo` int DEFAULT NULL, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `numero_segmento` (`id_segment`, `numero`), + KEY `id_fornitore` (`id_fornitore`), + KEY `id_stato` (`id_stato`), + KEY `id_categoria` (`id_categoria`), + KEY `data_scadenza` (`data_scadenza`), + KEY `data_limite_disdetta` (`data_limite_disdetta`), + KEY `rinnovo_automatico` (`rinnovo_automatico`), + KEY `id_contratto_precedente` (`id_contratto_precedente`), + KEY `id_contratto_successivo` (`id_contratto_successivo`), + CONSTRAINT `ac_contratti_fornitori_ibfk_1` FOREIGN KEY (`id_segment`) REFERENCES `zz_segments` (`id`) ON DELETE RESTRICT, + CONSTRAINT `ac_contratti_fornitori_ibfk_2` FOREIGN KEY (`id_fornitore`) REFERENCES `an_anagrafiche` (`idanagrafica`) ON DELETE RESTRICT, + CONSTRAINT `ac_contratti_fornitori_ibfk_3` FOREIGN KEY (`id_referente`) REFERENCES `an_referenti` (`id`) ON DELETE SET NULL, + CONSTRAINT `ac_contratti_fornitori_ibfk_4` FOREIGN KEY (`idagente`) REFERENCES `an_anagrafiche` (`idanagrafica`) ON DELETE SET NULL, + CONSTRAINT `ac_contratti_fornitori_ibfk_5` FOREIGN KEY (`id_stato`) REFERENCES `ac_stati_contratti_fornitori` (`id`) ON DELETE RESTRICT, + CONSTRAINT `ac_contratti_fornitori_ibfk_6` FOREIGN KEY (`id_categoria`) REFERENCES `ac_categorie_contratti_fornitori` (`id`) ON DELETE SET NULL, + CONSTRAINT `ac_contratti_fornitori_ibfk_7` FOREIGN KEY (`id_contratto_precedente`) REFERENCES `ac_contratti_fornitori` (`id`) ON DELETE SET NULL, + CONSTRAINT `ac_contratti_fornitori_ibfk_8` FOREIGN KEY (`id_contratto_successivo`) REFERENCES `ac_contratti_fornitori` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO `ac_stati_contratti_fornitori` (`nome`, `colore`, `ordine`, `enabled`) VALUES +('Bozza', '#6c757d', 10, 1), +('Attivo', '#28a745', 20, 1), +('In scadenza', '#f39c12', 30, 0), +('Disdetto', '#dc3545', 40, 1), +('Terminato', '#343a40', 50, 1); + +INSERT INTO `ac_categorie_contratti_fornitori` (`nome`, `enabled`) VALUES +('Telefonia', 1), ('Cloud', 1), ('Software', 1), ('Assicurazioni', 1), +('Leasing', 1), ('Energia', 1), ('Consulenza', 1), ('Manutenzione', 1), +('Noleggio', 1), ('Licenze', 1), ('Altro', 1); + +INSERT INTO `zz_segments` +(`id_module`, `name`, `clause`, `position`, `pattern`, `note`, `dicitura_fissa`, `predefined`, `predefined_accredito`, `predefined_addebito`, `autofatture`, `for_fe`, `is_sezionale`, `created_at`, `updated_at`, `is_fiscale`) +VALUES +(@id_modulo_cf, 'Contratti fornitori', '1=1', 'WHR', '####/yy', '', '', 1, 0, 0, 0, 0, 1, NOW(), NOW(), 0); + +SET @id_segment_cf := LAST_INSERT_ID(); + +INSERT INTO `zz_group_module` (`idgruppo`, `idmodule`) +SELECT `id`, @id_modulo_cf FROM `zz_groups` WHERE `nome` = 'Amministratori'; + +INSERT INTO `zz_group_segment` (`id_gruppo`, `id_segment`) +SELECT `id`, @id_segment_cf FROM `zz_groups` WHERE `nome` = 'Amministratori'; + +INSERT INTO `zz_views` +(`id_module`, `name`, `query`, `order`, `search`, `slow`, `format`, `html_format`, `visible`, `summable`, `avg`, `default`) +VALUES +(@id_modulo_cf, 'Numero', '`ac_contratti_fornitori`.`numero`', 1, 1, 0, 0, 0, 1, 0, 0, 1), +(@id_modulo_cf, 'Fornitore', '`an_anagrafiche`.`ragione_sociale`', 2, 1, 0, 0, 0, 1, 0, 0, 0), +(@id_modulo_cf, 'Contratto', '`ac_contratti_fornitori`.`nome`', 3, 1, 0, 0, 0, 1, 0, 0, 0), +(@id_modulo_cf, 'Categoria', '`ac_categorie_contratti_fornitori`.`nome`', 4, 1, 0, 0, 0, 1, 0, 0, 0), +(@id_modulo_cf, 'Stato', 'CASE WHEN `ac_stati_contratti_fornitori`.`nome` = ''Attivo'' AND `ac_contratti_fornitori`.`data_scadenza` < CURDATE() THEN ''Scaduto'' WHEN `ac_stati_contratti_fornitori`.`nome` = ''Attivo'' AND `ac_contratti_fornitori`.`data_scadenza` BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 60 DAY) THEN ''In scadenza'' ELSE CONCAT('''', `ac_stati_contratti_fornitori`.`nome`, '''') END', 5, 1, 0, 0, 1, 1, 0, 0, 0), +(@id_modulo_cf, 'Data inizio', '`ac_contratti_fornitori`.`data_inizio`', 6, 1, 1, 0, 0, 1, 0, 0, 0), +(@id_modulo_cf, 'Data scadenza', '`ac_contratti_fornitori`.`data_scadenza`', 7, 1, 1, 0, 0, 1, 0, 0, 0), +(@id_modulo_cf, 'Termine disdetta', '`ac_contratti_fornitori`.`data_limite_disdetta`', 8, 1, 1, 0, 0, 1, 0, 0, 0), +(@id_modulo_cf, 'Rinnovo automatico', 'IF(`ac_contratti_fornitori`.`rinnovo_automatico` = 1, ''Sì'', ''No'')', 9, 1, 0, 0, 0, 1, 0, 0, 0), +(@id_modulo_cf, 'Importo', '`ac_contratti_fornitori`.`importo`', 10, 1, 0, 1, 0, 1, 1, 0, 0), +(@id_modulo_cf, 'Note operative', 'LEFT(`ac_contratti_fornitori`.`note`, 100)', 11, 1, 0, 0, 0, 1, 0, 0, 0), +(@id_modulo_cf, 'id', '`ac_contratti_fornitori`.`id`', 99, 0, 0, 0, 0, 0, 0, 0, 0); + +INSERT INTO `zz_views_lang` (`id_lang`, `id_record`, `title`) +SELECT `zz_langs`.`id`, `zz_views`.`id`, `zz_views`.`name` +FROM `zz_views` +CROSS JOIN `zz_langs` +WHERE `zz_views`.`id_module` = @id_modulo_cf; + +INSERT INTO `zz_group_view` (`id_gruppo`, `id_vista`) +SELECT `zz_groups`.`id`, `zz_views`.`id` +FROM `zz_groups` +CROSS JOIN `zz_views` +WHERE `zz_groups`.`nome` = 'Amministratori' AND `zz_views`.`id_module` = @id_modulo_cf; + +INSERT INTO `zz_widgets` +(`name`, `type`, `id_module`, `location`, `class`, `query`, `bgcolor`, `icon`, `print_link`, `more_link`, `more_link_type`, `php_include`, `enabled`, `order`, `help`) +VALUES +('CF - Disdette entro 30 giorni', 'stats', @id_modulo_cf, 'controller_top', 'col-md-3', 'SELECT COUNT(*) AS dato FROM `ac_contratti_fornitori` c INNER JOIN `ac_stati_contratti_fornitori` s ON s.`id` = c.`id_stato` WHERE s.`nome` = ''Attivo'' AND c.`data_limite_disdetta` BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 30 DAY)', 'yellow', 'fa fa-bell', '', '', 'link', '', 1, 1, 'Contratti attivi con termine utile di disdetta entro 30 giorni.'), +('CF - In scadenza entro 60 giorni', 'stats', @id_modulo_cf, 'controller_top', 'col-md-3', 'SELECT COUNT(*) AS dato FROM `ac_contratti_fornitori` c INNER JOIN `ac_stati_contratti_fornitori` s ON s.`id` = c.`id_stato` WHERE s.`nome` = ''Attivo'' AND c.`data_scadenza` BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 60 DAY)', 'orange', 'fa fa-calendar-times-o', '', '', 'link', '', 1, 2, 'Contratti ancora attivi con scadenza entro 60 giorni.'), +('CF - Scaduti ancora attivi', 'stats', @id_modulo_cf, 'controller_top', 'col-md-3', 'SELECT COUNT(*) AS dato FROM `ac_contratti_fornitori` c INNER JOIN `ac_stati_contratti_fornitori` s ON s.`id` = c.`id_stato` WHERE s.`nome` = ''Attivo'' AND c.`data_scadenza` < CURDATE()', 'red', 'fa fa-exclamation-triangle', '', '', 'link', '', 1, 3, 'Contratti ancora Attivi con data di scadenza già superata.'), +('CF - Rinnovi automatici entro 60 giorni', 'stats', @id_modulo_cf, 'controller_top', 'col-md-3', 'SELECT COUNT(*) AS dato FROM `ac_contratti_fornitori` c INNER JOIN `ac_stati_contratti_fornitori` s ON s.`id` = c.`id_stato` WHERE s.`nome` = ''Attivo'' AND c.`rinnovo_automatico` = 1 AND c.`data_scadenza` BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 60 DAY)', 'green', 'fa fa-repeat', '', '', 'link', '', 1, 4, 'Contratti con rinnovo automatico e scadenza entro 60 giorni.'); + +INSERT INTO `zz_widgets_lang` (`id_lang`, `id_record`, `title`, `text`) +SELECT `zz_langs`.`id`, `zz_widgets`.`id`, + REPLACE(`zz_widgets`.`name`, 'CF - ', ''), + REPLACE(`zz_widgets`.`name`, 'CF - ', '') +FROM `zz_widgets` +CROSS JOIN `zz_langs` +WHERE `zz_widgets`.`id_module` = @id_modulo_cf; From 9e356fab210c023f976ea343e8cf9373c7ed0dbb Mon Sep 17 00:00:00 2001 From: Sajo <75281007+sajotrei@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:02:11 +0200 Subject: [PATCH 2/4] Documenta autore del modulo Contratti fornitori --- modules/contratti_fornitori/init.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/contratti_fornitori/init.php b/modules/contratti_fornitori/init.php index ed8b6ef9b..839c92b69 100644 --- a/modules/contratti_fornitori/init.php +++ b/modules/contratti_fornitori/init.php @@ -18,6 +18,13 @@ * along with this program. If not, see . */ +/** + * Modulo Contratti fornitori. + * + * @author sajotrei + * @link https://github.com/sajotrei + */ + include_once __DIR__.'/../../core.php'; if (!empty($id_record)) { From 7f06de1de5caff98f1c4817770a1839abcab328a Mon Sep 17 00:00:00 2001 From: Sajo <75281007+sajotrei@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:28:09 +0200 Subject: [PATCH 3/4] Allinea rinnovo Contratti fornitori --- modules/contratti_fornitori/buttons.php | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/modules/contratti_fornitori/buttons.php b/modules/contratti_fornitori/buttons.php index b64fed9ff..0b8ffa306 100644 --- a/modules/contratti_fornitori/buttons.php +++ b/modules/contratti_fornitori/buttons.php @@ -8,23 +8,19 @@ * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . */ include_once __DIR__.'/../../core.php'; +require_once __DIR__.'/src/ContrattoFornitoreService.php'; + +use Modules\ContrattiFornitori\ContrattoFornitoreService; if (!empty($id_record)) { $manualWithoutDuration = ($record['tipo_validita'] ?? null) === 'manual' && empty($record['mesi_rinnovo']); - $canRenew = !empty($record['data_scadenza']) && empty($record['id_contratto_successivo']) && !$manualWithoutDuration; + $isActive = ($record['stato_nome'] ?? null) === ContrattoFornitoreService::STATO_ATTIVO; + $canRenew = $isActive && !empty($record['data_scadenza']) && empty($record['id_contratto_successivo']) && !$manualWithoutDuration; - echo '
'; + echo '
'; echo ''; echo '
'; From 4c13445a41ccb81417dc11b264bb8df989ded7b0 Mon Sep 17 00:00:00 2001 From: Sajo <75281007+sajotrei@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:32:15 +0200 Subject: [PATCH 4/4] Allinea interfaccia Contratti fornitori --- modules/contratti_fornitori/edit.php | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/modules/contratti_fornitori/edit.php b/modules/contratti_fornitori/edit.php index 7908ff126..c6e7774bc 100644 --- a/modules/contratti_fornitori/edit.php +++ b/modules/contratti_fornitori/edit.php @@ -8,14 +8,6 @@ * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . */ include_once __DIR__.'/../../core.php'; @@ -106,7 +98,7 @@
'.tr('Disdetta').'
'.tr('Il termine viene calcolato sottraendo il preavviso dalla data di scadenza.').'
{[ "type":"date","label":"'.tr('Termine ultimo per la disdetta').'","name":"data_limite_disdetta","value":"$data_limite_disdetta$","disabled":1,"help":"'.tr('Data entro cui inviare la comunicazione di disdetta.').'" ]}
'.tr('Rinnovo').'
'.tr('Configura l’eventuale rinnovo automatico e le relative condizioni.').'
{[ "type":"checkbox","label":"'.tr('Rinnovo automatico').'","name":"rinnovo_automatico","value":"$rinnovo_automatico$","help":"'.tr('Indica che il contratto si rinnova automaticamente alla scadenza.').'" ]}
{[ "type":"number","label":"'.tr('Durata rinnovo').'","name":"mesi_rinnovo","decimals":0,"value":"$mesi_rinnovo$","icon-after":"'.tr('mesi').'","help":"'.tr('Durata del nuovo periodo contrattuale.').'" ]}
{[ "type":"text","label":"'.tr('Condizioni di rinnovo').'","name":"condizioni_rinnovo","value":"$condizioni_rinnovo$","help":"'.tr('Eventuali variazioni di prezzo, durata o condizioni.').'" ]}
-

'.tr('Informazioni economiche indicative').'

{[ "type":"number","label":"'.tr('Importo indicativo').'","name":"importo","decimals":2,"value":"$importo$","icon-after":"€" ]}
{[ "type":"select","label":"'.tr('Periodicità').'","name":"periodicita","values":"list=\"mensile\":\"Mensile\",\"trimestrale\":\"Trimestrale\",\"semestrale\":\"Semestrale\",\"annuale\":\"Annuale\",\"una_tantum\":\"Una tantum\",\"variabile\":\"Variabile\"","value":"$periodicita$" ]}
{[ "type":"text","label":"'.tr('Note economiche').'","name":"note_economiche","value":"$note_economiche$" ]}
+

'.tr('Informazioni economiche indicative').'

{[ "type":"number","label":"'.tr('Importo indicativo').'","name":"importo","decimals":2,"value":"$importo$","icon-after":"€" ]}
{[ "type":"select","label":"'.tr('Periodicità').'","name":"periodicita","values":"list=\"mensile\":\"Mensile\",\"bimestrale\":\"Bimestrale\",\"trimestrale\":\"Trimestrale\",\"semestrale\":\"Semestrale\",\"annuale\":\"Annuale\",\"una_tantum\":\"Una tantum\",\"variabile\":\"Variabile\"","value":"$periodicita$" ]}
{[ "type":"text","label":"'.tr('Note economiche').'","name":"note_economiche","value":"$note_economiche$" ]}

'.tr('Note').'

{[ "type":"textarea","label":"'.tr('Note operative').'","name":"note","value":"$note$","rows":6 ]}
{( "name": "filelist_and_upload", "id": "'.random_int(1, 999).'", "id_record": "'.$id_record.'", "id_module": "'.$id_module.'", "id_plugin": "" )} @@ -184,4 +176,4 @@ function updateRenewalFields() { return false; }); }); - + \ No newline at end of file