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:
parent
b254e27660
commit
b7413ee622
7 changed files with 650 additions and 49 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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, '');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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, '');
|
||||
|
|
|
|||
222
test/phpunit/PropalCommandeFactureWorkflowTest.php
Normal file
222
test/phpunit/PropalCommandeFactureWorkflowTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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, '');
|
||||
|
|
|
|||
Loading…
Reference in a new issue