Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions Core/Lib/Accounting/PartidaReclassifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php
/**
* This file is part of FacturaScripts
* Copyright (C) 2026 Carlos Garcia Gomez <carlos@facturascripts.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

namespace FacturaScripts\Core\Lib\Accounting;

use FacturaScripts\Core\Base\DataBase;
use FacturaScripts\Core\Tools;
use FacturaScripts\Dinamic\Model\Partida;
use FacturaScripts\Dinamic\Model\Subcuenta;

/**
* Reclasifica partidas: mueve una o varias partidas a otra subcuenta del
* mismo ejercicio, opcionalmente reescribiendo el concepto, y actualiza los
* saldos de las subcuentas de origen y de destino.
*
* Evita tener que borrar y recrear asientos (o editar la base de datos a
* mano) cuando hay que corregir la subcuenta de apuntes ya contabilizados.
*
* @author Santiago Lopez <santilh@gmail.com>
*/
class PartidaReclassifier
{
/**
* Mueve las partidas indicadas a la subcuenta de destino. Todas las
* partidas deben pertenecer al mismo ejercicio que la subcuenta de
* destino, el ejercicio debe estar abierto y los asientos deben ser
* editables. La operación es transaccional: o se mueven todas o ninguna.
*
* @param int[] $idpartidas
* @param Subcuenta $newSubaccount
* @param string $newConcept concepto nuevo para las líneas movidas (vacío = mantener)
*
* @return bool
*/
public static function move(array $idpartidas, Subcuenta $newSubaccount, string $newConcept = ''): bool
{
if (empty($idpartidas)) {
Tools::log()->warning('no-selected-item');
return false;
}

if (false === $newSubaccount->exists()) {
Tools::log()->warning('subaccount-not-found', ['%subAccountCode%' => $newSubaccount->codsubcuenta]);
return false;
}

$db = new DataBase();
$db->beginTransaction();

// subcuentas de origen, para actualizar sus saldos al final
$oldSubaccounts = [];

foreach ($idpartidas as $idpartida) {
$partida = new Partida();
if (false === $partida->load($idpartida)) {
Tools::log()->warning('record-not-found');
$db->rollback();
return false;
}

// la subcuenta de destino debe ser del mismo ejercicio que el asiento
$asiento = $partida->getAccountingEntry();
if ($asiento->codejercicio !== $newSubaccount->codejercicio) {
Tools::log()->warning('cant-change-accounting-entry-exercise');
$db->rollback();
return false;
}

$oldSubaccounts[$partida->idsubcuenta] = $partida->idsubcuenta;

$partida->setAccount($newSubaccount);
if ($newConcept !== '') {
$partida->concepto = $newConcept;
}

// save() comprueba que el ejercicio está abierto y el asiento es editable
if (false === $partida->save()) {
$db->rollback();
return false;
}
}

$db->commit();

// actualizamos los saldos de las subcuentas de origen y destino
foreach ($oldSubaccounts as $idsubcuenta) {
$oldSubaccount = new Subcuenta();
if ($oldSubaccount->load($idsubcuenta)) {
$oldSubaccount->updateBalance();
}
}
$newSubaccount->updateBalance();

return true;
}
}
214 changes: 214 additions & 0 deletions Test/Core/Lib/PartidaReclassifierTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<?php
/**
* This file is part of FacturaScripts
* Copyright (C) 2026 Carlos Garcia Gomez <carlos@facturascripts.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

namespace FacturaScripts\Test\Core\Lib;

use FacturaScripts\Core\Lib\Accounting\PartidaReclassifier;
use FacturaScripts\Core\Model\Asiento;
use FacturaScripts\Core\Model\Cuenta;
use FacturaScripts\Core\Model\Ejercicio;
use FacturaScripts\Core\Model\Partida;
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 PartidaReclassifierTest 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 testMoveLine(): void
{
// creamos un asiento con dos líneas
$asiento = new Asiento();
$asiento->concepto = 'Test reclasificar';
$this->assertTrue($asiento->save(), 'asiento-cant-save');

$codejercicio = $asiento->getExercise()->codejercicio;
$subcuenta1 = $this->getSampleSubaccount($codejercicio, 0);
$subcuenta2 = $this->getSampleSubaccount($codejercicio, 1);
$target = $this->getSampleSubaccount($codejercicio, 2);

$firstLine = $asiento->getNewLine();
$firstLine->setAccount($subcuenta1);
$firstLine->concepto = 'Test linea 1';
$firstLine->debe = 100;
$this->assertTrue($firstLine->save(), 'linea-cant-save-1');

$secondLine = $asiento->getNewLine();
$secondLine->setAccount($subcuenta2);
$secondLine->concepto = 'Test linea 2';
$secondLine->haber = 100;
$this->assertTrue($secondLine->save(), 'linea-cant-save-2');

// movemos la primera línea a la subcuenta de destino
$this->assertTrue(
PartidaReclassifier::move([$firstLine->idpartida], $target),
'reclassifier-failed'
);

// la partida apunta ahora a la subcuenta de destino, con el mismo concepto
$moved = new Partida();
$this->assertTrue($moved->load($firstLine->idpartida), 'partida-not-found');
$this->assertEquals($target->idsubcuenta, $moved->idsubcuenta, 'idsubcuenta-not-updated');
$this->assertEquals($target->codsubcuenta, $moved->codsubcuenta, 'codsubcuenta-not-updated');
$this->assertEquals('Test linea 1', $moved->concepto, 'concepto-changed');

// los saldos de origen y destino se han actualizado
$subcuenta1->load($subcuenta1->idsubcuenta);
$this->assertEqualsWithDelta(0.0, $subcuenta1->debe, 0.001, 'old-subaccount-not-updated');

$target->load($target->idsubcuenta);
$this->assertEqualsWithDelta(100.0, $target->debe, 0.001, 'new-subaccount-not-updated');

// eliminamos
$this->assertTrue($asiento->delete(), 'asiento-cant-delete');
}

public function testMoveLineWithNewConcept(): void
{
// creamos un asiento con dos líneas
$asiento = new Asiento();
$asiento->concepto = 'Test reclasificar concepto';
$this->assertTrue($asiento->save(), 'asiento-cant-save');

$codejercicio = $asiento->getExercise()->codejercicio;
$subcuenta1 = $this->getSampleSubaccount($codejercicio, 0);
$subcuenta2 = $this->getSampleSubaccount($codejercicio, 1);
$target = $this->getSampleSubaccount($codejercicio, 2);

$firstLine = $asiento->getNewLine();
$firstLine->setAccount($subcuenta1);
$firstLine->concepto = 'Concepto antiguo';
$firstLine->debe = 50;
$this->assertTrue($firstLine->save(), 'linea-cant-save-1');

$secondLine = $asiento->getNewLine();
$secondLine->setAccount($subcuenta2);
$secondLine->concepto = 'Contrapartida';
$secondLine->haber = 50;
$this->assertTrue($secondLine->save(), 'linea-cant-save-2');

// movemos reescribiendo el concepto
$this->assertTrue(
PartidaReclassifier::move([$firstLine->idpartida], $target, 'Concepto nuevo'),
'reclassifier-failed'
);

$moved = new Partida();
$this->assertTrue($moved->load($firstLine->idpartida), 'partida-not-found');
$this->assertEquals('Concepto nuevo', $moved->concepto, 'concepto-not-updated');
$this->assertEquals($target->idsubcuenta, $moved->idsubcuenta, 'idsubcuenta-not-updated');

// eliminamos
$this->assertTrue($asiento->delete(), 'asiento-cant-delete');
}

public function testRejectDifferentExercise(): void
{
// creamos un asiento con una línea equilibrada
$asiento = new Asiento();
$asiento->concepto = 'Test reclasificar otro ejercicio';
$this->assertTrue($asiento->save(), 'asiento-cant-save');

$codejercicio = $asiento->getExercise()->codejercicio;
$subcuenta1 = $this->getSampleSubaccount($codejercicio, 0);

$firstLine = $asiento->getNewLine();
$firstLine->setAccount($subcuenta1);
$firstLine->concepto = 'Test linea 1';
$firstLine->debe = 25;
$this->assertTrue($firstLine->save(), 'linea-cant-save-1');

// creamos otro ejercicio con una subcuenta
$otherExercise = new Ejercicio();
$otherExercise->codejercicio = '2001';
$otherExercise->nombre = 'Test 2001';
$otherExercise->fechainicio = '2001-01-01';
$otherExercise->fechafin = '2001-12-31';
$this->assertTrue($otherExercise->save(), 'ejercicio-cant-save');

$otherAccount = new Cuenta();
$otherAccount->codejercicio = $otherExercise->codejercicio;
$otherAccount->codcuenta = '100';
$otherAccount->descripcion = 'Capital';
$this->assertTrue($otherAccount->save(), 'cuenta-cant-save');

$otherSubaccount = new Subcuenta();
$otherSubaccount->codejercicio = $otherExercise->codejercicio;
$otherSubaccount->codsubcuenta = '1000000000';
$otherSubaccount->descripcion = 'Capital social';
$otherSubaccount->idcuenta = $otherAccount->idcuenta;
$this->assertTrue($otherSubaccount->save(), 'subcuenta-cant-save');

// no se puede mover una partida a una subcuenta de otro ejercicio
$this->assertFalse(
PartidaReclassifier::move([$firstLine->idpartida], $otherSubaccount),
'reclassifier-should-fail'
);

// la partida no ha cambiado
$unchanged = new Partida();
$this->assertTrue($unchanged->load($firstLine->idpartida), 'partida-not-found');
$this->assertEquals($subcuenta1->idsubcuenta, $unchanged->idsubcuenta, 'partida-changed');

// eliminamos
$this->assertTrue($asiento->delete(), 'asiento-cant-delete');
$this->assertTrue($otherSubaccount->delete(), 'subcuenta-cant-delete');
$this->assertTrue($otherAccount->delete(), 'cuenta-cant-delete');
$this->assertTrue($otherExercise->delete(), 'ejercicio-cant-delete');
}

public function testRejectEmptyList(): void
{
$subcuenta = new Subcuenta();
$this->assertFalse(PartidaReclassifier::move([], $subcuenta), 'empty-list-should-fail');
}

private function getSampleSubaccount(string $codejercicio, int $offset): ?Subcuenta
{
$where = [Where::eq('codejercicio', $codejercicio)];
foreach (Subcuenta::all($where, ['codsubcuenta' => 'ASC'], $offset, 1) as $item) {
return $item;
}

return null;
}

protected function tearDown(): void
{
$this->logErrors();
}
}