+{[ "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" ]}
+
';
+?>
+
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.').'" ]}