Skip to content

Commit 760ce34

Browse files
lanzalibreclaude
andcommitted
Añadir BalanceRecalculator: recálculo síncrono de saldos de un ejercicio
Recalcula los saldos en caché de subcuentas y cuentas a partir de las partidas, en una sola llamada. Complementa a PartidaWorker/CuentaWorker para importaciones masivas y scripts CLI donde la WorkQueue no se procesa. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 258f372 commit 760ce34

2 files changed

Lines changed: 382 additions & 0 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<?php
2+
/**
3+
* This file is part of FacturaScripts
4+
* Copyright (C) 2026 Carlos Garcia Gomez <carlos@facturascripts.com>
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Lesser General Public License as
8+
* published by the Free Software Foundation, either version 3 of the
9+
* License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
namespace FacturaScripts\Core\Lib\Accounting;
21+
22+
use FacturaScripts\Core\Base\DataBase;
23+
use FacturaScripts\Core\Tools;
24+
use FacturaScripts\Dinamic\Model\Cuenta;
25+
use FacturaScripts\Dinamic\Model\Partida;
26+
use FacturaScripts\Dinamic\Model\Subcuenta;
27+
28+
/**
29+
* Recalcula de forma síncrona los saldos en caché de todas las subcuentas y
30+
* cuentas de un ejercicio a partir de las partidas.
31+
*
32+
* Complementa a los workers PartidaWorker y CuentaWorker (asíncronos, por
33+
* subcuenta): las importaciones masivas y los scripts CLI no procesan la
34+
* WorkQueue, por lo que los saldos en caché quedan desactualizados. Esta
35+
* clase permite reconstruirlos todos en una sola llamada.
36+
*
37+
* @author Santiago Lopez <santilh@gmail.com>
38+
*/
39+
class BalanceRecalculator
40+
{
41+
/** Tolerancia para no escribir cambios irrelevantes, igual que en los workers */
42+
const TOLERANCE = 0.009;
43+
44+
/**
45+
* Recalcula los saldos de las subcuentas del ejercicio a partir de sus
46+
* partidas, y después los de las cuentas a partir de sus subcuentas y
47+
* cuentas hijas.
48+
*
49+
* @param string $codejercicio
50+
*
51+
* @return bool
52+
*/
53+
public static function run(string $codejercicio): bool
54+
{
55+
$db = new DataBase();
56+
if (false === $db->connected()) {
57+
$db->connect();
58+
}
59+
60+
return self::recalculateSubaccounts($db, $codejercicio)
61+
&& self::recalculateAccounts($db, $codejercicio);
62+
}
63+
64+
protected static function recalculateAccounts(DataBase $db, string $codejercicio): bool
65+
{
66+
// sumas de las subcuentas agrupadas por cuenta
67+
$totals = [];
68+
$sql = 'SELECT idcuenta, COALESCE(SUM(debe), 0) AS debe, COALESCE(SUM(haber), 0) AS haber'
69+
. ' FROM ' . Subcuenta::tableName()
70+
. ' WHERE codejercicio = ' . $db->var2str($codejercicio)
71+
. ' GROUP BY idcuenta';
72+
foreach ($db->select($sql) as $row) {
73+
$totals[$row['idcuenta']] = [
74+
'debe' => (float)$row['debe'],
75+
'haber' => (float)$row['haber']
76+
];
77+
}
78+
79+
// leemos todas las cuentas del ejercicio para poder acumular las hijas en sus padres
80+
$accounts = [];
81+
$children = [];
82+
$sqlAccounts = 'SELECT idcuenta, parent_idcuenta, debe, haber, saldo FROM ' . Cuenta::tableName()
83+
. ' WHERE codejercicio = ' . $db->var2str($codejercicio);
84+
foreach ($db->select($sqlAccounts) as $row) {
85+
$accounts[$row['idcuenta']] = $row;
86+
if (!empty($row['parent_idcuenta']) && $row['parent_idcuenta'] != $row['idcuenta']) {
87+
$children[$row['parent_idcuenta']][] = $row['idcuenta'];
88+
}
89+
}
90+
91+
// calculamos cada cuenta: sus subcuentas más sus cuentas hijas (recursivo con memoria)
92+
$calculated = [];
93+
foreach (array_keys($accounts) as $idcuenta) {
94+
self::accountTotal($idcuenta, $totals, $children, $calculated);
95+
}
96+
97+
// actualizamos solo las cuentas con diferencias
98+
foreach ($accounts as $idcuenta => $row) {
99+
$debe = Tools::round($calculated[$idcuenta]['debe']);
100+
$haber = Tools::round($calculated[$idcuenta]['haber']);
101+
$diffDebe = abs((float)$row['debe'] - $debe);
102+
$diffHaber = abs((float)$row['haber'] - $haber);
103+
$diffSaldo = abs((float)$row['saldo'] - ($debe - $haber));
104+
if ($diffDebe < self::TOLERANCE && $diffHaber < self::TOLERANCE && $diffSaldo < self::TOLERANCE) {
105+
continue;
106+
}
107+
108+
$sqlUpdate = 'UPDATE ' . Cuenta::tableName()
109+
. ' SET debe = ' . $db->var2str($debe)
110+
. ', haber = ' . $db->var2str($haber)
111+
. ', saldo = ' . $db->var2str(Tools::round($debe - $haber))
112+
. ' WHERE idcuenta = ' . $db->var2str($idcuenta);
113+
if (false === $db->exec($sqlUpdate)) {
114+
return false;
115+
}
116+
}
117+
118+
return true;
119+
}
120+
121+
protected static function recalculateSubaccounts(DataBase $db, string $codejercicio): bool
122+
{
123+
// sumas reales de las partidas de cada subcuenta del ejercicio
124+
$sql = 'SELECT s.idsubcuenta, s.debe, s.haber, s.saldo,'
125+
. ' COALESCE(SUM(p.debe), 0) AS suma_debe, COALESCE(SUM(p.haber), 0) AS suma_haber'
126+
. ' FROM ' . Subcuenta::tableName() . ' s'
127+
. ' LEFT JOIN ' . Partida::tableName() . ' p ON p.idsubcuenta = s.idsubcuenta'
128+
. ' WHERE s.codejercicio = ' . $db->var2str($codejercicio)
129+
. ' GROUP BY s.idsubcuenta, s.debe, s.haber, s.saldo';
130+
131+
foreach ($db->select($sql) as $row) {
132+
$debe = Tools::round((float)$row['suma_debe']);
133+
$haber = Tools::round((float)$row['suma_haber']);
134+
135+
// si no hay diferencias, no escribimos
136+
$diffDebe = abs((float)$row['debe'] - $debe);
137+
$diffHaber = abs((float)$row['haber'] - $haber);
138+
$diffSaldo = abs((float)$row['saldo'] - ($debe - $haber));
139+
if ($diffDebe < self::TOLERANCE && $diffHaber < self::TOLERANCE && $diffSaldo < self::TOLERANCE) {
140+
continue;
141+
}
142+
143+
$sqlUpdate = 'UPDATE ' . Subcuenta::tableName()
144+
. ' SET debe = ' . $db->var2str($debe)
145+
. ', haber = ' . $db->var2str($haber)
146+
. ', saldo = ' . $db->var2str(Tools::round($debe - $haber))
147+
. ' WHERE idsubcuenta = ' . $db->var2str($row['idsubcuenta']);
148+
if (false === $db->exec($sqlUpdate)) {
149+
return false;
150+
}
151+
}
152+
153+
return true;
154+
}
155+
156+
/**
157+
* Total de una cuenta: sus subcuentas más las cuentas hijas, con memoria
158+
* para no recalcular y protección frente a ciclos.
159+
*/
160+
private static function accountTotal($idcuenta, array &$totals, array &$children, array &$calculated, array $visited = []): array
161+
{
162+
if (isset($calculated[$idcuenta])) {
163+
return $calculated[$idcuenta];
164+
}
165+
if (isset($visited[$idcuenta])) {
166+
return ['debe' => 0.0, 'haber' => 0.0];
167+
}
168+
$visited[$idcuenta] = true;
169+
170+
$total = $totals[$idcuenta] ?? ['debe' => 0.0, 'haber' => 0.0];
171+
foreach ($children[$idcuenta] ?? [] as $childId) {
172+
$childTotal = self::accountTotal($childId, $totals, $children, $calculated, $visited);
173+
$total['debe'] += $childTotal['debe'];
174+
$total['haber'] += $childTotal['haber'];
175+
}
176+
177+
$calculated[$idcuenta] = $total;
178+
return $total;
179+
}
180+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
<?php
2+
/**
3+
* This file is part of FacturaScripts
4+
* Copyright (C) 2026 Carlos Garcia Gomez <carlos@facturascripts.com>
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Lesser General Public License as
8+
* published by the Free Software Foundation, either version 3 of the
9+
* License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
namespace FacturaScripts\Test\Core\Lib;
21+
22+
use FacturaScripts\Core\Base\DataBase;
23+
use FacturaScripts\Core\Lib\Accounting\BalanceRecalculator;
24+
use FacturaScripts\Core\Model\Asiento;
25+
use FacturaScripts\Core\Model\Cuenta;
26+
use FacturaScripts\Core\Model\Ejercicio;
27+
use FacturaScripts\Core\Model\Subcuenta;
28+
use FacturaScripts\Core\Tools;
29+
use FacturaScripts\Core\Where;
30+
use FacturaScripts\Test\Traits\DefaultSettingsTrait;
31+
use FacturaScripts\Test\Traits\LogErrorsTrait;
32+
use PHPUnit\Framework\TestCase;
33+
34+
final class BalanceRecalculatorTest extends TestCase
35+
{
36+
use DefaultSettingsTrait;
37+
use LogErrorsTrait;
38+
39+
public static function setUpBeforeClass(): void
40+
{
41+
self::setDefaultSettings();
42+
43+
// nos aseguramos de que existe el ejercicio actual antes de instalar el plan contable
44+
$exercise = new Ejercicio();
45+
$exercise->idempresa = Tools::settings('default', 'idempresa', 1);
46+
$exercise->loadFromDate(Tools::date());
47+
48+
self::installAccountingPlan();
49+
self::removeTaxRegularization();
50+
}
51+
52+
public function testRecalculateSubaccountBalances(): void
53+
{
54+
// creamos un asiento con dos líneas
55+
$asiento = new Asiento();
56+
$asiento->concepto = 'Test recalculo saldos';
57+
$this->assertTrue($asiento->save(), 'asiento-cant-save');
58+
59+
$ejercicio = $asiento->getExercise();
60+
$subcuenta1 = $this->getSampleSubaccount($ejercicio->codejercicio, 0);
61+
$subcuenta2 = $this->getSampleSubaccount($ejercicio->codejercicio, 1);
62+
$this->assertNotNull($subcuenta1, 'no-subaccount-1');
63+
$this->assertNotNull($subcuenta2, 'no-subaccount-2');
64+
65+
$firstLine = $asiento->getNewLine();
66+
$firstLine->codsubcuenta = $subcuenta1->codsubcuenta;
67+
$firstLine->concepto = 'Test linea 1';
68+
$firstLine->debe = 100;
69+
$this->assertTrue($firstLine->save(), 'linea-cant-save-1');
70+
71+
$secondLine = $asiento->getNewLine();
72+
$secondLine->codsubcuenta = $subcuenta2->codsubcuenta;
73+
$secondLine->concepto = 'Test linea 2';
74+
$secondLine->haber = 100;
75+
$this->assertTrue($secondLine->save(), 'linea-cant-save-2');
76+
77+
// corrompemos los saldos en caché de las subcuentas
78+
$db = new DataBase();
79+
$sql = 'UPDATE ' . Subcuenta::tableName() . ' SET debe = 999, haber = 888, saldo = 111'
80+
. ' WHERE idsubcuenta IN (' . $db->var2str($subcuenta1->idsubcuenta)
81+
. ', ' . $db->var2str($subcuenta2->idsubcuenta) . ')';
82+
$this->assertTrue($db->exec($sql), 'cant-corrupt-subaccounts');
83+
84+
// recalculamos
85+
$this->assertTrue(BalanceRecalculator::run($ejercicio->codejercicio), 'recalculator-failed');
86+
87+
// comprobamos que los saldos vuelven a coincidir con las partidas
88+
$subcuenta1->load($subcuenta1->idsubcuenta);
89+
$this->assertEqualsWithDelta(100.0, $subcuenta1->debe, 0.001, 'sub1-debe-wrong');
90+
$this->assertEqualsWithDelta(0.0, $subcuenta1->haber, 0.001, 'sub1-haber-wrong');
91+
$this->assertEqualsWithDelta(100.0, $subcuenta1->saldo, 0.001, 'sub1-saldo-wrong');
92+
93+
$subcuenta2->load($subcuenta2->idsubcuenta);
94+
$this->assertEqualsWithDelta(0.0, $subcuenta2->debe, 0.001, 'sub2-debe-wrong');
95+
$this->assertEqualsWithDelta(100.0, $subcuenta2->haber, 0.001, 'sub2-haber-wrong');
96+
$this->assertEqualsWithDelta(-100.0, $subcuenta2->saldo, 0.001, 'sub2-saldo-wrong');
97+
98+
// eliminamos el asiento y comprobamos que el recálculo deja las subcuentas a cero
99+
$this->assertTrue($asiento->delete(), 'asiento-cant-delete');
100+
$this->assertTrue(BalanceRecalculator::run($ejercicio->codejercicio), 'recalculator-failed-2');
101+
102+
$subcuenta1->load($subcuenta1->idsubcuenta);
103+
$this->assertEqualsWithDelta(0.0, $subcuenta1->debe, 0.001, 'sub1-debe-not-zero');
104+
$this->assertEqualsWithDelta(0.0, $subcuenta1->saldo, 0.001, 'sub1-saldo-not-zero');
105+
}
106+
107+
public function testRecalculateAccountBalances(): void
108+
{
109+
// creamos un asiento con dos líneas
110+
$asiento = new Asiento();
111+
$asiento->concepto = 'Test recalculo cuentas';
112+
$this->assertTrue($asiento->save(), 'asiento-cant-save');
113+
114+
$ejercicio = $asiento->getExercise();
115+
$subcuenta1 = $this->getSampleSubaccount($ejercicio->codejercicio, 0);
116+
$subcuenta2 = $this->getSampleSubaccount($ejercicio->codejercicio, 1);
117+
118+
$firstLine = $asiento->getNewLine();
119+
$firstLine->codsubcuenta = $subcuenta1->codsubcuenta;
120+
$firstLine->concepto = 'Test linea 1';
121+
$firstLine->debe = 50;
122+
$this->assertTrue($firstLine->save(), 'linea-cant-save-1');
123+
124+
$secondLine = $asiento->getNewLine();
125+
$secondLine->codsubcuenta = $subcuenta2->codsubcuenta;
126+
$secondLine->concepto = 'Test linea 2';
127+
$secondLine->haber = 50;
128+
$this->assertTrue($secondLine->save(), 'linea-cant-save-2');
129+
130+
// corrompemos el saldo en caché de la cuenta de la primera subcuenta
131+
$db = new DataBase();
132+
$sql = 'UPDATE ' . Cuenta::tableName() . ' SET debe = 777, haber = 666, saldo = 555'
133+
. ' WHERE idcuenta = ' . $db->var2str($subcuenta1->idcuenta);
134+
$this->assertTrue($db->exec($sql), 'cant-corrupt-account');
135+
136+
// recalculamos
137+
$this->assertTrue(BalanceRecalculator::run($ejercicio->codejercicio), 'recalculator-failed');
138+
139+
// la cuenta debe sumar los saldos de sus subcuentas
140+
$cuenta = new Cuenta();
141+
$this->assertTrue($cuenta->load($subcuenta1->idcuenta), 'account-not-found');
142+
143+
$expectedDebe = 0.0;
144+
$expectedHaber = 0.0;
145+
foreach (Subcuenta::all([Where::eq('idcuenta', $cuenta->idcuenta)], [], 0, 0) as $sub) {
146+
$expectedDebe += $sub->debe;
147+
$expectedHaber += $sub->haber;
148+
}
149+
foreach ($cuenta->getChildren() as $child) {
150+
$expectedDebe += $child->debe;
151+
$expectedHaber += $child->haber;
152+
}
153+
$this->assertEqualsWithDelta($expectedDebe, $cuenta->debe, 0.001, 'account-debe-wrong');
154+
$this->assertEqualsWithDelta($expectedHaber, $cuenta->haber, 0.001, 'account-haber-wrong');
155+
$this->assertEqualsWithDelta($expectedDebe - $expectedHaber, $cuenta->saldo, 0.001, 'account-saldo-wrong');
156+
157+
// eliminamos
158+
$this->assertTrue($asiento->delete(), 'asiento-cant-delete');
159+
}
160+
161+
public function testUnusedSubaccountIsZeroed(): void
162+
{
163+
// necesitamos el ejercicio por defecto: lo obtenemos de un asiento temporal
164+
$asiento = new Asiento();
165+
$asiento->concepto = 'Test subcuenta sin uso';
166+
$this->assertTrue($asiento->save(), 'asiento-cant-save');
167+
$codejercicio = $asiento->getExercise()->codejercicio;
168+
$this->assertTrue($asiento->delete(), 'asiento-cant-delete');
169+
170+
// buscamos una subcuenta sin partidas y le ponemos un saldo falso
171+
$subcuenta = $this->getSampleSubaccount($codejercicio, 2);
172+
$this->assertNotNull($subcuenta, 'no-subaccount');
173+
174+
$db = new DataBase();
175+
$sql = 'UPDATE ' . Subcuenta::tableName() . ' SET debe = 123, haber = 45, saldo = 78'
176+
. ' WHERE idsubcuenta = ' . $db->var2str($subcuenta->idsubcuenta);
177+
$this->assertTrue($db->exec($sql), 'cant-corrupt-subaccount');
178+
179+
// recalculamos y comprobamos que vuelve a cero (no tiene partidas)
180+
$this->assertTrue(BalanceRecalculator::run($codejercicio), 'recalculator-failed');
181+
182+
$subcuenta->load($subcuenta->idsubcuenta);
183+
$this->assertEqualsWithDelta(0.0, $subcuenta->debe, 0.001, 'debe-not-zero');
184+
$this->assertEqualsWithDelta(0.0, $subcuenta->haber, 0.001, 'haber-not-zero');
185+
$this->assertEqualsWithDelta(0.0, $subcuenta->saldo, 0.001, 'saldo-not-zero');
186+
}
187+
188+
private function getSampleSubaccount(string $codejercicio, int $offset): ?Subcuenta
189+
{
190+
$where = [Where::eq('codejercicio', $codejercicio)];
191+
foreach (Subcuenta::all($where, ['codsubcuenta' => 'ASC'], $offset, 1) as $item) {
192+
return $item;
193+
}
194+
195+
return null;
196+
}
197+
198+
protected function tearDown(): void
199+
{
200+
$this->logErrors();
201+
}
202+
}

0 commit comments

Comments
 (0)