Roberto Santini 3 周前
父节点
当前提交
a9cc1edf4f

+ 5
- 0
.env.example 查看文件

@@ -109,4 +109,9 @@ SKEBBY_PASSWORD=
109 109
 # SKEBBY_ACCESS_TOKEN=
110 110
 SKEBBY_SENDER=
111 111
 SKEBBY_MESSAGE_TYPE=GP
112
+
113
+# Fatture in Cloud API v2 (in alternativa: Configurazione > Fatture in Cloud)
114
+# Token manuale con scope entity.clients:r e entity.clients:a
115
+# FATTUREINCLOUD_COMPANY_ID=
116
+# FATTUREINCLOUD_ACCESS_TOKEN=
112 117
 # SKEBBY_BASE_URL=https://api.skebby.it/API/v1.0/REST

+ 10
- 1
app/DataTables/AziendaDataTable.php 查看文件

@@ -33,6 +33,14 @@ class AziendaDataTable extends DataTable
33 33
             ->addColumn('utenti_conta', function ($entity) {
34 34
                 return $entity->utenti()->soloUtenti()->count();
35 35
             })
36
+            ->addColumn('fic_label', function ($entity) {
37
+                if ($entity->ficCollegamento) {
38
+                    return '<span class="badge bg-label-success">Collegato</span>';
39
+                }
40
+
41
+                return '<span class="text-muted">—</span>';
42
+            })
43
+            ->rawColumns(['fic_label', 'action'])
36 44
             ->setRowId('id');
37 45
     }
38 46
 
@@ -41,7 +49,7 @@ class AziendaDataTable extends DataTable
41 49
      */
42 50
     public function query(Azienda $model): QueryBuilder
43 51
     {
44
-        return $model->newQuery();
52
+        return $model->newQuery()->with('ficCollegamento');
45 53
     }
46 54
 
47 55
     public function html(): HtmlBuilder
@@ -73,6 +81,7 @@ class AziendaDataTable extends DataTable
73 81
             Column::make('piva')->title('P. IVA')->responsivePriority(3),
74 82
             Column::make('email')->title('Email')->responsivePriority(2),
75 83
             Column::make('telefono')->title('Telefono'),
84
+            Column::computed('fic_label')->title('FiC')->searchable(false)->orderable(false),
76 85
             Column::make('utenti_conta')->title('Utenti')->attributes(['class' => 'text-center'])->searchable(false)->orderable(false),
77 86
             Column::computed('action')
78 87
                 ->exportable(false)

+ 256
- 0
app/Http/Controllers/AziendaFicController.php 查看文件

@@ -0,0 +1,256 @@
1
+<?php
2
+
3
+namespace App\Http\Controllers;
4
+
5
+use App\Models\Azienda;
6
+use App\Models\Config;
7
+use App\Models\FicCollegamento;
8
+use App\Services\FattureInCloudClient;
9
+use Illuminate\Http\Request;
10
+use Illuminate\Routing\Controllers\HasMiddleware;
11
+use Illuminate\Routing\Controllers\Middleware;
12
+use Illuminate\Validation\ValidationException;
13
+use RuntimeException;
14
+
15
+class AziendaFicController extends Controller implements HasMiddleware
16
+{
17
+    public static function middleware(): array
18
+    {
19
+        return [
20
+            new Middleware('permission:edit-azienda'),
21
+        ];
22
+    }
23
+
24
+    public function show($id, FattureInCloudClient $fic)
25
+    {
26
+        $azienda = $this->azienda($id);
27
+        Config::syncDefinitions();
28
+
29
+        $payload = [
30
+            'configured' => $fic->configured(),
31
+            'azienda' => [
32
+                'id' => $azienda->id,
33
+                'label' => $azienda->full_name,
34
+            ],
35
+            'collegamento' => null,
36
+            'confronto' => null,
37
+            'message' => $fic->configured()
38
+                ? null
39
+                : 'Inserisci Company ID e access token in Configurazione → Fatture in Cloud. Il token deve avere gli scope entity.clients:r e entity.clients:a.',
40
+        ];
41
+
42
+        if ($fic->configured() && $azienda->ficCollegamento) {
43
+            try {
44
+                $client = $fic->getClient((int) $azienda->ficCollegamento->fic_client_id);
45
+                $payload['collegamento'] = [
46
+                    'id' => (int) $client['id'],
47
+                    'text' => $fic->clientLabel($client),
48
+                ];
49
+                $payload['confronto'] = $fic->confronto($azienda, $client);
50
+            } catch (RuntimeException $e) {
51
+                $payload['error'] = $e->getMessage();
52
+                $payload['collegamento'] = [
53
+                    'id' => (int) $azienda->ficCollegamento->fic_client_id,
54
+                    'text' => 'Cliente #'.$azienda->ficCollegamento->fic_client_id,
55
+                ];
56
+            }
57
+        }
58
+
59
+        return response()->json($payload);
60
+    }
61
+
62
+    public function clienti(Request $request, FattureInCloudClient $fic)
63
+    {
64
+        $request->validate([
65
+            'term' => 'nullable|string|max:100',
66
+            'q' => 'nullable|string|max:100',
67
+            'page' => 'nullable|integer|min:1',
68
+        ]);
69
+
70
+        try {
71
+            $term = (string) ($request->input('term') ?: $request->input('q') ?: '');
72
+            $found = $fic->searchClients($term, (int) $request->input('page', 1));
73
+        } catch (RuntimeException $e) {
74
+            return response()->json(['message' => $e->getMessage()], 422);
75
+        }
76
+
77
+        return response()->json([
78
+            'results' => $found['results'],
79
+            'pagination' => ['more' => $found['more']],
80
+        ]);
81
+    }
82
+
83
+    public function confronto($id, Request $request, FattureInCloudClient $fic)
84
+    {
85
+        $azienda = $this->azienda($id);
86
+        $validated = $request->validate([
87
+            'fic_client_id' => 'required|integer|min:1',
88
+        ]);
89
+
90
+        $this->assertClientFree((int) $validated['fic_client_id'], $azienda->id);
91
+
92
+        try {
93
+            $client = $fic->getClient((int) $validated['fic_client_id']);
94
+        } catch (RuntimeException $e) {
95
+            return response()->json(['message' => $e->getMessage()], 422);
96
+        }
97
+
98
+        return response()->json([
99
+            'fic_client_id' => (int) $client['id'],
100
+            'remote_label' => $fic->clientLabel($client),
101
+            'rows' => $fic->confronto($azienda, $client),
102
+        ]);
103
+    }
104
+
105
+    public function applica($id, Request $request, FattureInCloudClient $fic)
106
+    {
107
+        $azienda = $this->azienda($id);
108
+        $validated = $request->validate([
109
+            'crea' => 'nullable|boolean',
110
+            'fic_client_id' => 'required_without:crea|nullable|integer|min:1',
111
+            'scelte' => 'nullable|array',
112
+            'scelte.*' => 'in:local,remote',
113
+        ]);
114
+
115
+        try {
116
+            if ($request->boolean('crea')) {
117
+                $client = $this->creaRemoto($azienda, $fic);
118
+            } else {
119
+                $client = $this->allinea($azienda, $fic, (int) $validated['fic_client_id'], $validated['scelte'] ?? []);
120
+            }
121
+        } catch (ValidationException $e) {
122
+            throw $e;
123
+        } catch (RuntimeException $e) {
124
+            return response()->json(['message' => $e->getMessage()], 422);
125
+        }
126
+
127
+        return response()->json([
128
+            'success' => true,
129
+            'message' => $request->boolean('crea')
130
+                ? 'Cliente creato su Fatture in Cloud e collegato.'
131
+                : 'Anagrafica allineata.',
132
+            'fic_client_id' => (int) ($client['id'] ?? 0),
133
+            'remote_label' => $fic->clientLabel($client),
134
+        ]);
135
+    }
136
+
137
+    public function scollega($id)
138
+    {
139
+        $azienda = $this->azienda($id);
140
+        $azienda->ficCollegamento()?->delete();
141
+
142
+        return response()->json([
143
+            'success' => true,
144
+            'message' => 'Collegamento rimosso. Il cliente su Fatture in Cloud non è stato cancellato.',
145
+        ]);
146
+    }
147
+
148
+    /**
149
+     * @param  array<string, string>  $scelte
150
+     * @return array<string, mixed>
151
+     */
152
+    private function allinea(Azienda $azienda, FattureInCloudClient $fic, int $ficClientId, array $scelte): array
153
+    {
154
+        $this->assertClientFree($ficClientId, $azienda->id);
155
+        $client = $fic->getClient($ficClientId);
156
+        $rows = $fic->confronto($azienda, $client);
157
+        $remote = $fic->remoteValues($client);
158
+
159
+        $missing = [];
160
+        foreach ($rows as $row) {
161
+            if (! $row['equal'] && ! isset($scelte[$row['key']])) {
162
+                $missing[] = $row['label'];
163
+            }
164
+        }
165
+        if ($missing) {
166
+            throw ValidationException::withMessages([
167
+                'scelte' => 'Scegli quale valore tenere per: '.implode(', ', $missing).'.',
168
+            ]);
169
+        }
170
+
171
+        $pushLocal = [];
172
+        $errors = [];
173
+        foreach ($rows as $row) {
174
+            if ($row['equal']) {
175
+                continue;
176
+            }
177
+            if ($scelte[$row['key']] === 'local') {
178
+                $pushLocal[] = $row['key'];
179
+                continue;
180
+            }
181
+            $error = $fic->applyRemoteToAzienda($azienda, $row['key'], $remote[$row['key']]['raw']);
182
+            if ($error) {
183
+                $errors[$row['key']] = $error;
184
+            }
185
+        }
186
+        if ($errors) {
187
+            throw ValidationException::withMessages($errors);
188
+        }
189
+
190
+        $updated = $fic->updateClient($ficClientId, $fic->payloadFromLocal($azienda, $pushLocal));
191
+        $azienda->save();
192
+        $this->collega($azienda, (int) ($updated['id'] ?? $ficClientId));
193
+
194
+        return $updated;
195
+    }
196
+
197
+    /**
198
+     * @return array<string, mixed>
199
+     */
200
+    private function creaRemoto(Azienda $azienda, FattureInCloudClient $fic): array
201
+    {
202
+        $local = $fic->localValues($azienda);
203
+        $name = trim((string) ($local['name']['raw']['value'] ?? ''));
204
+        if ($name === '') {
205
+            throw ValidationException::withMessages([
206
+                'name' => 'Serve una ragione sociale per creare il cliente su Fatture in Cloud.',
207
+            ]);
208
+        }
209
+
210
+        $data = array_filter(
211
+            $fic->payloadFromLocal($azienda),
212
+            static fn ($value) => $value !== null && $value !== ''
213
+        );
214
+        $data['type'] = 'company';
215
+        $data['name'] = $name;
216
+
217
+        $created = $fic->createClient($data);
218
+        $this->collega($azienda, (int) $created['id']);
219
+
220
+        return $created;
221
+    }
222
+
223
+    private function collega(Azienda $azienda, int $ficClientId): void
224
+    {
225
+        $this->assertClientFree($ficClientId, $azienda->id);
226
+        FicCollegamento::query()->updateOrCreate(
227
+            ['user_id' => $azienda->id],
228
+            ['fic_client_id' => $ficClientId]
229
+        );
230
+    }
231
+
232
+    private function assertClientFree(int $ficClientId, int $userId): void
233
+    {
234
+        $taken = FicCollegamento::query()
235
+            ->where('fic_client_id', $ficClientId)
236
+            ->where('user_id', '!=', $userId)
237
+            ->first();
238
+        if (! $taken) {
239
+            return;
240
+        }
241
+
242
+        $other = Azienda::query()->find($taken->user_id);
243
+        $label = $other?->full_name ?: ('#'.$taken->user_id);
244
+
245
+        throw ValidationException::withMessages([
246
+            'fic_client_id' => 'Questo cliente Fatture in Cloud è già collegato a '.$label.'.',
247
+        ]);
248
+    }
249
+
250
+    private function azienda($id): Azienda
251
+    {
252
+        return Azienda::query()
253
+            ->with(['comune.provincia', 'ficCollegamento'])
254
+            ->findOrFail($id);
255
+    }
256
+}

+ 4
- 0
app/Http/Controllers/ConfigController.php 查看文件

@@ -90,6 +90,10 @@ class ConfigController extends Controller implements HasMiddleware
90 90
           continue;
91 91
         }
92 92
 
93
+        if ($config->type === 'password' && trim((string) ($values[$key] ?? '')) === '') {
94
+          continue;
95
+        }
96
+
93 97
         $config->value = $this->normalizeValue($config->type, $values[$key] ?? null);
94 98
         $config->save();
95 99
       }

+ 131
- 2
app/Http/Controllers/FatturazioneController.php 查看文件

@@ -5,12 +5,15 @@ namespace App\Http\Controllers;
5 5
 use App\DataTables\FatturaDataTable;
6 6
 use App\Models\Config;
7 7
 use App\Models\Fattura;
8
+use App\Models\FatturaRiga;
8 9
 use App\Services\FatturazioneGeneraService;
9 10
 use Carbon\Carbon;
10 11
 use Illuminate\Http\Request;
11 12
 use Illuminate\Routing\Controllers\HasMiddleware;
12 13
 use Illuminate\Routing\Controllers\Middleware;
13 14
 use Illuminate\Support\Facades\Auth;
15
+use Illuminate\Support\Facades\DB;
16
+use Illuminate\Validation\Rule;
14 17
 use RuntimeException;
15 18
 
16 19
 class FatturazioneController extends Controller implements HasMiddleware
@@ -31,7 +34,7 @@ class FatturazioneController extends Controller implements HasMiddleware
31 34
       new Middleware('permission:view-fatturazione', only: ['admin_index', 'admin_genera', 'admin_show']),
32 35
       new Middleware('permission:genera-fatturazione', only: ['admin_genera']),
33 36
       new Middleware('permission:create-fattura', only: ['admin_crea']),
34
-      new Middleware('permission:edit-fattura', only: ['admin_emetti', 'admin_annulla']),
37
+      new Middleware('permission:edit-fattura', only: ['admin_update', 'admin_emetti', 'admin_annulla']),
35 38
       new Middleware('permission:delete-fattura', only: ['admin_destroy']),
36 39
     ];
37 40
   }
@@ -90,11 +93,137 @@ class FatturazioneController extends Controller implements HasMiddleware
90 93
 
91 94
   public function admin_show($id)
92 95
   {
93
-    $fattura = Fattura::with(['user', 'createdBy', 'comune', 'righe'])->findOrFail($id);
96
+    $fattura = Fattura::with([
97
+      'user',
98
+      'createdBy',
99
+      'comune.provincia',
100
+      'righe' => fn ($q) => $q->orderBy('ordine')->orderBy('id'),
101
+    ])->findOrFail($id);
94 102
 
95 103
     return view('fatturazione.show', compact('fattura'));
96 104
   }
97 105
 
106
+  public function admin_update(Request $request, $id)
107
+  {
108
+    $fattura = Fattura::findOrFail($id);
109
+
110
+    $validated = $request->validate([
111
+      'numero' => 'nullable|integer|min:1',
112
+      'stato' => ['required', Rule::in(array_keys(Fattura::STATI))],
113
+      'data' => 'nullable|date',
114
+      'data_scadenza' => 'nullable|date',
115
+      'periodo_da' => 'nullable|date',
116
+      'periodo_a' => 'nullable|date',
117
+      'ragione_sociale' => 'nullable|string|max:255',
118
+      'piva' => 'nullable|string|max:255',
119
+      'cod_fiscale' => 'nullable|string|max:255',
120
+      'indirizzo' => 'nullable|string|max:255',
121
+      'email' => 'nullable|email|max:255',
122
+      'telefono' => 'nullable|string|max:255',
123
+      'comune_id' => 'nullable|exists:comune,id',
124
+      'note' => 'nullable|string',
125
+      'iva_percentuale' => 'nullable|numeric|min:0|max:100',
126
+      'imponibile' => 'nullable|numeric',
127
+      'iva' => 'nullable|numeric',
128
+      'totale' => 'nullable|numeric',
129
+      'righe' => 'nullable|array',
130
+      'righe.*.id' => 'nullable|integer',
131
+      'righe.*.tipo' => ['nullable', Rule::in(array_keys(FatturaRiga::TIPI))],
132
+      'righe.*.descrizione' => 'nullable|string|max:255',
133
+      'righe.*.quantita' => 'nullable|numeric',
134
+      'righe.*.unita_misura' => 'nullable|string|max:255',
135
+      'righe.*.prezzo_unitario' => 'nullable|numeric',
136
+      'righe.*.sconto' => 'nullable|numeric|min:0|max:100',
137
+      'righe.*.importo' => 'nullable|numeric',
138
+      'righe.*.minuti' => 'nullable|integer|min:0',
139
+      'righe.*.nota' => 'nullable|string',
140
+    ]);
141
+
142
+    DB::transaction(function () use ($fattura, $validated) {
143
+      $fattura->numero = $validated['numero'] ?? null;
144
+      $fattura->stato = $validated['stato'];
145
+      $fattura->data = $validated['data'] ?? null;
146
+      $fattura->data_scadenza = $validated['data_scadenza'] ?? null;
147
+      $fattura->periodo_da = $validated['periodo_da'] ?? null;
148
+      $fattura->periodo_a = $validated['periodo_a'] ?? null;
149
+      $fattura->ragione_sociale = $validated['ragione_sociale'] ?? null;
150
+      $fattura->piva = $validated['piva'] ?? null;
151
+      $fattura->cod_fiscale = $validated['cod_fiscale'] ?? null;
152
+      $fattura->indirizzo = $validated['indirizzo'] ?? null;
153
+      $fattura->email = $validated['email'] ?? null;
154
+      $fattura->telefono = $validated['telefono'] ?? null;
155
+      $fattura->comune_id = $validated['comune_id'] ?? null;
156
+      $fattura->note = $validated['note'] ?? null;
157
+      $fattura->iva_percentuale = $validated['iva_percentuale'] ?? null;
158
+      $fattura->imponibile = $validated['imponibile'] ?? null;
159
+      $fattura->iva = $validated['iva'] ?? null;
160
+      $fattura->totale = $validated['totale'] ?? null;
161
+      $fattura->save();
162
+
163
+      $keptIds = [];
164
+      $ordine = 0;
165
+      foreach ($validated['righe'] ?? [] as $row) {
166
+        $existing = null;
167
+        if (! empty($row['id'])) {
168
+          $existing = FatturaRiga::query()
169
+            ->where('fattura_id', $fattura->id)
170
+            ->where('id', $row['id'])
171
+            ->first();
172
+        }
173
+
174
+        $hasTesto = trim((string) ($row['descrizione'] ?? '')) !== ''
175
+          || trim((string) ($row['nota'] ?? '')) !== '';
176
+        $hasImporto = isset($row['importo']) && (float) $row['importo'] != 0.0;
177
+        $hasPrezzo = isset($row['prezzo_unitario']) && (float) $row['prezzo_unitario'] != 0.0;
178
+        $blank = ! $hasTesto && ! $hasImporto && ! $hasPrezzo;
179
+        if (! $existing && $blank) {
180
+          continue;
181
+        }
182
+
183
+        $qty = (float) ($row['quantita'] ?? 0);
184
+        $prezzo = (float) ($row['prezzo_unitario'] ?? 0);
185
+        $sconto = (float) ($row['sconto'] ?? 0);
186
+        $importo = $row['importo'] ?? null;
187
+        if ($importo === null || $importo === '') {
188
+          $importo = round($qty * $prezzo * (1 - min(100, max(0, $sconto)) / 100), 2);
189
+        }
190
+
191
+        $payload = [
192
+          'tipo' => $row['tipo'] ?: ($existing->tipo ?? FatturaRiga::TIPO_MANUALE),
193
+          'descrizione' => $row['descrizione'] ?? null,
194
+          'quantita' => $row['quantita'] ?? null,
195
+          'unita_misura' => $row['unita_misura'] ?? null,
196
+          'prezzo_unitario' => $row['prezzo_unitario'] ?? null,
197
+          'sconto' => $row['sconto'] ?? 0,
198
+          'iva_percentuale' => $fattura->iva_percentuale,
199
+          'importo' => $importo,
200
+          'minuti' => $row['minuti'] ?? null,
201
+          'nota' => $row['nota'] ?? null,
202
+          'ordine' => ++$ordine,
203
+        ];
204
+
205
+        if ($existing) {
206
+          $existing->fill($payload);
207
+          $existing->save();
208
+          $keptIds[] = $existing->id;
209
+        } else {
210
+          $created = FatturaRiga::create($payload + ['fattura_id' => $fattura->id]);
211
+          $keptIds[] = $created->id;
212
+        }
213
+      }
214
+
215
+      $delete = FatturaRiga::query()->where('fattura_id', $fattura->id);
216
+      if ($keptIds) {
217
+        $delete->whereNotIn('id', $keptIds);
218
+      }
219
+      $delete->delete();
220
+    });
221
+
222
+    return redirect()
223
+      ->route('admin.fatturazione.show', $fattura->id)
224
+      ->with('success', 'Fattura aggiornata.');
225
+  }
226
+
98 227
   public function admin_emetti($id)
99 228
   {
100 229
     $fattura = Fattura::findOrFail($id);

+ 5
- 0
app/Models/Azienda.php 查看文件

@@ -18,4 +18,9 @@ class Azienda extends User
18 18
             $model->is_gruppo = false;
19 19
         });
20 20
     }
21
+
22
+    public function ficCollegamento()
23
+    {
24
+        return $this->hasOne(FicCollegamento::class, 'user_id', 'id');
25
+    }
21 26
 }

+ 8
- 0
app/Models/Config.php 查看文件

@@ -30,6 +30,9 @@ class Config extends \App\Models\AbstractModels\AbstractConfig
30 30
   const FATTURAZIONE_COSTO_USCITA = 'FATTURAZIONE_COSTO_USCITA';
31 31
   const FATTURAZIONE_RINNOVI_DA = 'FATTURAZIONE_RINNOVI_DA';
32 32
 
33
+  const FIC_COMPANY_ID = 'FIC_COMPANY_ID';
34
+  const FIC_ACCESS_TOKEN = 'FIC_ACCESS_TOKEN';
35
+
33 36
   public static function getConfig(){
34 37
     $config = [];
35 38
 
@@ -72,6 +75,11 @@ class Config extends \App\Models\AbstractModels\AbstractConfig
72 75
       ['key' => self::FATTURAZIONE_RINNOVI_DA, 'value' => '2026-09-01', 'display_name' => 'Fattura i rinnovi a partire da', 'type' => 'date'],
73 76
     ];
74 77
 
78
+    $config['Fatture in Cloud'] = [
79
+      ['key' => self::FIC_COMPANY_ID, 'value' => null, 'display_name' => 'Company ID', 'type' => 'text'],
80
+      ['key' => self::FIC_ACCESS_TOKEN, 'value' => null, 'display_name' => 'Access token', 'type' => 'password'],
81
+    ];
82
+
75 83
     return $config;
76 84
   }
77 85
 

+ 2
- 0
app/Models/FatturaRiga.php 查看文件

@@ -6,6 +6,7 @@ class FatturaRiga extends \App\Models\AbstractModels\AbstractFatturaRiga
6 6
     public const TIPO_TEMPO = 'tempo';
7 7
     public const TIPO_EXTRA_CONTRATTO = 'extra_contratto';
8 8
     public const TIPO_EXTRA_ORDINE = 'extra_ordine';
9
+    public const TIPO_ORDINE = 'ordine';
9 10
     public const TIPO_SPESA = 'spesa';
10 11
     public const TIPO_USCITA = 'uscita';
11 12
     public const TIPO_CANONE = 'canone';
@@ -15,6 +16,7 @@ class FatturaRiga extends \App\Models\AbstractModels\AbstractFatturaRiga
15 16
         self::TIPO_TEMPO => 'Ore',
16 17
         self::TIPO_EXTRA_CONTRATTO => 'Ore extra contratto',
17 18
         self::TIPO_EXTRA_ORDINE => 'Ore extra ordine',
19
+        self::TIPO_ORDINE => 'Ordine',
18 20
         self::TIPO_SPESA => 'Spesa',
19 21
         self::TIPO_USCITA => 'Costo d\'uscita',
20 22
         self::TIPO_CANONE => 'Canone / rinnovo',

+ 25
- 0
app/Models/FicCollegamento.php 查看文件

@@ -0,0 +1,25 @@
1
+<?php
2
+
3
+namespace App\Models;
4
+
5
+use Illuminate\Database\Eloquent\Model;
6
+
7
+class FicCollegamento extends Model
8
+{
9
+    protected $table = 'fic_collegamento';
10
+
11
+    protected $fillable = [
12
+        'user_id',
13
+        'fic_client_id',
14
+    ];
15
+
16
+    protected $casts = [
17
+        'user_id' => 'integer',
18
+        'fic_client_id' => 'integer',
19
+    ];
20
+
21
+    public function user()
22
+    {
23
+        return $this->belongsTo(User::class, 'user_id', 'id');
24
+    }
25
+}

+ 212
- 12
app/Services/FatturazioneGeneraService.php 查看文件

@@ -30,6 +30,7 @@ class FatturazioneGeneraService
30 30
         $annotazioniGiaFatturate = $this->annotazioneIdsGiaFatturate();
31 31
 
32 32
         $clienti = [];
33
+        $ordiniCollegati = [];
33 34
         foreach ($annotazioni as $annotazione) {
34 35
             $segnalazione = $annotazione->segnalazione;
35 36
             if (! $segnalazione) {
@@ -46,6 +47,15 @@ class FatturazioneGeneraService
46 47
                 $clienti[$key] = $this->nuovoCliente($destinatario);
47 48
             }
48 49
 
50
+            $ordine = $segnalazione->ordineLavoro;
51
+            if ($ordine && $ordine->stato !== 'annullato' && ! isset($ordiniCollegati[$ordine->id])) {
52
+                $ordiniCollegati[$ordine->id] = [
53
+                    'key' => $key,
54
+                    'ordine' => $ordine,
55
+                    'segnalazione_id' => (int) $segnalazione->id,
56
+                ];
57
+            }
58
+
49 59
             $allocazione = $this->allocaAnnotazione($annotazione, $segnalazione, $pools, $tariffaDefault);
50 60
             if (in_array((int) $annotazione->id, $annotazioniGiaFatturate, true)) {
51 61
                 continue;
@@ -53,19 +63,26 @@ class FatturazioneGeneraService
53 63
             $this->accumulaTempo($clienti[$key], $segnalazione, $annotazione, $allocazione);
54 64
         }
55 65
 
66
+        $this->accumulaOrdini($clienti, $ordiniCollegati);
56 67
         $this->accumulaRinnovi($clienti, $from, $to, $privatiAziendaId);
57 68
 
58 69
         $clienti = collect($clienti)
59
-            ->filter(fn (array $c) => $c['totale'] > 0 || $c['minuti_totali'] > 0 || count($c['rinnovi']) > 0)
60 70
             ->map(function (array $c) {
61 71
                 $c['segnalazioni'] = collect($c['segnalazioni'])
72
+                    ->reject(fn (array $s) => $this->segnalazioneSoloCopertaDaContratto($s))
62 73
                     ->sortBy('numero')
63 74
                     ->values()
64 75
                     ->all();
76
+                $c['ordini'] = collect($c['ordini'])->values()->all();
65 77
                 $c['rinnovi'] = collect($c['rinnovi'])->values()->all();
66 78
 
67 79
                 return $c;
68
-            });
80
+            })
81
+            ->filter(fn (array $c) => $c['totale'] > 0
82
+                || $c['minuti_fatturare'] > 0
83
+                || count($c['segnalazioni']) > 0
84
+                || count($c['ordini']) > 0
85
+                || count($c['rinnovi']) > 0);
69 86
 
70 87
         $aziende = $clienti->filter(fn (array $c) => $c['tipo'] === 'azienda')
71 88
             ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
@@ -133,7 +150,7 @@ class FatturazioneGeneraService
133 150
                     'quantita' => $voce['quantita'],
134 151
                     'unita_misura' => $voce['unita_misura'],
135 152
                     'prezzo_unitario' => $voce['prezzo_unitario'],
136
-                    'sconto' => 0,
153
+                    'sconto' => $voce['sconto'] ?? 0,
137 154
                     'iva_percentuale' => $fattura->iva_percentuale,
138 155
                     'importo' => $voce['importo'],
139 156
                     'minuti' => $voce['minuti'],
@@ -303,12 +320,14 @@ class FatturazioneGeneraService
303 320
             'piva' => $destinatario['piva'],
304 321
             'cod_fiscale' => $destinatario['cod_fiscale'],
305 322
             'segnalazioni' => [],
323
+            'ordini' => [],
306 324
             'rinnovi' => [],
307 325
             'minuti_totali' => 0,
308 326
             'minuti_coperti' => 0,
309 327
             'minuti_fatturare' => 0,
310 328
             'importo_tempi' => 0.0,
311 329
             'importo_spese' => 0.0,
330
+            'importo_ordini' => 0.0,
312 331
             'importo_rinnovi' => 0.0,
313 332
             'totale' => 0.0,
314 333
             'ha_tariffa_mancante' => false,
@@ -603,7 +622,7 @@ class FatturazioneGeneraService
603 622
         $cliente['minuti_fatturare'] += $allocazione['minuti_fatturare'];
604 623
         $cliente['importo_tempi'] = round($cliente['importo_tempi'] + $allocazione['importo_tempo'], 2);
605 624
         $cliente['importo_spese'] = round($cliente['importo_spese'] + $allocazione['importo_spesa'] + $importoUscita, 2);
606
-        $cliente['totale'] = round($cliente['importo_tempi'] + $cliente['importo_spese'] + $cliente['importo_rinnovi'], 2);
625
+        $this->ricalcolaTotaleCliente($cliente);
607 626
         $cliente['ha_tariffa_mancante'] = $cliente['ha_tariffa_mancante'] || $allocazione['tariffa_mancante'];
608 627
     }
609 628
 
@@ -632,12 +651,12 @@ class FatturazioneGeneraService
632 651
             }
633 652
 
634 653
             foreach ($contratto->righe as $riga) {
654
+                $periodoKey = (string) ($riga->periodo ?: $contratto->periodo ?: 'una_tantum');
635 655
                 foreach ($this->dateOccorrenzeRigaNelPeriodo($contratto, $riga, $from, $to) as $occurrence) {
636
-                    if ($occurrence->copy()->startOfDay()->lt($rinnoviDa)) {
656
+                    [$pDa, $pA] = $this->finestraCiclo($periodoKey, $occurrence, $this->decorrenzaRiga($contratto, $riga));
657
+                    if ($this->cicloPrecedenteAllaSoglia($occurrence, $pDa, $rinnoviDa)) {
637 658
                         continue;
638 659
                     }
639
-                    $periodoKey = (string) ($riga->periodo ?: $contratto->periodo ?: 'una_tantum');
640
-                    [$pDa, $pA] = $this->finestraCiclo($periodoKey, $occurrence);
641 660
                     if ($this->canoneGiaFatturato($contratto->id, $riga->id, $pDa, $pA)) {
642 661
                         continue;
643 662
                     }
@@ -659,10 +678,10 @@ class FatturazioneGeneraService
659 678
                 ]);
660 679
                 $fake->setRelation('contrattoServizio', $contratto);
661 680
                 foreach ($this->dateOccorrenzeRigaNelPeriodo($contratto, $fake, $from, $to) as $occurrence) {
662
-                    if ($occurrence->copy()->startOfDay()->lt($rinnoviDa)) {
681
+                    [$pDa, $pA] = $this->finestraCiclo($periodo, $occurrence, $this->decorrenzaRiga($contratto, $fake));
682
+                    if ($this->cicloPrecedenteAllaSoglia($occurrence, $pDa, $rinnoviDa)) {
663 683
                         continue;
664 684
                     }
665
-                    [$pDa, $pA] = $this->finestraCiclo($periodo, $occurrence);
666 685
                     if ($this->canoneGiaFatturato($contratto->id, null, $pDa, $pA)) {
667 686
                         continue;
668 687
                     }
@@ -794,12 +813,51 @@ class FatturazioneGeneraService
794 813
         return $dates;
795 814
     }
796 815
 
816
+    protected function decorrenzaRiga(ContrattoServizio $contratto, ContrattoServizioRiga $riga): ?Carbon
817
+    {
818
+        if ($riga->data_inizio) {
819
+            return Carbon::parse($riga->data_inizio)->startOfDay();
820
+        }
821
+        if ($contratto->data_inizio) {
822
+            return Carbon::parse($contratto->data_inizio)->startOfDay();
823
+        }
824
+
825
+        return null;
826
+    }
827
+
828
+    /**
829
+     * Il ciclo non si fattura se è stato aperto prima della soglia
830
+     * (già gestito sulla piattaforma precedente).
831
+     */
832
+    protected function cicloPrecedenteAllaSoglia(Carbon $occurrence, Carbon $periodoDa, Carbon $soglia): bool
833
+    {
834
+        $soglia = $soglia->copy()->startOfDay();
835
+
836
+        return $occurrence->copy()->startOfDay()->lt($soglia)
837
+            || $periodoDa->copy()->startOfDay()->lt($soglia);
838
+    }
839
+
797 840
     /**
841
+     * Finestra del ciclo ancorata alla decorrenza del contratto.
842
+     * Un annuale partito il 01/01 resta 01/01–31/12 anche se la generazione cade dopo.
843
+     *
798 844
      * @return array{0: Carbon, 1: Carbon}
799 845
      */
800
-    protected function finestraCiclo(string $periodo, Carbon $occurrence): array
846
+    protected function finestraCiclo(string $periodo, Carbon $occurrence, ?Carbon $decorrenza = null): array
801 847
     {
802 848
         $d = $occurrence->copy()->startOfDay();
849
+        $step = match ($periodo) {
850
+            'mensile' => 1,
851
+            'trimestrale' => 3,
852
+            'semestrale' => 6,
853
+            'annuale' => 12,
854
+            default => 0,
855
+        };
856
+        if ($step > 0 && $decorrenza) {
857
+            $inizio = $this->inizioCiclo($decorrenza, $d, $step);
858
+
859
+            return [$inizio, $inizio->copy()->addMonthsNoOverflow($step)->subDay()->startOfDay()];
860
+        }
803 861
 
804 862
         return match ($periodo) {
805 863
             'mensile' => [$d->copy()->startOfMonth(), $d->copy()->endOfMonth()->startOfDay()],
@@ -812,6 +870,24 @@ class FatturazioneGeneraService
812 870
         };
813 871
     }
814 872
 
873
+    protected function inizioCiclo(Carbon $decorrenza, Carbon $data, int $stepMonths): Carbon
874
+    {
875
+        $inizio = $decorrenza->copy()->startOfDay();
876
+        $data = $data->copy()->startOfDay();
877
+        if ($inizio->gt($data) || $stepMonths < 1) {
878
+            return $inizio;
879
+        }
880
+
881
+        $mesi = (int) floor($inizio->floatDiffInMonths($data));
882
+        $steps = intdiv(max(0, $mesi), $stepMonths);
883
+        $inizio->addMonthsNoOverflow($steps * $stepMonths);
884
+        if ($inizio->gt($data)) {
885
+            $inizio->subMonthsNoOverflow($stepMonths);
886
+        }
887
+
888
+        return $inizio->startOfDay();
889
+    }
890
+
815 891
     protected function minutiContrattoPrima(int $contrattoId, ?Carbon $windowFrom, Carbon $billingFrom): int
816 892
     {
817 893
         $query = Annotazione::query()
@@ -937,6 +1013,7 @@ class FatturazioneGeneraService
937 1013
     protected function totaliSezione(array $clienti): array
938 1014
     {
939 1015
         $importoRinnovi = 0.0;
1016
+        $importoOrdini = 0.0;
940 1017
         $importoTempi = 0.0;
941 1018
         $importoSpese = 0.0;
942 1019
         $minutiFatturare = 0;
@@ -945,6 +1022,7 @@ class FatturazioneGeneraService
945 1022
 
946 1023
         foreach ($clienti as $c) {
947 1024
             $importoRinnovi += $c['importo_rinnovi'];
1025
+            $importoOrdini += $c['importo_ordini'];
948 1026
             $importoTempi += $c['importo_tempi'];
949 1027
             $importoSpese += $c['importo_spese'];
950 1028
             $minutiFatturare += $c['minuti_fatturare'];
@@ -960,8 +1038,9 @@ class FatturazioneGeneraService
960 1038
             'clienti' => count($clienti),
961 1039
             'importo_tempi' => round($importoTempi, 2),
962 1040
             'importo_spese' => round($importoSpese, 2),
1041
+            'importo_ordini' => round($importoOrdini, 2),
963 1042
             'importo_rinnovi' => round($importoRinnovi, 2),
964
-            'totale' => round($importoTempi + $importoSpese + $importoRinnovi, 2),
1043
+            'totale' => round($importoTempi + $importoSpese + $importoOrdini + $importoRinnovi, 2),
965 1044
             'minuti_fatturare' => $minutiFatturare,
966 1045
             'minuti_coperti' => $minutiCoperti,
967 1046
             'scadenze' => $nScadenze,
@@ -1089,8 +1168,129 @@ class FatturazioneGeneraService
1089 1168
             'nota' => null,
1090 1169
         ];
1091 1170
         $cliente['importo_rinnovi'] = round($cliente['importo_rinnovi'] + $importo, 2);
1171
+        $this->ricalcolaTotaleCliente($cliente);
1172
+    }
1173
+
1174
+    /**
1175
+     * Gli ordini collegati alle segnalazioni del periodo si fatturano una volta,
1176
+     * con le righe commerciali dell'ordine (o delle offerte, se l'ordine non ne ha).
1177
+     *
1178
+     * @param  array<int, array{key: string, ordine: OrdineLavoro, segnalazione_id: int}>  $ordiniCollegati
1179
+     */
1180
+    protected function accumulaOrdini(array &$clienti, array $ordiniCollegati): void
1181
+    {
1182
+        foreach ($ordiniCollegati as $item) {
1183
+            /** @var OrdineLavoro $ordine */
1184
+            $ordine = $item['ordine'];
1185
+            $key = $item['key'];
1186
+            if (! isset($clienti[$key]) || $this->ordineGiaFatturato((int) $ordine->id)) {
1187
+                continue;
1188
+            }
1189
+
1190
+            $voci = [];
1191
+            $righeVista = [];
1192
+            foreach ($this->righeCommercialiOrdine($ordine) as $riga) {
1193
+                $importo = $this->importoRigaCommerciale($riga);
1194
+                if ($importo <= 0) {
1195
+                    continue;
1196
+                }
1197
+                $desc = trim((string) ($riga->descrizione ?? ''));
1198
+                $descrizione = $ordine->label.($desc !== '' ? ' — '.$desc : '');
1199
+                if (mb_strlen($descrizione) > 255) {
1200
+                    $descrizione = mb_substr($descrizione, 0, 252).'…';
1201
+                }
1202
+                $voci[] = [
1203
+                    'tipo' => FatturaRiga::TIPO_ORDINE,
1204
+                    'descrizione' => $descrizione,
1205
+                    'quantita' => $riga->quantita ?? 1,
1206
+                    'unita_misura' => $riga->unita_misura ?: 'forfait',
1207
+                    'prezzo_unitario' => $riga->prezzo_unitario,
1208
+                    'sconto' => $riga->sconto ?? 0,
1209
+                    'importo' => $importo,
1210
+                    'minuti' => null,
1211
+                    'annotazione_id' => null,
1212
+                    'segnalazione_id' => $item['segnalazione_id'],
1213
+                    'contratto_servizio_id' => null,
1214
+                    'contratto_servizio_riga_id' => null,
1215
+                    'ordine_lavoro_id' => $ordine->id,
1216
+                    'prodotto_id' => $riga->prodotto_id,
1217
+                    'periodo_da' => null,
1218
+                    'periodo_a' => null,
1219
+                    'nota' => null,
1220
+                ];
1221
+                $righeVista[] = [
1222
+                    'descrizione' => $desc !== '' ? $desc : $ordine->label,
1223
+                    'importo' => $importo,
1224
+                ];
1225
+            }
1226
+            if ($voci === []) {
1227
+                continue;
1228
+            }
1229
+
1230
+            $somma = 0.0;
1231
+            foreach ($voci as $voce) {
1232
+                $clienti[$key]['voci'][] = $voce;
1233
+                $somma += (float) $voce['importo'];
1234
+            }
1235
+            $somma = round($somma, 2);
1236
+            $clienti[$key]['ordini'][] = [
1237
+                'id' => $ordine->id,
1238
+                'label' => $ordine->label,
1239
+                'segnalazione_id' => $item['segnalazione_id'],
1240
+                'importo' => $somma,
1241
+                'righe' => $righeVista,
1242
+            ];
1243
+            $clienti[$key]['importo_ordini'] = round($clienti[$key]['importo_ordini'] + $somma, 2);
1244
+            $this->ricalcolaTotaleCliente($clienti[$key]);
1245
+        }
1246
+    }
1247
+
1248
+    protected function righeCommercialiOrdine(OrdineLavoro $ordine): Collection
1249
+    {
1250
+        $ordine->loadMissing(['righe', 'offerte.righe']);
1251
+        $righe = $ordine->righe->isNotEmpty()
1252
+            ? $ordine->righe
1253
+            : $ordine->offerte->flatMap->righe;
1254
+
1255
+        return $righe->sortBy([['ordine', 'asc'], ['id', 'asc']])->values();
1256
+    }
1257
+
1258
+    protected function importoRigaCommerciale(object $riga): float
1259
+    {
1260
+        $qty = (float) ($riga->quantita ?? 0);
1261
+        $prezzo = (float) ($riga->prezzo_unitario ?? 0);
1262
+        $sconto = (float) ($riga->sconto ?? 0);
1263
+        $totale = $qty * $prezzo;
1264
+        if ($sconto > 0) {
1265
+            $totale -= $totale * $sconto / 100;
1266
+        }
1267
+
1268
+        return round($totale, 2);
1269
+    }
1270
+
1271
+    protected function ordineGiaFatturato(int $ordineId): bool
1272
+    {
1273
+        return FatturaRiga::query()
1274
+            ->suFattureAttive()
1275
+            ->where('tipo', FatturaRiga::TIPO_ORDINE)
1276
+            ->where('ordine_lavoro_id', $ordineId)
1277
+            ->exists();
1278
+    }
1279
+
1280
+    /**
1281
+     * Ticket interamente coperto dal contratto: nessuna ora, spesa o uscita da fatturare.
1282
+     */
1283
+    protected function segnalazioneSoloCopertaDaContratto(array $segnalazione): bool
1284
+    {
1285
+        return ($segnalazione['copertura'] ?? '') === 'contratto'
1286
+            && (int) ($segnalazione['minuti_fatturare'] ?? 0) === 0
1287
+            && (float) ($segnalazione['importo'] ?? 0) <= 0;
1288
+    }
1289
+
1290
+    protected function ricalcolaTotaleCliente(array &$cliente): void
1291
+    {
1092 1292
         $cliente['totale'] = round(
1093
-            $cliente['importo_tempi'] + $cliente['importo_spese'] + $cliente['importo_rinnovi'],
1293
+            $cliente['importo_tempi'] + $cliente['importo_spese'] + $cliente['importo_ordini'] + $cliente['importo_rinnovi'],
1094 1294
             2
1095 1295
         );
1096 1296
     }

+ 411
- 0
app/Services/FattureInCloudClient.php 查看文件

@@ -0,0 +1,411 @@
1
+<?php
2
+
3
+namespace App\Services;
4
+
5
+use App\Models\Azienda;
6
+use App\Models\Comune;
7
+use App\Models\Config;
8
+use Illuminate\Http\Client\Response;
9
+use Illuminate\Support\Facades\Http;
10
+use RuntimeException;
11
+
12
+class FattureInCloudClient
13
+{
14
+    public const FIELDS = [
15
+        'name' => 'Ragione sociale',
16
+        'vat_number' => 'P. IVA',
17
+        'tax_code' => 'Codice fiscale',
18
+        'email' => 'Email',
19
+        'phone' => 'Telefono',
20
+        'address_street' => 'Indirizzo',
21
+        'citta' => 'Città',
22
+        'notes' => 'Note',
23
+    ];
24
+
25
+    public function configured(): bool
26
+    {
27
+        return $this->companyId() !== null && $this->token() !== null;
28
+    }
29
+
30
+    public function companyId(): ?string
31
+    {
32
+        return $this->credential(Config::FIC_COMPANY_ID, 'services.fattureincloud.company_id');
33
+    }
34
+
35
+    public function token(): ?string
36
+    {
37
+        return $this->credential(Config::FIC_ACCESS_TOKEN, 'services.fattureincloud.access_token');
38
+    }
39
+
40
+    /**
41
+     * @return array{results: array<int, array{id: int, text: string}>, more: bool}
42
+     */
43
+    public function searchClients(string $term, int $page = 1): array
44
+    {
45
+        $params = [
46
+            'fieldset' => 'detailed',
47
+            'sort' => 'name',
48
+            'page' => max(1, $page),
49
+            'per_page' => 20,
50
+        ];
51
+        $term = trim(str_replace("'", '', $term));
52
+        if ($term !== '') {
53
+            $params['q'] = "(name like '%{$term}%' or vat_number like '%{$term}%' or tax_code like '%{$term}%')";
54
+        }
55
+
56
+        $json = $this->decode($this->http()->get($this->url('/entities/clients'), $params));
57
+        $results = [];
58
+        foreach ($json['data'] ?? [] as $client) {
59
+            if (! is_array($client) || empty($client['id'])) {
60
+                continue;
61
+            }
62
+            $results[] = [
63
+                'id' => (int) $client['id'],
64
+                'text' => $this->clientLabel($client),
65
+            ];
66
+        }
67
+
68
+        $current = (int) ($json['current_page'] ?? $page);
69
+        $last = (int) ($json['last_page'] ?? $current);
70
+
71
+        return [
72
+            'results' => $results,
73
+            'more' => $current < $last,
74
+        ];
75
+    }
76
+
77
+    public function getClient(int $id): array
78
+    {
79
+        $json = $this->decode($this->http()->get($this->url('/entities/clients/'.$id), [
80
+            'fieldset' => 'detailed',
81
+        ]));
82
+        $client = $json['data'] ?? null;
83
+        if (! is_array($client) || empty($client['id'])) {
84
+            throw new RuntimeException('Cliente Fatture in Cloud non trovato.');
85
+        }
86
+
87
+        return $client;
88
+    }
89
+
90
+    public function updateClient(int $id, array $data): array
91
+    {
92
+        if ($data === []) {
93
+            return $this->getClient($id);
94
+        }
95
+
96
+        $json = $this->decode($this->http()->put($this->url('/entities/clients/'.$id), [
97
+            'data' => $data,
98
+        ]));
99
+
100
+        return is_array($json['data'] ?? null) ? $json['data'] : $this->getClient($id);
101
+    }
102
+
103
+    public function createClient(array $data): array
104
+    {
105
+        $data['type'] = $data['type'] ?? 'company';
106
+        $json = $this->decode($this->http()->post($this->url('/entities/clients'), [
107
+            'data' => $data,
108
+        ]));
109
+        $client = $json['data'] ?? null;
110
+        if (! is_array($client) || empty($client['id'])) {
111
+            throw new RuntimeException('Fatture in Cloud non ha restituito il cliente creato.');
112
+        }
113
+
114
+        return $client;
115
+    }
116
+
117
+    public function clientLabel(array $client): string
118
+    {
119
+        $name = trim((string) ($client['name'] ?? ''));
120
+        $vat = trim((string) ($client['vat_number'] ?? ''));
121
+        if ($name === '') {
122
+            $name = 'Cliente #'.($client['id'] ?? '');
123
+        }
124
+
125
+        return $vat !== '' ? $name.' — P.IVA '.$vat : $name;
126
+    }
127
+
128
+    /**
129
+     * @return array<int, array{key: string, label: string, local: string, remote: string, equal: bool}>
130
+     */
131
+    public function confronto(Azienda $azienda, array $client): array
132
+    {
133
+        $local = $this->localValues($azienda);
134
+        $remote = $this->remoteValues($client);
135
+        $rows = [];
136
+        foreach (self::FIELDS as $key => $label) {
137
+            $rows[] = [
138
+                'key' => $key,
139
+                'label' => $label,
140
+                'local' => $local[$key]['display'],
141
+                'remote' => $remote[$key]['display'],
142
+                'equal' => $local[$key]['compare'] === $remote[$key]['compare'],
143
+            ];
144
+        }
145
+
146
+        return $rows;
147
+    }
148
+
149
+    /**
150
+     * @return array<string, mixed>
151
+     */
152
+    public function payloadFromLocal(Azienda $azienda, ?array $onlyKeys = null): array
153
+    {
154
+        $local = $this->localValues($azienda);
155
+        $data = ['type' => 'company'];
156
+        foreach (array_keys(self::FIELDS) as $key) {
157
+            if ($onlyKeys !== null && ! in_array($key, $onlyKeys, true)) {
158
+                continue;
159
+            }
160
+            $data = array_merge($data, $this->remoteAttributes($key, $local[$key]['raw']));
161
+        }
162
+        unset($data['type']);
163
+
164
+        return $data;
165
+    }
166
+
167
+    /**
168
+     * @param  array<string, mixed>  $raw
169
+     * @return array<string, mixed>
170
+     */
171
+    public function remoteAttributes(string $key, array $raw): array
172
+    {
173
+        return match ($key) {
174
+            'citta' => [
175
+                'address_city' => $raw['city'] ?? '',
176
+                'address_province' => $raw['province'] ?? '',
177
+            ],
178
+            default => [$key => $raw['value'] ?? ''],
179
+        };
180
+    }
181
+
182
+    /**
183
+     * @return array<string, array{display: string, compare: string, raw: array<string, string>}>
184
+     */
185
+    public function localValues(Azienda $azienda): array
186
+    {
187
+        $azienda->loadMissing('comune.provincia');
188
+        $city = trim((string) ($azienda->comune?->nome ?? ''));
189
+        $province = strtoupper(trim((string) ($azienda->comune?->provincia?->sigla ?? '')));
190
+
191
+        return [
192
+            'name' => $this->pack(trim((string) ($azienda->ragione_sociale ?: $azienda->nome))),
193
+            'vat_number' => [
194
+                'display' => trim((string) $azienda->piva),
195
+                'compare' => $this->normalizeVat($azienda->piva),
196
+                'raw' => ['value' => $this->normalizeVat($azienda->piva)],
197
+            ],
198
+            'tax_code' => $this->pack(strtoupper(trim((string) $azienda->cod_fiscale))),
199
+            'email' => $this->pack(strtolower(trim((string) $azienda->email)), trim((string) $azienda->email)),
200
+            'phone' => $this->pack($this->normalizePhone($azienda->telefono), trim((string) $azienda->telefono)),
201
+            'address_street' => $this->pack(trim((string) $azienda->indirizzo)),
202
+            'citta' => [
203
+                'display' => $this->cittaLabel($city, $province),
204
+                'compare' => mb_strtolower($city).'|'.$province,
205
+                'raw' => ['city' => $city, 'province' => $province],
206
+            ],
207
+            'notes' => $this->pack(trim((string) $azienda->note)),
208
+        ];
209
+    }
210
+
211
+    /**
212
+     * @return array<string, array{display: string, compare: string, raw: array<string, string>}>
213
+     */
214
+    public function remoteValues(array $client): array
215
+    {
216
+        $city = trim((string) ($client['address_city'] ?? ''));
217
+        $province = strtoupper(trim((string) ($client['address_province'] ?? '')));
218
+
219
+        return [
220
+            'name' => $this->pack(trim((string) ($client['name'] ?? ''))),
221
+            'vat_number' => $this->pack($this->normalizeVat($client['vat_number'] ?? ''), trim((string) ($client['vat_number'] ?? ''))),
222
+            'tax_code' => $this->pack(strtoupper(trim((string) ($client['tax_code'] ?? '')))),
223
+            'email' => $this->pack(strtolower(trim((string) ($client['email'] ?? ''))), trim((string) ($client['email'] ?? ''))),
224
+            'phone' => $this->pack($this->normalizePhone($client['phone'] ?? ''), trim((string) ($client['phone'] ?? ''))),
225
+            'address_street' => $this->pack(trim((string) ($client['address_street'] ?? ''))),
226
+            'citta' => [
227
+                'display' => $this->cittaLabel($city, $province),
228
+                'compare' => mb_strtolower($city).'|'.$province,
229
+                'raw' => ['city' => $city, 'province' => $province],
230
+            ],
231
+            'notes' => $this->pack(trim((string) ($client['notes'] ?? ''))),
232
+        ];
233
+    }
234
+
235
+    public function applyRemoteToAzienda(Azienda $azienda, string $key, array $raw): ?string
236
+    {
237
+        $value = trim((string) ($raw['value'] ?? ''));
238
+
239
+        return match ($key) {
240
+            'name' => $this->applyName($azienda, $value),
241
+            'vat_number' => $this->applyString($azienda, 'piva', $value, 255),
242
+            'tax_code' => $this->applyString($azienda, 'cod_fiscale', strtoupper($value), 16),
243
+            'email' => $this->applyEmail($azienda, $value),
244
+            'phone' => $this->applyString($azienda, 'telefono', $value, 255),
245
+            'address_street' => $this->applyString($azienda, 'indirizzo', $value, 255),
246
+            'notes' => $this->applyNote($azienda, $value),
247
+            'citta' => $this->applyCitta($azienda, (string) ($raw['city'] ?? ''), (string) ($raw['province'] ?? '')),
248
+            default => null,
249
+        };
250
+    }
251
+
252
+    /**
253
+     * @param  array{display: string, compare: string, raw: array<string, string>}  $packed
254
+     */
255
+    private function pack(string $compare, ?string $display = null): array
256
+    {
257
+        return [
258
+            'display' => $display ?? $compare,
259
+            'compare' => $compare,
260
+            'raw' => ['value' => $display ?? $compare],
261
+        ];
262
+    }
263
+
264
+    private function applyName(Azienda $azienda, string $value): ?string
265
+    {
266
+        if ($value === '') {
267
+            return 'La ragione sociale remota è vuota.';
268
+        }
269
+        if (mb_strlen($value) > 255) {
270
+            return 'La ragione sociale supera i 255 caratteri.';
271
+        }
272
+        $previous = $azienda->ragione_sociale;
273
+        $azienda->ragione_sociale = $value;
274
+        if (! $azienda->nome || $azienda->nome === $previous) {
275
+            $azienda->nome = $value;
276
+        }
277
+
278
+        return null;
279
+    }
280
+
281
+    private function applyString(Azienda $azienda, string $attribute, string $value, int $max): ?string
282
+    {
283
+        if (mb_strlen($value) > $max) {
284
+            return 'Il valore supera i '.$max.' caratteri.';
285
+        }
286
+        $azienda->{$attribute} = $value !== '' ? $value : null;
287
+
288
+        return null;
289
+    }
290
+
291
+    private function applyEmail(Azienda $azienda, string $value): ?string
292
+    {
293
+        if ($value !== '' && ! filter_var($value, FILTER_VALIDATE_EMAIL)) {
294
+            return 'L\'email remota non è valida.';
295
+        }
296
+        $azienda->email = $value !== '' ? $value : null;
297
+
298
+        return null;
299
+    }
300
+
301
+    private function applyNote(Azienda $azienda, string $value): ?string
302
+    {
303
+        $azienda->note = $value !== '' ? $value : null;
304
+
305
+        return null;
306
+    }
307
+
308
+    private function applyCitta(Azienda $azienda, string $city, string $province): ?string
309
+    {
310
+        $city = trim($city);
311
+        $province = strtoupper(trim($province));
312
+        if ($city === '' && $province === '') {
313
+            $azienda->comune_id = null;
314
+
315
+            return null;
316
+        }
317
+        if ($city === '') {
318
+            return 'Fatture in Cloud non ha una città da copiare.';
319
+        }
320
+
321
+        $query = Comune::query()->whereRaw('LOWER(nome) = ?', [mb_strtolower($city)]);
322
+        if ($province !== '') {
323
+            $match = (clone $query)->whereHas('provincia', fn ($q) => $q->where('sigla', $province))->first();
324
+            if ($match) {
325
+                $azienda->comune_id = $match->id;
326
+
327
+                return null;
328
+            }
329
+        }
330
+        $match = $query->first();
331
+        if (! $match) {
332
+            $label = $this->cittaLabel($city, $province);
333
+
334
+            return 'Comune «'.$label.'» non trovato in anagrafica.';
335
+        }
336
+        $azienda->comune_id = $match->id;
337
+
338
+        return null;
339
+    }
340
+
341
+    private function cittaLabel(string $city, string $province): string
342
+    {
343
+        if ($city === '' && $province === '') {
344
+            return '';
345
+        }
346
+        if ($province === '') {
347
+            return $city;
348
+        }
349
+        if ($city === '') {
350
+            return $province;
351
+        }
352
+
353
+        return $city.' ('.$province.')';
354
+    }
355
+
356
+    private function normalizeVat(?string $value): string
357
+    {
358
+        $value = strtoupper(preg_replace('/\s+/', '', trim((string) $value)) ?? '');
359
+        if (str_starts_with($value, 'IT') && strlen($value) > 2) {
360
+            $value = substr($value, 2);
361
+        }
362
+
363
+        return $value;
364
+    }
365
+
366
+    private function normalizePhone(?string $value): string
367
+    {
368
+        return preg_replace('/\s+/', '', trim((string) $value)) ?? '';
369
+    }
370
+
371
+    private function credential(string $configKey, string $configPath): ?string
372
+    {
373
+        $fromConfig = trim((string) Config::valueOf($configKey, ''));
374
+        if ($fromConfig !== '') {
375
+            return $fromConfig;
376
+        }
377
+        $fromEnv = trim((string) config($configPath));
378
+
379
+        return $fromEnv !== '' ? $fromEnv : null;
380
+    }
381
+
382
+    private function url(string $path): string
383
+    {
384
+        return '/c/'.$this->companyId().$path;
385
+    }
386
+
387
+    private function http()
388
+    {
389
+        if (! $this->configured()) {
390
+            throw new RuntimeException('Collega Fatture in Cloud da Configurazione: servono Company ID e access token.');
391
+        }
392
+
393
+        return Http::baseUrl('https://api-v2.fattureincloud.it')
394
+            ->acceptJson()
395
+            ->withToken((string) $this->token())
396
+            ->timeout(25);
397
+    }
398
+
399
+    private function decode(Response $response): array
400
+    {
401
+        if ($response->successful()) {
402
+            return $response->json() ?? [];
403
+        }
404
+
405
+        $message = data_get($response->json(), 'error.message')
406
+            ?: data_get($response->json(), 'message')
407
+            ?: 'Fatture in Cloud ha risposto con errore '.$response->status().'.';
408
+
409
+        throw new RuntimeException((string) $message);
410
+    }
411
+}

+ 10
- 0
config/services.php 查看文件

@@ -73,6 +73,16 @@ return [
73 73
     | Auth: username/password (token) oppure user_key + access_token fissi
74 74
     | message_type: GP (alto), TI (medio), SI (basso)
75 75
     */
76
+    /*
77
+    | Fatture in Cloud API v2
78
+    | https://developers.fattureincloud.it/docs/getting-started/
79
+    | I valori salvati in Configurazione hanno la precedenza su queste variabili.
80
+    */
81
+    'fattureincloud' => [
82
+        'company_id' => env('FATTUREINCLOUD_COMPANY_ID'),
83
+        'access_token' => env('FATTUREINCLOUD_ACCESS_TOKEN'),
84
+    ],
85
+
76 86
     'skebby' => [
77 87
         'username' => env('SKEBBY_USERNAME'),
78 88
         'password' => env('SKEBBY_PASSWORD'),

+ 27
- 0
database/migrations/2026_10_05_231800_create_fic_collegamento_table.php 查看文件

@@ -0,0 +1,27 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    public function up(): void
10
+    {
11
+        Schema::create('fic_collegamento', function (Blueprint $table) {
12
+            $table->id();
13
+            $table->unsignedBigInteger('user_id');
14
+            $table->unsignedBigInteger('fic_client_id');
15
+            $table->timestamps();
16
+
17
+            $table->unique('user_id');
18
+            $table->unique('fic_client_id');
19
+            $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
20
+        });
21
+    }
22
+
23
+    public function down(): void
24
+    {
25
+        Schema::dropIfExists('fic_collegamento');
26
+    }
27
+};

+ 49
- 0
resources/views/azienda/fic-modal.blade.php 查看文件

@@ -0,0 +1,49 @@
1
+<div class="modal fade" id="ficAziendaModal" tabindex="-1" aria-hidden="true">
2
+  <div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
3
+    <div class="modal-content">
4
+      <div class="modal-header">
5
+        <div>
6
+          <h5 class="modal-title">Fatture in Cloud</h5>
7
+          <small class="text-muted" id="ficAziendaLabel"></small>
8
+        </div>
9
+        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
10
+      </div>
11
+      <div class="modal-body">
12
+        <div class="alert alert-warning d-none" id="ficConfigAlert"></div>
13
+        <div class="alert alert-danger d-none" id="ficErrorAlert"></div>
14
+
15
+        <div id="ficSearchWrap">
16
+          <label class="form-label" for="ficClientSelect">Cliente su Fatture in Cloud</label>
17
+          <select id="ficClientSelect" class="form-select" data-placeholder="Cerca per nome, P. IVA o codice fiscale"></select>
18
+          <div class="form-text">Scegli il cliente remoto da confrontare con l'anagrafica locale.</div>
19
+        </div>
20
+
21
+        <div id="ficConfronto" class="d-none mt-4">
22
+          <div class="d-flex flex-wrap gap-2 mb-3">
23
+            <button type="button" class="btn btn-sm btn-label-secondary" id="ficKeepLocal">Tieni tutto il locale</button>
24
+            <button type="button" class="btn btn-sm btn-label-secondary" id="ficKeepRemote">Tieni tutto il remoto</button>
25
+          </div>
26
+          <div class="table-responsive">
27
+            <table class="table table-sm align-middle mb-0">
28
+              <thead>
29
+                <tr>
30
+                  <th>Campo</th>
31
+                  <th>Locale</th>
32
+                  <th>Fatture in Cloud</th>
33
+                  <th style="width: 220px;">Tieni</th>
34
+                </tr>
35
+              </thead>
36
+              <tbody id="ficConfrontoBody"></tbody>
37
+            </table>
38
+          </div>
39
+        </div>
40
+      </div>
41
+      <div class="modal-footer flex-wrap">
42
+        <button type="button" class="btn btn-label-danger d-none" id="ficUnlink">Scollega</button>
43
+        <button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">Chiudi</button>
44
+        <button type="button" class="btn btn-label-primary d-none" id="ficCreate">Crea su Fatture in Cloud</button>
45
+        <button type="button" class="btn btn-primary d-none" id="ficApply" disabled>Applica</button>
46
+      </div>
47
+    </div>
48
+  </div>
49
+</div>

+ 312
- 0
resources/views/azienda/fic-scripts.blade.php 查看文件

@@ -0,0 +1,312 @@
1
+<script type="module">
2
+$(function () {
3
+  const modalEl = document.getElementById('ficAziendaModal');
4
+  if (!modalEl || !window.bootstrap) {
5
+    return;
6
+  }
7
+
8
+  const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
9
+  const $select = $('#ficClientSelect');
10
+  let aziendaId = null;
11
+  let linked = false;
12
+  let rows = [];
13
+
14
+  function showAlert(id, message) {
15
+    const el = document.getElementById(id);
16
+    if (!message) {
17
+      el.classList.add('d-none');
18
+      el.textContent = '';
19
+      return;
20
+    }
21
+    el.textContent = message;
22
+    el.classList.remove('d-none');
23
+  }
24
+
25
+  function escapeHtml(value) {
26
+    return String(value ?? '')
27
+      .replace(/&/g, '&amp;')
28
+      .replace(/</g, '&lt;')
29
+      .replace(/>/g, '&gt;')
30
+      .replace(/"/g, '&quot;');
31
+  }
32
+
33
+  function cell(value) {
34
+    const text = String(value ?? '').trim();
35
+    if (text === '') {
36
+      return '<span class="text-muted">—</span>';
37
+    }
38
+    return '<div style="white-space:pre-wrap">' + escapeHtml(text) + '</div>';
39
+  }
40
+
41
+  function selectedChoices() {
42
+    const scelte = {};
43
+    let pending = 0;
44
+    rows.forEach(function (row) {
45
+      if (row.equal) {
46
+        return;
47
+      }
48
+      const checked = document.querySelector('input[name="fic-scelta-' + row.key + '"]:checked');
49
+      if (!checked) {
50
+        pending++;
51
+        return;
52
+      }
53
+      scelte[row.key] = checked.value;
54
+    });
55
+    return { scelte, pending };
56
+  }
57
+
58
+  function refreshApply() {
59
+    const apply = document.getElementById('ficApply');
60
+    const hasClient = !!$select.val();
61
+    apply.classList.toggle('d-none', !hasClient);
62
+    document.getElementById('ficCreate').classList.toggle('d-none', hasClient || !aziendaId);
63
+    if (!hasClient) {
64
+      apply.disabled = true;
65
+      return;
66
+    }
67
+    apply.disabled = rows.length === 0 || selectedChoices().pending > 0;
68
+  }
69
+
70
+  function renderRows(nextRows) {
71
+    rows = nextRows || [];
72
+    const body = document.getElementById('ficConfrontoBody');
73
+    const wrap = document.getElementById('ficConfronto');
74
+    if (!rows.length) {
75
+      wrap.classList.add('d-none');
76
+      body.innerHTML = '';
77
+      refreshApply();
78
+      return;
79
+    }
80
+    body.innerHTML = rows.map(function (row) {
81
+      const choice = row.equal
82
+        ? '<span class="badge bg-label-success">Uguali</span>'
83
+        : '<div class="d-flex flex-column gap-1">'
84
+          + '<label class="form-check mb-0"><input class="form-check-input fic-choice" type="radio" name="fic-scelta-' + row.key + '" value="local"> <span class="form-check-label">Locale</span></label>'
85
+          + '<label class="form-check mb-0"><input class="form-check-input fic-choice" type="radio" name="fic-scelta-' + row.key + '" value="remote"> <span class="form-check-label">Remoto</span></label>'
86
+          + '</div>';
87
+      return '<tr class="' + (row.equal ? '' : 'table-warning') + '">'
88
+        + '<td class="fw-medium">' + escapeHtml(row.label) + '</td>'
89
+        + '<td>' + cell(row.local) + '</td>'
90
+        + '<td>' + cell(row.remote) + '</td>'
91
+        + '<td>' + choice + '</td>'
92
+        + '</tr>';
93
+    }).join('');
94
+    wrap.classList.remove('d-none');
95
+    refreshApply();
96
+  }
97
+
98
+  function setAll(side) {
99
+    document.querySelectorAll('#ficConfrontoBody input.fic-choice[value="' + side + '"]').forEach(function (input) {
100
+      input.checked = true;
101
+    });
102
+    refreshApply();
103
+  }
104
+
105
+  function loadConfronto(ficClientId) {
106
+    showAlert('ficErrorAlert', '');
107
+    renderRows([]);
108
+    $.get(@json(url('admin/azienda')) + '/' + aziendaId + '/fic/confronto', { fic_client_id: ficClientId })
109
+      .done(function (data) {
110
+        renderRows(data.rows || []);
111
+      })
112
+      .fail(function (xhr) {
113
+        showAlert('ficErrorAlert', (xhr.responseJSON && xhr.responseJSON.message) || 'Impossibile leggere il cliente remoto.');
114
+        refreshApply();
115
+      });
116
+  }
117
+
118
+  function initSelect() {
119
+    if ($select.hasClass('select2-hidden-accessible')) {
120
+      $select.select2('destroy');
121
+    }
122
+    $select.empty();
123
+    $select.select2({
124
+      dropdownParent: $('#ficAziendaModal'),
125
+      width: '100%',
126
+      allowClear: true,
127
+      placeholder: 'Cerca per nome, P. IVA o codice fiscale',
128
+      ajax: {
129
+        url: @json(route('admin.azienda.fic.clienti')),
130
+        dataType: 'json',
131
+        delay: 300,
132
+        data: function (params) {
133
+          return { term: params.term || '', page: params.page || 1 };
134
+        },
135
+        processResults: function (data) {
136
+          return {
137
+            results: data.results || [],
138
+            pagination: { more: !!(data.pagination && data.pagination.more) }
139
+          };
140
+        }
141
+      }
142
+    });
143
+  }
144
+
145
+  function openFor(id, label) {
146
+    aziendaId = id;
147
+    linked = false;
148
+    document.getElementById('ficAziendaLabel').textContent = label || '';
149
+    showAlert('ficConfigAlert', '');
150
+    showAlert('ficErrorAlert', '');
151
+    renderRows([]);
152
+    document.getElementById('ficUnlink').classList.add('d-none');
153
+    document.getElementById('ficSearchWrap').classList.remove('d-none');
154
+    initSelect();
155
+    modal.show();
156
+
157
+    $.get(@json(url('admin/azienda')) + '/' + id + '/fic')
158
+      .done(function (data) {
159
+        if (!data.configured) {
160
+          const el = document.getElementById('ficConfigAlert');
161
+          el.classList.remove('d-none');
162
+          el.innerHTML = 'Inserisci Company ID e access token nella <a href="' + @json(route('admin.config.index')) + '">configurazione</a>, scheda Fatture in Cloud. Il token va creato come da <a href="https://developers.fattureincloud.it/docs/getting-started/" target="_blank" rel="noopener">guida Fatture in Cloud</a> e deve avere gli scope <code>entity.clients:r</code> e <code>entity.clients:a</code>.';
163
+          document.getElementById('ficSearchWrap').classList.add('d-none');
164
+          document.getElementById('ficCreate').classList.add('d-none');
165
+          return;
166
+        }
167
+        document.getElementById('ficCreate').classList.remove('d-none');
168
+        if (data.error) {
169
+          showAlert('ficErrorAlert', data.error);
170
+        }
171
+        if (data.collegamento) {
172
+          linked = true;
173
+          document.getElementById('ficUnlink').classList.remove('d-none');
174
+          const option = new Option(data.collegamento.text, data.collegamento.id, true, true);
175
+          $select.append(option).trigger('change');
176
+        }
177
+        if (data.confronto) {
178
+          renderRows(data.confronto);
179
+        }
180
+      })
181
+      .fail(function () {
182
+        showAlert('ficErrorAlert', 'Impossibile aprire il confronto.');
183
+      });
184
+  }
185
+
186
+  $('#dataTable_azienda').on('click', '.js-fic-azienda', function (e) {
187
+    e.preventDefault();
188
+    openFor($(this).data('id'), $(this).data('label'));
189
+  });
190
+
191
+  $select.on('select2:select', function (e) {
192
+    loadConfronto(e.params.data.id);
193
+  });
194
+
195
+  $select.on('select2:clear', function () {
196
+    renderRows([]);
197
+    refreshApply();
198
+  });
199
+
200
+  document.getElementById('ficConfrontoBody').addEventListener('change', function (e) {
201
+    if (e.target.classList.contains('fic-choice')) {
202
+      refreshApply();
203
+    }
204
+  });
205
+  document.getElementById('ficKeepLocal').addEventListener('click', function () { setAll('local'); });
206
+  document.getElementById('ficKeepRemote').addEventListener('click', function () { setAll('remote'); });
207
+
208
+  document.getElementById('ficApply').addEventListener('click', function () {
209
+    const picked = selectedChoices();
210
+    if (picked.pending > 0 || !$select.val()) {
211
+      return;
212
+    }
213
+    const button = this;
214
+    button.disabled = true;
215
+    $.ajax({
216
+      url: @json(url('admin/azienda')) + '/' + aziendaId + '/fic',
217
+      method: 'POST',
218
+      data: {
219
+        _token: @json(csrf_token()),
220
+        fic_client_id: $select.val(),
221
+        scelte: picked.scelte
222
+      },
223
+      success: function (res) {
224
+        if (window.LaravelDataTables && window.LaravelDataTables['dataTable_azienda']) {
225
+          window.LaravelDataTables['dataTable_azienda'].ajax.reload(null, false);
226
+        }
227
+        if (typeof notyf !== 'undefined') {
228
+          notyf.success((res && res.message) || 'Anagrafica allineata.');
229
+        }
230
+        modal.hide();
231
+      },
232
+      error: function (xhr) {
233
+        const json = xhr.responseJSON || {};
234
+        const first = json.errors ? Object.values(json.errors).flat()[0] : null;
235
+        showAlert('ficErrorAlert', first || json.message || 'Impossibile applicare il confronto.');
236
+        button.disabled = false;
237
+      }
238
+    });
239
+  });
240
+
241
+  document.getElementById('ficCreate').addEventListener('click', function () {
242
+    Swal.fire({
243
+      title: 'Creare il cliente su Fatture in Cloud?',
244
+      text: 'Verrà creato un cliente azienda con i dati locali e collegato a questa anagrafica.',
245
+      icon: 'question',
246
+      showCancelButton: true,
247
+      confirmButtonText: 'Crea',
248
+      cancelButtonText: 'Annulla',
249
+      customClass: { confirmButton: 'btn btn-primary me-2', cancelButton: 'btn btn-label-secondary' },
250
+      buttonsStyling: false
251
+    }).then(function (result) {
252
+      if (!result.isConfirmed) {
253
+        return;
254
+      }
255
+      $.ajax({
256
+        url: @json(url('admin/azienda')) + '/' + aziendaId + '/fic',
257
+        method: 'POST',
258
+        data: { _token: @json(csrf_token()), crea: 1 },
259
+        success: function (res) {
260
+          if (window.LaravelDataTables && window.LaravelDataTables['dataTable_azienda']) {
261
+            window.LaravelDataTables['dataTable_azienda'].ajax.reload(null, false);
262
+          }
263
+          if (typeof notyf !== 'undefined') {
264
+            notyf.success((res && res.message) || 'Cliente creato.');
265
+          }
266
+          modal.hide();
267
+        },
268
+        error: function (xhr) {
269
+          const json = xhr.responseJSON || {};
270
+          const first = json.errors ? Object.values(json.errors).flat()[0] : null;
271
+          showAlert('ficErrorAlert', first || json.message || 'Impossibile creare il cliente.');
272
+        }
273
+      });
274
+    });
275
+  });
276
+
277
+  document.getElementById('ficUnlink').addEventListener('click', function () {
278
+    Swal.fire({
279
+      title: 'Scollegare il cliente?',
280
+      text: 'Il cliente su Fatture in Cloud resta dov\'è. Si toglie solo il collegamento.',
281
+      icon: 'warning',
282
+      showCancelButton: true,
283
+      confirmButtonText: 'Scollega',
284
+      cancelButtonText: 'Annulla',
285
+      customClass: { confirmButton: 'btn btn-danger me-2', cancelButton: 'btn btn-label-secondary' },
286
+      buttonsStyling: false
287
+    }).then(function (result) {
288
+      if (!result.isConfirmed) {
289
+        return;
290
+      }
291
+      $.ajax({
292
+        url: @json(url('admin/azienda')) + '/' + aziendaId + '/fic',
293
+        method: 'DELETE',
294
+        data: { _token: @json(csrf_token()) },
295
+        success: function (res) {
296
+          linked = false;
297
+          if (window.LaravelDataTables && window.LaravelDataTables['dataTable_azienda']) {
298
+            window.LaravelDataTables['dataTable_azienda'].ajax.reload(null, false);
299
+          }
300
+          if (typeof notyf !== 'undefined') {
301
+            notyf.success((res && res.message) || 'Collegamento rimosso.');
302
+          }
303
+          modal.hide();
304
+        },
305
+        error: function (xhr) {
306
+          showAlert('ficErrorAlert', (xhr.responseJSON && xhr.responseJSON.message) || 'Impossibile scollegare.');
307
+        }
308
+      });
309
+    });
310
+  });
311
+});
312
+</script>

+ 2
- 0
resources/views/azienda/index.blade.php 查看文件

@@ -49,6 +49,7 @@ $configData = Helper::appClasses();
49 49
 </div>
50 50
 
51 51
 @include('azienda.modal')
52
+@include('azienda.fic-modal')
52 53
 @endsection
53 54
 
54 55
 @section('page-script')
@@ -57,6 +58,7 @@ $configData = Helper::appClasses();
57 58
   window.aziendaModalReloadDataTable = 'dataTable_azienda';
58 59
 </script>
59 60
 @include('azienda.modal-scripts')
61
+@include('azienda.fic-scripts')
60 62
 <script type="module">
61 63
   $(document).ready(function () {
62 64
     const $table = $('#dataTable_azienda');

+ 5
- 0
resources/views/azienda/menu.blade.php 查看文件

@@ -15,6 +15,11 @@ use Illuminate\Support\Facades\Auth;
15 15
     </li>
16 16
     @endif
17 17
     @if(Auth::user()->can('edit-azienda'))
18
+    <li>
19
+      <a href="#" class="dropdown-item js-fic-azienda" data-id="{{ $entity->id }}" data-label="{{ $entity->full_name }}">
20
+        <i class="bx bx-cloud me-1"></i> Fatture in Cloud
21
+      </a>
22
+    </li>
18 23
     <li>
19 24
       <a href="#" class="dropdown-item js-edit-azienda" data-id="{{ $entity->id }}">
20 25
         <i class="bx bx-edit-alt me-1"></i> Modifica

+ 13
- 1
resources/views/config/index.blade.php 查看文件

@@ -5,6 +5,8 @@ $tabIcons = [
5 5
   'Segnalazioni' => 'bx-flag',
6 6
   'Documenti PDF' => 'bx-file',
7 7
   'Acquisto servizio' => 'bx-cart',
8
+  'Fatturazione' => 'bx-receipt',
9
+  'Fatture in Cloud' => 'bx-cloud',
8 10
 ];
9 11
 $groupKeys = array_keys($groups);
10 12
 @endphp
@@ -122,7 +124,7 @@ $groupKeys = array_keys($groups);
122 124
               @case('date')
123 125
               <input type="date" class="form-control" name="value[{{ $config->key }}]" id="{{ $fid }}-control" value="{{ $config->value }}" {{ $disabled }}>
124 126
               @if($config->key === Config::FATTURAZIONE_RINNOVI_DA)
125
-              <div class="form-text">I canoni con data di rinnovo precedente non vengono fatturati (già gestiti sulla piattaforma precedente).</div>
127
+              <div class="form-text">Non si fatturano i cicli aperti prima di questa data (già gestiti sulla piattaforma precedente). Un canone mensile partito prima entra dalla prima rata che inizia in questo giorno o dopo.</div>
126 128
               @endif
127 129
               @break
128 130
 
@@ -181,8 +183,18 @@ $groupKeys = array_keys($groups);
181 183
               </div>
182 184
               @break
183 185
 
186
+              @case('password')
187
+              <input type="password" class="form-control" name="value[{{ $config->key }}]" id="{{ $fid }}-control" value="" autocomplete="new-password" placeholder="{{ $config->value ? 'Token già salvato' : '' }}" {{ $disabled }}>
188
+              @if($config->key === Config::FIC_ACCESS_TOKEN)
189
+              <div class="form-text">Token manuale di <a href="https://developers.fattureincloud.it/docs/getting-started/" target="_blank" rel="noopener">Fatture in Cloud</a> con scope <code>entity.clients:r</code> e <code>entity.clients:a</code>. Lascia vuoto per non sostituirlo.</div>
190
+              @endif
191
+              @break
192
+
184 193
               @default
185 194
               <input type="text" class="form-control" name="value[{{ $config->key }}]" id="{{ $fid }}-control" value="{{ $config->value }}" {{ $disabled }}>
195
+              @if($config->key === Config::FIC_COMPANY_ID)
196
+              <div class="form-text">Identificativo azienda su Fatture in Cloud. Si legge anche da Impostazioni → API, oppure con il metodo List User Companies.</div>
197
+              @endif
186 198
             @endswitch
187 199
           </div>
188 200
           @empty

+ 4
- 2
resources/views/fatturazione/genera.blade.php 查看文件

@@ -66,7 +66,7 @@
66 66
       <div class="card-body">
67 67
         <span class="badge bg-label-primary mb-2">Totale da fatturare</span>
68 68
         <h4 class="mb-0">{{ Config::formatCosto($totali['totale']) }}</h4>
69
-        <p class="text-muted mb-0 small">Tempi, spese e canoni</p>
69
+        <p class="text-muted mb-0 small">Tempi, spese, ordini e canoni</p>
70 70
       </div>
71 71
     </div>
72 72
   </div>
@@ -102,8 +102,10 @@
102 102
 <p class="text-muted small mb-4">
103 103
   Tariffa default {{ number_format($esito['tariffa_default'], 2, ',', '.') }} €/ora.
104 104
   Le ore coperte da contratto o ordine non entrano nel totale; extra e ticket liberi sì.
105
+  Una segnalazione coperta dal contratto, senza ore né spese da fatturare, non compare in elenco.
106
+  L'ordine collegato a una segnalazione del periodo si fattura una volta, con le sue righe.
105 107
   Le scadenze sono segnalate ma non sommate (importo suggerito a parte).
106
-  I rinnovi si fatturano solo dal {{ Config::rinnoviDa()->format('d/m/Y') }} in poi.
108
+  I rinnovi si fatturano solo se il ciclo si apre dal {{ Config::rinnoviDa()->format('d/m/Y') }} in poi: un annuale partito il 01/01/2026 non entra nel 2026, un mensile sì dalla rata di quel giorno.
107 109
   Il costo d'uscita ({{ Config::formatCosto(Config::costoUscitaDefault()) }}) si aggiunge se spuntato in registrazione tempo.
108 110
 </p>
109 111
 

+ 37
- 4
resources/views/fatturazione/partials/cliente.blade.php 查看文件

@@ -37,14 +37,14 @@
37 37
       @endif
38 38
       @endcan
39 39
       <div class="row g-3 mb-4">
40
-        <div class="col-sm-4">
40
+        <div class="col-sm-6 col-xl-3">
41 41
           <div class="border rounded p-3 h-100">
42 42
             <div class="text-muted small">Ore da fatturare</div>
43 43
             <div class="fw-semibold">{{ Config::formatTempo($cliente['minuti_fatturare']) }}</div>
44 44
             <div class="text-muted small">Coperte: {{ Config::formatTempo($cliente['minuti_coperti']) }}</div>
45 45
           </div>
46 46
         </div>
47
-        <div class="col-sm-4">
47
+        <div class="col-sm-6 col-xl-3">
48 48
           <div class="border rounded p-3 h-100">
49 49
             <div class="text-muted small">Tempi + spese</div>
50 50
             <div class="fw-semibold">{{ Config::formatCosto($cliente['importo_tempi'] + $cliente['importo_spese']) }}</div>
@@ -53,7 +53,13 @@
53 53
             @endif
54 54
           </div>
55 55
         </div>
56
-        <div class="col-sm-4">
56
+        <div class="col-sm-6 col-xl-3">
57
+          <div class="border rounded p-3 h-100">
58
+            <div class="text-muted small">Ordini</div>
59
+            <div class="fw-semibold">{{ Config::formatCosto($cliente['importo_ordini'] ?? 0) }}</div>
60
+          </div>
61
+        </div>
62
+        <div class="col-sm-6 col-xl-3">
57 63
           <div class="border rounded p-3 h-100">
58 64
             <div class="text-muted small">Canoni / rinnovi</div>
59 65
             <div class="fw-semibold">{{ Config::formatCosto($cliente['importo_rinnovi']) }}</div>
@@ -128,6 +134,33 @@
128 134
       </div>
129 135
       @endif
130 136
 
137
+      @if(count($cliente['ordini'] ?? []) > 0)
138
+      <h6 class="mb-2">Ordini collegati</h6>
139
+      <div class="table-responsive mb-4">
140
+        <table class="table table-sm align-middle mb-0">
141
+          <thead>
142
+            <tr>
143
+              <th>Ordine</th>
144
+              <th class="text-end">Importo</th>
145
+            </tr>
146
+          </thead>
147
+          <tbody>
148
+            @foreach($cliente['ordini'] as $ordine)
149
+            <tr>
150
+              <td>
151
+                <a href="{{ route('admin.ordine_lavoro.show', $ordine['id']) }}">{{ $ordine['label'] }}</a>
152
+                @foreach($ordine['righe'] as $rigaOrdine)
153
+                <div class="text-muted small">{{ $rigaOrdine['descrizione'] }} · {{ Config::formatCosto($rigaOrdine['importo']) }}</div>
154
+                @endforeach
155
+              </td>
156
+              <td class="text-end">{{ Config::formatCosto($ordine['importo']) }}</td>
157
+            </tr>
158
+            @endforeach
159
+          </tbody>
160
+        </table>
161
+      </div>
162
+      @endif
163
+
131 164
       @if(count($cliente['rinnovi']) > 0)
132 165
       <h6 class="mb-2">Rinnovi e scadenze</h6>
133 166
       <div class="table-responsive">
@@ -180,7 +213,7 @@
180 213
       </div>
181 214
       @endif
182 215
 
183
-      @if(count($cliente['segnalazioni'] ?? []) === 0 && count($cliente['rinnovi'] ?? []) === 0)
216
+      @if(count($cliente['segnalazioni'] ?? []) === 0 && count($cliente['ordini'] ?? []) === 0 && count($cliente['rinnovi'] ?? []) === 0)
184 217
       <p class="text-muted mb-0">Nessuna voce nel periodo.</p>
185 218
       @endif
186 219
     </div>

+ 369
- 1
resources/views/fatturazione/show.blade.php 查看文件

@@ -10,12 +10,27 @@
10 10
 @endsection
11 11
 
12 12
 @section('content')
13
+@php
14
+  $dataInput = function ($value) {
15
+    return $value ? \Carbon\Carbon::parse($value)->format('Y-m-d') : '';
16
+  };
17
+  $numInput = function ($value) {
18
+    if ($value === null || $value === '') {
19
+      return '';
20
+    }
21
+    return rtrim(rtrim(number_format((float) $value, 2, '.', ''), '0'), '.');
22
+  };
23
+@endphp
24
+
13 25
 <div class="d-flex flex-wrap align-items-center gap-2 mb-4 pb-3 border-bottom">
14 26
   @can('edit-fattura')
27
+  <button type="submit" class="btn btn-primary btn-sm" form="formFattura">
28
+    <i class="bx bx-save me-1"></i>Salva
29
+  </button>
15 30
   @if($fattura->isBozza())
16 31
   <form method="POST" action="{{ route('admin.fatturazione.emetti', $fattura->id) }}" class="d-inline">
17 32
     @csrf
18
-    <button type="submit" class="btn btn-primary btn-sm">
33
+    <button type="submit" class="btn btn-label-primary btn-sm">
19 34
       <i class="bx bx-check me-1"></i>Emetti
20 35
     </button>
21 36
   </form>
@@ -57,7 +72,253 @@
57 72
 @if(session('error'))
58 73
 <div class="alert alert-danger">{{ session('error') }}</div>
59 74
 @endif
75
+@if($errors->any())
76
+<div class="alert alert-danger">
77
+  <div class="fw-semibold mb-1">Controlla i dati inseriti.</div>
78
+  <ul class="mb-0">
79
+    @foreach($errors->all() as $error)
80
+    <li>{{ $error }}</li>
81
+    @endforeach
82
+  </ul>
83
+</div>
84
+@endif
85
+
86
+@can('edit-fattura')
87
+@php
88
+  $righeForm = old('righe');
89
+  if ($righeForm === null) {
90
+    $righeForm = $fattura->righe->map(fn ($r) => [
91
+      'id' => $r->id,
92
+      'tipo' => $r->tipo,
93
+      'descrizione' => $r->descrizione,
94
+      'quantita' => $r->quantita,
95
+      'unita_misura' => $r->unita_misura,
96
+      'prezzo_unitario' => $r->prezzo_unitario,
97
+      'sconto' => $r->sconto,
98
+      'importo' => $r->importo,
99
+      'minuti' => $r->minuti,
100
+      'nota' => $r->nota,
101
+      'segnalazione_id' => $r->segnalazione_id,
102
+      'contratto_servizio_id' => $r->contratto_servizio_id,
103
+      'ordine_lavoro_id' => $r->ordine_lavoro_id,
104
+    ])->all();
105
+  }
106
+  $comuneId = old('comune_id', $fattura->comune_id);
107
+  $comune = $fattura->comune;
108
+  if ($comuneId && (string) $comuneId !== (string) $fattura->comune_id) {
109
+    $comune = \App\Models\Comune::with('provincia')->find($comuneId);
110
+  }
111
+@endphp
112
+<form method="POST" action="{{ route('admin.fatturazione.update', $fattura->id) }}" id="formFattura">
113
+  @csrf
114
+  @method('PUT')
115
+
116
+  <div class="card mb-4">
117
+    <div class="card-header"><h5 class="mb-0">Dati fattura</h5></div>
118
+    <div class="card-body">
119
+      <div class="row g-3">
120
+        <div class="col-md-2">
121
+          <label class="form-label" for="numero">Numero</label>
122
+          <input type="number" min="1" step="1" name="numero" id="numero" class="form-control" value="{{ old('numero', $fattura->numero) }}">
123
+          <div class="form-text">In testata compare come numero/anno della data.</div>
124
+        </div>
125
+        <div class="col-md-2">
126
+          <label class="form-label" for="stato">Stato</label>
127
+          <select name="stato" id="stato" class="form-select">
128
+            @foreach(\App\Models\Fattura::STATI as $val => $label)
129
+            <option value="{{ $val }}" @selected(old('stato', $fattura->stato) === $val)>{{ $label }}</option>
130
+            @endforeach
131
+          </select>
132
+        </div>
133
+        <div class="col-md-2">
134
+          <label class="form-label" for="data">Data</label>
135
+          <input type="date" name="data" id="data" class="form-control" value="{{ old('data', $dataInput($fattura->data)) }}">
136
+        </div>
137
+        <div class="col-md-2">
138
+          <label class="form-label" for="data_scadenza">Scadenza</label>
139
+          <input type="date" name="data_scadenza" id="data_scadenza" class="form-control" value="{{ old('data_scadenza', $dataInput($fattura->data_scadenza)) }}">
140
+        </div>
141
+        <div class="col-md-2">
142
+          <label class="form-label" for="periodo_da">Periodo da</label>
143
+          <input type="date" name="periodo_da" id="periodo_da" class="form-control" value="{{ old('periodo_da', $dataInput($fattura->periodo_da)) }}">
144
+        </div>
145
+        <div class="col-md-2">
146
+          <label class="form-label" for="periodo_a">Periodo a</label>
147
+          <input type="date" name="periodo_a" id="periodo_a" class="form-control" value="{{ old('periodo_a', $dataInput($fattura->periodo_a)) }}">
148
+        </div>
149
+
150
+        <div class="col-md-6">
151
+          <label class="form-label" for="ragione_sociale">Ragione sociale / cliente</label>
152
+          <input type="text" name="ragione_sociale" id="ragione_sociale" class="form-control" value="{{ old('ragione_sociale', $fattura->ragione_sociale) }}">
153
+        </div>
154
+        <div class="col-md-3">
155
+          <label class="form-label" for="piva">P. IVA</label>
156
+          <input type="text" name="piva" id="piva" class="form-control" value="{{ old('piva', $fattura->piva) }}">
157
+        </div>
158
+        <div class="col-md-3">
159
+          <label class="form-label" for="cod_fiscale">Codice fiscale</label>
160
+          <input type="text" name="cod_fiscale" id="cod_fiscale" class="form-control" value="{{ old('cod_fiscale', $fattura->cod_fiscale) }}">
161
+        </div>
162
+        <div class="col-md-6">
163
+          <label class="form-label" for="indirizzo">Indirizzo</label>
164
+          <input type="text" name="indirizzo" id="indirizzo" class="form-control" value="{{ old('indirizzo', $fattura->indirizzo) }}">
165
+        </div>
166
+        <div class="col-md-6">
167
+          <label class="form-label" for="comune_id">Comune</label>
168
+          <select name="comune_id" id="comune_id" class="form-select select2-comune" data-placeholder="Cerca comune">
169
+            @if($comune)
170
+            <option value="{{ $comune->id }}" selected>{{ $comune->nome }}@if($comune->provincia) ({{ $comune->provincia->sigla }})@endif</option>
171
+            @endif
172
+          </select>
173
+        </div>
174
+        <div class="col-md-4">
175
+          <label class="form-label" for="email">Email</label>
176
+          <input type="email" name="email" id="email" class="form-control" value="{{ old('email', $fattura->email) }}">
177
+        </div>
178
+        <div class="col-md-4">
179
+          <label class="form-label" for="telefono">Telefono</label>
180
+          <input type="text" name="telefono" id="telefono" class="form-control" value="{{ old('telefono', $fattura->telefono) }}">
181
+        </div>
182
+        <div class="col-md-4">
183
+          <label class="form-label" for="iva_percentuale">IVA %</label>
184
+          <input type="number" step="0.01" min="0" max="100" name="iva_percentuale" id="iva_percentuale" class="form-control" value="{{ old('iva_percentuale', $numInput($fattura->iva_percentuale)) }}">
185
+        </div>
186
+        <div class="col-12">
187
+          <label class="form-label" for="note">Note</label>
188
+          <textarea name="note" id="note" class="form-control" rows="2">{{ old('note', $fattura->note) }}</textarea>
189
+        </div>
190
+      </div>
191
+    </div>
192
+  </div>
193
+
194
+  <div class="card mb-4">
195
+    <div class="card-header d-flex justify-content-between align-items-center">
196
+      <h5 class="mb-0">Voci</h5>
197
+      <button type="button" class="btn btn-sm btn-outline-primary" id="addRiga">
198
+        <i class="bx bx-plus me-1"></i>Voce
199
+      </button>
200
+    </div>
201
+    <div class="table-responsive">
202
+      <table class="table mb-0" id="righeTable">
203
+        <thead>
204
+          <tr>
205
+            <th style="width: 160px;">Tipo</th>
206
+            <th>Descrizione</th>
207
+            <th style="width: 90px;">Qty</th>
208
+            <th style="width: 120px;">U.M.</th>
209
+            <th style="width: 110px;">Prezzo</th>
210
+            <th style="width: 90px;">Sconto %</th>
211
+            <th style="width: 120px;" class="text-end">Importo</th>
212
+            <th style="width: 48px;"></th>
213
+          </tr>
214
+        </thead>
215
+        <tbody>
216
+          @foreach($righeForm as $i => $riga)
217
+          <tr class="riga-row">
218
+            <td>
219
+              <input type="hidden" name="righe[{{ $i }}][id]" value="{{ $riga['id'] ?? '' }}">
220
+              <select name="righe[{{ $i }}][tipo]" class="form-select form-select-sm">
221
+                @foreach(\App\Models\FatturaRiga::TIPI as $val => $label)
222
+                <option value="{{ $val }}" @selected(($riga['tipo'] ?? '') === $val)>{{ $label }}</option>
223
+                @endforeach
224
+              </select>
225
+              <input type="number" min="0" step="1" name="righe[{{ $i }}][minuti]" class="form-control form-control-sm mt-1" value="{{ $riga['minuti'] ?? '' }}" placeholder="Minuti">
226
+            </td>
227
+            <td>
228
+              <input type="text" name="righe[{{ $i }}][descrizione]" class="form-control form-control-sm" value="{{ $riga['descrizione'] ?? '' }}" placeholder="Descrizione">
229
+              <textarea name="righe[{{ $i }}][nota]" class="form-control form-control-sm mt-1" rows="1" placeholder="Nota">{{ $riga['nota'] ?? '' }}</textarea>
230
+              @if(!empty($riga['segnalazione_id']))
231
+              <div class="small mt-1"><a href="{{ route('admin.segnalazione.show', $riga['segnalazione_id']) }}">Apri ticket</a></div>
232
+              @endif
233
+              @if(!empty($riga['contratto_servizio_id']))
234
+              <div class="small"><a href="{{ route('admin.contratto_servizio.show', $riga['contratto_servizio_id']) }}">Apri contratto</a></div>
235
+              @endif
236
+              @if(!empty($riga['ordine_lavoro_id']))
237
+              <div class="small"><a href="{{ route('admin.ordine_lavoro.show', $riga['ordine_lavoro_id']) }}">Apri ordine</a></div>
238
+              @endif
239
+            </td>
240
+            <td><input type="number" step="0.01" name="righe[{{ $i }}][quantita]" class="form-control form-control-sm qty-input" value="{{ $numInput($riga['quantita'] ?? null) }}"></td>
241
+            <td>
242
+              <select name="righe[{{ $i }}][unita_misura]" class="form-select form-select-sm">
243
+                <option value="">—</option>
244
+                @foreach(\App\Models\Prodotto::UNITA_MISURA as $val => $label)
245
+                <option value="{{ $val }}" @selected((string) ($riga['unita_misura'] ?? '') === (string) $val)>{{ $label }}</option>
246
+                @endforeach
247
+                @if(!empty($riga['unita_misura']) && !isset(\App\Models\Prodotto::UNITA_MISURA[$riga['unita_misura']]))
248
+                <option value="{{ $riga['unita_misura'] }}" selected>{{ $riga['unita_misura'] }}</option>
249
+                @endif
250
+              </select>
251
+            </td>
252
+            <td><input type="number" step="0.01" name="righe[{{ $i }}][prezzo_unitario]" class="form-control form-control-sm prezzo-input" value="{{ $numInput($riga['prezzo_unitario'] ?? null) }}"></td>
253
+            <td><input type="number" step="0.01" min="0" max="100" name="righe[{{ $i }}][sconto]" class="form-control form-control-sm sconto-input" value="{{ $numInput($riga['sconto'] ?? null) }}"></td>
254
+            <td><input type="number" step="0.01" name="righe[{{ $i }}][importo]" class="form-control form-control-sm text-end importo-input" value="{{ $numInput($riga['importo'] ?? null) }}"></td>
255
+            <td class="text-end"><button type="button" class="btn btn-sm btn-icon text-danger remove-riga" title="Elimina voce"><i class="bx bx-trash"></i></button></td>
256
+          </tr>
257
+          @endforeach
258
+        </tbody>
259
+        <tfoot>
260
+          <tr>
261
+            <th colspan="6" class="text-end align-middle">Imponibile</th>
262
+            <th>
263
+              <input type="number" step="0.01" name="imponibile" id="imponibile" class="form-control form-control-sm text-end totale-input" value="{{ old('imponibile', $numInput($fattura->imponibile)) }}">
264
+            </th>
265
+            <th></th>
266
+          </tr>
267
+          <tr>
268
+            <th colspan="6" class="text-end align-middle">IVA</th>
269
+            <th>
270
+              <input type="number" step="0.01" name="iva" id="iva" class="form-control form-control-sm text-end totale-input" value="{{ old('iva', $numInput($fattura->iva)) }}">
271
+            </th>
272
+            <th></th>
273
+          </tr>
274
+          <tr>
275
+            <th colspan="6" class="text-end align-middle">Totale</th>
276
+            <th>
277
+              <input type="number" step="0.01" name="totale" id="totale" class="form-control form-control-sm text-end fw-semibold totale-input" value="{{ old('totale', $numInput($fattura->totale)) }}">
278
+            </th>
279
+            <th></th>
280
+          </tr>
281
+        </tfoot>
282
+      </table>
283
+    </div>
284
+    <div class="card-footer d-flex justify-content-between align-items-center flex-wrap gap-2">
285
+      <span class="text-muted small">Quantità, prezzo e sconto aggiornano l'importo della voce. Puoi comunque correggere importo, imponibile, IVA e totale prima di salvare.</span>
286
+      <button type="button" class="btn btn-sm btn-label-secondary" id="ricalcolaTotali">Ricalcola</button>
287
+    </div>
288
+  </div>
289
+</form>
60 290
 
291
+<template id="riga-template">
292
+  <tr class="riga-row">
293
+    <td>
294
+      <input type="hidden" name="righe[__IDX__][id]" value="">
295
+      <select name="righe[__IDX__][tipo]" class="form-select form-select-sm">
296
+        @foreach(\App\Models\FatturaRiga::TIPI as $val => $label)
297
+        <option value="{{ $val }}" @selected($val === \App\Models\FatturaRiga::TIPO_MANUALE)>{{ $label }}</option>
298
+        @endforeach
299
+      </select>
300
+      <input type="number" min="0" step="1" name="righe[__IDX__][minuti]" class="form-control form-control-sm mt-1" placeholder="Minuti">
301
+    </td>
302
+    <td>
303
+      <input type="text" name="righe[__IDX__][descrizione]" class="form-control form-control-sm" placeholder="Descrizione">
304
+      <textarea name="righe[__IDX__][nota]" class="form-control form-control-sm mt-1" rows="1" placeholder="Nota"></textarea>
305
+    </td>
306
+    <td><input type="number" step="0.01" name="righe[__IDX__][quantita]" class="form-control form-control-sm qty-input" value="1"></td>
307
+    <td>
308
+      <select name="righe[__IDX__][unita_misura]" class="form-select form-select-sm">
309
+        <option value="">—</option>
310
+        @foreach(\App\Models\Prodotto::UNITA_MISURA as $val => $label)
311
+        <option value="{{ $val }}">{{ $label }}</option>
312
+        @endforeach
313
+      </select>
314
+    </td>
315
+    <td><input type="number" step="0.01" name="righe[__IDX__][prezzo_unitario]" class="form-control form-control-sm prezzo-input" value="0"></td>
316
+    <td><input type="number" step="0.01" min="0" max="100" name="righe[__IDX__][sconto]" class="form-control form-control-sm sconto-input" value="0"></td>
317
+    <td><input type="number" step="0.01" name="righe[__IDX__][importo]" class="form-control form-control-sm text-end importo-input" value="0"></td>
318
+    <td class="text-end"><button type="button" class="btn btn-sm btn-icon text-danger remove-riga" title="Elimina voce"><i class="bx bx-trash"></i></button></td>
319
+  </tr>
320
+</template>
321
+@else
61 322
 <div class="row g-4">
62 323
   <div class="col-md-4">
63 324
     <div class="card h-100">
@@ -70,6 +331,7 @@
70 331
         @if($fattura->piva)<p class="mb-1">P.IVA {{ $fattura->piva }}</p>@endif
71 332
         @if($fattura->cod_fiscale)<p class="mb-1">CF {{ $fattura->cod_fiscale }}</p>@endif
72 333
         @if($fattura->indirizzo)<p class="mb-1">{{ $fattura->indirizzo }}</p>@endif
334
+        @if($fattura->comune)<p class="mb-1">{{ $fattura->comune->nome }}@if($fattura->comune->provincia) ({{ $fattura->comune->provincia->sigla }})@endif</p>@endif
73 335
         @if($fattura->email)<p class="mb-1">{{ $fattura->email }}</p>@endif
74 336
         @if($fattura->telefono)<p class="mb-1">{{ $fattura->telefono }}</p>@endif
75 337
         <hr>
@@ -80,6 +342,9 @@
80 342
           –
81 343
           {{ $fattura->periodo_a ? \Carbon\Carbon::parse($fattura->periodo_a)->format('d/m/Y') : '—' }}
82 344
         </p>
345
+        @if($fattura->note)
346
+        <p class="mb-0 mt-2">{{ $fattura->note }}</p>
347
+        @endif
83 348
         @if($fattura->createdBy)
84 349
         <p class="text-muted small mb-0 mt-2">Creata da {{ $fattura->createdBy->full_name }}</p>
85 350
         @endif
@@ -149,4 +414,107 @@
149 414
     </div>
150 415
   </div>
151 416
 </div>
417
+@endcan
418
+@endsection
419
+
420
+@can('edit-fattura')
421
+@section('page-script')
422
+<script type="module">
423
+$(function () {
424
+  const form = document.getElementById('formFattura');
425
+  const tbody = document.querySelector('#righeTable tbody');
426
+  const template = document.getElementById('riga-template');
427
+  let idx = tbody.querySelectorAll('.riga-row').length;
428
+
429
+  $('.select2-comune').select2({
430
+    ajax: {
431
+      url: @json(route('comune.list')),
432
+      dataType: 'json',
433
+      delay: 250,
434
+      data: params => ({ term: params.term || '', page: params.page || 1 }),
435
+      processResults: data => ({
436
+        results: (data.results || data).map(c => ({ id: c.id, text: c.nome || c.text })),
437
+        pagination: { more: data.pagination ? data.pagination.more : false }
438
+      })
439
+    },
440
+    allowClear: true,
441
+    width: '100%',
442
+    placeholder: 'Cerca comune'
443
+  });
444
+
445
+  function round2(n) {
446
+    return Math.round((Number(n) + Number.EPSILON) * 100) / 100;
447
+  }
448
+
449
+  function num(el) {
450
+    if (!el) return 0;
451
+    const v = parseFloat(String(el.value).replace(',', '.'));
452
+    return Number.isFinite(v) ? v : 0;
453
+  }
454
+
455
+  function importoRiga(row) {
456
+    const qty = num(row.querySelector('.qty-input'));
457
+    const price = num(row.querySelector('.prezzo-input'));
458
+    const sconto = Math.min(100, Math.max(0, num(row.querySelector('.sconto-input'))));
459
+    return round2(qty * price * (1 - sconto / 100));
460
+  }
461
+
462
+  function scriviTotali() {
463
+    let imponibile = 0;
464
+    tbody.querySelectorAll('.importo-input').forEach(el => { imponibile += num(el); });
465
+    imponibile = round2(imponibile);
466
+    const pct = num(document.getElementById('iva_percentuale'));
467
+    const iva = round2(imponibile * pct / 100);
468
+    document.getElementById('imponibile').value = imponibile.toFixed(2);
469
+    document.getElementById('iva').value = iva.toFixed(2);
470
+    document.getElementById('totale').value = round2(imponibile + iva).toFixed(2);
471
+    form.dataset.totaliManual = '0';
472
+  }
473
+
474
+  function aggiornaImporto(row) {
475
+    const imp = row.querySelector('.importo-input');
476
+    if (imp) imp.value = importoRiga(row).toFixed(2);
477
+    if (form.dataset.totaliManual !== '1') scriviTotali();
478
+  }
479
+
480
+  document.getElementById('addRiga').addEventListener('click', function () {
481
+    const html = template.innerHTML.replaceAll('__IDX__', String(idx++));
482
+    tbody.insertAdjacentHTML('beforeend', html);
483
+    if (form.dataset.totaliManual !== '1') scriviTotali();
484
+  });
485
+
486
+  tbody.addEventListener('click', function (e) {
487
+    const btn = e.target.closest('.remove-riga');
488
+    if (!btn) return;
489
+    btn.closest('.riga-row')?.remove();
490
+    if (form.dataset.totaliManual !== '1') scriviTotali();
491
+  });
492
+
493
+  tbody.addEventListener('input', function (e) {
494
+    const row = e.target.closest('.riga-row');
495
+    if (!row) return;
496
+    if (e.target.classList.contains('qty-input') || e.target.classList.contains('prezzo-input') || e.target.classList.contains('sconto-input')) {
497
+      aggiornaImporto(row);
498
+    } else if (e.target.classList.contains('importo-input') && form.dataset.totaliManual !== '1') {
499
+      scriviTotali();
500
+    }
501
+  });
502
+
503
+  document.getElementById('iva_percentuale').addEventListener('input', function () {
504
+    if (form.dataset.totaliManual !== '1') scriviTotali();
505
+  });
506
+
507
+  form.querySelectorAll('.totale-input').forEach(el => {
508
+    el.addEventListener('input', function () {
509
+      form.dataset.totaliManual = '1';
510
+    });
511
+  });
512
+
513
+  document.getElementById('ricalcolaTotali').addEventListener('click', function () {
514
+    tbody.querySelectorAll('.riga-row').forEach(aggiornaImporto);
515
+    scriviTotali();
516
+  });
517
+});
518
+</script>
152 519
 @endsection
520
+@endcan

+ 7
- 0
routes/web.php 查看文件

@@ -17,6 +17,7 @@ use App\Http\Controllers\SegnalazioneController;
17 17
 use App\Http\Controllers\TempoImpiegatoController;
18 18
 use App\Http\Controllers\UserController;
19 19
 use App\Http\Controllers\AziendaController;
20
+use App\Http\Controllers\AziendaFicController;
20 21
 use App\Http\Controllers\ProdottoController;
21 22
 use App\Http\Controllers\OffertaController;
22 23
 use App\Http\Controllers\FatturazioneController;
@@ -119,6 +120,7 @@ Route::middleware(['auth:sanctum', config('jetstream.auth_session')])->prefix('a
119 120
     Route::get('fatturazione/genera', [FatturazioneController::class, 'admin_genera'])->name('admin.fatturazione.genera');
120 121
     Route::post('fatturazione/crea', [FatturazioneController::class, 'admin_crea'])->name('admin.fatturazione.crea');
121 122
     Route::get('fatturazione/{id}', [FatturazioneController::class, 'admin_show'])->name('admin.fatturazione.show')->whereNumber('id');
123
+    Route::put('fatturazione/{id}', [FatturazioneController::class, 'admin_update'])->name('admin.fatturazione.update')->whereNumber('id');
122 124
     Route::post('fatturazione/{id}/emetti', [FatturazioneController::class, 'admin_emetti'])->name('admin.fatturazione.emetti')->whereNumber('id');
123 125
     Route::post('fatturazione/{id}/annulla', [FatturazioneController::class, 'admin_annulla'])->name('admin.fatturazione.annulla')->whereNumber('id');
124 126
     Route::delete('fatturazione/{id}', [FatturazioneController::class, 'admin_destroy'])->name('admin.fatturazione.destroy')->whereNumber('id');
@@ -173,6 +175,11 @@ Route::middleware(['auth:sanctum', config('jetstream.auth_session')])->prefix('a
173 175
     // Azienda
174 176
     Route::get('azienda', [AziendaController::class, 'admin_index'])->name('admin.azienda.index');
175 177
     Route::post('azienda/store', [AziendaController::class, 'admin_store'])->name('admin.azienda.store');
178
+    Route::get('azienda/fic/clienti', [AziendaFicController::class, 'clienti'])->name('admin.azienda.fic.clienti');
179
+    Route::get('azienda/{id}/fic', [AziendaFicController::class, 'show'])->name('admin.azienda.fic.show')->whereNumber('id');
180
+    Route::get('azienda/{id}/fic/confronto', [AziendaFicController::class, 'confronto'])->name('admin.azienda.fic.confronto')->whereNumber('id');
181
+    Route::post('azienda/{id}/fic', [AziendaFicController::class, 'applica'])->name('admin.azienda.fic.applica')->whereNumber('id');
182
+    Route::delete('azienda/{id}/fic', [AziendaFicController::class, 'scollega'])->name('admin.azienda.fic.scollega')->whereNumber('id');
176 183
     Route::get('azienda/{id}', [AziendaController::class, 'admin_show'])->name('admin.azienda.show');
177 184
     Route::delete('azienda/{id}', [AziendaController::class, 'admin_delete'])->name('admin.azienda.delete');
178 185
 

正在加载...
取消
保存