NEW: Improve phpunit test coverage for Commande, Propal and Facture (#39517)

* NEW: Improve phpunit test coverage for Commande, Propal and Facture

Add line CRUD (addline/updateline/deleteLine) and total-consistency
assertions, reuse the specimen-invariant regression check (previously
only in FactureTest) across all three, and add a new integration test
covering the full Propal -> Commande -> Facture conversion chain
(thirdparty, notes, totals and object_linked propagation).

Two shared assertions were added to CommonClassTest for this:
assertLineTotalsMatchHeader() and assertMatchesFreshSpecimen().

* Update commande.class.php

* FIX PropalCommandeFactureWorkflowTest reads wrong invoice after createFromOrder

Facture::createFromOrder() returns a status flag (1/-1), not the new
invoice id, unlike Commande::createFromProposal(). The test was doing
fetch($result) with $result==1, which happened to no-op locally (no
invoice with rowid=1) but fetched an unrelated invoice in CI, causing
a spurious thirdparty mismatch. Use $facture->id, already set by
create() inside createFromOrder(), like every other caller of this
method does.
This commit is contained in:
Frédéric FRANCE 2026-08-19 02:35:04 +02:00 committed by GitHub
parent b254e27660
commit b7413ee622
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 650 additions and 49 deletions

View file

@ -11,7 +11,7 @@
* Copyright (C) 2014-2015 Marcos García <marcosgdf@gmail.com>
* Copyright (C) 2018 Nicolas ZABOURI <info@inovea-conseil.com>
* Copyright (C) 2016-2022 Ferran Marcet <fmarcet@2byte.es>
* Copyright (C) 2021-2025 Frédéric France <frederic.france@free.fr>
* Copyright (C) 2021-2026 Frédéric France <frederic.france@free.fr>
* Copyright (C) 2022 Gauthier VERDOL <gauthier.verdol@atm-consulting.fr>
* Copyright (C) 2024-2026 MDW <mdeweerd@users.noreply.github.com>
* Copyright (C) 2024 William Mead <william.mead@manchenumerique.fr>

View file

@ -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';

View file

@ -1,7 +1,7 @@
<?php
/* Copyright (C) 2010 Laurent Destailleur <eldy@users.sourceforge.net>
* Copyright (C) 2023 Alexandre Janniaux <alexandre.janniaux@gmail.com>
* Copyright (C) 2024-2025 Frédéric France <frederic.france@free.fr>
* Copyright (C) 2024-2026 Frédéric France <frederic.france@free.fr>
*
* 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, '');

View file

@ -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<int|string> $fieldstoignorearray Fields to ignore in the comparison (passed to objCompare)
* @param array<mixed> $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
*/

View file

@ -1,6 +1,6 @@
<?php
/* Copyright (C) 2010 Laurent Destailleur <eldy@users.sourceforge.net>
* Copyright (C) 2018-2025 Frédéric France <frederic.france@free.fr>
* Copyright (C) 2018-2026 Frédéric France <frederic.france@free.fr>
* Copyright (C) 2023 Alexandre Janniaux <alexandre.janniaux@gmail.com>
* Copyright (C) 2024 MDW <mdeweerd@users.noreply.github.com>
*
@ -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, '');

View file

@ -0,0 +1,222 @@
<?php
/* Copyright (C) 2026 Frédéric France <frederic.france@free.fr>
*
* 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 <https://www.gnu.org/licenses/>.
* 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;
}
}

View file

@ -1,7 +1,7 @@
<?php
/* Copyright (C) 2010 Laurent Destailleur <eldy@users.sourceforge.net>
* Copyright (C) 2023 Alexandre Janniaux <alexandre.janniaux@gmail.com>
* Copyright (C) 2024 Frédéric France <frederic.france@free.fr>
* Copyright (C) 2024-2026 Frédéric France <frederic.france@free.fr>
*
* 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, '');