Ajout : ./garradin
[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 'fluxs' =>
208 [ [ 'compte' => $resultat > 0 ? 129 : 120
209 , 'montant' => abs($resultat) ]
210 ]
211 ]);
212 }
213
214 return true;
215 }
216
217 public function delete($id)
218 {
219 $db = DB::getInstance();
220
221 // Ne pas supprimer un compte qui est utilisé !
222 if ($db->simpleQuerySingle('SELECT 1 FROM compta_journal WHERE id_exercice = ? LIMIT 1;', false, $id))
223 {
224 throw new UserException('Cet exercice ne peut être supprimé car des opérations comptables y sont liées.');
225 }
226
227 $db->simpleExec('DELETE FROM compta_exercices WHERE id = ?;', (int)$id);
228
229 return true;
230 }
231
232 public function get($id)
233 {
234 $db = DB::getInstance();
235 return $db->simpleQuerySingle('SELECT *, strftime(\'%s\', debut) AS debut,
236 strftime(\'%s\', fin) AS fin FROM compta_exercices WHERE id = ?;', true, (int)$id);
237 }
238
239 public function getCurrent()
240 {
241 $db = DB::getInstance();
242 return $db->querySingle('SELECT *, strftime(\'%s\', debut) AS debut, strftime(\'%s\', fin) FROM compta_exercices
243 WHERE cloture = 0 LIMIT 1;', true);
244 }
245
246 public function getCurrentId()
247 {
248 $db = DB::getInstance();
249 return $db->querySingle('SELECT id FROM compta_exercices WHERE cloture = 0 LIMIT 1;');
250 }
251
252 public function getList()
253 {
254 $db = DB::getInstance();
255 return $db->simpleStatementFetchAssocKey('SELECT id, *, strftime(\'%s\', debut) AS debut,
256 strftime(\'%s\', fin) AS fin,
257 (SELECT COUNT(*) FROM compta_journal WHERE id_exercice = compta_exercices.id) AS nb_operations
258 FROM compta_exercices ORDER BY fin DESC;', SQLITE3_ASSOC);
259 }
260
261 protected function _checkFields(&$data)
262 {
263 if (empty($data['libelle']) || !trim($data['libelle']))
264 {
265 throw new UserException('Le libellé ne peut rester vide.');
266 }
267
268 $data['libelle'] = trim($data['libelle']);
269
270 if (empty($data['debut']) || !checkdate(substr($data['debut'], 5, 2), substr($data['debut'], 8, 2), substr($data['debut'], 0, 4)))
271 {
272 throw new UserException('Date de début vide ou invalide.');
273 }
274
275 if (empty($data['fin']) || !checkdate(substr($data['fin'], 5, 2), substr($data['fin'], 8, 2), substr($data['fin'], 0, 4)))
276 {
277 throw new UserException('Date de fin vide ou invalide.');
278 }
279
280 return true;
281 }
282
283
284 public function getJournal($exercice)
285 {
286 $db = DB::getInstance();
287 $query = 'SELECT *, strftime(\'%s\', date) AS date, -montant AS montant_oppose
288 FROM compta_flux
289 LEFT JOIN compta_journal ON compta_journal.id = compta_flux.id_journal
290 WHERE compta_journal.id_exercice = '.(int)$exercice.'
291 ORDER BY date, id;';
292 return $db->simpleStatementFetch($query);
293 }
294
295 public function getGrandLivre($exercice)
296 {
297 $db = DB::getInstance();
298 $livre = ['classes' => [], 'debit' => 0.0, 'credit' => 0.0];
299
300 $res = $db->prepare('SELECT compte FROM compta_flux
301 LEFT JOIN compta_journal ON compta_journal.id = compta_flux.id_journal
302 WHERE id_exercice = '.(int)$exercice.'
303 ORDER BY base64(compte) COLLATE BINARY ASC;'
304 )->execute();
305
306 while ($row = $res->fetchArray(SQLITE3_NUM))
307 {
308 $compte = $row[0];
309
310 if (is_null($compte))
311 continue;
312
313 $classe = substr($compte, 0, 1);
314 $parent = substr($compte, 0, 2);
315
316 if (!array_key_exists($classe, $livre['classes']))
317 {
318 $livre['classes'][$classe] = [];
319 }
320
321 if (!array_key_exists($parent, $livre['classes'][$classe]))
322 {
323 $livre['classes'][$classe][$parent] = [
324 'total' => 0.0,
325 'comptes' => [],
326 ];
327 }
328
329 $livre['classes'][$classe][$parent]['comptes'][$compte] = ['debit' => 0.0, 'credit' => 0.0, 'journal' => []];
330
331 $livre['classes'][$classe][$parent]['comptes'][$compte]['journal'] = $db->simpleStatementFetch(
332 'SELECT *, strftime(\'%s\', date) AS date FROM compta_journal
333 LEFT JOIN compta_flux ON compta_journal.id = compta_flux.id_journal
334 WHERE compte = :compte AND id_exercice = '.(int)$exercice.'
335 ORDER BY date, numero_piece, id;', SQLITE3_ASSOC, ['compte' => $compte]);
336
337 $debit = (float) $db->simpleQuerySingle(
338 'SELECT SUM(montant) FROM compta_journal
339 LEFT JOIN compta_flux ON compta_journal.id = compta_flux.id_journal
340 WHERE compte = ? AND montant > 0 AND id_exercice = '.(int)$exercice.';',
341 false, $compte);
342 $credit = (float) $db->simpleQuerySingle(
343 'SELECT -SUM(montant) FROM compta_journal
344 LEFT JOIN compta_flux ON compta_journal.id = compta_flux.id_journal
345 WHERE compte = ? AND montant < 0 AND id_exercice = '.(int)$exercice.';',
346 false, $compte);
347
348 $livre['classes'][$classe][$parent]['comptes'][$compte]['debit'] = $debit;
349 $livre['classes'][$classe][$parent]['comptes'][$compte]['credit'] = $credit;
350
351 $livre['classes'][$classe][$parent]['total'] += $debit;
352 $livre['classes'][$classe][$parent]['total'] -= $credit;
353
354 $livre['debit'] += $debit;
355 $livre['credit'] += $credit;
356 }
357
358 $res->finalize();
359
360 return $livre;
361 }
362
363 public function getCompteResultat($exercice)
364 {
365 $db = DB::getInstance();
366
367 $charges = ['comptes' => [], 'total' => 0.0];
368 $produits = ['comptes' => [], 'total' => 0.0];
369 $resultat = 0.0;
370
371 $res = $db->prepare('SELECT compte, SUM(debit), SUM(credit)
372 FROM
373 (SELECT compte, SUM(montant) AS debit, 0 AS credit
374 FROM compta_journal
375 LEFT JOIN compta_flux ON compta_journal.id = compta_flux.id_journal
376 WHERE montant > 0 AND id_exercice = '.(int)$exercice.' GROUP BY compte
377 UNION
378 SELECT compte, 0 AS debit, -SUM(montant) AS credit
379 FROM compta_journal
380 LEFT JOIN compta_flux ON compta_journal.id = compta_flux.id_journal
381 WHERE montant < 0 AND id_exercice = '.(int)$exercice.' GROUP BY compte)
382 WHERE compte LIKE \'6%\' OR compte LIKE \'7%\'
383 GROUP BY compte
384 ORDER BY base64(compte) COLLATE BINARY ASC;'
385 )->execute();
386
387
388 while ($row = $res->fetchArray(SQLITE3_NUM))
389 {
390 list($compte, $debit, $credit) = $row;
391 print_r([$compte, $debit, $credit]);
392 $classe = substr($compte, 0, 1);
393 $parent = substr($compte, 0, 2);
394
395 if ($classe == 6)
396 {
397 if (!isset($charges['comptes'][$parent]))
398 {
399 $charges['comptes'][$parent] = ['comptes' => [], 'solde' => 0.0];
400 }
401
402 $solde = round($debit - $credit, 2);
403
404 if (empty($solde))
405 continue;
406
407 $charges['comptes'][$parent]['comptes'][$compte] = $solde;
408 $charges['total'] += $solde;
409 $charges['comptes'][$parent]['solde'] += $solde;
410 }
411 elseif ($classe == 7)
412 {
413 if (!isset($produits['comptes'][$parent]))
414 {
415 $produits['comptes'][$parent] = ['comptes' => [], 'solde' => 0.0];
416 }
417
418 $solde = round($credit - $debit, 2);
419
420 if (empty($solde))
421 continue;
422
423 $produits['comptes'][$parent]['comptes'][$compte] = $solde;
424 $produits['total'] += $solde;
425 $produits['comptes'][$parent]['solde'] += $solde;
426 }
427 }
428
429 $res->finalize();
430
431 $resultat = $produits['total'] - $charges['total'];
432
433 return ['charges' => $charges, 'produits' => $produits, 'resultat' => $resultat];
434 }
435
436 /**
437 * Calculer le bilan comptable pour l'exercice $exercice
438 * @param integer $exercice ID de l'exercice dont il faut produire le bilan
439 * @param boolean $resultat true s'il faut calculer le résultat de l'exercice (utile pour un exercice en cours)
440 * @return array Un tableau multi-dimensionnel avec deux clés : actif et passif
441 */
442 public function getBilan($exercice)
443 {
444 $db = DB::getInstance();
445
446 $include = [Compta_Comptes::ACTIF, Compta_Comptes::PASSIF,
447 Compta_Comptes::PASSIF | Compta_Comptes::ACTIF];
448
449 $actif = ['comptes' => [], 'total' => 0.0];
450 $passif = ['comptes' => [], 'total' => 0.0];
451
452 $resultat = $this->getCompteResultat($exercice);
453
454 if ($resultat['resultat'] >= 0)
455 {
456 $passif['comptes']['12'] = [
457 'comptes' => ['120' => $resultat['resultat']],
458 'solde' => $resultat['resultat']
459 ];
460
461 $passif['total'] = $resultat['resultat'];
462 }
463 else
464 {
465 $passif['comptes']['12'] = [
466 'comptes' => ['129' => $resultat['resultat']],
467 'solde' => $resultat['resultat']
468 ];
469
470 $passif['total'] = $resultat['resultat'];
471 }
472
473 // Y'a sûrement moyen d'améliorer tout ça pour que le maximum de travail
474 // soit fait au niveau du SQL, mais pour le moment ça marche
475 $res = $db->prepare('SELECT compte, debit, credit, (SELECT position FROM compta_comptes WHERE id = compte) AS position
476 FROM
477 (SELECT compte, SUM(montant) AS debit, NULL AS credit
478 FROM compta_journal
479 LEFT JOIN compta_flux ON compta_journal.id = compta_flux.id_journal
480 WHERE montant > 0 AND id_exercice = '.(int)$exercice.' GROUP BY compte
481 UNION
482 SELECT compte, NULL AS debit, SUM(montant) AS credit
483 FROM compta_journal
484 LEFT JOIN compta_flux ON compta_journal.id = compta_flux.id_journal
485 WHERE montant < 0 AND id_exercice = '.(int)$exercice.' GROUP BY compte)
486 WHERE compte IN (SELECT id FROM compta_comptes WHERE position IN ('.implode(', ', $include).'))
487 ORDER BY base64(compte) COLLATE BINARY ASC;'
488 )->execute();
489
490 while ($row = $res->fetchArray(SQLITE3_NUM))
491 {
492 list($compte, $debit, $credit, $position) = $row;
493 $parent = substr($compte, 0, 2);
494 $classe = $compte[0];
495
496 if (($position & Compta_Comptes::ACTIF) && ($position & Compta_Comptes::PASSIF))
497 {
498 $solde = $debit - $credit;
499
500 if ($solde > 0)
501 $position = 'actif';
502 elseif ($solde < 0)
503 $position = 'passif';
504 else
505 continue;
506
507 $solde = abs($solde);
508 }
509 else if ($position & Compta_Comptes::ACTIF)
510 {
511 $position = 'actif';
512 $solde = $debit - $credit;
513 }
514 else if ($position & Compta_Comptes::PASSIF)
515 {
516 $position = 'passif';
517 $solde = $credit - $debit;
518 }
519 else
520 {
521 continue;
522 }
523
524 if (!isset(${$position}['comptes'][$parent]))
525 {
526 ${$position}['comptes'][$parent] = ['comptes' => [], 'solde' => 0];
527 }
528
529 if (!isset(${$position}['comptes'][$parent]['comptes'][$compte]))
530 {
531 ${$position}['comptes'][$parent]['comptes'][$compte] = 0;
532 }
533
534 $solde = round($solde, 2);
535 ${$position}['comptes'][$parent]['comptes'][$compte] += $solde;
536 ${$position}['total'] += $solde;
537 ${$position}['comptes'][$parent]['solde'] += $solde;
538 }
539
540 $res->finalize();
541
542 // Suppression des soldes nuls
543 foreach ($passif['comptes'] as $parent=>$p)
544 {
545 if ($p['solde'] == 0)
546 {
547 unset($passif['comptes'][$parent]);
548 continue;
549 }
550
551 foreach ($p['comptes'] as $id=>$solde)
552 {
553 if ($solde == 0)
554 {
555 unset($passif['comptes'][$parent]['comptes'][$id]);
556 }
557 }
558 }
559
560 foreach ($actif['comptes'] as $parent=>$p)
561 {
562 if (empty($p['solde']))
563 {
564 unset($actif['comptes'][$parent]);
565 continue;
566 }
567
568 foreach ($p['comptes'] as $id=>$solde)
569 {
570 if (empty($solde))
571 {
572 unset($actif['comptes'][$parent]['comptes'][$id]);
573 }
574 }
575 }
576
577 return ['actif' => $actif, 'passif' => $passif];
578 }
579 }
580
581 ?>