diff --git a/Core/Lib/Accounting/TrialBalance.php b/Core/Lib/Accounting/TrialBalance.php new file mode 100644 index 0000000000..81c157df90 --- /dev/null +++ b/Core/Lib/Accounting/TrialBalance.php @@ -0,0 +1,123 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\Accounting; + +use FacturaScripts\Core\Base\DataBase; +use FacturaScripts\Core\Model\Asiento; +use FacturaScripts\Core\Tools; +use FacturaScripts\Dinamic\Model\Partida; + +/** + * Balance de sumas y saldos calculado directamente desde las partidas (nunca + * desde los saldos en caché de subcuentas/cuentas, que pueden estar + * desactualizados tras importaciones masivas). Agrupa por grupo (1 dígito), + * cuenta (3 dígitos) o subcuenta, con filtro opcional de fechas, y excluye + * por defecto los asientos de regularización y cierre. + * + * @author Santiago Lopez + */ +class TrialBalance +{ + const LEVEL_GROUP = 'grupo'; + const LEVEL_ACCOUNT = 'cuenta'; + const LEVEL_SUBACCOUNT = 'subcuenta'; + + /** + * Devuelve el balance de sumas y saldos del ejercicio como lista de filas + * ['codigo' => string, 'debe' => float, 'haber' => float, 'saldo' => float], + * ordenadas por código. + * + * Parámetros opcionales: + * - fecha_desde / fecha_hasta: acotar por fecha de asiento + * - include_closing: incluir los asientos de regularización (R) y cierre (C), + * excluidos por defecto para que el balance no salga a cero en ejercicios cerrados + * + * @param string $codejercicio + * @param string $level una de las constantes LEVEL_* + * @param array $params + * + * @return array + */ + public static function generate(string $codejercicio, string $level = self::LEVEL_GROUP, array $params = []): array + { + $lengths = [ + self::LEVEL_GROUP => 1, + self::LEVEL_ACCOUNT => 3, + self::LEVEL_SUBACCOUNT => 10 + ]; + $length = $lengths[$level] ?? 1; + + $db = new DataBase(); + $sql = 'SELECT SUBSTR(p.codsubcuenta, 1, ' . $length . ') AS codigo,' + . ' COALESCE(SUM(p.debe), 0) AS debe, COALESCE(SUM(p.haber), 0) AS haber' + . ' FROM ' . Partida::tableName() . ' p' + . ' JOIN ' . Asiento::tableName() . ' a ON a.idasiento = p.idasiento' + . ' WHERE a.codejercicio = ' . $db->var2str($codejercicio); + + if (empty($params['include_closing'])) { + $sql .= " AND (a.operacion IS NULL OR a.operacion NOT IN (" + . $db->var2str(Asiento::OPERATION_REGULARIZATION) . ', ' + . $db->var2str(Asiento::OPERATION_CLOSING) . '))'; + } + if (!empty($params['fecha_desde'])) { + $sql .= ' AND a.fecha >= ' . $db->var2str($params['fecha_desde']); + } + if (!empty($params['fecha_hasta'])) { + $sql .= ' AND a.fecha <= ' . $db->var2str($params['fecha_hasta']); + } + + $sql .= ' GROUP BY SUBSTR(p.codsubcuenta, 1, ' . $length . ') ORDER BY codigo'; + + $rows = []; + foreach ($db->select($sql) as $row) { + $debe = Tools::round((float)$row['debe']); + $haber = Tools::round((float)$row['haber']); + $rows[] = [ + 'codigo' => $row['codigo'], + 'debe' => $debe, + 'haber' => $haber, + 'saldo' => Tools::round($debe - $haber) + ]; + } + + return $rows; + } + + /** + * Resultado del ejercicio calculado desde las partidas: la suma de + * (haber - debe) de los grupos 6 y 7. Positivo = beneficio. + * + * @param string $codejercicio + * @param array $params los mismos que generate() + * + * @return float + */ + public static function result(string $codejercicio, array $params = []): float + { + $result = 0.0; + foreach (self::generate($codejercicio, self::LEVEL_GROUP, $params) as $row) { + if (in_array($row['codigo'], ['6', '7'])) { + $result += $row['haber'] - $row['debe']; + } + } + + return Tools::round($result); + } +} diff --git a/Test/Core/Lib/TrialBalanceTest.php b/Test/Core/Lib/TrialBalanceTest.php new file mode 100644 index 0000000000..48454d2e69 --- /dev/null +++ b/Test/Core/Lib/TrialBalanceTest.php @@ -0,0 +1,218 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Test\Core\Lib; + +use FacturaScripts\Core\Lib\Accounting\TrialBalance; +use FacturaScripts\Core\Model\Asiento; +use FacturaScripts\Core\Model\Ejercicio; +use FacturaScripts\Core\Tools; +use FacturaScripts\Core\Where; +use FacturaScripts\Dinamic\Model\Subcuenta; +use FacturaScripts\Test\Traits\DefaultSettingsTrait; +use FacturaScripts\Test\Traits\LogErrorsTrait; +use PHPUnit\Framework\TestCase; + +final class TrialBalanceTest extends TestCase +{ + use DefaultSettingsTrait; + use LogErrorsTrait; + + public static function setUpBeforeClass(): void + { + self::setDefaultSettings(); + + // nos aseguramos de que existe el ejercicio actual antes de instalar el plan contable + $exercise = new Ejercicio(); + $exercise->idempresa = Tools::settings('default', 'idempresa', 1); + $exercise->loadFromDate(Tools::date()); + + self::installAccountingPlan(); + self::removeTaxRegularization(); + } + + public function testTrialBalanceByGroup(): void + { + // creamos el asiento para conocer el ejercicio por defecto + $asiento = new Asiento(); + $asiento->concepto = 'Test balance por grupo'; + $this->assertTrue($asiento->save(), 'asiento-cant-save'); + $codejercicio = $asiento->getExercise()->codejercicio; + + $sub5 = $this->getSubaccountByGroup($codejercicio, '5'); + $sub6 = $this->getSubaccountByGroup($codejercicio, '6'); + $sub7 = $this->getSubaccountByGroup($codejercicio, '7'); + $this->assertNotNull($sub5, 'no-group-5-subaccount'); + $this->assertNotNull($sub6, 'no-group-6-subaccount'); + $this->assertNotNull($sub7, 'no-group-7-subaccount'); + + // el balance puede contener datos de otros tests: comparamos diferencias + $before = $this->indexByCode(TrialBalance::generate($codejercicio)); + $resultBefore = TrialBalance::result($codejercicio); + + // DR 6xx 100, DR 5xx 50 / CR 7xx 150 + $this->addLine($asiento, $sub6, 100, 0); + $this->addLine($asiento, $sub5, 50, 0); + $this->addLine($asiento, $sub7, 0, 150); + + $after = $this->indexByCode(TrialBalance::generate($codejercicio)); + $this->assertEqualsWithDelta(100.0, ($after['6']['debe'] ?? 0) - ($before['6']['debe'] ?? 0), 0.001, 'group-6-debe-wrong'); + $this->assertEqualsWithDelta(50.0, ($after['5']['debe'] ?? 0) - ($before['5']['debe'] ?? 0), 0.001, 'group-5-debe-wrong'); + $this->assertEqualsWithDelta(150.0, ($after['7']['haber'] ?? 0) - ($before['7']['haber'] ?? 0), 0.001, 'group-7-haber-wrong'); + + // resultado = (haber - debe) de los grupos 6 y 7: -100 + 150 = +50 + $this->assertEqualsWithDelta(50.0, TrialBalance::result($codejercicio) - $resultBefore, 0.001, 'result-wrong'); + + // eliminamos y comprobamos que el balance vuelve al estado anterior + $this->assertTrue($asiento->delete(), 'asiento-cant-delete'); + $this->assertEqualsWithDelta($resultBefore, TrialBalance::result($codejercicio), 0.001, 'result-not-restored'); + } + + public function testTrialBalanceByAccountAndSubaccount(): void + { + $asiento = new Asiento(); + $asiento->concepto = 'Test balance por cuenta'; + $this->assertTrue($asiento->save(), 'asiento-cant-save'); + $codejercicio = $asiento->getExercise()->codejercicio; + + $sub6 = $this->getSubaccountByGroup($codejercicio, '6'); + $sub7 = $this->getSubaccountByGroup($codejercicio, '7'); + + $account6 = substr($sub6->codsubcuenta, 0, 3); + $beforeAccount = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_ACCOUNT)); + $beforeSub = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_SUBACCOUNT)); + + // DR 6xx 30 / CR 7xx 30 + $this->addLine($asiento, $sub6, 30, 0); + $this->addLine($asiento, $sub7, 0, 30); + + // nivel cuenta: la cuenta de la subcuenta 6xx suma 30 al debe + $afterAccount = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_ACCOUNT)); + $this->assertEqualsWithDelta(30.0, + ($afterAccount[$account6]['debe'] ?? 0) - ($beforeAccount[$account6]['debe'] ?? 0), 0.001, + 'account-debe-wrong'); + + // nivel subcuenta: la subcuenta exacta suma 30 al debe + $afterSub = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_SUBACCOUNT)); + $this->assertEqualsWithDelta(30.0, + ($afterSub[$sub6->codsubcuenta]['debe'] ?? 0) - ($beforeSub[$sub6->codsubcuenta]['debe'] ?? 0), 0.001, + 'subaccount-debe-wrong'); + + $this->assertTrue($asiento->delete(), 'asiento-cant-delete'); + } + + public function testExcludesClosingEntries(): void + { + // asiento de regularización (operacion R) + $asiento = new Asiento(); + $asiento->concepto = 'Test regularización'; + $asiento->operacion = Asiento::OPERATION_REGULARIZATION; + $this->assertTrue($asiento->save(), 'asiento-cant-save'); + $codejercicio = $asiento->getExercise()->codejercicio; + + $sub6 = $this->getSubaccountByGroup($codejercicio, '6'); + $sub7 = $this->getSubaccountByGroup($codejercicio, '7'); + + $before = $this->indexByCode(TrialBalance::generate($codejercicio)); + $beforeIncluding = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_GROUP, ['include_closing' => true])); + + $this->addLine($asiento, $sub6, 77, 0); + $this->addLine($asiento, $sub7, 0, 77); + + // por defecto los asientos R quedan fuera del balance + $after = $this->indexByCode(TrialBalance::generate($codejercicio)); + $this->assertEqualsWithDelta(0.0, ($after['6']['debe'] ?? 0) - ($before['6']['debe'] ?? 0), 0.001, 'closing-not-excluded'); + + // con include_closing sí aparecen + $afterIncluding = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_GROUP, ['include_closing' => true])); + $this->assertEqualsWithDelta(77.0, + ($afterIncluding['6']['debe'] ?? 0) - ($beforeIncluding['6']['debe'] ?? 0), 0.001, + 'closing-not-included'); + + $this->assertTrue($asiento->delete(), 'asiento-cant-delete'); + } + + public function testDateFilter(): void + { + $asiento = new Asiento(); + $asiento->concepto = 'Test filtro fechas'; + $this->assertTrue($asiento->save(), 'asiento-cant-save'); + $codejercicio = $asiento->getExercise()->codejercicio; + + $sub6 = $this->getSubaccountByGroup($codejercicio, '6'); + $sub7 = $this->getSubaccountByGroup($codejercicio, '7'); + + // rango que excluye la fecha del asiento (posterior a hoy) + $tomorrow = date('Y-m-d', strtotime($asiento->fecha . ' +1 day')); + $beforeOut = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_GROUP, ['fecha_desde' => $tomorrow])); + $beforeIn = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_GROUP, ['fecha_hasta' => $asiento->fecha])); + + $this->addLine($asiento, $sub6, 11, 0); + $this->addLine($asiento, $sub7, 0, 11); + + // fuera del rango: sin cambios + $afterOut = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_GROUP, ['fecha_desde' => $tomorrow])); + $this->assertEqualsWithDelta(0.0, ($afterOut['6']['debe'] ?? 0) - ($beforeOut['6']['debe'] ?? 0), 0.001, 'date-filter-not-applied'); + + // dentro del rango: aparece + $afterIn = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_GROUP, ['fecha_hasta' => $asiento->fecha])); + $this->assertEqualsWithDelta(11.0, ($afterIn['6']['debe'] ?? 0) - ($beforeIn['6']['debe'] ?? 0), 0.001, 'date-filter-wrong'); + + $this->assertTrue($asiento->delete(), 'asiento-cant-delete'); + } + + private function addLine(Asiento $asiento, Subcuenta $subcuenta, float $debe, float $haber): void + { + $line = $asiento->getNewLine(); + $line->setAccount($subcuenta); + $line->concepto = 'Test partida'; + $line->debe = $debe; + $line->haber = $haber; + $this->assertTrue($line->save(), 'linea-cant-save'); + } + + private function getSubaccountByGroup(string $codejercicio, string $group): ?Subcuenta + { + $where = [ + Where::eq('codejercicio', $codejercicio), + Where::gte('codsubcuenta', $group), + Where::lt('codsubcuenta', (string)((int)$group + 1)) + ]; + foreach (Subcuenta::all($where, ['codsubcuenta' => 'ASC'], 0, 1) as $item) { + return $item; + } + + return null; + } + + private function indexByCode(array $rows): array + { + $indexed = []; + foreach ($rows as $row) { + $indexed[$row['codigo']] = $row; + } + + return $indexed; + } + + protected function tearDown(): void + { + $this->logErrors(); + } +}