diff --git a/Core/Lib/Accounting/PartidaReclassifier.php b/Core/Lib/Accounting/PartidaReclassifier.php new file mode 100644 index 0000000000..8f9d1d8379 --- /dev/null +++ b/Core/Lib/Accounting/PartidaReclassifier.php @@ -0,0 +1,112 @@ + + * + * 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\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 + */ +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; + } +} diff --git a/Test/Core/Lib/PartidaReclassifierTest.php b/Test/Core/Lib/PartidaReclassifierTest.php new file mode 100644 index 0000000000..70ada57030 --- /dev/null +++ b/Test/Core/Lib/PartidaReclassifierTest.php @@ -0,0 +1,214 @@ + + * + * 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\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(); + } +}