Index: src/include/lib/Garradin/Accounting/Years.php ================================================================== --- src/include/lib/Garradin/Accounting/Years.php +++ src/include/lib/Garradin/Accounting/Years.php @@ -22,12 +22,40 @@ static public function listOpen() { $em = EntityManager::getInstance(Year::class); return $em->all('SELECT * FROM @TABLE WHERE closed = 0 ORDER BY end_date;'); } + + static public function listClosed() + { + $em = EntityManager::getInstance(Year::class); + return $em->all('SELECT * FROM @TABLE WHERE closed = 1 ORDER BY end_date;'); + } static public function list() { $em = EntityManager::getInstance(Year::class); return $em->all('SELECT * FROM @TABLE ORDER BY end_date;'); } + + static public function getNewYearDates(): array + { + $last_year = EntityManager::findOne(Year::class, 'SELECT * FROM @TABLE ORDER BY end_date DESC LIMIT 1;'); + + if ($last_year) { + $diff = $last_year->start_date->diff($last_year->end_date); + + $start_date = clone $last_year->end_date; + $start_date->modify('+1 day'); + + $end_date = clone $start_date; + $end_date->add($diff); + } + else { + $start_date = new \DateTime; + $end_date = clone $start_date; + $end_date->modify('+1 year'); + } + + return [$start_date, $end_date]; + } } Index: src/include/lib/Garradin/Entities/Accounting/Account.php ================================================================== --- src/include/lib/Garradin/Entities/Accounting/Account.php +++ src/include/lib/Garradin/Entities/Accounting/Account.php @@ -44,10 +44,13 @@ const TYPE_ANALYTICAL = 6; const TYPE_VOLUNTEERING = 7; const TYPE_THIRD_PARTY = 8; + const TYPE_OPENING = 9; + const TYPE_CLOSING = 10; + const TYPES_NAMES = [ '', 'Recettes', 'Dépenses', 'Banque', @@ -54,10 +57,12 @@ 'Caisse', 'Attente d\'encaissement', 'Analytique', 'Bénévolat', 'Tiers', + 'Ouverture', + 'Clôture', ]; protected $id; protected $id_chart; protected $code; Index: src/include/lib/Garradin/Entities/Accounting/Line.php ================================================================== --- src/include/lib/Garradin/Entities/Accounting/Line.php +++ src/include/lib/Garradin/Entities/Accounting/Line.php @@ -49,20 +49,36 @@ } $value = $match[1] . str_pad((int)@$match[2], 2, '0', STR_PAD_RIGHT); $value = (int) $value; } + elseif ($key == 'id_analytical' && $value == 0) { + $value = null; + } $value = parent::filterUserValue($key, $value); return $value; } + + public function importForm(array $source = null) + { + if (null === $source) { + $source = $_POST; + } + + if (empty($source['id_analytical'])) { + unset($source['id_analytical']); + } + + return parent::importForm($source); + } public function selfCheck(): void { parent::selfCheck(); - $this->assert($this->credit || $this->debit, 'Aucun montant au débit ou au crédit.'); + $this->assert($this->credit || $this->debit, 'Aucun montant au débit ou au crédit'); $this->assert(($this->credit * $this->debit) === 0 && ($this->credit + $this->debit) > 0, 'Ligne non équilibrée : crédit ou débit doit valoir zéro.'); $this->assert($this->id_transaction, 'Aucun mouvement n\'a été indiqué pour cette ligne.'); $this->assert($this->reconciled === 0 || $this->reconciled === 1); } } Index: src/include/lib/Garradin/Entities/Accounting/Transaction.php ================================================================== --- src/include/lib/Garradin/Entities/Accounting/Transaction.php +++ src/include/lib/Garradin/Entities/Accounting/Transaction.php @@ -7,10 +7,11 @@ use Garradin\Fichiers; use Garradin\Accounting\Accounts; use Garradin\ValidationException; use Garradin\DB; use Garradin\Config; +use Garradin\Utils; class Transaction extends Entity { const TABLE = 'acc_transactions'; @@ -228,11 +229,13 @@ 'id_analytical' => !empty($source['id_analytical']) ? $source['id_analytical'] : null, ]); $this->add($line); } else { - foreach ($source['lines'] as $i => $line) { + $lines = Utils::array_transpose($source['lines']); + + foreach ($lines as $i => $line) { $line['id_account'] = @count($line['account']) ? key($line['account']) : null; if (!$line['id_account']) { throw new ValidationException('Numéro de compte invalide sur la ligne ' . ($i+1)); } @@ -252,19 +255,69 @@ $this->importForm(); $this->_old_lines = $this->getLines(); $this->_lines = []; - foreach ($source['lines'] as $i => $line) { + $lines = Utils::array_transpose($source['lines']); + + foreach ($lines as $i => $line) { $line['id_account'] = @count($line['account']) ? key($line['account']) : null; if (!$line['id_account']) { - var_dump($source); exit; throw new ValidationException('Numéro de compte invalide sur la ligne ' . ($i+1)); } - $line = (new Line)->import($line); + $line = (new Line)->importForm($line); + $this->add($line); + } + } + + public function importFromBalanceForm(Year $year, ?array $source = null): void + { + if (null === $source) { + $source = $_POST; + } + + if (!isset($source['lines']) || !is_array($source['lines'])) { + throw new ValidationException('Aucun contenu trouvé dans le formulaire.'); + } + + $this->label = 'Balance d\'ouverture'; + $this->date = $year->start_date; + $this->id_year = $year->id(); + + $lines = Utils::array_transpose($source['lines']); + $debit = $credit = 0; + + foreach ($lines as $line) { + $line['id_account'] = @count($line['account']) ? key($line['account']) : null; + $line = (new Line)->importForm($line); + $this->add($line); + + $debit += $line->debit; + $credit += $line->credit; + } + + if ($debit != $credit) { + // Add final balance line + $line = new Line; + + if ($debit > $credit) { + $line->debit = $debit - $credit; + } + else { + $line->credit = $credit - $debit; + } + + $open_account = EntityManager::findOne(Account::class, 'SELECT * FROM @TABLE WHERE id_chart = ? AND type = ? LIMIT 1;', $year->id_chart, Account::TYPE_OPENING); + + if (!$open_account) { + throw new ValidationException('Aucun compte favori de bilan d\'ouverture n\'existe dans le plan comptable'); + } + + $line->id_account = $open_account->id(); + $this->add($line); } } public function year() Index: src/include/lib/Garradin/Entities/Accounting/Year.php ================================================================== --- src/include/lib/Garradin/Entities/Accounting/Year.php +++ src/include/lib/Garradin/Entities/Accounting/Year.php @@ -7,94 +7,144 @@ use Garradin\DB; use Garradin\UserException; class Year extends Entity { - const TABLE = 'acc_years'; - - protected $id; - protected $label; - protected $start_date; - protected $end_date; - protected $closed = 0; - protected $id_chart; - - protected $_types = [ - 'id' => 'int', - 'label' => 'string', - 'start_date' => 'date', - 'end_date' => 'date', - 'closed' => 'int', - 'id_chart' => 'int', - ]; - - protected $_form_rules = [ - 'label' => 'required|string|max:200', - 'start_date' => 'required|date_format:d/m/Y', - 'end_date' => 'required|date_format:d/m/Y', - ]; - - public function selfCheck(): void - { - parent::selfCheck(); - $this->assert($this->start_date < $this->end_date, 'La date de fin doit être postérieure à la date de début'); - $this->assert($this->closed === 0 || $this->closed === 1); - $this->assert($this->closed == 1 || !isset($this->_modified['closed']), 'Il est interdit de réouvrir un exercice clôturé'); - - $db = DB::getInstance(); - - $this->assert($this->id_chart !== null); - - // Vérifier qu'on ne crée pas 2 exercices qui se recoupent - if ($this->exists()) { - $this->assert( - !$db->test(self::TABLE, 'id != :id AND ((start_date <= :start_date AND end_date >= :start_date) OR (start_date <= :end_date AND end_date >= :start_date))', - ['id' => $this->id(), 'start_date' => $this->start_date->format('Y-m-d'), 'end_date' => $this->end_date->format('Y-m-d')]), - 'La date de début ou de fin se recoupe avec un exercice existant.' - ); - - $this->assert( - !$db->test(Transaction::TABLE, 'id_year = ? AND date < ?', $this->id(), $this->start_date->format('Y-m-d')), - 'Des mouvements de cet exercice ont une date antérieure à la date de début de l\'exercice.' - ); - - $this->assert( - !$db->test(Transaction::TABLE, 'id_year = ? AND date > ?', $this->id(), $this->end_date->format('Y-m-d')), - 'Des mouvements de cet exercice ont une date postérieure à la date de fin de l\'exercice.' - ); - } - else { - $this->assert( - !$db->test(self::TABLE, '(start_date <= :start_date AND end_date >= :start_date) OR (start_date <= :end_date AND end_date >= :start_date)', - ['start_date' => $this->start_date->format('Y-m-d'), 'end_date' => $this->end_date->format('Y-m-d')]), - 'La date de début ou de fin se recoupe avec un exercice existant.' - ); - } - } - - public function close() - { - if ($this->closed) { - throw new \LogicException('Cet exercice est déjà clôturé'); - } - - $this->set('closed', 1); - } - - public function delete(): bool - { - $db = DB::getInstance(); - - // Ne pas supprimer un compte qui est utilisé ! - if ($db->test(Transaction::TABLE, $db->where('id_year', $this->id()))) - { - throw new UserException('Cet exercice ne peut être supprimé car des mouvements y sont liés.'); - } - - return parent::delete(); - } - - public function chart() - { - return EntityManager::findOneById(Chart::class, $this->id_chart); - } + const TABLE = 'acc_years'; + + protected $id; + protected $label; + protected $start_date; + protected $end_date; + protected $closed = 0; + protected $id_chart; + + protected $_types = [ + 'id' => 'int', + 'label' => 'string', + 'start_date' => 'date', + 'end_date' => 'date', + 'closed' => 'int', + 'id_chart' => 'int', + ]; + + protected $_form_rules = [ + 'label' => 'required|string|max:200', + 'start_date' => 'required|date_format:d/m/Y', + 'end_date' => 'required|date_format:d/m/Y', + ]; + + public function selfCheck(): void + { + parent::selfCheck(); + $this->assert($this->start_date < $this->end_date, 'La date de fin doit être postérieure à la date de début'); + $this->assert($this->closed === 0 || $this->closed === 1); + $this->assert($this->closed == 1 || !isset($this->_modified['closed']), 'Il est interdit de réouvrir un exercice clôturé'); + + $db = DB::getInstance(); + + $this->assert($this->id_chart !== null); + + // Vérifier qu'on ne crée pas 2 exercices qui se recoupent + if ($this->exists()) { + $this->assert( + !$db->test(self::TABLE, 'id != :id AND ((start_date <= :start_date AND end_date >= :start_date) OR (start_date <= :end_date AND end_date >= :start_date))', + ['id' => $this->id(), 'start_date' => $this->start_date->format('Y-m-d'), 'end_date' => $this->end_date->format('Y-m-d')]), + 'La date de début ou de fin se recoupe avec un exercice existant.' + ); + + $this->assert( + !$db->test(Transaction::TABLE, 'id_year = ? AND date < ?', $this->id(), $this->start_date->format('Y-m-d')), + 'Des mouvements de cet exercice ont une date antérieure à la date de début de l\'exercice.' + ); + + $this->assert( + !$db->test(Transaction::TABLE, 'id_year = ? AND date > ?', $this->id(), $this->end_date->format('Y-m-d')), + 'Des mouvements de cet exercice ont une date postérieure à la date de fin de l\'exercice.' + ); + } + else { + $this->assert( + !$db->test(self::TABLE, '(start_date <= :start_date AND end_date >= :start_date) OR (start_date <= :end_date AND end_date >= :start_date)', + ['start_date' => $this->start_date->format('Y-m-d'), 'end_date' => $this->end_date->format('Y-m-d')]), + 'La date de début ou de fin se recoupe avec un exercice existant.' + ); + } + } + + public function close() + { + if ($this->closed) { + throw new \LogicException('Cet exercice est déjà clôturé'); + } + + $this->set('closed', 1); + } + + public function delete(): bool + { + // Ne pas supprimer un compte qui est utilisé ! + if ($count = $this->countTransactions()) { + throw new UserException(sprintf('Cet exercice ne peut être supprimé car %d écritures y sont liées.', $count)); + } + + return parent::delete(); + } + + public function countTransactions(): int + { + $db = DB::getInstance(); + return $db->count(Transaction::TABLE, $db->where('id_year', $this->id())); + } + + public function chart() + { + return EntityManager::findOneById(Chart::class, $this->id_chart); + } + + public function openBalanceFromForm(?array $source = null): Transaction + { + if (null === $source) { + $source = $_POST; + } + + if (!isset($source['lines']) || !is_array($source['lines'])) { + throw new UserException('Aucun contenu trouvé dans le formulaire.'); + } + + if (!isset($lines['account'], $lines['credit'], $lines['debit'])) { + throw new UserException('Problème de contenu dans le formulaire.'); + } + + $transaction = new Transaction; + $transaction->label = 'Balance d\'ouverture'; + $transaction->date = $this->date; + $transaction->id_year = $this->id(); + + $lines = Utils::array_transpose($source['lines']); + $debit = $credit = 0; + + foreach ($lines as $line) { + $line['id_account'] = @count($line['account']) ? key($line['account']) : null; + $line = (new Line)->importForm($line); + $transaction->add($line); + + $debit += $line->debit; + $credit += $line->credit; + } + + $line = new Line; + + if ($debit > $credit) { + $line->debit = $debit - $credit; + } + else { + $line->credit = $credit - $debit; + } + + $line->id_account = EntityManager::findOne(Account::class, 'SELECT * FROM @TABLE WHERE id_chart = ? AND type = ? LIMIT 1;', $this->id_chart, Account::TYPE_OPENING); + + $transaction->add($line); + + return $transaction; + } } Index: src/include/lib/Garradin/Utils.php ================================================================== --- src/include/lib/Garradin/Utils.php +++ src/include/lib/Garradin/Utils.php @@ -871,6 +871,30 @@ case 'document': return '🗅'; default: throw new \InvalidArgumentException('Unknown icon shape: ' . $shape); } } + + static public function array_transpose(array $array): array + { + $out = []; + $count = null; + + foreach ($array as $column => $rows) { + if (null !== $count && count($rows) != $count) { + throw new \LogicException('Array is inconsistent'); + } + + $count = count($rows); + + foreach ($rows as $k => $v) { + if (!isset($out[$k])) { + $out[$k] = []; + } + + $out[$k][$column] = $v; + } + } + + return $out; + } } Index: src/templates/acc/transactions/_lines_form.tpl ================================================================== --- src/templates/acc/transactions/_lines_form.tpl +++ src/templates/acc/transactions/_lines_form.tpl @@ -21,11 +21,11 @@
{foreach from=$lines key="k" item="line"}