diff --git a/htdocs/commande/class/commande.class.php b/htdocs/commande/class/commande.class.php index fd39f1a1783..e8c6163da81 100644 --- a/htdocs/commande/class/commande.class.php +++ b/htdocs/commande/class/commande.class.php @@ -11,7 +11,7 @@ * Copyright (C) 2014-2015 Marcos García * Copyright (C) 2018 Nicolas ZABOURI * Copyright (C) 2016-2022 Ferran Marcet - * Copyright (C) 2021-2025 Frédéric France + * Copyright (C) 2021-2026 Frédéric France * Copyright (C) 2022 Gauthier VERDOL * Copyright (C) 2024-2026 MDW * Copyright (C) 2024 William Mead diff --git a/test/phpunit/AllTests.php b/test/phpunit/AllTests.php index 3932e883239..424706d7cd9 100644 --- a/test/phpunit/AllTests.php +++ b/test/phpunit/AllTests.php @@ -242,6 +242,8 @@ class AllTests $suite->addTestSuite('CommonInvoiceTest'); require_once dirname(__FILE__).'/FactureTest.php'; $suite->addTestSuite('FactureTest'); + require_once dirname(__FILE__).'/PropalCommandeFactureWorkflowTest.php'; + $suite->addTestSuite('PropalCommandeFactureWorkflowTest'); require_once dirname(__FILE__).'/FactureRecTest.php'; $suite->addTestSuite('FactureRecTest'); require_once dirname(__FILE__).'/FactureTestRounding.php'; diff --git a/test/phpunit/CommandeTest.php b/test/phpunit/CommandeTest.php index c27a614fa90..3d200fde4e6 100644 --- a/test/phpunit/CommandeTest.php +++ b/test/phpunit/CommandeTest.php @@ -1,7 +1,7 @@ * Copyright (C) 2023 Alexandre Janniaux - * Copyright (C) 2024-2025 Frédéric France + * Copyright (C) 2024-2026 Frédéric France * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -119,6 +119,12 @@ class CommandeTest extends CommonClassTest $this->assertLessThan($result, 0); print __METHOD__." id=".$id." result=".$result."\n"; + + // Specimen lines are built from real products picked at random (see Commande::initAsSpecimen), so the + // exact line count is not stable (a kit/BOM product can expand into extra lines) - only check totals coherence. + $this->assertNotEmpty($localobject->lines); + $this->assertLineTotalsMatchHeader($localobject, 'after fetch'); + return $localobject; } @@ -147,13 +153,116 @@ class CommandeTest extends CommonClassTest return $localobject; } + /** + * testCommandeAddLine + * + * @param Commande $localobject Commande + * @return array{0:Commande,1:int} Commande and id of the line added + * + * @depends testCommandeUpdate + * The depends says test is run only if previous is ok + */ + public function testCommandeAddLine($localobject) + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $localobject->fetch_thirdparty(); + $beforelinecount = count($localobject->lines); + $beforetotalht = (float) $localobject->total_ht; + + $lineid = $localobject->addline('PHPUnit addline test', 100, 2, 20); // 2 x 100 HT at 20% VAT = 200 HT / 40 VAT / 240 TTC + + print __METHOD__." id=".$localobject->id." lineid=".$lineid."\n"; + $this->assertGreaterThan(0, $lineid, $localobject->errorsToString()); + + $localobject->fetch($localobject->id); + $this->assertCount($beforelinecount + 1, $localobject->lines); + $this->assertEqualsWithDelta($beforetotalht + 200, (float) $localobject->total_ht, 0.01, 'total_ht not updated after addline'); + $this->assertLineTotalsMatchHeader($localobject, 'after addline'); + + return array($localobject, $lineid); + } + + /** + * testCommandeUpdateLine + * + * @param array{0:Commande,1:int} $params Commande and id of the line to update + * @return array{0:Commande,1:int} Commande and id of the line updated + * + * @depends testCommandeAddLine + * The depends says test is run only if previous is ok + */ + public function testCommandeUpdateLine($params) + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + list($localobject, $lineid) = $params; + $beforelinecount = count($localobject->lines); + $beforetotalht = (float) $localobject->total_ht; + + $result = $localobject->updateline($lineid, 'PHPUnit addline test', 100, 3, 0, 20); // qty 2 -> 3, so +100 HT / +20 VAT / +120 TTC + + print __METHOD__." id=".$localobject->id." lineid=".$lineid." result=".$result."\n"; + $this->assertGreaterThan(0, $result, $localobject->errorsToString()); + + $localobject->fetch($localobject->id); + $this->assertCount($beforelinecount, $localobject->lines); + $this->assertEqualsWithDelta($beforetotalht + 100, (float) $localobject->total_ht, 0.01, 'total_ht not updated after updateline'); + $this->assertLineTotalsMatchHeader($localobject, 'after updateline'); + + return array($localobject, $lineid); + } + + /** + * testCommandeDeleteLine + * + * @param array{0:Commande,1:int} $params Commande and id of the line to delete + * @return Commande + * + * @depends testCommandeUpdateLine + * The depends says test is run only if previous is ok + */ + public function testCommandeDeleteLine($params) + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + list($localobject, $lineid) = $params; + $beforelinecount = count($localobject->lines); + $beforetotalht = (float) $localobject->total_ht; + + $result = $localobject->deleteLine($user, $lineid); + + print __METHOD__." id=".$localobject->id." lineid=".$lineid." result=".$result."\n"; + $this->assertGreaterThan(0, $result, $localobject->errorsToString()); + + $localobject->fetch($localobject->id); + // Back to the original specimen lines, with the same totals + $this->assertCount($beforelinecount - 1, $localobject->lines); + $this->assertEqualsWithDelta($beforetotalht - 300, (float) $localobject->total_ht, 0.01, 'total_ht not updated after deleteLine'); + $this->assertLineTotalsMatchHeader($localobject, 'after deleteLine'); + + return $localobject; + } + /** * testCommandeValid * * @param Commande $localobject Order * @return Commande * - * @depends testCommandeUpdate + * @depends testCommandeDeleteLine * The depends says test is run only if previous is ok */ public function testCommandeValid($localobject) @@ -168,6 +277,30 @@ class CommandeTest extends CommonClassTest print __METHOD__." id=".$localobject->id." result=".$result."\n"; $this->assertLessThan($result, 0); + + // Test everything is still the same as a freshly built specimen with the same mutation applied + // (catches unwanted field changes introduced by update()/valid()) + $this->assertMatchesFreshSpecimen( + $localobject, + function ($specimen) { + $specimen->note_private = 'New note private after update'; + }, + array( + 'newref', 'oldcopy', 'oldref', 'id', 'lines', 'line', 'client', 'thirdparty', 'brouillon', 'specimen', + 'fk_user_author', 'user_author_id', 'user_creation_id', 'user_modification_id', + 'date', 'date_commande', 'date_creation', 'date_validation', 'date_lim_reglement', 'datem', 'date_modification', + 'ref', 'statut', 'status', 'socid', 'billed', 'fk_incoterms', 'actiontypecode', 'actionmsg2', 'actionmsg', + 'mode_reglement', 'cond_reglement', 'mode_reglement_code', 'cond_reglement_code', 'availability_code', 'demand_reason_code', + 'cond_reglement_doc', 'modelpdf', + // Totals are ignored here: specimen lines reference random real products, and a kit/BOM product can + // expand into extra lines with a different amount - total correctness is checked by assertLineTotalsMatchHeader() instead. + 'total_ht', 'total_tva', 'total_ttc', 'total_localtax1', 'total_localtax2', + 'multicurrency_total_ht', 'multicurrency_total_tva', 'multicurrency_total_ttc', 'fk_multicurrency', 'multicurrency_code', 'multicurrency_tx', + 'trackid', 'user_creat', 'user_valid', 'note', + ), + array('tosell' => 1) + ); + return $localobject; } @@ -212,11 +345,6 @@ class CommandeTest extends CommonClassTest $langs = $this->savlangs; $db = $this->savdb; - /*$result=$localobject->setstatus(0); - print __METHOD__." id=".$localobject->id." result=".$result."\n"; - $this->assertLessThan($result, 0); - */ - $localobject->info($localobject->id); print __METHOD__." localobject->date_creation=".$localobject->date_creation."\n"; $this->assertNotEquals($localobject->date_creation, ''); diff --git a/test/phpunit/CommonClassTest.class.php b/test/phpunit/CommonClassTest.class.php index 2ea96ccb1f3..39c8ba85a4e 100644 --- a/test/phpunit/CommonClassTest.class.php +++ b/test/phpunit/CommonClassTest.class.php @@ -382,6 +382,56 @@ abstract class CommonClassTest extends TestCase return $retAr; } + /** + * Assert that the sum of the persisted line totals matches the object header totals. + * Catches bugs where update_price() forgets a line, or a total is not recalculated after a line change. + * + * @param CommonObject $localobject Object with a ->lines array of line objects having total_ht/total_tva/total_ttc + * @param string $message Extra message to show on failure + * @return void + */ + protected function assertLineTotalsMatchHeader($localobject, $message = '') + { + $sumht = 0.0; + $sumtva = 0.0; + $sumttc = 0.0; + foreach ($localobject->lines as $line) { + $sumht += (float) $line->total_ht; + $sumtva += (float) $line->total_tva; + $sumttc += (float) $line->total_ttc; + } + + $this->assertEqualsWithDelta($sumht, (float) $localobject->total_ht, 0.01, 'total_ht does not match sum of lines. '.$message); + $this->assertEqualsWithDelta($sumtva, (float) $localobject->total_tva, 0.01, 'total_tva does not match sum of lines. '.$message); + $this->assertEqualsWithDelta($sumttc, (float) $localobject->total_ttc, 0.01, 'total_ttc does not match sum of lines. '.$message); + } + + /** + * Compare $localobject against a freshly built specimen of the same class (with the same mutation applied) + * to detect fields unexpectedly changed by a lifecycle action such as update() or valid(). + * + * @param object $localobject Object to check, already gone through create()/update()/valid()... + * @param callable $mutate Callback(object $specimen): void applying the same mutation that was applied to $localobject + * @param array $fieldstoignorearray Fields to ignore in the comparison (passed to objCompare) + * @param array $specimenparam Param array passed to initAsSpecimen() + * @return void + */ + protected function assertMatchesFreshSpecimen($localobject, callable $mutate, array $fieldstoignorearray, array $specimenparam = array()) + { + global $db; + + $class = get_class($localobject); + $newlocalobject = new $class($db); + $newlocalobject->initAsSpecimen($specimenparam); + $mutate($newlocalobject); + + $clonedobject = clone $localobject; + unset($clonedobject->array_options); + + $arraywithdiff = $this->objCompare($clonedobject, $newlocalobject, true, $fieldstoignorearray); + $this->assertEquals(array(), $arraywithdiff, 'Found differences '.var_export($arraywithdiff, true)); + } + /** * Map deprecated module names to new module names */ diff --git a/test/phpunit/FactureTest.php b/test/phpunit/FactureTest.php index 6c8ed113892..96e0b225ab9 100644 --- a/test/phpunit/FactureTest.php +++ b/test/phpunit/FactureTest.php @@ -1,6 +1,6 @@ - * Copyright (C) 2018-2025 Frédéric France + * Copyright (C) 2018-2026 Frédéric France * Copyright (C) 2023 Alexandre Janniaux * Copyright (C) 2024 MDW * @@ -112,6 +112,12 @@ class FactureTest extends CommonClassTest $this->assertLessThan($result, 0); print __METHOD__." id=".$id." result=".$result."\n"; + + // Specimen lines are built from real products picked at random (see Facture::initAsSpecimen), so the + // exact line count is not stable (a kit/BOM product can expand into extra lines) - only check totals coherence. + $this->assertNotEmpty($localobject->lines); + $this->assertLineTotalsMatchHeader($localobject, 'after fetch'); + return $localobject; } @@ -140,13 +146,116 @@ class FactureTest extends CommonClassTest return $localobject; } + /** + * testFactureAddLine + * + * @param Facture $localobject Invoice + * @return array{0:Facture,1:int} Invoice and id of the line added + * + * @depends testFactureUpdate + * The depends says test is run only if previous is ok + */ + public function testFactureAddLine($localobject) + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $localobject->fetch_thirdparty(); + $beforelinecount = count($localobject->lines); + $beforetotalht = (float) $localobject->total_ht; + + $lineid = $localobject->addline('PHPUnit addline test', 100, 2, 20); // 2 x 100 HT at 20% VAT = 200 HT / 40 VAT / 240 TTC + + print __METHOD__." id=".$localobject->id." lineid=".$lineid."\n"; + $this->assertGreaterThan(0, $lineid, $localobject->errorsToString()); + + $localobject->fetch($localobject->id); + $this->assertCount($beforelinecount + 1, $localobject->lines); + $this->assertEqualsWithDelta($beforetotalht + 200, (float) $localobject->total_ht, 0.01, 'total_ht not updated after addline'); + $this->assertLineTotalsMatchHeader($localobject, 'after addline'); + + return array($localobject, $lineid); + } + + /** + * testFactureUpdateLine + * + * @param array{0:Facture,1:int} $params Invoice and id of the line to update + * @return array{0:Facture,1:int} Invoice and id of the line updated + * + * @depends testFactureAddLine + * The depends says test is run only if previous is ok + */ + public function testFactureUpdateLine($params) + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + list($localobject, $lineid) = $params; + $beforelinecount = count($localobject->lines); + $beforetotalht = (float) $localobject->total_ht; + + $result = $localobject->updateline($lineid, 'PHPUnit addline test', 100, 3, 0, '', '', 20); // qty 2 -> 3, so +100 HT / +20 VAT / +120 TTC + + print __METHOD__." id=".$localobject->id." lineid=".$lineid." result=".$result."\n"; + $this->assertGreaterThan(0, $result, $localobject->errorsToString()); + + $localobject->fetch($localobject->id); + $this->assertCount($beforelinecount, $localobject->lines); + $this->assertEqualsWithDelta($beforetotalht + 100, (float) $localobject->total_ht, 0.01, 'total_ht not updated after updateline'); + $this->assertLineTotalsMatchHeader($localobject, 'after updateline'); + + return array($localobject, $lineid); + } + + /** + * testFactureDeleteLine + * + * @param array{0:Facture,1:int} $params Invoice and id of the line to delete + * @return Facture + * + * @depends testFactureUpdateLine + * The depends says test is run only if previous is ok + */ + public function testFactureDeleteLine($params) + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + list($localobject, $lineid) = $params; + $beforelinecount = count($localobject->lines); + $beforetotalht = (float) $localobject->total_ht; + + $result = $localobject->deleteLine($lineid); + + print __METHOD__." id=".$localobject->id." lineid=".$lineid." result=".$result."\n"; + $this->assertGreaterThan(0, $result, $localobject->errorsToString()); + + $localobject->fetch($localobject->id); + // Back to the original specimen lines, with the same totals + $this->assertCount($beforelinecount - 1, $localobject->lines); + $this->assertEqualsWithDelta($beforetotalht - 300, (float) $localobject->total_ht, 0.01, 'total_ht not updated after deleteLine'); + $this->assertLineTotalsMatchHeader($localobject, 'after deleteLine'); + + return $localobject; + } + /** * testFactureValid * * @param Facture $localobject Invoice - * @return void + * @return Facture * - * @depends testFactureUpdate + * @depends testFactureDeleteLine * The depends says test is run only if previous is ok */ public function testFactureValid($localobject) @@ -166,33 +275,26 @@ class FactureTest extends CommonClassTest $this->assertLessThan($result, 0); - // Test everything is still the same as specimen - $newlocalobject = new Facture($db); - $newlocalobject->initAsSpecimen(); - $this->changeProperties($newlocalobject); - - // Hack to avoid test to be wrong when some modules are one - unset($localobject->array_options); - //unset($localobject->array_options['options_reseller']); - //unset($localobject->array_options['options_reseller']); - - $arraywithdiff = $this->objCompare( + // Test everything is still the same as a freshly built specimen with the same mutation applied + // (catches unwanted field changes introduced by update()/validate()) + $this->assertMatchesFreshSpecimen( $localobject, - $newlocalobject, - true, - // Not comparing: + function ($specimen) { + $this->changeProperties($specimen); + }, array( - 'newref','oldcopy','oldref','id','lines','client','thirdparty','brouillon', 'fk_user_author', 'user_modification_id', 'date_creation','date_validation','datem','date_modification', - 'ref','statut','status','paye','ref','actiontypecode','actionmsg2','actionmsg','mode_reglement','cond_reglement', + 'newref', 'oldcopy', 'oldref', 'id', 'lines', 'line', 'client', 'thirdparty', 'brouillon', 'fk_user_author', 'fk_user_modif', 'user_modification_id', 'date_creation', 'date_validation', 'datem', 'date_modification', + 'ref', 'statut', 'status', 'paye', 'ref', 'actiontypecode', 'actionmsg2', 'actionmsg', 'mode_reglement', 'cond_reglement', 'cond_reglement_doc', 'modelpdf', - 'multicurrency_total_ht','multicurrency_total_tva', 'multicurrency_total_ttc','fk_multicurrency','multicurrency_code','multicurrency_tx', - 'retained_warranty' ,'retained_warranty_date_limit', 'retained_warranty_fk_cond_reglement', 'specimen', 'situation_cycle_ref', 'situation_counter', 'situation_final', - 'trackid','user_creat','user_valid', 'note' + // Totals are ignored here: specimen lines reference random real products, and a kit/BOM product can + // expand into extra lines with a different amount - total correctness is checked by assertLineTotalsMatchHeader() instead. + 'total_ht', 'total_tva', 'total_ttc', + 'multicurrency_total_ht', 'multicurrency_total_tva', 'multicurrency_total_ttc', 'fk_multicurrency', 'multicurrency_code', 'multicurrency_tx', + 'retained_warranty', 'retained_warranty_date_limit', 'retained_warranty_fk_cond_reglement', 'specimen', 'situation_cycle_ref', 'situation_counter', 'situation_final', + 'trackid', 'user_creat', 'user_valid', 'note' ) ); - $this->assertEquals($arraywithdiff, array(), 'Found differences '.var_export($arraywithdiff, true)); // Actual, Expected. If it differs, do a var_dump($arraywithdiff) to see what differs - return $localobject; } @@ -213,11 +315,6 @@ class FactureTest extends CommonClassTest $langs = $this->savlangs; $db = $this->savdb; - /*$result=$localobject->setstatus(0); - print __METHOD__." id=".$localobject->id." result=".$result."\n"; - $this->assertLessThan($result, 0); - */ - $localobject->info($localobject->id); print __METHOD__." localobject->date_creation=".$localobject->date_creation."\n"; $this->assertNotEquals($localobject->date_creation, ''); diff --git a/test/phpunit/PropalCommandeFactureWorkflowTest.php b/test/phpunit/PropalCommandeFactureWorkflowTest.php new file mode 100644 index 00000000000..6844d8a48cb --- /dev/null +++ b/test/phpunit/PropalCommandeFactureWorkflowTest.php @@ -0,0 +1,222 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * or see https://www.gnu.org/ + */ + +/** + * \file test/phpunit/PropalCommandeFactureWorkflowTest.php + * \ingroup test + * \brief PHPUnit test of the Propal -> Commande -> Facture business workflow + * \remarks To run this script as CLI: phpunit filename.php + */ + +global $conf,$user,$langs,$db; +require_once dirname(__FILE__).'/../../htdocs/master.inc.php'; +require_once dirname(__FILE__).'/../../htdocs/comm/propal/class/propal.class.php'; +require_once dirname(__FILE__).'/../../htdocs/commande/class/commande.class.php'; +require_once dirname(__FILE__).'/../../htdocs/compta/facture/class/facture.class.php'; +require_once dirname(__FILE__).'/CommonClassTest.class.php'; + +if (empty($user->id)) { + print "Load permissions for admin user nb 1\n"; + $user->fetch(1); + $user->loadRights(); +} +$conf->global->MAIN_DISABLE_ALL_MAILS = 1; + + +/** + * Tests that a customer proposal, once converted into an order and then into an invoice, + * carries its thirdparty, lines, totals and notes correctly through the chain, and that + * each object is linked back to the one it was created from. + * + * @backupGlobals disabled + * @backupStaticAttributes enabled + * @remarks backupGlobals must be disabled to have db,conf,user and lang not erased. + */ +class PropalCommandeFactureWorkflowTest extends CommonClassTest +{ + /** + * setUpBeforeClass + * + * @return void + */ + public static function setUpBeforeClass(): void + { + self::assertTrue(isModEnabled('propal'), 'module commercial proposal must be enabled'); + self::assertTrue(isModEnabled('order'), 'module customer order must be enabled'); + self::assertTrue(isModEnabled('invoice'), 'module customer invoice must be enabled'); + parent::setUpBeforeClass(); + } + + /** + * testWorkflowCreateAndValidatePropal + * + * Entry point of the chain: create a thirdparty and a proposal for it, then validate the proposal. + * + * @return Propal + */ + public function testWorkflowCreateAndValidatePropal() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $soc = new Societe($db); + $soc->name = 'PropalCommandeFactureWorkflowTest Unittest'; + $socid = $soc->create($user); + $this->assertGreaterThan(0, $socid, $soc->errorsToString()); + + // Use initAsSpecimen() only for realistic header defaults (payment terms, availability, ...). + // Its lines reference random real products, which can be kits/BOMs that silently expand into extra + // lines on each create()/createFrom...() down the chain: replace them with controlled, product-less + // lines so the totals asserted at each step of the chain are deterministic. + $propal = new Propal($db); + $propal->initAsSpecimen(array('tosell' => 1)); + $propal->lines = array(); + $propal->total_ht = 0; + $propal->total_tva = 0; + $propal->total_ttc = 0; + $propal->socid = $socid; + $propal->note_public = 'PHPUnit workflow test note (public)'; + $propal->note_private = 'PHPUnit workflow test note (private)'; + $result = $propal->create($user); + $this->assertGreaterThan(0, $result, $propal->errorsToString()); + + $lineid1 = $propal->addline('Workflow line A', 100, 2, 20); // 200 HT / 40 VAT / 240 TTC + $this->assertGreaterThan(0, $lineid1, $propal->errorsToString()); + $lineid2 = $propal->addline('Workflow line B', 50, 3, 10); // 150 HT / 15 VAT / 165 TTC + $this->assertGreaterThan(0, $lineid2, $propal->errorsToString()); + + $propal->fetch($propal->id); + $this->assertEqualsWithDelta(350.0, (float) $propal->total_ht, 0.01, 'total_ht of the 2 controlled lines'); + + $result = $propal->valid($user); + $this->assertGreaterThan(0, $result, $propal->errorsToString()); + + $propal->fetch($propal->id); + $this->assertEquals(Propal::STATUS_VALIDATED, $propal->status); + $this->assertLineTotalsMatchHeader($propal, 'on validated proposal'); + + return $propal; + } + + /** + * testWorkflowCreateOrderFromPropal + * + * @param Propal $propal Proposal validated by the previous test + * @return Commande + * + * @depends testWorkflowCreateAndValidatePropal + * The depends says test is run only if previous is ok + */ + public function testWorkflowCreateOrderFromPropal($propal) + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $commande = new Commande($db); + $result = $commande->createFromProposal($propal, $user); + + print __METHOD__." propalid=".$propal->id." result=".$result."\n"; + $this->assertGreaterThan(0, $result, $commande->errorsToString()); + + $commande->fetch($result); + + $this->assertEquals($propal->socid, $commande->socid, 'Order thirdparty must match proposal thirdparty'); + $this->assertEquals($propal->note_public, $commande->note_public, 'Order public note must be propagated from proposal'); + $this->assertEquals($propal->note_private, $commande->note_private, 'Order private note must be propagated from proposal'); + $this->assertCount(count($propal->lines), $commande->lines, 'Order must have the same number of lines as the proposal'); + $this->assertEqualsWithDelta((float) $propal->total_ht, (float) $commande->total_ht, 0.01, 'Order total_ht must match proposal total_ht'); + $this->assertEqualsWithDelta((float) $propal->total_tva, (float) $commande->total_tva, 0.01, 'Order total_tva must match proposal total_tva'); + $this->assertEqualsWithDelta((float) $propal->total_ttc, (float) $commande->total_ttc, 0.01, 'Order total_ttc must match proposal total_ttc'); + $this->assertLineTotalsMatchHeader($commande, 'on order created from proposal'); + + // The order must be linked back to its originating proposal (llx_element_element) + $commande->fetchObjectLinked(); + $this->assertArrayHasKey('propal', $commande->linkedObjectsIds, 'Order must be linked to its originating proposal'); + $this->assertContains($propal->id, $commande->linkedObjectsIds['propal']); + + $result = $commande->valid($user); + print __METHOD__." id=".$commande->id." valid result=".$result."\n"; + $this->assertGreaterThan(0, $result, $commande->errorsToString()); + + $commande->fetch($commande->id); + $this->assertEquals(Commande::STATUS_VALIDATED, $commande->status); + + return $commande; + } + + /** + * testWorkflowCreateInvoiceFromOrder + * + * @param Commande $commande Order validated by the previous test + * @return Facture + * + * @depends testWorkflowCreateOrderFromPropal + * The depends says test is run only if previous is ok + */ + public function testWorkflowCreateInvoiceFromOrder($commande) + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $facture = new Facture($db); + $result = $facture->createFromOrder($commande, $user); + + // createFromOrder() returns a status flag (1/-1), not the new invoice id (unlike + // Commande::createFromProposal()) - the created invoice's id must be read from $facture->id. + print __METHOD__." commandeid=".$commande->id." result=".$result."\n"; + $this->assertGreaterThan(0, $result, $facture->errorsToString()); + + $facture->fetch($facture->id); + + $this->assertEquals($commande->socid, $facture->socid, 'Invoice thirdparty must match order thirdparty'); + $this->assertEquals($commande->note_public, $facture->note_public, 'Invoice public note must be propagated from order'); + $this->assertEquals($commande->note_private, $facture->note_private, 'Invoice private note must be propagated from order'); + $this->assertCount(count($commande->lines), $facture->lines, 'Invoice must have the same number of lines as the order'); + $this->assertEqualsWithDelta((float) $commande->total_ht, (float) $facture->total_ht, 0.01, 'Invoice total_ht must match order total_ht'); + $this->assertEqualsWithDelta((float) $commande->total_tva, (float) $facture->total_tva, 0.01, 'Invoice total_tva must match order total_tva'); + $this->assertEqualsWithDelta((float) $commande->total_ttc, (float) $facture->total_ttc, 0.01, 'Invoice total_ttc must match order total_ttc'); + $this->assertLineTotalsMatchHeader($facture, 'on invoice created from order'); + + // The invoice must be linked back to its originating order (llx_element_element) + $facture->fetchObjectLinked(); + $this->assertArrayHasKey('commande', $facture->linkedObjectsIds, 'Invoice must be linked to its originating order'); + $this->assertContains($commande->id, $facture->linkedObjectsIds['commande']); + + // Force to default setup, same as FactureTest::testFactureValid + $conf->global->FAC_FORCE_DATE_VALIDATION = 0; + $conf->global->INVOICE_CHECK_POSTERIOR_DATE = 0; + + $result = $facture->validate($user); + print __METHOD__." id=".$facture->id." validate result=".$result."\n"; + $this->assertGreaterThan(0, $result, $facture->errorsToString()); + + $facture->fetch($facture->id); + $this->assertEquals(Facture::STATUS_VALIDATED, $facture->status); + + return $facture; + } +} diff --git a/test/phpunit/PropalTest.php b/test/phpunit/PropalTest.php index 5eac9bb1060..1b04f0e1022 100644 --- a/test/phpunit/PropalTest.php +++ b/test/phpunit/PropalTest.php @@ -1,7 +1,7 @@ * Copyright (C) 2023 Alexandre Janniaux - * Copyright (C) 2024 Frédéric France + * Copyright (C) 2024-2026 Frédéric France * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -94,6 +94,12 @@ class PropalTest extends CommonClassTest $this->assertLessThan($result, 0); print __METHOD__." id=".$id." result=".$result."\n"; + + // Specimen lines are built from real products picked at random (see Propal::initAsSpecimen), so the + // exact line count is not stable (a kit/BOM product can expand into extra lines) - only check totals coherence. + $this->assertNotEmpty($localobject->lines); + $this->assertLineTotalsMatchHeader($localobject, 'after fetch'); + return $localobject; } @@ -125,8 +131,8 @@ class PropalTest extends CommonClassTest /** * testPropalAddLine * - * @param Propal $localobject Proposal - * @return Propal + * @param Propal $localobject Proposal + * @return array{0:Propal,1:int} Proposal and id of the line added * * @depends testPropalUpdate * The depends says test is run only if previous is ok @@ -140,10 +146,88 @@ class PropalTest extends CommonClassTest $db = $this->savdb; $localobject->fetch_thirdparty(); - $result = $localobject->addline('Added line', 10, 2, 19.6); + $beforelinecount = count($localobject->lines); + $beforetotalht = (float) $localobject->total_ht; + + $lineid = $localobject->addline('PHPUnit addline test', 100, 2, 20); // 2 x 100 HT at 20% VAT = 200 HT / 40 VAT / 240 TTC + + print __METHOD__." id=".$localobject->id." lineid=".$lineid."\n"; + $this->assertGreaterThan(0, $lineid, $localobject->errorsToString()); + + $localobject->fetch($localobject->id); + $this->assertCount($beforelinecount + 1, $localobject->lines); + $this->assertEqualsWithDelta($beforetotalht + 200, (float) $localobject->total_ht, 0.01, 'total_ht not updated after addline'); + $this->assertLineTotalsMatchHeader($localobject, 'after addline'); + + return array($localobject, $lineid); + } + + /** + * testPropalUpdateLine + * + * @param array{0:Propal,1:int} $params Proposal and id of the line to update + * @return array{0:Propal,1:int} Proposal and id of the line updated + * + * @depends testPropalAddLine + * The depends says test is run only if previous is ok + */ + public function testPropalUpdateLine($params) + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + list($localobject, $lineid) = $params; + $beforelinecount = count($localobject->lines); + $beforetotalht = (float) $localobject->total_ht; + + $result = $localobject->updateline($lineid, 100, 3, 0, 20, 0, 0, 'PHPUnit addline test'); // qty 2 -> 3, so +100 HT / +20 VAT / +120 TTC + + print __METHOD__." id=".$localobject->id." lineid=".$lineid." result=".$result."\n"; + $this->assertGreaterThan(0, $result, $localobject->errorsToString()); + + $localobject->fetch($localobject->id); + $this->assertCount($beforelinecount, $localobject->lines); + $this->assertEqualsWithDelta($beforetotalht + 100, (float) $localobject->total_ht, 0.01, 'total_ht not updated after updateline'); + $this->assertLineTotalsMatchHeader($localobject, 'after updateline'); + + return array($localobject, $lineid); + } + + /** + * testPropalDeleteLine + * + * @param array{0:Propal,1:int} $params Proposal and id of the line to delete + * @return Propal + * + * @depends testPropalUpdateLine + * The depends says test is run only if previous is ok + */ + public function testPropalDeleteLine($params) + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + list($localobject, $lineid) = $params; + $beforelinecount = count($localobject->lines); + $beforetotalht = (float) $localobject->total_ht; + + $result = $localobject->deleteLine($lineid); + + print __METHOD__." id=".$localobject->id." lineid=".$lineid." result=".$result."\n"; + $this->assertGreaterThan(0, $result, $localobject->errorsToString()); + + $localobject->fetch($localobject->id); + // Back to the original specimen lines, with the same totals + $this->assertCount($beforelinecount - 1, $localobject->lines); + $this->assertEqualsWithDelta($beforetotalht - 300, (float) $localobject->total_ht, 0.01, 'total_ht not updated after deleteLine'); + $this->assertLineTotalsMatchHeader($localobject, 'after deleteLine'); - $this->assertLessThan($result, 0); - print __METHOD__." id=".$localobject->id." result=".$result."\n"; return $localobject; } @@ -153,7 +237,7 @@ class PropalTest extends CommonClassTest * @param Propal $localobject Proposal * @return Propal * - * @depends testPropalAddLine + * @depends testPropalDeleteLine * The depends says test is run only if previous is ok */ public function testPropalValid($localobject) @@ -168,6 +252,29 @@ class PropalTest extends CommonClassTest print __METHOD__." id=".$localobject->id." result=".$result."\n"; $this->assertLessThan($result, 0); + + // Test everything is still the same as a freshly built specimen with the same mutation applied + // (catches unwanted field changes introduced by update()/valid()) + $this->assertMatchesFreshSpecimen( + $localobject, + function ($specimen) { + $specimen->note_private = 'New note private after update'; + }, + array( + 'newref', 'oldcopy', 'oldref', 'id', 'entity', 'lines', 'line', 'client', 'thirdparty', 'brouillon', 'specimen', + 'fk_user_author', 'user_author_id', 'user_creation_id', 'user_modification_id', 'user_validation_id', + 'date', 'datec', 'datev', 'datep', 'date_creation', 'date_validation', 'date_lim_reglement', 'fin_validite', 'datem', 'date_modification', + 'ref', 'statut', 'status', 'socid', 'billed', 'fk_incoterms', 'actiontypecode', 'actionmsg2', 'actionmsg', + 'mode_reglement', 'cond_reglement', 'mode_reglement_code', 'cond_reglement_code', 'availability', 'availability_code', 'demand_reason', 'demand_reason_code', + 'cond_reglement_doc', 'modelpdf', 'total', + // Totals are ignored here: specimen lines reference random real products, and a kit/BOM product can + // expand into extra lines with a different amount - total correctness is checked by assertLineTotalsMatchHeader() instead. + 'total_ht', 'total_tva', 'total_ttc', 'total_localtax1', 'total_localtax2', + 'multicurrency_total_ht', 'multicurrency_total_tva', 'multicurrency_total_ttc', 'fk_multicurrency', 'multicurrency_code', 'multicurrency_tx', + 'trackid', 'user_creat', 'user_valid', 'note', + ) + ); + return $localobject; } @@ -188,11 +295,6 @@ class PropalTest extends CommonClassTest $langs = $this->savlangs; $db = $this->savdb; - /*$result=$localobject->setstatus(0); - print __METHOD__." id=".$localobject->id." result=".$result."\n"; - $this->assertLessThan($result, 0); - */ - $localobject->info($localobject->id); print __METHOD__." localobject->date_creation=".$localobject->date_creation."\n"; $this->assertNotEquals($localobject->date_creation, '');