02edb1477f8852015b894e0e27a484983fb01e4c
[garradin.git] / include / class.compta_exercices.php
1 <?php
2
3 namespace Garradin;
4
5 class Compta_Exercices
6 {
7 public function add($data)
8 {
9 $this->_checkFields($data);
10
11 $db = DB::getInstance();
12
13 if ($db->simpleQuerySingle('SELECT 1 FROM compta_exercices WHERE
14 (debut <= :debut AND fin >= :debut) OR (debut <= :fin AND fin >= :fin);', false,
15 ['debut' => $data['debut'], 'fin' => $data['fin']]))
16 {
17 throw new UserException('La date de début ou de fin se recoupe avec un autre exercice.');
18 }
19
20 if ($db->querySingle('SELECT 1 FROM compta_exercices WHERE cloture = 0;'))
21 {
22 throw new UserException('Il n\'est pas possible de créer un nouvel exercice tant qu\'il existe un exercice non-clôturé.');
23 }
24
25 $db->simpleInsert('compta_exercices', [
26 'libelle' => trim($data['libelle']),
27 'debut' => $data['debut'],
28 'fin' => $data['fin'],
29 ]);
30
31 return $db->lastInsertRowId();
32 }
33
34 public function edit($id, $data)
35 {
36 $db = DB::getInstance();
37
38 $this->_checkFields($data);
39
40 // Evitons que les exercices se croisent
41 if ($db->simpleQuerySingle('SELECT 1 FROM compta_exercices WHERE id != :id AND
42 ((debut <= :debut AND fin >= :debut) OR (debut <= :fin AND fin >= :fin));', false,
43 ['debut' => $data['debut'], 'fin' => $data['fin'], 'id' => (int) $id]))
44 {
45 throw new UserException('La date de début ou de fin se recoupe avec un autre exercice.');
46 }
47
48 // On vérifie qu'on ne va pas mettre des opérations en dehors de tout exercice
49 if ($db->simpleQuerySingle('SELECT 1 FROM compta_journal WHERE id_exercice = ?
50 AND date < ? LIMIT 1;', false, (int)$id, $data['debut']))
51 {
52 throw new UserException('Des opérations de cet exercice ont une date antérieure à la date de début de l\'exercice.');
53 }
54
55 if ($db->simpleQuerySingle('SELECT 1 FROM compta_journal WHERE id_exercice = ?
56 AND date > ? LIMIT 1;', false, (int)$id, $data['fin']))
57 {
58 throw new UserException('Des opérations de cet exercice ont une date postérieure à la date de fin de l\'exercice.');
59 }
60
61 $db->simpleUpdate('compta_exercices', [
62 'libelle' => trim($data['libelle']),
63 'debut' => $data['debut'],
64 'fin' => $data['fin'],
65 ], 'id = \''.(int)$id.'\'');
66
67 return true;
68 }
69
70 /**
71 * Clôturer un exercice et en ouvrir un nouveau
72 * Le report à nouveau n'est pas effectué automatiquement par cette fonction, voir doReports pour ça.
73 * @param integer $id ID de l'exercice à clôturer
74 * @param string $end Date de clôture de l'exercice au format Y-m-d
75 * @return integer L'ID du nouvel exercice créé
76 */
77 public function close($id, $end)
78 {
79 $db = DB::getInstance();
80
81 if (!utils::checkDate($end))
82 {
83 throw new UserException('Date de fin vide ou invalide.');
84 }
85
86 $db->exec('BEGIN;');
87
88 // Clôture de l'exercice
89 $db->simpleUpdate('compta_exercices', [
90 'cloture' => 1,
91 'fin' => $end,
92 ], 'id = \''.(int)$id.'\'');
93
94 // Date de début du nouvel exercice : lendemain de la clôture du précédent exercice
95 $new_begin = utils::modifyDate($end, '+1 day');
96
97 // Date de fin du nouvel exercice : un an moins un jour après l'ouverture
98 $new_end = utils::modifyDate($new_begin, '+1 year -1 day');
99
100 // Enfin sauf s'il existe déjà des opérations après cette date, auquel cas la date de fin
101 // est fixée à la date de la dernière opération, ceci pour ne pas avoir d'opération
102 // orpheline d'exercice
103 $last = $db->simpleQuerySingle('SELECT date FROM compta_journal WHERE id_exercice = ? AND date >= ? ORDER BY date DESC LIMIT 1;', false, $id, $new_end);
104 $new_end = $last ?: $new_end;
105
106 // Création du nouvel exercice
107 $new_id = $this->add([
108 'debut' => $new_begin,
109 'fin' => $new_end,
110 'libelle' => 'Nouvel exercice'
111 ]);
112
113 // Ré-attribution des opérations de l'exercice à clôturer qui ne sont pas dans son
114 // intervale au nouvel exercice
115 $db->simpleExec('UPDATE compta_journal SET id_exercice = ? WHERE id_exercice = ? AND date >= ?;',
116 $new_id, $id, $new_begin);
117
118 $db->exec('END;');
119
120 return $new_id;
121 }
122
123 /**
124 * Créer les reports à nouveau issus de l'exercice $old_id dans le nouvel exercice courant
125 * @param integer $old_id ID de l'ancien exercice
126 * @param integer $new_id ID du nouvel exercice
127 * @param string $date Date Y-m-d donnée aux opérations créées
128 * @return boolean true si succès
129 */
130 public function doReports($old_id, $date)
131 {
132 $db = DB::getInstance();
133
134 $db->exec('BEGIN;');
135
136 $this->solderResultat($old_id, $date);
137
138 $report_crediteur = 110;
139 $report_debiteur = 119;
140
141 // Récupérer chacun des comptes de bilan et leurs soldes (uniquement les classes 1 à 5)
142 $statement = $db->simpleStatement('SELECT compta_comptes.id AS compte, compta_comptes.position AS position,
143 COALESCE((SELECT SUM(montant) FROM compta_journal WHERE compte_debit = compta_comptes.id AND id_exercice = :id), 0)
144 - COALESCE((SELECT SUM(montant) FROM compta_journal WHERE compte_credit = compta_comptes.id AND id_exercice = :id), 0) AS solde
145 FROM compta_comptes
146 INNER JOIN compta_journal ON compta_comptes.id = compta_journal.compte_debit
147 OR compta_comptes.id = compta_journal.compte_credit
148 WHERE id_exercice = :id AND solde != 0 AND CAST(substr(compta_comptes.id, 1, 1) AS INTEGER) <= 5
149 GROUP BY compta_comptes.id;', ['id' => $old_id]);
150
151 $diff = 0;
152 $journal = new Compta_Journal;
153
154 while ($row = $statement->fetchArray(SQLITE3_ASSOC))
155 {
156 $solde = ($row['position'] & Compta_Comptes::ACTIF) ? abs($row['solde']) : -abs($row['solde']);
157 $solde = round($solde, 2);
158
159 $diff += $solde;
160
161 if (empty($solde))
162 {
163 continue;
164 }
165
166 // Chaque solde de compte est reporté dans le nouvel exercice
167 $journal->add([
168 'libelle' => 'Report à nouveau',
169 'date' => $date,
170 'montant' => abs($solde),
171 'compte_debit' => ($solde < 0 ? NULL : $row['compte']),
172 'compte_credit' => ($solde > 0 ? NULL : $row['compte']),
173 'remarques' => 'Report de solde créé automatiquement à la clôture de l\'exercice précédent',
174 ]);
175 }
176
177 // FIXME utiliser $diff pour équilibrer
178
179 $db->exec('END;');
180
181 return true;
182 }
183
184 /**
185 * Solder les comptes de charge et de produits de l'exercice N
186 * et les inscrire au résultat de l'exercice N+1
187 * @param integer $exercice ID de l'exercice à solder
188 * @param string $date Date de début de l'exercice Y-m-d
189 * @return boolean true en cas de succès
190 */
191 public function solderResultat($exercice, $date)
192 {
193 $db = DB::getInstance();
194
195 $resultat_excedent = 120;
196 $resultat_debiteur = 129;
197
198 $resultat = $this->getCompteResultat($exercice);
199 $resultat = $resultat['resultat'];
200
201 if ($resultat != 0)
202 {
203 $journal = new Compta_Journal;
204 $journal->add([
205 'libelle' => 'Résultat de l\'exercice précédent',
206 'date' => $date,
207 'montant' => abs($resultat),
208 'compte_debit' => $resultat < 0 ? 129 : NULL,
209 'compte_credit' => $resultat > 0 ? 120 : NULL,
210 ]);
211 }
212
213 return true;
214 }
215
216 public function delete($id)
217 {
218 $db = DB::getInstance();
219
220 // Ne pas supprimer un compte qui est utilisé !
221 if ($db->simpleQuerySingle('SELECT 1 FROM compta_journal WHERE id_exercice = ? LIMIT 1;', false, $id))
222 {
223 throw new UserException('Cet exercice ne peut être supprimé car des opérations comptables y sont liées.');
224 }
225
226 $db->simpleExec('DELETE FROM compta_exercices WHERE id = ?;', (int)$id);
227
228 return true;
229 }
230
231 public function get($id)
232 {
233 $db = DB::getInstance();
234 return $db->simpleQuerySingle('SELECT *, strftime(\'%s\', debut) AS debut,
235 strftime(\'%s\', fin) AS fin FROM compta_exercices WHERE id = ?;', true, (int)$id);
236 }
237
238 public function getCurrent()
239 {
240 $db = DB::getInstance();
241 return $db->querySingle('SELECT *, strftime(\'%s\', debut) AS debut, strftime(\'%s\', fin) FROM compta_exercices
242 WHERE cloture = 0 LIMIT 1;', true);
243 }
244
245 public function getCurrentId()
246 {
247 $db = DB::getInstance();
248 return $db->querySingle('SELECT id FROM compta_exercices WHERE cloture = 0 LIMIT 1;');
249 }
250
251 public function getList()
252 {
253 $db = DB::getInstance();
254 return $db->simpleStatementFetchAssocKey('SELECT id, *, strftime(\'%s\', debut) AS debut,
255 strftime(\'%s\', fin) AS fin,
256 (SELECT COUNT(*) FROM compta_journal WHERE id_exercice = compta_exercices.id) AS nb_operations
257 FROM compta_exercices ORDER BY fin DESC;', SQLITE3_ASSOC);
258 }
259
260 protected function _checkFields(&$data)
261 {
262 if (empty($data['libelle']) || !trim($data['libelle']))
263 {
264 throw new UserException('Le libellé ne peut rester vide.');
265 }
266
267 $data['libelle'] = trim($data['libelle']);
268
269 if (empty($data['debut']) || !checkdate(substr($data['debut'], 5, 2), substr($data['debut'], 8, 2), substr($data['debut'], 0, 4)))
270 {
271 throw new UserException('Date de début vide ou invalide.');
272 }
273
274 if (empty($data['fin']) || !checkdate(substr($data['fin'], 5, 2), substr($data['fin'], 8, 2), substr($data['fin'], 0, 4)))
275 {
276 throw new UserException('Date de fin vide ou invalide.');
277 }
278
279 return true;
280 }
281
282
283 public function getJournal($exercice)
284 {
285 $db = DB::getInstance();
286 $query = 'SELECT *, strftime(\'%s\', date) AS date FROM compta_journal
287 WHERE id_exercice = '.(int)$exercice.' ORDER BY date, id;';
288 return $db->simpleStatementFetch($query);
289 }
290
291 public function getGrandLivre($exercice)
292 {
293 $db = DB::getInstance();
294 $livre = ['classes' => [], 'debit' => 0.0, 'credit' => 0.0];
295
296 $res = $db->prepare('SELECT compte FROM
297 (SELECT compte_debit AS compte FROM compta_journal
298 WHERE id_exercice = '.(int)$exercice.' GROUP BY compte_debit
299 UNION
300 SELECT compte_credit AS compte FROM compta_journal
301 WHERE id_exercice = '.(int)$exercice.' GROUP BY compte_credit)
302 ORDER BY base64(compte) COLLATE BINARY ASC;'
303 )->execute();
304
305 while ($row = $res->fetchArray(SQLITE3_NUM))
306 {
307 $compte = $row[0];
308
309 if (is_null($compte))
310 continue;
311
312 $classe = substr($compte, 0, 1);
313 $parent = substr($compte, 0, 2);
314
315 if (!array_key_exists($classe, $livre['classes']))
316 {
317 $livre['classes'][$classe] = [];
318 }
319
320 if (!array_key_exists($parent, $livre['classes'][$classe]))
321 {
322 $livre['classes'][$classe][$parent] = [
323 'total' => 0.0,
324 'comptes' => [],
325 ];
326 }
327
328 $livre['classes'][$classe][$parent]['comptes'][$compte] = ['debit' => 0.0, 'credit' => 0.0, 'journal' => []];
329
330 $livre['classes'][$classe][$parent]['comptes'][$compte]['journal'] = $db->simpleStatementFetch(
331 'SELECT *, strftime(\'%s\', date) AS date FROM (
332 SELECT * FROM compta_journal WHERE compte_debit = :compte AND id_exercice = '.(int)$exercice.'
333 UNION
334 SELECT * FROM compta_journal WHERE compte_credit = :compte AND id_exercice = '.(int)$exercice.'
335 )
336 ORDER BY date, numero_piece, id;', SQLITE3_ASSOC, ['compte' => $compte]);
337
338 $debit = (float) $db->simpleQuerySingle(
339 'SELECT SUM(montant) FROM compta_journal WHERE compte_debit = ? AND id_exercice = '.(int)$exercice.';',
340 false, $compte);
341
342 $credit = (float) $db->simpleQuerySingle(
343 'SELECT SUM(montant) FROM compta_journal WHERE compte_credit = ? AND id_exercice = '.(int)$exercice.';',
344 false, $compte);
345
346 $livre['classes'][$classe][$parent]['comptes'][$compte]['debit'] = $debit;
347 $livre['classes'][$classe][$parent]['comptes'][$compte]['credit'] = $credit;
348
349 $livre['classes'][$classe][$parent]['total'] += $debit;
350 $livre['classes'][$classe][$parent]['total'] -= $credit;
351
352 $livre['debit'] += $debit;
353 $livre['credit'] += $credit;
354 }
355
356 $res->finalize();
357
358 return $livre;
359 }
360
361 public function getCompteResultat($exercice)
362 {
363 $db = DB::getInstance();
364
365 $charges = ['comptes' => [], 'total' => 0.0];
366 $produits = ['comptes' => [], 'total' => 0.0];
367 $resultat = 0.0;
368
369 $res = $db->prepare('SELECT compte, SUM(debit), SUM(credit)
370 FROM
371 (SELECT compte_debit AS compte, SUM(montant) AS debit, 0 AS credit
372 FROM compta_journal WHERE id_exercice = '.(int)$exercice.' GROUP BY compte_debit
373 UNION
374 SELECT compte_credit AS compte, 0 AS debit, SUM(montant) AS credit
375 FROM compta_journal WHERE id_exercice = '.(int)$exercice.' GROUP BY compte_credit)
376 WHERE compte LIKE \'6%\' OR compte LIKE \'7%\'
377 GROUP BY compte
378 ORDER BY base64(compte) COLLATE BINARY ASC;'
379 )->execute();
380
381 while ($row = $res->fetchArray(SQLITE3_NUM))
382 {
383 list($compte, $debit, $credit) = $row;
384 $classe = substr($compte, 0, 1);
385 $parent = substr($compte, 0, 2);
386
387 if ($classe == 6)
388 {
389 if (!isset($charges['comptes'][$parent]))
390 {
391 $charges['comptes'][$parent] = ['comptes' => [], 'solde' => 0.0];
392 }
393
394 $solde = round($debit - $credit, 2);
395
396 if (empty($solde))
397 continue;
398
399 $charges['comptes'][$parent]['comptes'][$compte] = $solde;
400 $charges['total'] += $solde;
401 $charges['comptes'][$parent]['solde'] += $solde;
402 }
403 elseif ($classe == 7)
404 {
405 if (!isset($produits['comptes'][$parent]))
406 {
407 $produits['comptes'][$parent] = ['comptes' => [], 'solde' => 0.0];
408 }
409
410 $solde = round($credit - $debit, 2);
411
412 if (empty($solde))
413 continue;
414
415 $produits['comptes'][$parent]['comptes'][$compte] = $solde;
416 $produits['total'] += $solde;
417 $produits['comptes'][$parent]['solde'] += $solde;
418 }
419 }
420
421 $res->finalize();
422
423 $resultat = $produits['total'] - $charges['total'];
424
425 return ['charges' => $charges, 'produits' => $produits, 'resultat' => $resultat];
426 }
427
428 /**
429 * Calculer le bilan comptable pour l'exercice $exercice
430 * @param integer $exercice ID de l'exercice dont il faut produire le bilan
431 * @param boolean $resultat true s'il faut calculer le résultat de l'exercice (utile pour un exercice en cours)
432 * @return array Un tableau multi-dimensionnel avec deux clés : actif et passif
433 */
434 public function getBilan($exercice)
435 {
436 $db = DB::getInstance();
437
438 $include = [Compta_Comptes::ACTIF, Compta_Comptes::PASSIF,
439 Compta_Comptes::PASSIF | Compta_Comptes::ACTIF];
440
441 $actif = ['comptes' => [], 'total' => 0.0];
442 $passif = ['comptes' => [], 'total' => 0.0];
443
444 $resultat = $this->getCompteResultat($exercice);
445
446 if ($resultat['resultat'] >= 0)
447 {
448 $passif['comptes']['12'] = [
449 'comptes' => ['120' => $resultat['resultat']],
450 'solde' => $resultat['resultat']
451 ];
452
453 $passif['total'] = $resultat['resultat'];
454 }
455 else
456 {
457 $passif['comptes']['12'] = [
458 'comptes' => ['129' => $resultat['resultat']],
459 'solde' => $resultat['resultat']
460 ];
461
462 $passif['total'] = $resultat['resultat'];
463 }
464
465 // Y'a sûrement moyen d'améliorer tout ça pour que le maximum de travail
466 // soit fait au niveau du SQL, mais pour le moment ça marche
467 $res = $db->prepare('SELECT compte, debit, credit, (SELECT position FROM compta_comptes WHERE id = compte) AS position
468 FROM
469 (SELECT compte_debit AS compte, SUM(montant) AS debit, NULL AS credit
470 FROM compta_journal WHERE id_exercice = '.(int)$exercice.' GROUP BY compte_debit
471 UNION
472 SELECT compte_credit AS compte, NULL AS debit, SUM(montant) AS credit
473 FROM compta_journal WHERE id_exercice = '.(int)$exercice.' GROUP BY compte_credit)
474 WHERE compte IN (SELECT id FROM compta_comptes WHERE position IN ('.implode(', ', $include).'))
475 ORDER BY base64(compte) COLLATE BINARY ASC;'
476 )->execute();
477
478 while ($row = $res->fetchArray(SQLITE3_NUM))
479 {
480 list($compte, $debit, $credit, $position) = $row;
481 $parent = substr($compte, 0, 2);
482 $classe = $compte[0];
483
484 if (($position & Compta_Comptes::ACTIF) && ($position & Compta_Comptes::PASSIF))
485 {
486 $solde = $debit - $credit;
487
488 if ($solde > 0)
489 $position = 'actif';
490 elseif ($solde < 0)
491 $position = 'passif';
492 else
493 continue;
494
495 $solde = abs($solde);
496 }
497 else if ($position & Compta_Comptes::ACTIF)
498 {
499 $position = 'actif';
500 $solde = $debit - $credit;
501 }
502 else if ($position & Compta_Comptes::PASSIF)
503 {
504 $position = 'passif';
505 $solde = $credit - $debit;
506 }
507 else
508 {
509 continue;
510 }
511
512 if (!isset(${$position}['comptes'][$parent]))
513 {
514 ${$position}['comptes'][$parent] = ['comptes' => [], 'solde' => 0];
515 }
516
517 if (!isset(${$position}['comptes'][$parent]['comptes'][$compte]))
518 {
519 ${$position}['comptes'][$parent]['comptes'][$compte] = 0;
520 }
521
522 $solde = round($solde, 2);
523 ${$position}['comptes'][$parent]['comptes'][$compte] += $solde;
524 ${$position}['total'] += $solde;
525 ${$position}['comptes'][$parent]['solde'] += $solde;
526 }
527
528 $res->finalize();
529
530 // Suppression des soldes nuls
531 foreach ($passif['comptes'] as $parent=>$p)
532 {
533 if ($p['solde'] == 0)
534 {
535 unset($passif['comptes'][$parent]);
536 continue;
537 }
538
539 foreach ($p['comptes'] as $id=>$solde)
540 {
541 if ($solde == 0)
542 {
543 unset($passif['comptes'][$parent]['comptes'][$id]);
544 }
545 }
546 }
547
548 foreach ($actif['comptes'] as $parent=>$p)
549 {
550 if (empty($p['solde']))
551 {
552 unset($actif['comptes'][$parent]);
553 continue;
554 }
555
556 foreach ($p['comptes'] as $id=>$solde)
557 {
558 if (empty($solde))
559 {
560 unset($actif['comptes'][$parent]['comptes'][$id]);
561 }
562 }
563 }
564
565 return ['actif' => $actif, 'passif' => $passif];
566 }
567 }
568
569 ?>