diff --git a/htdocs/comm/propal/card.php b/htdocs/comm/propal/card.php index b6041bc6e40..cd3c5c28b00 100644 --- a/htdocs/comm/propal/card.php +++ b/htdocs/comm/propal/card.php @@ -714,7 +714,10 @@ if (empty($reshook)) { $tva_tx .= ' (' . $lines[$i]->vat_src_code . ')'; } - $result = $object->addline($desc, $lines[$i]->subprice, $lines[$i]->qty, $tva_tx, $lines[$i]->localtax1_tx, $lines[$i]->localtax2_tx, $lines[$i]->fk_product, $lines[$i]->remise_percent, 'HT', 0, $lines[$i]->info_bits, $product_type, $lines[$i]->rang, $lines[$i]->special_code, $fk_parent_line, $lines[$i]->fk_fournprice, $lines[$i]->pa_ht, $label, $date_start, $date_end, $array_options, $lines[$i]->fk_unit); + // Preserve the TTC entry mode of the source line: a line entered including tax must + // stay in TTC so its total is computed from the typed value, without rounding drift. + $line_price_base_type = $lines[$i]->getPriceBaseType(); + $result = $object->addline($desc, $lines[$i]->subprice, $lines[$i]->qty, $tva_tx, $lines[$i]->localtax1_tx, $lines[$i]->localtax2_tx, $lines[$i]->fk_product, $lines[$i]->remise_percent, $line_price_base_type, (float) $lines[$i]->subprice_ttc, $lines[$i]->info_bits, $product_type, $lines[$i]->rang, $lines[$i]->special_code, $fk_parent_line, $lines[$i]->fk_fournprice, $lines[$i]->pa_ht, $label, $date_start, $date_end, $array_options, $lines[$i]->fk_unit); if ($result > 0) { $lineid = $result; @@ -1072,7 +1075,7 @@ if (empty($reshook)) { } if ($line->product_type == 1) { // only service line // Preserve the original entry mode of the line so the total is not drifted by rounding. - $line_price_base_type = $line->wasEnteredIncludingTax() ? 'TTC' : 'HT'; + $line_price_base_type = $line->getPriceBaseType(); $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; $result = $object->updateline($line->id, $line_pu, $line->qty, $line->remise_percent, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, $line->desc, $line_price_base_type, $line->info_bits, $line->special_code, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->product_type, $alldate_start, $alldate_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice); $object->lines[$key] = $object->line; @@ -1089,7 +1092,7 @@ if (empty($reshook)) { continue; } // Preserve the original entry mode of the line so the total is not drifted by rounding. - $line_price_base_type = $line->wasEnteredIncludingTax() ? 'TTC' : 'HT'; + $line_price_base_type = $line->getPriceBaseType(); $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; $result = $object->updateline($line->id, $line_pu, $line->qty, $line->remise_percent, $vat_rate, $localtax1_rate, $localtax2_rate, $line->desc, $line_price_base_type, $line->info_bits, $line->special_code, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->product_type, $line->date_start, $line->date_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice); $object->lines[$key] = $object->line; @@ -1107,7 +1110,7 @@ if (empty($reshook)) { $tvatx .= ' (' . $line->vat_src_code . ')'; } // Preserve the original entry mode of the line so the total is not drifted by rounding. - $line_price_base_type = $line->wasEnteredIncludingTax() ? 'TTC' : 'HT'; + $line_price_base_type = $line->getPriceBaseType(); $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; $result = $object->updateline($line->id, $line_pu, $line->qty, (float) $remise_percent, $tvatx, $line->localtax1_tx, $line->localtax2_tx, $line->desc, $line_price_base_type, $line->info_bits, $line->special_code, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->product_type, $line->date_start, $line->date_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice); $object->lines[$key] = $object->line; diff --git a/htdocs/comm/propal/class/propal.class.php b/htdocs/comm/propal/class/propal.class.php index 882ca701000..0f11936adf4 100644 --- a/htdocs/comm/propal/class/propal.class.php +++ b/htdocs/comm/propal/class/propal.class.php @@ -1369,17 +1369,19 @@ class Propal extends CommonObject $origintype = $this->element; } + // Preserve the original entry mode of the line so the total is computed from the typed value (no rounding drift). + $line_price_base_type = $line->getPriceBaseType(); $result = $this->addline( $line->desc, - $line->subprice, + (float) $line->subprice, $line->qty, $vatrate, $line->localtax1_tx, $line->localtax2_tx, $line->fk_product, $line->remise_percent, - 'HT', - 0, + $line_price_base_type, + (float) $line->subprice_ttc, $line->info_bits, $line->product_type, $line->rang, diff --git a/htdocs/comm/propal/class/propaleligne.class.php b/htdocs/comm/propal/class/propaleligne.class.php index 6a5a51cdc18..037282195be 100644 --- a/htdocs/comm/propal/class/propaleligne.class.php +++ b/htdocs/comm/propal/class/propaleligne.class.php @@ -20,6 +20,7 @@ * Copyright (C) 2022 Gauthier VERDOL * Copyright (C) 2023 William Mead * Copyright (C) 2024-2026 MDW + * Copyright (C) 2026 Lionel Vessiller * * 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 @@ -365,19 +366,6 @@ class PropaleLigne extends CommonObjectLine $this->db = $db; } - /** - * Return true if the unit price was originally entered including tax (TTC mode). - * Useful to preserve the entry mode on no-op edits and to avoid total drift. - * Note: cannot use !empty() because MySQL returns doubles as strings like "0.00000000" - * which empty() treats as non-empty. - * - * @return bool - */ - public function wasEnteredIncludingTax() - { - return isset($this->subprice_ttc) && (float) $this->subprice_ttc != 0; - } - /** * Retrieve the propal line object * diff --git a/htdocs/commande/card.php b/htdocs/commande/card.php index ea362e509c6..6fa95a3ea39 100644 --- a/htdocs/commande/card.php +++ b/htdocs/commande/card.php @@ -17,6 +17,7 @@ * Copyright (C) 2023-2024 Benjamin Falière * Copyright (C) 2024-2026 MDW * Copyright (C) 2025 Lenin Rivas + * Copyright (C) 2026 Lionel Vessiller * * 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 @@ -494,6 +495,8 @@ if (empty($reshook)) { $tva_tx .= ' (' . $lines[$i]->vat_src_code . ')'; } + // Preserve the original entry mode of the line so the total is computed from the typed value (no rounding drift). + $line_price_base_type = $lines[$i]->getPriceBaseType(); $result = $object->addline( $desc, $lines[$i]->subprice, @@ -505,8 +508,8 @@ if (empty($reshook)) { $lines[$i]->remise_percent, $lines[$i]->info_bits, $lines[$i]->fk_remise_except, - 'HT', - 0, + $line_price_base_type, + (float) $lines[$i]->subprice_ttc, $date_start, $date_end, $product_type, @@ -766,7 +769,10 @@ if (empty($reshook)) { continue; } if ($line->product_type == 1) { // only service line - $result = $object->updateline($line->id, $line->desc, $line->subprice, $line->qty, $line->remise_percent, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, 'HT', $line->info_bits, $alldate_start, $alldate_end, $line->product_type, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->special_code, $line->array_options, $line->fk_unit, $line->multicurrency_subprice); + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $line->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; + $result = $object->updateline($line->id, $line->desc, $line_pu, $line->qty, $line->remise_percent, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, $line_price_base_type, $line->info_bits, $alldate_start, $alldate_end, $line->product_type, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->special_code, $line->array_options, $line->fk_unit, $line->multicurrency_subprice); } } } elseif ($action == 'addline' && GETPOST('submitforalllines', 'alpha') && GETPOST('vatforalllines', 'alpha') !== '' && $usercancreate) { @@ -779,7 +785,10 @@ if (empty($reshook)) { if ($line->special_code == SUBTOTALS_SPECIAL_CODE) { continue; } - $result = $object->updateline($line->id, $line->desc, $line->subprice, $line->qty, $line->remise_percent, $vat_rate, $localtax1_rate, $localtax2_rate, 'HT', $line->info_bits, $line->date_start, $line->date_end, $line->product_type, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->special_code, $line->array_options, $line->fk_unit, $line->multicurrency_subprice); + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $line->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; + $result = $object->updateline($line->id, $line->desc, $line_pu, $line->qty, $line->remise_percent, $vat_rate, $localtax1_rate, $localtax2_rate, $line_price_base_type, $line->info_bits, $line->date_start, $line->date_end, $line->product_type, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->special_code, $line->array_options, $line->fk_unit, $line->multicurrency_subprice); } } elseif ($action == 'addline' && GETPOST('submitforalllines', 'alpha') && GETPOST('remiseforalllines', 'alpha') !== '' && $usercancreate) { // Define remise_percent @@ -793,7 +802,10 @@ if (empty($reshook)) { if (!empty($line->vat_src_code)) { $tvatx .= ' (' . $line->vat_src_code . ')'; } - $result = $object->updateline($line->id, $line->desc, $line->subprice, $line->qty, (float) $remise_percent, $tvatx, $line->localtax1_tx, $line->localtax2_tx, 'HT', $line->info_bits, $line->date_start, $line->date_end, $line->product_type, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->special_code, $line->array_options, $line->fk_unit, $line->multicurrency_subprice); + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $line->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; + $result = $object->updateline($line->id, $line->desc, $line_pu, $line->qty, (float) $remise_percent, $tvatx, $line->localtax1_tx, $line->localtax2_tx, $line_price_base_type, $line->info_bits, $line->date_start, $line->date_end, $line->product_type, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->special_code, $line->array_options, $line->fk_unit, $line->multicurrency_subprice); } } elseif ($action == 'addline' && $usercancreate && ( (GETPOST('submitforallmargins', 'alpha') && GETPOST('marginforalllines', 'alpha') !== '') || @@ -1247,23 +1259,29 @@ if (empty($reshook)) { $tmpvat = (float) price2num(preg_replace('/\s*\(.*\)/', '', $tva_tx)); $tmpprodvat = (float) price2num(preg_replace('/\s*\(.*\)/', '', (string) $prod->tva_tx)); - // Set unit price to use + // Set unit price to use. + // Force price_base_type to match what the user actually entered, so the total is computed + // from the typed value and not from the converted/rounded value (avoids 0.01 rounding drift). if (!empty($price_ht) || (string) $price_ht === '0') { $pu_ht = (float) price2num($price_ht, 'MU'); $pu_ttc = (float) price2num((float) $pu_ht * (1 + ((float) $tmpvat / 100)), 'MU'); + $price_base_type = 'HT'; } elseif (!empty($price_ht_devise) || (string) $price_ht_devise === '0') { $pu_ht_devise = price2num($price_ht_devise, 'MU'); $pu_ttc_devise = (float) price2num((float) $pu_ht_devise * (1 + ((float) $tmpvat / 100)), 'MU'); $pu_ht = ''; $pu_ttc = ''; + $price_base_type = 'HT'; } elseif (!empty($price_ttc) || (string) $price_ttc === '0') { $pu_ttc = (float) price2num($price_ttc, 'MU'); $pu_ht = (float) price2num((float) $pu_ttc / (1 + ((float) $tmpvat / 100)), 'MU'); + $price_base_type = 'TTC'; } elseif (!empty($price_ttc_devise) || (string) $price_ttc_devise === '0') { $pu_ttc_devise = (float) price2num($price_ttc_devise, 'MU'); $pu_ht_devise = (float) price2num((float) $pu_ttc_devise / (1 + ((float) $tmpvat / 100)), 'MU'); $pu_ht = ''; $pu_ttc = ''; + $price_base_type = 'TTC'; } elseif ($tmpvat != $tmpprodvat) { // Is this still used ? if ($price_base_type != 'HT') { @@ -1653,11 +1671,25 @@ if (empty($reshook)) { $remise_percent = GETPOST('remise_percent') != '' ? price2num(GETPOST('remise_percent'), '', 2) : 0; + // The form JS clears the other field when the user edits one of them: only the modified field is filled. + // When both fields are submitted, the user did not change the price - we must preserve the original + // storage mode of the line, otherwise a no-op save would shift the total by rounding. $pu = $pu_ht; $price_base_type = 'HT'; - if (empty($pu) && !empty($pu_ttc)) { + if (empty($pu_ht) && !empty($pu_ttc)) { $pu = $pu_ttc; $price_base_type = 'TTC'; + } elseif (!empty($pu_ht) && !empty($pu_ttc)) { + foreach ($object->lines as $line_obj) { + if ($line_obj->id == GETPOSTINT('lineid')) { + // Line was originally entered in TTC mode (subprice_ttc filled by addline) + if ($line_obj->wasEnteredIncludingTax()) { + $pu = $pu_ttc; + $price_base_type = 'TTC'; + } + break; + } + } } // Check minimum price diff --git a/htdocs/commande/class/commande.class.php b/htdocs/commande/class/commande.class.php index f43f1156c05..9f72a3ff9d8 100644 --- a/htdocs/commande/class/commande.class.php +++ b/htdocs/commande/class/commande.class.php @@ -16,6 +16,7 @@ * Copyright (C) 2024-2026 MDW * Copyright (C) 2024 William Mead * Copyright (C) 2026 Vincent de Grandpré + * Copyright (C) 2026 Lionel Vessiller * * 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 @@ -1127,9 +1128,11 @@ class Commande extends CommonOrder $line->ref_ext = ''; } + // Preserve the original entry mode of the line so the total is computed from the typed value (no rounding drift). + $line_price_base_type = $line->getPriceBaseType(); $result = $this->addline( $line->desc, - $line->subprice, + (float) $line->subprice, $line->qty, $vatrate, $line->localtax1_tx, @@ -1138,8 +1141,8 @@ class Commande extends CommonOrder $line->remise_percent, $line->info_bits, $line->fk_remise_except, - 'HT', - 0, + $line_price_base_type, + (float) $line->subprice_ttc, $line->date_start, $line->date_end, $line->product_type, @@ -1425,6 +1428,7 @@ class Commande extends CommonOrder $line->desc = $object->lines[$i]->desc; $line->price = $object->lines[$i]->price; $line->subprice = $object->lines[$i]->subprice; + $line->subprice_ttc = $object->lines[$i]->subprice_ttc; // Preserve the TTC entry mode so create() keeps the typed value (no rounding drift). $line->vat_src_code = $object->lines[$i]->vat_src_code; $line->tva_tx = $object->lines[$i]->tva_tx; $line->localtax1_tx = $object->lines[$i]->localtax1_tx; @@ -1809,6 +1813,8 @@ class Commande extends CommonOrder $this->line->fk_remise_except = $fk_remise_except; $this->line->remise_percent = $remise_percent; $this->line->subprice = (float) $pu_ht; + // Persist the original entry mode of the line so updateline() can preserve it later. + $this->line->subprice_ttc = ($price_base_type === 'TTC') ? (float) $pu_ttc : 0; $this->line->rang = $ranktouse; $this->line->info_bits = $info_bits; $this->line->total_ht = (float) $total_ht; @@ -3344,6 +3350,8 @@ class Commande extends CommonOrder $this->line->localtax2_type = empty($localtaxes_type[2]) ? '' : $localtaxes_type[2]; $this->line->remise_percent = $remise_percent; $this->line->subprice = (float) $pu_ht; + // Persist the original entry mode of the line so a no-op edit can preserve it later. + $this->line->subprice_ttc = ($price_base_type === 'TTC') ? (float) $pu_ttc : 0; $this->line->info_bits = $info_bits; $this->line->special_code = $special_code; $this->line->total_ht = (float) $total_ht; diff --git a/htdocs/commande/class/orderline.class.php b/htdocs/commande/class/orderline.class.php index 7b04d10e2fe..af6b3baf4fd 100644 --- a/htdocs/commande/class/orderline.class.php +++ b/htdocs/commande/class/orderline.class.php @@ -15,6 +15,7 @@ * Copyright (C) 2022 Gauthier VERDOL * Copyright (C) 2024-2026 MDW * Copyright (C) 2024 William Mead + * Copyright (C) 2026 Lionel Vessiller * * 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 @@ -224,7 +225,7 @@ class OrderLine extends CommonOrderLine public function fetch($rowid) { $sql = 'SELECT cd.rowid, cd.fk_commande, cd.fk_parent_line, cd.fk_product, cd.product_type, cd.label as custom_label, cd.description, cd.price, cd.qty, cd.tva_tx, cd.localtax1_tx, cd.localtax2_tx,'; - $sql .= ' cd.remise, cd.remise_percent, cd.fk_remise_except, cd.subprice, cd.ref_ext,'; + $sql .= ' cd.remise, cd.remise_percent, cd.fk_remise_except, cd.subprice, cd.subprice_ttc, cd.ref_ext,'; $sql .= ' cd.info_bits, cd.total_ht, cd.total_tva, cd.total_localtax1, cd.total_localtax2, cd.total_ttc, cd.fk_product_fournisseur_price as fk_fournprice, cd.buy_price_ht as pa_ht, cd.rang, cd.special_code,'; $sql .= ' cd.fk_unit,'; $sql .= ' cd.fk_multicurrency, cd.multicurrency_code, cd.multicurrency_subprice, cd.multicurrency_total_ht, cd.multicurrency_total_tva, cd.multicurrency_total_ttc,'; @@ -254,6 +255,7 @@ class OrderLine extends CommonOrderLine $this->qty = $objp->qty; $this->price = $objp->price; $this->subprice = $objp->subprice; + $this->subprice_ttc = $objp->subprice_ttc; $this->ref_ext = $objp->ref_ext; $this->vat_src_code = $objp->vat_src_code; $this->tva_tx = $objp->tva_tx; @@ -481,7 +483,7 @@ class OrderLine extends CommonOrderLine $sql = 'INSERT INTO '.MAIN_DB_PREFIX.'commandedet'; $sql .= ' (fk_commande, fk_parent_line, label, description, qty, ref_ext,'; $sql .= ' vat_src_code, tva_tx, localtax1_tx, localtax2_tx, localtax1_type, localtax2_type,'; - $sql .= ' fk_product, product_type, remise_percent, subprice, price, fk_remise_except,'; + $sql .= ' fk_product, product_type, remise_percent, subprice, subprice_ttc, price, fk_remise_except,'; $sql .= ' special_code, rang, fk_product_fournisseur_price, buy_price_ht,'; $sql .= ' info_bits, total_ht, total_tva, total_localtax1, total_localtax2, total_ttc, date_start, date_end,'; $sql .= ' fk_unit,'; @@ -503,6 +505,7 @@ class OrderLine extends CommonOrderLine $sql .= " ".((int) $this->product_type).","; $sql .= " '".price2num($this->remise_percent)."',"; $sql .= " ".(price2num($this->subprice) !== '' ? price2num($this->subprice) : "null").","; + $sql .= " ".price2num($this->subprice_ttc).","; $sql .= " ".($this->price != '' ? "'".price2num($this->price)."'" : "null").","; $sql .= ' '.(!empty($this->fk_remise_except) ? ((int) $this->fk_remise_except) : "null").','; $sql .= ' '.((int) $this->special_code).','; @@ -661,6 +664,7 @@ class OrderLine extends CommonOrderLine $sql .= " , qty=".price2num($this->qty); $sql .= " , ref_ext='".$this->db->escape($this->ref_ext)."'"; $sql .= " , subprice=".price2num($this->subprice); + $sql .= " , subprice_ttc=".price2num($this->subprice_ttc); $sql .= " , remise_percent=".price2num($this->remise_percent); $sql .= " , price=".price2num($this->price); // TODO A virer $sql .= " , remise=".price2num($this->remise); // TODO A virer diff --git a/htdocs/compta/facture/card.php b/htdocs/compta/facture/card.php index c9ed70bfecb..37287f75ed7 100644 --- a/htdocs/compta/facture/card.php +++ b/htdocs/compta/facture/card.php @@ -22,6 +22,7 @@ * Copyright (C) 2025 Lenin Rivas * Copyright (C) 2026 Vincent de Grandpré * Copyright (C) 2026 Joachim Küter + * Copyright (C) 2026 Lionel Vessiller * * 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 @@ -1531,6 +1532,7 @@ if (empty($reshook)) { $line->fk_parent_line = $fk_parent_line; $line->subprice = -$line->subprice; // invert price for object + $line->subprice_ttc = -$line->subprice_ttc; // keep the TTC entry mode with the inverted sign (no rounding drift) // $line->pa_ht = $line->pa_ht; // we chose to have buy/cost price always positive, so no revert of sign here $line->total_ht = -$line->total_ht; $line->total_tva = -$line->total_tva; @@ -2098,6 +2100,8 @@ if (empty($reshook)) { $localtax1_tx = get_localtax($tva_tx, 1, $object->thirdparty); $localtax2_tx = get_localtax($tva_tx, 2, $object->thirdparty); + // Preserve the original entry mode of the line so the total is computed from the typed value (no rounding drift). + $line_price_base_type = $lines[$i]->getPriceBaseType(); $result = $object->addline( $desc, $lines[$i]->subprice, @@ -2112,8 +2116,8 @@ if (empty($reshook)) { 0, (int) $lines[$i]->info_bits, isset($lines[$i]->fk_remise_except) ? $lines[$i]->fk_remise_except : null, - 'HT', - 0, + $line_price_base_type, + (float) $lines[$i]->subprice_ttc, $product_type, $lines[$i]->rang, $lines[$i]->special_code, @@ -2401,7 +2405,10 @@ if (empty($reshook)) { continue; } if ($line->product_type == 1) { // only service line - $result = $object->updateline($line->id, $line->desc, $line->subprice, $line->qty, $line->remise_percent, $alldate_start, $alldate_end, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, 'HT', $line->info_bits, $line->product_type, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->special_code, $line->array_options, $line->situation_percent, $line->fk_unit, $line->multicurrency_subprice); + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $line->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; + $result = $object->updateline($line->id, $line->desc, $line_pu, $line->qty, $line->remise_percent, $alldate_start, $alldate_end, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, $line_price_base_type, $line->info_bits, $line->product_type, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->special_code, $line->array_options, $line->situation_percent, $line->fk_unit, $line->multicurrency_subprice); } } } elseif ($action == 'addline' && GETPOST('submitforalllines', 'alpha') && GETPOST('vatforalllines', 'alpha') !== '' && $usercancreate) { @@ -2414,7 +2421,10 @@ if (empty($reshook)) { if ($line->special_code == SUBTOTALS_SPECIAL_CODE) { continue; } - $result = $object->updateline($line->id, $line->desc, $line->subprice, $line->qty, $line->remise_percent, $line->date_start, $line->date_end, $vat_rate, $localtax1_rate, $localtax2_rate, 'HT', $line->info_bits, $line->product_type, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->special_code, $line->array_options, $line->situation_percent, $line->fk_unit, $line->multicurrency_subprice); + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $line->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; + $result = $object->updateline($line->id, $line->desc, $line_pu, $line->qty, $line->remise_percent, $line->date_start, $line->date_end, $vat_rate, $localtax1_rate, $localtax2_rate, $line_price_base_type, $line->info_bits, $line->product_type, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->special_code, $line->array_options, $line->situation_percent, $line->fk_unit, $line->multicurrency_subprice); } } elseif ($action == 'addline' && GETPOST('submitforalllines', 'alpha') && GETPOST('remiseforalllines', 'alpha') !== '' && $usercancreate) { // Define vat_rate @@ -2428,7 +2438,10 @@ if (empty($reshook)) { if (!empty($line->vat_src_code)) { $tvatx .= ' ('.$line->vat_src_code.')'; } - $result = $object->updateline($line->id, $line->desc, $line->subprice, $line->qty, (float) $remise_percent, $line->date_start, $line->date_end, $tvatx, $line->localtax1_tx, $line->localtax2_tx, 'HT', $line->info_bits, $line->product_type, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->special_code, $line->array_options, $line->situation_percent, $line->fk_unit, $line->multicurrency_subprice); + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $line->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; + $result = $object->updateline($line->id, $line->desc, $line_pu, $line->qty, (float) $remise_percent, $line->date_start, $line->date_end, $tvatx, $line->localtax1_tx, $line->localtax2_tx, $line_price_base_type, $line->info_bits, $line->product_type, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->special_code, $line->array_options, $line->situation_percent, $line->fk_unit, $line->multicurrency_subprice); } } elseif ($action == 'confirm_addtitleline' && $usercancreate) { // Handling adding a new title line for subtotals module @@ -2706,24 +2719,29 @@ if (empty($reshook)) { $tmpvat = (float) price2num(preg_replace('/\s*\(.*\)/', '', $tva_tx)); $tmpprodvat = price2num(preg_replace('/\s*\(.*\)/', '', (string) $prod->tva_tx)); - // Set unit price to use - // TODO We should not have this + // Set unit price to use. + // Force price_base_type to match what the user actually entered, so the total is computed + // from the typed value and not from the converted/rounded value (avoids 0.01 rounding drift). if (!empty($price_ht) || $price_ht === '0') { $pu_ht = price2num($price_ht, 'MU'); $pu_ttc = price2num((float) $pu_ht * (1 + ($tmpvat / 100)), 'MU'); + $price_base_type = 'HT'; } elseif (!empty($price_ht_devise) || $price_ht_devise === '0') { $pu_ht_devise = price2num($price_ht_devise, 'MU'); $pu_ttc_devise = (float) price2num((float) $pu_ht_devise * (1 + ((float) $tmpvat / 100)), 'MU'); $pu_ht = ''; $pu_ttc = ''; + $price_base_type = 'HT'; } elseif (!empty($price_ttc) || $price_ttc === '0') { $pu_ttc = price2num($price_ttc, 'MU'); $pu_ht = price2num((float) $pu_ttc / (1 + ($tmpvat / 100)), 'MU'); + $price_base_type = 'TTC'; } elseif (!empty($price_ttc_devise) || (string) $price_ttc_devise === '0') { $pu_ttc_devise = (float) price2num($price_ttc_devise, 'MU'); $pu_ht_devise = (float) price2num((float) $pu_ttc_devise / (1 + ((float) $tmpvat / 100)), 'MU'); $pu_ht = ''; $pu_ttc = ''; + $price_base_type = 'TTC'; } elseif ($tmpvat != $tmpprodvat) { // Is this still used ? if ($price_base_type != 'HT') { @@ -3231,11 +3249,25 @@ if (empty($reshook)) { $remise_percent = 0; } - $price_base_type = 'HT'; + // The form JS clears the other field when the user edits one of them: only the modified field is filled. + // When both fields are submitted, the user did not change the price - we must preserve the original + // storage mode of the line, otherwise a no-op save would shift the total by rounding. $pu = $pu_ht; - if (empty($pu) && !empty($pu_ttc)) { + $price_base_type = 'HT'; + if (empty($pu_ht) && !empty($pu_ttc)) { $pu = $pu_ttc; $price_base_type = 'TTC'; + } elseif (!empty($pu_ht) && !empty($pu_ttc)) { + foreach ($object->lines as $line_obj) { + if ($line_obj->id == GETPOSTINT('lineid')) { + // Line was originally entered in TTC mode (subprice_ttc filled by addline) + if ($line_obj->wasEnteredIncludingTax()) { + $pu = $pu_ttc; + $price_base_type = 'TTC'; + } + break; + } + } } // Check minimum price diff --git a/htdocs/compta/facture/class/facture.class.php b/htdocs/compta/facture/class/facture.class.php index 2ce1fffbd0d..0f4a4f86c3c 100644 --- a/htdocs/compta/facture/class/facture.class.php +++ b/htdocs/compta/facture/class/facture.class.php @@ -25,6 +25,7 @@ * Copyright (C) 2024-2025 Frédéric France * Copyright (C) 2025 Lenin Rivas * Copyright (C) 2026 Vincent de Grandpré + * Copyright (C) 2026 Lionel Vessiller * * 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 @@ -898,9 +899,11 @@ class Facture extends CommonInvoice $newinvoiceline->fk_remise_except = $discountId; } + // Preserve the original entry mode of the line so the total is computed from the typed value (no rounding drift). + $line_price_base_type = $newinvoiceline->getPriceBaseType(); $result = $this->addline( $newinvoiceline->desc, - $newinvoiceline->subprice, + (float) $newinvoiceline->subprice, $newinvoiceline->qty, $vatrate, $newinvoiceline->localtax1_tx, @@ -912,8 +915,8 @@ class Facture extends CommonInvoice $newinvoiceline->fk_code_ventilation, $newinvoiceline->info_bits, $newinvoiceline->fk_remise_except, - 'HT', - 0, + $line_price_base_type, + (float) $newinvoiceline->subprice_ttc, $newinvoiceline->product_type, $newinvoiceline->rang, $newinvoiceline->special_code, @@ -1246,6 +1249,7 @@ class Facture extends CommonInvoice $facture->lines[$i]->fk_prev_id = $this->lines[$i]->rowid; if ($invertdetail) { $facture->lines[$i]->subprice = -$facture->lines[$i]->subprice; + $facture->lines[$i]->subprice_ttc = -$facture->lines[$i]->subprice_ttc; // Keep the TTC entry mode with the inverted sign so the credit note has no rounding drift. $facture->lines[$i]->total_ht = -$facture->lines[$i]->total_ht; $facture->lines[$i]->total_tva = -$facture->lines[$i]->total_tva; $facture->lines[$i]->total_localtax1 = -$facture->lines[$i]->total_localtax1; @@ -1461,6 +1465,7 @@ class Facture extends CommonInvoice $line->label = $src_line->label; $line->desc = $src_line->desc; $line->subprice = $src_line->subprice; + $line->subprice_ttc = $src_line->subprice_ttc; // Preserve the TTC entry mode so create() keeps the typed value (no rounding drift). $line->total_ht = $src_line->total_ht; $line->total_tva = $src_line->total_tva; $line->total_localtax1 = $src_line->total_localtax1; @@ -1603,6 +1608,7 @@ class Facture extends CommonInvoice $line->label = $object->lines[$i]->label; $line->desc = $object->lines[$i]->desc; $line->subprice = $object->lines[$i]->subprice; + $line->subprice_ttc = $object->lines[$i]->subprice_ttc; // Preserve the TTC entry mode so create() keeps the typed value (no rounding drift). $line->total_ht = $object->lines[$i]->total_ht; $line->total_tva = $object->lines[$i]->total_tva; $line->total_localtax1 = $object->lines[$i]->total_localtax1; @@ -2527,7 +2533,7 @@ class Facture extends CommonInvoice } $sql = 'SELECT l.rowid, l.fk_facture, l.fk_product, l.fk_parent_line, l.label as custom_label, l.description, l.product_type, l.price, l.qty, l.vat_src_code, l.tva_tx,'; - $sql .= ' l.localtax1_tx, l.localtax2_tx, l.localtax1_type, l.localtax2_type, l.remise_percent, l.fk_remise_except, l.subprice, l.ref_ext,'; + $sql .= ' l.localtax1_tx, l.localtax2_tx, l.localtax1_type, l.localtax2_type, l.remise_percent, l.fk_remise_except, l.subprice, l.subprice_ttc, l.ref_ext,'; $sql .= ' l.situation_percent, l.fk_prev_id,'; $sql .= ' l.rang, l.special_code, l.batch, l.fk_warehouse,'; $sql .= ' l.date_start as date_start, l.date_end as date_end,'; @@ -2592,6 +2598,7 @@ class Facture extends CommonInvoice $line->fk_product_type = $objp->fk_product_type; // Type of product $line->qty = $objp->qty; $line->subprice = $objp->subprice; + $line->subprice_ttc = $objp->subprice_ttc; $line->ref_ext = $objp->ref_ext; // line external ref $line->vat_src_code = $objp->vat_src_code; @@ -4435,6 +4442,8 @@ class Facture extends CommonInvoice $this->line->qty = ($this->type == self::TYPE_CREDIT_NOTE ? abs((float) $qty) : (float) $qty); // For credit note, quantity is always positive and unit price negative $this->line->subprice = ($this->type == self::TYPE_CREDIT_NOTE ? -abs((float) $pu_ht) : (float) $pu_ht); // For credit note, unit price always negative, always positive otherwise + // Persist the original entry mode of the line so updateline() can preserve it later. + $this->line->subprice_ttc = ($price_base_type === 'TTC') ? ($this->type == self::TYPE_CREDIT_NOTE ? -abs((float) $pu_ttc) : (float) $pu_ttc) : 0; $this->line->vat_src_code = $vat_src_code; $this->line->tva_tx = $txtva; @@ -4777,6 +4786,8 @@ class Facture extends CommonInvoice $this->line->remise_percent = $remise_percent; $this->line->subprice = ($apply_abs_price_on_credit_note ? -abs((float) $pu_ht) : (float) $pu_ht); // For credit note, unit price always negative, always positive otherwise + // Persist the original entry mode of the line so a no-op edit can preserve it later. + $this->line->subprice_ttc = ($price_base_type === 'TTC') ? ($apply_abs_price_on_credit_note ? -abs((float) $pu_ttc) : (float) $pu_ttc) : 0; $this->line->date_start = $date_start; $this->line->date_end = $date_end; $this->line->total_ht = (($apply_abs_price_on_credit_note || $qty < 0) ? -abs((float) $total_ht) : (float) $total_ht); // For credit note and if qty is negative, total is negative diff --git a/htdocs/compta/facture/class/factureligne.class.php b/htdocs/compta/facture/class/factureligne.class.php index c428357356b..730618c2465 100644 --- a/htdocs/compta/facture/class/factureligne.class.php +++ b/htdocs/compta/facture/class/factureligne.class.php @@ -22,6 +22,7 @@ * Copyright (C) 2023 Nick Fragoulis * Copyright (C) 2024-2026 MDW * Copyright (C) 2024-2025 Frédéric France + * Copyright (C) 2026 Lionel Vessiller * * 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 @@ -223,7 +224,7 @@ class FactureLigne extends CommonInvoiceLine } $sql = 'SELECT fd.rowid, fd.fk_facture, fd.fk_parent_line, fd.fk_product, fd.product_type, fd.label as custom_label, fd.description, fd.price, fd.qty, fd.vat_src_code, fd.tva_tx,'; - $sql .= ' fd.localtax1_tx, fd. localtax2_tx, fd.remise, fd.remise_percent, fd.fk_remise_except, fd.subprice, fd.ref_ext,'; + $sql .= ' fd.localtax1_tx, fd. localtax2_tx, fd.remise, fd.remise_percent, fd.fk_remise_except, fd.subprice, fd.subprice_ttc, fd.ref_ext,'; $sql .= ' fd.date_start as date_start, fd.date_end as date_end, fd.fk_product_fournisseur_price as fk_fournprice, fd.buy_price_ht as pa_ht,'; $sql .= ' fd.info_bits, fd.special_code, fd.total_ht, fd.total_tva, fd.total_ttc, fd.total_localtax1, fd.total_localtax2, fd.rang,'; $sql .= ' fd.fk_code_ventilation,'; @@ -280,6 +281,7 @@ class FactureLigne extends CommonInvoiceLine $this->desc = $objp->description; $this->qty = $objp->qty; $this->subprice = $objp->subprice; + $this->subprice_ttc = $objp->subprice_ttc; $this->ref_ext = $objp->ref_ext; $this->vat_src_code = $objp->vat_src_code; $this->tva_tx = $objp->tva_tx; @@ -496,7 +498,7 @@ class FactureLigne extends CommonInvoiceLine $sql = 'INSERT INTO '.MAIN_DB_PREFIX.'facturedet'; $sql .= ' (fk_facture, fk_parent_line, label, description, qty,'; $sql .= ' vat_src_code, tva_tx, localtax1_tx, localtax2_tx, localtax1_type, localtax2_type,'; - $sql .= ' fk_product, product_type, remise_percent, subprice, ref_ext, fk_remise_except,'; + $sql .= ' fk_product, product_type, remise_percent, subprice, subprice_ttc, ref_ext, fk_remise_except,'; $sql .= ' date_start, date_end, fk_code_ventilation,'; $sql .= ' rang, special_code, fk_product_fournisseur_price, buy_price_ht,'; $sql .= ' info_bits, total_ht, total_tva, total_ttc, total_localtax1, total_localtax2,'; @@ -520,6 +522,7 @@ class FactureLigne extends CommonInvoiceLine $sql .= " ".((int) $this->product_type).","; $sql .= " ".price2num($this->remise_percent).","; $sql .= " ".price2num($this->subprice).","; + $sql .= " ".price2num($this->subprice_ttc).","; $sql .= " '".$this->db->escape($this->ref_ext)."',"; $sql .= ' '.(!empty($this->fk_remise_except) ? ((int) $this->fk_remise_except) : "null").','; $sql .= " ".(!empty($this->date_start) ? "'".$this->db->idate($this->date_start)."'" : "null").","; @@ -730,6 +733,7 @@ class FactureLigne extends CommonInvoiceLine $sql .= ", ref_ext='".$this->db->escape($this->ref_ext)."'"; $sql .= ", label=".(!empty($this->label) ? "'".$this->db->escape($this->label)."'" : "null"); $sql .= ", subprice=".price2num($this->subprice); + $sql .= ", subprice_ttc=".price2num($this->subprice_ttc); $sql .= ", remise_percent=".price2num($this->remise_percent); if ($this->fk_remise_except) { $sql .= ", fk_remise_except = ".((int) $this->fk_remise_except); diff --git a/htdocs/contrat/card.php b/htdocs/contrat/card.php index 4cdb1d36c3b..12347a24568 100644 --- a/htdocs/contrat/card.php +++ b/htdocs/contrat/card.php @@ -15,6 +15,7 @@ * Copyright (C) 2024-2025 MDW * Copyright (C) 2024-2026 Alexandre Spangaro * Copyright (C) 2025 William Mead + * Copyright (C) 2026 Lionel Vessiller * * 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 @@ -425,6 +426,9 @@ if (empty($reshook)) { $localtax1_tx = get_localtax($txtva, 1, $object->thirdparty); $localtax2_tx = get_localtax($txtva, 2, $object->thirdparty); + // Preserve the TTC entry mode of the source line: a line entered including tax must + // stay in TTC so its total is computed from the typed value, without rounding drift. + $line_price_base_type = $lines[$i]->getPriceBaseType(); $result = $object->addline( $desc, $lines[$i]->subprice, @@ -436,8 +440,8 @@ if (empty($reshook)) { $lines[$i]->remise_percent, $lines[$i]->date_start, $lines[$i]->date_end, - 'HT', - 0, + $line_price_base_type, + (float) $lines[$i]->subprice_ttc, $lines[$i]->info_bits, $lines[$i]->fk_fournprice, $lines[$i]->pa_ht, @@ -838,6 +842,11 @@ if (empty($reshook)) { $objectline->fk_product = GETPOSTINT('idprod'); $objectline->description = GETPOST('product_desc', 'restricthtml'); $objectline->subprice = (float) price2num(GETPOST('elprice'), 'MU'); + // The contract line edit form is HT-only: if the user actually changed the HT unit price, + // the line is no longer in TTC entry mode, so drop the stored TTC value. + if (isset($objectline->oldcopy) && (float) $objectline->subprice != (float) $objectline->oldcopy->subprice) { + $objectline->subprice_ttc = 0; + } $objectline->qty = (float) price2num(GETPOST('elqty'), 'MS'); $objectline->remise_percent = $remise_percent; $objectline->tva_tx = ($txtva ? $txtva : 0); // Field may be disabled, so we use vat rate 0 diff --git a/htdocs/contrat/class/contrat.class.php b/htdocs/contrat/class/contrat.class.php index 926c87dae8c..480b054ffff 100644 --- a/htdocs/contrat/class/contrat.class.php +++ b/htdocs/contrat/class/contrat.class.php @@ -15,6 +15,7 @@ * Copyright (C) 2024-2026 MDW * Copyright (C) 2026 Charlene Benke * Copyright (C) 2026 Alexandre Spangaro * * 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 @@ -859,7 +860,7 @@ class Contrat extends CommonObject // Selects contract lines related to a product $sql = "SELECT p.label as product_label, p.description as product_desc, p.ref as product_ref, p.fk_product_type as product_type,"; - $sql .= " d.rowid, d.fk_contrat, d.statut as status, d.description, d.subprice, d.vat_src_code, d.tva_tx, d.localtax1_tx, d.localtax2_tx, d.localtax1_type, d.localtax2_type, d.qty, d.remise_percent, d.fk_product_fournisseur_price as fk_fournprice, d.buy_price_ht as pa_ht,"; + $sql .= " d.rowid, d.fk_contrat, d.statut as status, d.description, d.subprice, d.subprice_ttc, d.vat_src_code, d.tva_tx, d.localtax1_tx, d.localtax2_tx, d.localtax1_type, d.localtax2_type, d.qty, d.remise_percent, d.fk_product_fournisseur_price as fk_fournprice, d.buy_price_ht as pa_ht,"; $sql .= " d.total_ht,"; $sql .= " d.total_tva,"; $sql .= " d.total_localtax1,"; @@ -907,6 +908,7 @@ class Contrat extends CommonObject $line->localtax1_type = $objp->localtax1_type; $line->localtax2_type = $objp->localtax2_type; $line->subprice = $objp->subprice; + $line->subprice_ttc = $objp->subprice_ttc; $line->statut = $objp->status; // For backward compatibility $line->status = $objp->status; $line->remise_percent = $objp->remise_percent; @@ -1564,6 +1566,8 @@ class Contrat extends CommonObject $total_ttc = $tabprice[2]; $total_localtax1 = $tabprice[9]; $total_localtax2 = $tabprice[10]; + $pu_ht = $tabprice[3]; + $pu_ttc = $tabprice[5]; if (count($localtaxes_type) > 0) { $localtax1_type = $localtaxes_type[0]; @@ -1580,7 +1584,7 @@ class Contrat extends CommonObject // if buy price not defined, define buyprice as configured in margin admin if ($pa_ht == 0) { - $result = $this->defineBuyPrice($pu_ht, $remise_percent, $fk_product); + $result = $this->defineBuyPrice((float) $pu_ht, $remise_percent, $fk_product); if ($result < 0) { return -1; } else { @@ -1591,7 +1595,7 @@ class Contrat extends CommonObject // Insertion dans la base $sql = "INSERT INTO ".MAIN_DB_PREFIX."contratdet"; $sql .= " (fk_contrat, label, description, fk_product, qty, tva_tx, vat_src_code,"; - $sql .= " localtax1_tx, localtax2_tx, localtax1_type, localtax2_type, remise_percent, subprice,"; + $sql .= " localtax1_tx, localtax2_tx, localtax1_type, localtax2_type, remise_percent, subprice, subprice_ttc,"; $sql .= " total_ht, total_tva, total_localtax1, total_localtax2, total_ttc,"; $sql .= " info_bits,"; $sql .= " fk_product_fournisseur_price, buy_price_ht"; @@ -1615,6 +1619,7 @@ class Contrat extends CommonObject $sql .= " '".$this->db->escape($localtax2_type)."',"; $sql .= " ".price2num($remise_percent).","; $sql .= " ".price2num($pu_ht).","; + $sql .= " ".($price_base_type === 'TTC' ? price2num($pu_ttc) : "0").","; $sql .= " ".price2num($total_ht).",".price2num($total_tva).",".price2num($total_localtax1).",".price2num($total_localtax2).",".price2num($total_ttc).","; $sql .= " ".((int) $info_bits).","; if (isset($fk_fournprice)) { @@ -1712,7 +1717,6 @@ class Contrat extends CommonObject $qty = trim((string) $qty); $desc = trim($desc); $desc = trim($desc); - $subprice = price2num($pu); $tvatx = price2num($tvatx); $localtax1tx = price2num($localtax1tx); $localtax2tx = price2num($localtax2tx); @@ -1747,6 +1751,8 @@ class Contrat extends CommonObject $total_ttc = $tabprice[2]; $total_localtax1 = $tabprice[9]; $total_localtax2 = $tabprice[10]; + $pu_ht = $tabprice[3]; + $pu_ttc = $tabprice[5]; $localtax1_type = (empty($localtaxes_type[0]) ? '' : $localtaxes_type[0]); $localtax2_type = (empty($localtaxes_type[2]) ? '' : $localtaxes_type[2]); @@ -1766,7 +1772,9 @@ class Contrat extends CommonObject } $sql = "UPDATE ".MAIN_DB_PREFIX."contratdet SET description = '".$this->db->escape($desc)."'"; - $sql .= ",subprice = ".((float) price2num($subprice)); + $sql .= ",subprice = ".((float) price2num($pu_ht)); + // Persist the original entry mode of the line so a no-op edit can preserve it later. + $sql .= ",subprice_ttc = ".($price_base_type === 'TTC' ? (float) price2num($pu_ttc) : 0); $sql .= ",remise_percent = ".((float) price2num($remise_percent)); $sql .= ",qty = ".((float) $qty); $sql .= ",tva_tx = ".((float) price2num($tvatx)); @@ -2677,7 +2685,11 @@ class Contrat extends CommonObject if (!$error) { foreach ($this->lines as $line) { - $result = $clonedObj->addline($line->description, $line->subprice, $line->qty, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, $line->fk_product, $line->remise_percent, $line->date_start, $line->date_cloture, 'HT', 0, $line->info_bits, $line->fk_fournprice, $line->pa_ht, $line->array_options, $line->fk_unit, $line->rang); + // Preserve the original entry mode of the line. Contrat::addline() stores subprice from the + // $pu_ht argument as-is (like the card, which pre-computes it), so we pass the stored HT and + // flag TTC + subprice_ttc so the total is computed from the typed value (no rounding drift). + $line_price_base_type = $line->getPriceBaseType(); + $result = $clonedObj->addline($line->description, (float) $line->subprice, $line->qty, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, $line->fk_product, $line->remise_percent, $line->date_start, $line->date_cloture, $line_price_base_type, (float) $line->subprice_ttc, $line->info_bits, $line->fk_fournprice, $line->pa_ht, $line->array_options, $line->fk_unit, $line->rang); if ($result < 0) { $error++; $this->setErrorsFromObject($clonedObj); diff --git a/htdocs/contrat/class/contratligne.class.php b/htdocs/contrat/class/contratligne.class.php index db0020ca717..c08ec204392 100644 --- a/htdocs/contrat/class/contratligne.class.php +++ b/htdocs/contrat/class/contratligne.class.php @@ -13,6 +13,7 @@ * Copyright (C) 2015-2018 Ferran Marcet * Copyright (C) 2024 William Mead * Copyright (C) 2024-2026 MDW + * Copyright (C) 2026 Lionel Vessiller * * 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 @@ -493,6 +494,7 @@ class ContratLigne extends CommonObjectLine $sql .= " t.remise_percent,"; $sql .= " t.fk_remise_except,"; $sql .= " t.subprice,"; + $sql .= " t.subprice_ttc,"; $sql .= " t.total_ht,"; $sql .= " t.total_tva,"; $sql .= " t.total_localtax1,"; @@ -551,6 +553,7 @@ class ContratLigne extends CommonObjectLine $this->remise_percent = $obj->remise_percent; $this->fk_remise_except = $obj->fk_remise_except; $this->subprice = $obj->subprice; + $this->subprice_ttc = $obj->subprice_ttc; $this->total_ht = $obj->total_ht; $this->total_tva = $obj->total_tva; $this->total_localtax1 = $obj->total_localtax1; @@ -644,7 +647,11 @@ class ContratLigne extends CommonObjectLine // and this is done at the line level, which has its own VAT rate $localtaxes_type = getLocalTaxesFromRate($this->tva_tx, 0, $this->thirdparty, $mysoc); - $tabprice = calcul_price_total($this->qty, $this->subprice, $this->remise_percent, (float) $this->tva_tx, $this->localtax1_tx, $this->localtax2_tx, 0, 'HT', 0, 1, $mysoc, $localtaxes_type); + // Compute the total from the value the user actually entered, to avoid a rounding drift. + $line_price_base_type = $this->getPriceBaseType(); + $pu_for_calc = $this->wasEnteredIncludingTax() ? (float) $this->subprice_ttc : (float) $this->subprice; + + $tabprice = calcul_price_total($this->qty, $pu_for_calc, $this->remise_percent, (float) $this->tva_tx, $this->localtax1_tx, $this->localtax2_tx, 0, $line_price_base_type, 0, 1, $mysoc, $localtaxes_type); $this->total_ht = (float) $tabprice[0]; $this->total_tva = (float) $tabprice[1]; $this->total_ttc = (float) $tabprice[2]; @@ -693,6 +700,7 @@ class ContratLigne extends CommonObjectLine $sql .= " remise_percent = ".price2num($this->remise_percent).","; $sql .= " fk_remise_except = ".($this->fk_remise_except > 0 ? ((int) $this->fk_remise_except) : "null").","; $sql .= " subprice = ".($this->subprice != '' ? ((float) $this->subprice) : "null").","; + $sql .= " subprice_ttc = ".($this->subprice_ttc != '' ? ((float) $this->subprice_ttc) : "0").","; $sql .= " total_ht = ".((float) $this->total_ht).","; $sql .= " total_tva = ".((float) $this->total_tva).","; $sql .= " total_localtax1 = ".((float) $this->total_localtax1).","; @@ -822,7 +830,7 @@ class ContratLigne extends CommonObjectLine // Insertion dans la base $sql = "INSERT INTO ".MAIN_DB_PREFIX."contratdet"; $sql .= " (fk_contrat, label, description, fk_product, qty, vat_src_code, tva_tx,"; - $sql .= " localtax1_tx, localtax2_tx, localtax1_type, localtax2_type, remise_percent, subprice,"; + $sql .= " localtax1_tx, localtax2_tx, localtax1_type, localtax2_type, remise_percent, subprice, subprice_ttc,"; $sql .= " total_ht, total_tva, total_localtax1, total_localtax2, total_ttc,"; $sql .= " info_bits,"; $sql .= " rang,"; @@ -842,7 +850,7 @@ class ContratLigne extends CommonObjectLine $sql .= " '".$this->db->escape($this->localtax2_tx)."',"; $sql .= " '".$this->db->escape($this->localtax1_type)."',"; $sql .= " '".$this->db->escape($this->localtax2_type)."',"; - $sql .= " ".price2num($this->remise_percent).",".price2num($this->subprice).","; + $sql .= " ".price2num($this->remise_percent).",".price2num($this->subprice).",".price2num($this->subprice_ttc).","; $sql .= " ".price2num($this->total_ht).",".price2num($this->total_tva).",".price2num($this->total_localtax1).",".price2num($this->total_localtax2).",".price2num($this->total_ttc).","; $sql .= " '".$this->db->escape((string) $this->info_bits)."',"; $sql .= " ".(empty($this->rang) ? '0' : (int) $this->rang).","; diff --git a/htdocs/core/class/commonobjectline.class.php b/htdocs/core/class/commonobjectline.class.php index ccf282de069..336600d29f6 100644 --- a/htdocs/core/class/commonobjectline.class.php +++ b/htdocs/core/class/commonobjectline.class.php @@ -3,6 +3,7 @@ * Copyright (C) 2012 Cedric Salvador * Copyright (C) 2024-2026 MDW * Copyright (C) 2024-2025 Frédéric France + * Copyright (C) 2026 Lionel Vessiller * * 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 @@ -417,4 +418,29 @@ abstract class CommonObjectLine extends CommonObject return $parent_element->getNomUrl($withpicto).' - Line #'.$this->id; // @phan-suppress-current-line PhanPluginUnknownObjectMethodCall } + + /** + * Return true if the unit price was originally entered including tax (TTC mode). + * Useful to preserve the entry mode on no-op edits and to avoid total drift. + * Note: cannot use !empty() because MySQL returns doubles as strings like "0.00000000" + * which empty() treats as non-empty. + * + * @return bool + */ + public function wasEnteredIncludingTax() + { + return isset($this->subprice_ttc) && (float) $this->subprice_ttc != 0; + } + + /** + * Return the price base type ('TTC' or 'HT') matching how the unit price was entered. + * Shortcut over wasEnteredIncludingTax() to keep the entry mode when re-adding a line + * (clone, conversion, bulk action) so the total is recomputed from the typed value. + * + * @return string 'TTC' if entered including tax, 'HT' otherwise + */ + public function getPriceBaseType() + { + return $this->wasEnteredIncludingTax() ? 'TTC' : 'HT'; + } } diff --git a/htdocs/fourn/class/fournisseur.commande.class.php b/htdocs/fourn/class/fournisseur.commande.class.php index a53dbe6641c..730ff813049 100644 --- a/htdocs/fourn/class/fournisseur.commande.class.php +++ b/htdocs/fourn/class/fournisseur.commande.class.php @@ -1702,6 +1702,12 @@ class CommandeFournisseur extends CommonOrder //$this->special_code = $line->special_code; // TODO : remove this in 9.0 and add special_code param to addline() // This include test on qty if option SUPPLIER_ORDER_WITH_NOPRICEDEFINED is not set + // Preserve the original entry mode of the line so the total is computed from the typed value (no rounding drift). + // In TTC mode, do not forward the HT currency price as pu_ht_devise: under multicurrency it would + // reset the local price and recompute from the HT currency amount (read as TTC) -> 0.01 drift. + // Like SupplierProposal (which does not pass it), the currency price is re-derived from the local price. + $line_price_base_type = $line->getPriceBaseType(); + $line_pu_devise = ($line_price_base_type === 'TTC') ? 0 : (float) $line->multicurrency_subprice; $result = $this->addline( (string) $line->desc, (float) $line->subprice, @@ -1713,7 +1719,7 @@ class CommandeFournisseur extends CommonOrder 0, (string) ($line->ref_supplier ? $line->ref_supplier : $line->ref_fourn), // $line->ref_fourn comes from field ref into table of lines. Value may be a ref that does not exists anymore, so we first try with value of product (float) $line->remise_percent, - 'HT', + $line_price_base_type, (float) $line->subprice_ttc, (int) $line->product_type, (int) $line->info_bits, @@ -1722,7 +1728,7 @@ class CommandeFournisseur extends CommonOrder $line->date_end ?? null, $line->array_options ?? [], $line->fk_unit ?? null, - (float) $line->multicurrency_subprice, // pu_ht_devise + $line_pu_devise, // pu_ht_devise (string) $line->origin, // origin (int) $line->origin_id, // origin_id (int) ($line->rang ?? -1), // rang @@ -2259,7 +2265,7 @@ class CommandeFournisseur extends CommonOrder $this->line->product_type = $product_type; $this->line->remise_percent = $remise_percent; $this->line->subprice = (float) $pu_ht; - $this->line->subprice_ttc = (float) $pu_ttc; + $this->line->subprice_ttc = ($price_base_type === 'TTC') ? (float) $pu_ttc : 0; $this->line->rang = $rang; $this->line->info_bits = $info_bits; @@ -3296,7 +3302,7 @@ class CommandeFournisseur extends CommonOrder $this->line->multicurrency_total_ttc = (float) $multicurrency_total_ttc; $this->line->subprice = (float) $pu_ht; - $this->line->subprice_ttc = (float) $pu_ttc; + $this->line->subprice_ttc = ($price_base_type === 'TTC') ? (float) $pu_ttc : 0; $this->line->price = $this->line->subprice; $this->line->remise_percent = $remise_percent; diff --git a/htdocs/fourn/class/fournisseur.facture.class.php b/htdocs/fourn/class/fournisseur.facture.class.php index 65f2fa234d6..06e23050303 100644 --- a/htdocs/fourn/class/fournisseur.facture.class.php +++ b/htdocs/fourn/class/fournisseur.facture.class.php @@ -659,16 +659,19 @@ class FactureFournisseur extends CommonInvoice if ($resql_insert) { $idligne = $this->db->last_insert_id(MAIN_DB_PREFIX.'facture_fourn_det'); + // Preserve the original entry mode of the line so the total is computed from the typed value (no rounding drift). + $line_price_base_type = $this->lines[$i]->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? (float) $this->lines[$i]->subprice_ttc : $this->lines[$i]->subprice; $res = $this->updateline( $idligne, $this->lines[$i]->desc ? $this->lines[$i]->desc : $this->lines[$i]->description, - $this->lines[$i]->subprice, + $line_pu, $this->lines[$i]->tva_tx.($this->lines[$i]->vat_src_code ? ' ('.$this->lines[$i]->vat_src_code.')' : ''), $this->lines[$i]->localtax1_tx, $this->lines[$i]->localtax2_tx, $this->lines[$i]->qty, $this->lines[$i]->fk_product, - 'HT', + $line_price_base_type, (!empty($this->lines[$i]->info_bits) ? $this->lines[$i]->info_bits : ''), $this->lines[$i]->product_type, $this->lines[$i]->remise_percent, @@ -2286,6 +2289,8 @@ class FactureFournisseur extends CommonInvoice $supplierinvoiceline->qty = ($this->type == self::TYPE_CREDIT_NOTE ? abs((float) $qty) : (float) $qty); // For credit note, quantity is always positive and unit price negative $supplierinvoiceline->subprice = ($this->type == self::TYPE_CREDIT_NOTE ? -abs((float) $pu_ht) : (float) $pu_ht); // For credit note, unit price always negative, always positive otherwise + // Only keep the TTC unit price when the line was entered including tax, so it acts as a reliable "TTC entry mode" marker. + $supplierinvoiceline->subprice_ttc = ($price_base_type === 'TTC') ? ($this->type == self::TYPE_CREDIT_NOTE ? -abs((float) $tabprice[5]) : (float) $tabprice[5]) : 0; $supplierinvoiceline->vat_src_code = $vat_src_code; $supplierinvoiceline->tva_tx = $txtva; @@ -2494,7 +2499,8 @@ class FactureFournisseur extends CommonInvoice $line->subprice = ($this->type == self::TYPE_CREDIT_NOTE ? -abs((float) $pu_ht) : (float) $pu_ht); // For credit note, unit price always negative, always positive otherwise $line->pu_ht = $line->subprice; // deprecated - $line->subprice_ttc = ($this->type == self::TYPE_CREDIT_NOTE ? -abs((float) $pu_ttc) : (float) $pu_ttc); // For credit note, unit price always negative, always positive otherwise + // Only keep the TTC unit price when the line was entered including tax, so it acts as a reliable "TTC entry mode" marker. + $line->subprice_ttc = ($price_base_type === 'TTC') ? ($this->type == self::TYPE_CREDIT_NOTE ? -abs((float) $pu_ttc) : (float) $pu_ttc) : 0; // For credit note, unit price always negative, always positive otherwise $line->pu_ttc = $line->subprice_ttc; // deprecated $line->remise_percent = $remise_percent; diff --git a/htdocs/fourn/class/fournisseur.facture.ligne.class.php b/htdocs/fourn/class/fournisseur.facture.ligne.class.php index 20b164cd392..b7ef19f9496 100644 --- a/htdocs/fourn/class/fournisseur.facture.ligne.class.php +++ b/htdocs/fourn/class/fournisseur.facture.ligne.class.php @@ -722,7 +722,8 @@ class SupplierInvoiceLine extends CommonObjectLine $sql .= " ".price2num($this->remise_percent).","; $sql .= ' '.(!empty($this->fk_remise_except) ? ((int) $this->fk_remise_except) : "null").','; $sql .= " ".price2num($this->subprice).","; - $sql .= " ".(!empty($this->qty) ? price2num($this->total_ttc / $this->qty) : price2num($this->total_ttc)).","; + // pu_ttc holds the TTC entry mode: the typed TTC unit price when entered including tax, 0 otherwise (like update()). + $sql .= " ".price2num($this->subprice_ttc).","; $sql .= " ".(!empty($this->date_start) ? "'".$this->db->idate($this->date_start)."'" : "null").","; $sql .= " ".(!empty($this->date_end) ? "'".$this->db->idate($this->date_end)."'" : "null").","; $sql .= ' '.(!empty($this->fk_code_ventilation) ? ((int) $this->fk_code_ventilation) : 0).','; diff --git a/htdocs/fourn/commande/card.php b/htdocs/fourn/commande/card.php index 1d4b8aab90b..0e1d4aeef0d 100644 --- a/htdocs/fourn/commande/card.php +++ b/htdocs/fourn/commande/card.php @@ -448,7 +448,10 @@ if (empty($reshook)) { $alldate_end = dol_mktime(GETPOSTINT('alldate_endhour'), GETPOSTINT('alldate_endmin'), 0, GETPOSTINT('alldate_endmonth'), GETPOSTINT('alldate_endday'), GETPOSTINT('alldate_endyear')); foreach ($object->lines as $line) { if ($line->product_type == 1) { // only service line - $result = $object->updateline($line->id, $line->desc, $line->subprice, $line->qty, (float) $line->remise_percent, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, 'HT', $line->info_bits, $line->product_type, 0, $alldate_start, $alldate_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice, $line->ref_supplier); + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $line->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; + $result = $object->updateline($line->id, $line->desc, $line_pu, $line->qty, (float) $line->remise_percent, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, $line_price_base_type, $line->info_bits, $line->product_type, 0, $alldate_start, $alldate_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice, $line->ref_supplier); } } } elseif ($action == 'addline' && GETPOST('submitforalllines', 'alpha') && GETPOST('remiseforalllines', 'alpha') !== '' && $usercancreate) { @@ -456,7 +459,10 @@ if (empty($reshook)) { $remise_percent = (GETPOST('remiseforalllines') ? GETPOST('remiseforalllines') : 0); $remise_percent = str_replace('*', '', $remise_percent); foreach ($object->lines as $line) { - $result = $object->updateline($line->id, $line->desc, $line->subprice, $line->qty, (float) $remise_percent, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, 'HT', $line->info_bits, $line->product_type, 0, $line->date_start, $line->date_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice, $line->ref_supplier); + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $line->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; + $result = $object->updateline($line->id, $line->desc, $line_pu, $line->qty, (float) $remise_percent, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, $line_price_base_type, $line->info_bits, $line->product_type, 0, $line->date_start, $line->date_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice, $line->ref_supplier); } } elseif ($action == 'addline' && GETPOST('submitforalllines', 'aZ09') && GETPOST('vatforalllines', 'alpha') !== '' && $usercancreate) { // Define new vat_rate for all lines @@ -468,7 +474,10 @@ if (empty($reshook)) { if ($line->special_code == SUBTOTALS_SPECIAL_CODE) { continue; } - $result = $object->updateline($line->id, $line->desc, $line->subprice, $line->qty, (float) $line->remise_percent, $vat_rate, $localtax1_rate, $localtax2_rate, 'HT', $line->info_bits, $line->product_type, 0, $line->date_start, $line->date_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice, $line->ref_supplier); + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $line->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; + $result = $object->updateline($line->id, $line->desc, $line_pu, $line->qty, (float) $line->remise_percent, $vat_rate, $localtax1_rate, $localtax2_rate, $line_price_base_type, $line->info_bits, $line->product_type, 0, $line->date_start, $line->date_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice, $line->ref_supplier); } } elseif ($action == 'confirm_addtitleline' && $usercancreate) { // Handling adding a new title line for subtotals module @@ -813,15 +822,18 @@ if (empty($reshook)) { $localtax1_tx = get_localtax($tva_tx, 1, $mysoc, $object->thirdparty); $localtax2_tx = get_localtax($tva_tx, 2, $mysoc, $object->thirdparty); + // Keep the entry mode chosen by the user so the total is computed from the typed value (no rounding drift). if (GETPOST('price_ht') != '' || GETPOST('multicurrency_price_ht') != '') { + $price_base_type = 'HT'; $pu_ht = price2num($price_ht, 'MU'); // $pu_ht must be rounded according to settings - $pu_ttc = ''; + $pu_ttc = 0; + $pu_ht_devise = price2num($price_ht_devise, 'CU'); } else { + $price_base_type = 'TTC'; $pu_ttc = price2num(GETPOST('price_ttc'), 'MU'); - $pu_ht = price2num((float) $pu_ttc / (1 + ((float) $tva_tx / 100)), 'MU'); // $pu_ht must be rounded according to settings + $pu_ht = 0; + $pu_ht_devise = price2num($price_ttc_devise, 'CU'); } - $price_base_type = 'HT'; - $pu_ht_devise = price2num($price_ht_devise, 'CU'); $result = $object->addline($desc, (float) $pu_ht, (float) $qty, $tva_tx, $localtax1_tx, $localtax2_tx, 0, 0, $ref_supplier, $remise_percent, $price_base_type, (float) $pu_ttc, $type, 0, 0, $date_start, $date_end, $array_options, $fk_unit, (float) $pu_ht_devise); } @@ -1014,20 +1026,28 @@ if (empty($reshook)) { $localtax1_rate = get_localtax($vat_rate, 1, $mysoc, $object->thirdparty); $localtax2_rate = get_localtax($vat_rate, 2, $mysoc, $object->thirdparty); - if (GETPOST('price_ht') != '') { - $price_base_type = 'HT'; - $ht = price2num(GETPOST('price_ht'), '', 2); - } else { - $reg = array(); - $vatratecleaned = $vat_rate; - if (preg_match('/^(.*)\s*\((.*)\)$/', $vat_rate, $reg)) { // If vat is "xx (yy)" - $vatratecleaned = trim($reg[1]); - $vatratecode = $reg[2]; - } + $pu_ht = price2num(GETPOST('price_ht'), '', 2); + $pu_ttc = price2num(GETPOST('price_ttc'), '', 2); - $ttc = price2num(GETPOST('price_ttc'), '', 2); - $ht = (float) $ttc / (1 + ((float) $vatratecleaned / 100)); - $price_base_type = 'HT'; + // The form JS clears the other field when the user edits one of them: only the modified field is filled. + // When both fields are submitted, the user did not change the price - we must preserve the original + // storage mode of the line, otherwise a no-op save would shift the total by rounding. + $ht = $pu_ht; + $price_base_type = 'HT'; + if (empty($pu_ht) && !empty($pu_ttc)) { + $ht = $pu_ttc; + $price_base_type = 'TTC'; + } elseif (!empty($pu_ht) && !empty($pu_ttc)) { + foreach ($object->lines as $line_obj) { + if ($line_obj->id == GETPOSTINT('lineid')) { + // Line was originally entered in TTC mode (subprice_ttc filled by addline) + if ($line_obj->wasEnteredIncludingTax()) { + $ht = $pu_ttc; + $price_base_type = 'TTC'; + } + break; + } + } } $pu_ht_devise = price2num(GETPOST('multicurrency_subprice'), 'CU', 2); @@ -1045,7 +1065,7 @@ if (empty($reshook)) { $result = $object->updateline( $lineid, GETPOST('product_desc', 'restricthtml'), - $ht, + (float) $ht, (float) price2num(GETPOST('qty'), 'MS'), (float) price2num(GETPOST('remise_percent'), '', 2), $vat_rate, @@ -1686,6 +1706,8 @@ if (empty($reshook)) { $tva_tx = get_default_tva($soc, $mysoc, $lines[$i]->fk_product, $product_fourn_price_id); } + // Preserve the original entry mode of the line so the total is computed from the typed value (no rounding drift). + $line_price_base_type = $lines[$i]->getPriceBaseType(); $result = $object->addline( $desc, $lines[$i]->subprice, @@ -1697,8 +1719,8 @@ if (empty($reshook)) { $product_fourn_price_id, $ref_supplier, $lines[$i]->remise_percent, - 'HT', - 0, + $line_price_base_type, + (float) $lines[$i]->subprice_ttc, $lines[$i]->product_type, 0, 0, diff --git a/htdocs/fourn/facture/card.php b/htdocs/fourn/facture/card.php index 13a8789b770..3ef6a61867b 100644 --- a/htdocs/fourn/facture/card.php +++ b/htdocs/fourn/facture/card.php @@ -1411,7 +1411,13 @@ if (empty($reshook)) { $object->special_code = $lines[$i]->special_code; // FIXME If currency different from main currency, take multicurrency price - if ($object->multicurrency_code != $conf->currency || $object->multicurrency_tx != 1) { + // Preserve the original entry mode of the line so the total is computed from the typed value (no rounding drift). + $line_price_base_type = $lines[$i]->getPriceBaseType(); + if ($line_price_base_type === 'TTC') { + // TTC mode: use the local TTC unit price; the currency price is re-derived (no rounding drift). + $pu = (float) $lines[$i]->subprice_ttc; + $pu_currency = 0; + } elseif ($object->multicurrency_code != $conf->currency || $object->multicurrency_tx != 1) { $pu = 0; $pu_currency = $lines[$i]->multicurrency_subprice; } else { @@ -1433,7 +1439,7 @@ if (empty($reshook)) { (int) $date_end, 0, $lines[$i]->info_bits, - 'HT', + $line_price_base_type, $product_type, $lines[$i]->rang, 0, @@ -1513,12 +1519,28 @@ if (empty($reshook)) { $tva_tx = (GETPOST('tva_tx') ? GETPOST('tva_tx') : 0); $tva_tx = str_replace('*', '', $tva_tx); - if (GETPOST('price_ht') != '' || GETPOST('multicurrency_subprice') != '') { - $up = price2num(GETPOST('price_ht'), '', 2); - $price_base_type = 'HT'; - } else { - $up = price2num(GETPOST('price_ttc'), '', 2); + $pu_ht = price2num(GETPOST('price_ht'), '', 2); + $pu_ttc = price2num(GETPOST('price_ttc'), '', 2); + + // The form JS clears the other field when the user edits one of them: only the modified field is filled. + // When both fields are submitted, the user did not change the price - we must preserve the original + // storage mode of the line, otherwise a no-op save would shift the total by rounding. + $up = $pu_ht; + $price_base_type = 'HT'; + if (empty($pu_ht) && !empty($pu_ttc)) { + $up = $pu_ttc; $price_base_type = 'TTC'; + } elseif (!empty($pu_ht) && !empty($pu_ttc)) { + foreach ($object->lines as $line_obj) { + if ($line_obj->id == GETPOSTINT('lineid')) { + // Line was originally entered in TTC mode (subprice_ttc filled by addline) + if ($line_obj->wasEnteredIncludingTax()) { + $up = $pu_ttc; + $price_base_type = 'TTC'; + } + break; + } + } } if (GETPOST('productid') > 0) { @@ -1619,7 +1641,10 @@ if (empty($reshook)) { $alldate_end = dol_mktime(GETPOSTINT('alldate_endhour'), GETPOSTINT('alldate_endmin'), 0, GETPOSTINT('alldate_endmonth'), GETPOSTINT('alldate_endday'), GETPOSTINT('alldate_endyear')); foreach ($object->lines as $line) { if ($line->product_type == 1) { // only service line - $result = $object->updateline($line->id, $line->desc, $line->subprice, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, $line->qty, $line->fk_product, 'HT', $line->info_bits, $line->product_type, $line->remise_percent, 0, $alldate_start, $alldate_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice, $line->ref_supplier, $line->rang); + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $line->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; + $result = $object->updateline($line->id, $line->desc, $line_pu, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, $line->qty, $line->fk_product, $line_price_base_type, $line->info_bits, $line->product_type, $line->remise_percent, 0, $alldate_start, $alldate_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice, $line->ref_supplier, $line->rang); } } } elseif ($action == 'addline' && GETPOST('submitforalllines', 'alpha') && GETPOST('remiseforalllines', 'alpha') !== '' && $usercancreate) { @@ -1627,7 +1652,10 @@ if (empty($reshook)) { $remise_percent = (GETPOST('remiseforalllines') ? GETPOST('remiseforalllines') : 0); $remise_percent = (float) str_replace('*', '', $remise_percent); foreach ($object->lines as $line) { - $result = $object->updateline($line->id, $line->desc, $line->subprice, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, $line->qty, $line->fk_product, 'HT', $line->info_bits, $line->product_type, $remise_percent, 0, $line->date_start, $line->date_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice, $line->ref_supplier, $line->rang); + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $line->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; + $result = $object->updateline($line->id, $line->desc, $line_pu, $line->tva_tx, $line->localtax1_tx, $line->localtax2_tx, $line->qty, $line->fk_product, $line_price_base_type, $line->info_bits, $line->product_type, $remise_percent, 0, $line->date_start, $line->date_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice, $line->ref_supplier, $line->rang); } } elseif ($action == 'addline' && GETPOST('submitforalllines', 'aZ09') && GETPOST('vatforalllines', 'alpha') != '' && $usercancreate) { // Define vat_rate @@ -1636,7 +1664,10 @@ if (empty($reshook)) { $localtax1_rate = get_localtax($vat_rate, 1, $object->thirdparty, $mysoc); $localtax2_rate = get_localtax($vat_rate, 2, $object->thirdparty, $mysoc); foreach ($object->lines as $line) { - $result = $object->updateline($line->id, $line->desc, $line->subprice, $vat_rate, $localtax1_rate, $localtax2_rate, $line->qty, $line->fk_product, 'HT', $line->info_bits, $line->product_type, $line->remise_percent, 0, $line->date_start, $line->date_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice, $line->ref_supplier, $line->rang); + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $line->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; + $result = $object->updateline($line->id, $line->desc, $line_pu, $vat_rate, $localtax1_rate, $localtax2_rate, $line->qty, $line->fk_product, $line_price_base_type, $line->info_bits, $line->product_type, $line->remise_percent, 0, $line->date_start, $line->date_end, $line->array_options, $line->fk_unit, $line->multicurrency_subprice, $line->ref_supplier, $line->rang); } } elseif ($action == 'addline' && $usercancreate) { // Add a product line @@ -1923,16 +1954,18 @@ if (empty($reshook)) { $localtax1_tx = get_localtax($tva_tx, 1, $mysoc, $object->thirdparty); $localtax2_tx = get_localtax($tva_tx, 2, $mysoc, $object->thirdparty); + // Keep the entry mode chosen by the user so the total is computed from the typed value (no rounding drift). if (GETPOST('price_ht') != '' || GETPOST('multicurrency_price_ht') != '') { - $pu_ht = price2num($price_ht, 'MU'); // $pu_ht must be rounded according to settings + $price_base_type = 'HT'; + $pu = price2num($price_ht, 'MU'); // $pu must be rounded according to settings + $pu_devise = price2num($price_ht_devise, 'CU'); } else { - $pu_ttc = price2num(GETPOST('price_ttc'), 'MU'); - $pu_ht = price2num((float) $pu_ttc / (1 + ((float) $tva_tx / 100)), 'MU'); // $pu_ht must be rounded according to settings + $price_base_type = 'TTC'; + $pu = price2num(GETPOST('price_ttc'), 'MU'); + $pu_devise = price2num($price_ttc_devise, 'CU'); } - $price_base_type = 'HT'; - $pu_devise = price2num($price_ht_devise, 'CU'); - $result = $object->addline($line_desc, (float) $pu_ht, $tva_tx, $localtax1_tx, $localtax2_tx, (float) $qty, 0, $remise_percent, $date_start, $date_end, 0, $tva_npr, $price_base_type, $type, -1, 0, $array_options, $fk_unit, 0, (float) $pu_devise, $ref_supplier); + $result = $object->addline($line_desc, (float) $pu, $tva_tx, $localtax1_tx, $localtax2_tx, (float) $qty, 0, $remise_percent, $date_start, $date_end, 0, $tva_npr, $price_base_type, $type, -1, 0, $array_options, $fk_unit, 0, (float) $pu_devise, $ref_supplier); } //print "xx".$tva_tx; exit; diff --git a/htdocs/install/mysql/migration/24.0.0-25.0.0.sql b/htdocs/install/mysql/migration/24.0.0-25.0.0.sql index 4511b59d338..364e8c177e4 100644 --- a/htdocs/install/mysql/migration/24.0.0-25.0.0.sql +++ b/htdocs/install/mysql/migration/24.0.0-25.0.0.sql @@ -68,4 +68,14 @@ ALTER TABLE llx_element_element ADD COLUMN tms timestamp DEFAULT CURRENT_TIMESTA ALTER TABLE llx_c_action_trigger ADD COLUMN enabled varchar(255); +-- Fix #37658 - subprice_ttc (pu_ttc for supplier invoices) now flags a line entered including tax (0 when +-- entered excluding tax). Supplier lines used to store it unconditionally (even for lines entered excluding +-- tax), so reset it on existing supplier lines to avoid them being wrongly treated as entered including tax +-- on clone/edit/bulk actions. A line can be re-entered including tax to set the value again. +-- Guarded on the upgrade source version (MAIN_VERSION_LAST_UPGRADE is still the source version at this point, +-- updated only at the end of step5) so re-running the migration on a 25.x base does NOT wipe values set since. +UPDATE llx_commande_fournisseurdet SET subprice_ttc = 0 WHERE subprice_ttc <> 0 AND EXISTS (SELECT c.rowid FROM llx_const as c WHERE c.name = 'MAIN_VERSION_LAST_UPGRADE' AND c.value < '25.0.0'); +UPDATE llx_facture_fourn_det SET pu_ttc = 0 WHERE pu_ttc <> 0 AND EXISTS (SELECT c.rowid FROM llx_const as c WHERE c.name = 'MAIN_VERSION_LAST_UPGRADE' AND c.value < '25.0.0'); +UPDATE llx_supplier_proposaldet SET subprice_ttc = 0 WHERE subprice_ttc <> 0 AND EXISTS (SELECT c.rowid FROM llx_const as c WHERE c.name = 'MAIN_VERSION_LAST_UPGRADE' AND c.value < '25.0.0'); + -- end of migration diff --git a/htdocs/subtotals/class/commonsubtotal.class.php b/htdocs/subtotals/class/commonsubtotal.class.php index 36c77f90af6..884479a6537 100644 --- a/htdocs/subtotals/class/commonsubtotal.class.php +++ b/htdocs/subtotals/class/commonsubtotal.class.php @@ -3,7 +3,7 @@ * Copyright (C) 2024 MDW * Copyright (C) 2024-2025 Frédéric France * Copyright (C) 2025 Charlene Benke - + * Copyright (C) 2026 Lionel Vessiller * * 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 @@ -653,10 +653,13 @@ trait CommonSubtotal } } else { if ($current_module == 'facture' && $this instanceof Facture) { + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $this->lines[$i]->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? $this->lines[$i]->subprice_ttc : $this->lines[$i]->subprice; $result = $this->updateline( $this->lines[$i]->id, $this->lines[$i]->desc, - $this->lines[$i]->subprice, + $line_pu, $this->lines[$i]->qty, $mode == 'discount' ? $value : $this->lines[$i]->remise_percent, $this->lines[$i]->date_start, @@ -664,7 +667,7 @@ trait CommonSubtotal $mode == 'tva' ? $value : $this->lines[$i]->tva_tx, $this->lines[$i]->localtax1_tx, $this->lines[$i]->localtax2_tx, - 'HT', + $line_price_base_type, $this->lines[$i]->info_bits, $this->lines[$i]->product_type, $this->lines[$i]->fk_parent_line, @@ -679,16 +682,19 @@ trait CommonSubtotal $this->lines[$i]->multicurrency_subprice ); } elseif ($current_module == 'commande' && $this instanceof Commande) { + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $this->lines[$i]->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? $this->lines[$i]->subprice_ttc : $this->lines[$i]->subprice; $result = $this->updateline( $this->lines[$i]->id, $this->lines[$i]->desc, - $this->lines[$i]->subprice, + $line_pu, $this->lines[$i]->qty, $mode == 'discount' ? $value : $this->lines[$i]->remise_percent, $mode == 'tva' ? $value : $this->lines[$i]->tva_tx, $this->lines[$i]->localtax1_rate, $this->lines[$i]->localtax2_rate, - 'HT', + $line_price_base_type, $this->lines[$i]->info_bits, $this->lines[$i]->date_start, $this->lines[$i]->date_end, @@ -705,7 +711,7 @@ trait CommonSubtotal ); } elseif ($current_module == 'propal' && $this instanceof Propal) { // Preserve the original entry mode of the line so the total is not drifted by rounding. - $line_price_base_type = $this->lines[$i]->wasEnteredIncludingTax() ? 'TTC' : 'HT'; + $line_price_base_type = $this->lines[$i]->getPriceBaseType(); $line_pu = ($line_price_base_type === 'TTC') ? $this->lines[$i]->subprice_ttc : $this->lines[$i]->subprice; $result = $this->updateline( $this->lines[$i]->id, diff --git a/htdocs/supplier_proposal/card.php b/htdocs/supplier_proposal/card.php index 42cd575371c..f74064ca68c 100644 --- a/htdocs/supplier_proposal/card.php +++ b/htdocs/supplier_proposal/card.php @@ -15,6 +15,7 @@ * Copyright (C) 2022 Gauthier VERDOL * Copyright (C) 2024 Alexandre Spangaro * Copyright (C) 2024-2026 MDW + * Copyright (C) 2026 Lionel Vessiller * * 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 @@ -470,6 +471,9 @@ if (empty($reshook)) { $array_options = array(); } + // Preserve the TTC entry mode of the source line: a line entered including tax must + // stay in TTC so its total is computed from the typed value, without rounding drift. + $line_price_base_type = $lines[$i]->getPriceBaseType(); $result = $object->addline( $desc, $lines[$i]->subprice, @@ -479,8 +483,8 @@ if (empty($reshook)) { $lines[$i]->localtax2_tx, $lines[$i]->fk_product, $lines[$i]->remise_percent, - 'HT', - 0, + $line_price_base_type, + (float) $lines[$i]->subprice_ttc, $lines[$i]->info_bits, $product_type, $lines[$i]->rang, @@ -661,7 +665,13 @@ if (empty($reshook)) { if ($line->special_code == SUBTOTALS_SPECIAL_CODE) { continue; } - $result = $object->updateline($line->id, $line->subprice, $line->qty, (float) $line->remise_percent, $vat_rate, $localtax1_rate, $localtax2_rate, $line->desc, 'HT', $line->info_bits, $line->special_code, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->product_type, $line->array_options, $line->ref_fourn, $line->fk_unit, $line->multicurrency_subprice); + // Preserve the original entry mode of the line so the total is not drifted by rounding. + $line_price_base_type = $line->getPriceBaseType(); + $line_pu = ($line_price_base_type === 'TTC') ? (float) $line->subprice_ttc : (float) $line->subprice; + // In TTC mode, do not forward the HT currency price: under multicurrency updateline() would reset + // the local price and recompute from the HT currency amount (read as TTC) -> the TTC value is lost. + $line_pu_devise = ($line_price_base_type === 'TTC') ? 0 : (float) $line->multicurrency_subprice; + $result = $object->updateline($line->id, $line_pu, $line->qty, (float) $line->remise_percent, $vat_rate, $localtax1_rate, $localtax2_rate, $line->desc, $line_price_base_type, $line->info_bits, $line->special_code, $line->fk_parent_line, 0, $line->fk_fournprice, $line->pa_ht, $line->label, $line->product_type, $line->array_options, $line->ref_fourn, $line->fk_unit, $line_pu_devise); } } elseif ($action == 'confirm_addtitleline' && $usercancreate) { // Handling adding a new title line for subtotals module @@ -1014,14 +1024,18 @@ if (empty($reshook)) { $localtax1_tx = get_localtax($tva_tx, 1, $mysoc, $object->thirdparty); $localtax2_tx = get_localtax($tva_tx, 2, $mysoc, $object->thirdparty); + // Keep the entry mode chosen by the user so the total is computed from the typed value (no rounding drift). if (GETPOST('price_ht') != '' || GETPOST('multicurrency_price_ht') != '') { + $price_base_type = 'HT'; $pu_ht = price2num($price_ht, 'MU'); // $pu_ht must be rounded according to settings + $pu_ttc = 0; + $pu_ht_devise = price2num($price_ht_devise, 'CU'); } else { + $price_base_type = 'TTC'; $pu_ttc = price2num(GETPOST('price_ttc'), 'MU'); - $pu_ht = price2num((float) $pu_ttc / (1 + ((float) $tva_tx / 100)), 'MU'); // $pu_ht must be rounded according to settings + $pu_ht = 0; + $pu_ht_devise = price2num($price_ttc_devise, 'CU'); } - $price_base_type = 'HT'; - $pu_ht_devise = price2num($price_ht_devise, 'CU'); $info_bits = 0; $result = $object->addline( @@ -1217,6 +1231,8 @@ if (empty($reshook)) { } elseif ($action == 'updateline' && $usercancreate && GETPOST('save') == $langs->trans("Save")) { // Update a line within proposal $vat_rate = (GETPOST('tva_tx') ? GETPOST('tva_tx') : 0); + $pu_ht = price2num(GETPOST('price_ht'), '', 2); + $pu_ttc = price2num(GETPOST('price_ttc'), '', 2); // Define info_bits $info_bits = 0; @@ -1232,22 +1248,6 @@ if (empty($reshook)) { $localtax1_rate = get_localtax($vat_rate, 1, $mysoc, $object->thirdparty); $localtax2_rate = get_localtax($vat_rate, 2, $mysoc, $object->thirdparty); - if (GETPOST('price_ht') != '') { - $price_base_type = 'HT'; - $ht = price2num(GETPOST('price_ht'), '', 2); - } else { - $reg = array(); - $vatratecleaned = $vat_rate; - if (preg_match('/^(.*)\s*\((.*)\)$/', $vat_rate, $reg)) { // If vat is "xx (yy)" - $vatratecleaned = trim($reg[1]); - $vatratecode = $reg[2]; - } - - $ttc = price2num(GETPOST('price_ttc'), '', 2); - $ht = (float) $ttc / (1 + ((float) $vatratecleaned / 100)); - $price_base_type = 'HT'; - } - $pu_ht_devise = price2num(GETPOST('multicurrency_subprice'), 'CU', 2); // Add buying price @@ -1270,6 +1270,27 @@ if (empty($reshook)) { $special_code = 3; } + // The form JS clears the other field when the user edits one of them: only the modified field is filled. + // When both fields are submitted, the user did not change the price - we must preserve the original + // storage mode of the line, otherwise a no-op save would shift the total by rounding. + $ht = $pu_ht; + $price_base_type = 'HT'; + if (empty($pu_ht) && !empty($pu_ttc)) { + $ht = $pu_ttc; + $price_base_type = 'TTC'; + } elseif (!empty($pu_ht) && !empty($pu_ttc)) { + foreach ($object->lines as $line_obj) { + if ($line_obj->id == GETPOSTINT('lineid')) { + // Line was originally entered in TTC mode (subprice_ttc filled by addline) + if ($line_obj->wasEnteredIncludingTax()) { + $ht = $pu_ttc; + $price_base_type = 'TTC'; + } + break; + } + } + } + // Check minimum price $productid = GETPOSTINT('productid'); if (!empty($productid)) { @@ -1308,9 +1329,15 @@ if (empty($reshook)) { $ref_supplier = GETPOST('fourn_ref', 'alpha'); $fk_unit = GETPOSTINT('units'); + // In TTC mode, do not forward the HT currency price: under multicurrency updateline() would reset + // the local price and recompute from the HT currency amount (read as TTC) -> the TTC value is lost. + if ($price_base_type === 'TTC') { + $pu_ht_devise = 0; + } + $result = $object->updateline( GETPOSTINT('lineid'), - $ht, + (float) $ht, (float) price2num(GETPOST('qty'), 'MS', 2), (float) price2num(GETPOST('remise_percent'), '', 2), $vat_rate, diff --git a/htdocs/supplier_proposal/class/supplier_proposal.class.php b/htdocs/supplier_proposal/class/supplier_proposal.class.php index 3cf86f4cace..cabc9f2de09 100644 --- a/htdocs/supplier_proposal/class/supplier_proposal.class.php +++ b/htdocs/supplier_proposal/class/supplier_proposal.class.php @@ -18,6 +18,7 @@ * Copyright (C) 2022 Gauthier VERDOL * Copyright (C) 2024-2026 MDW * Copyright (C) 2026 Vincent de Grandpré + * Copyright (C) 2026 Lionel Vessiller * * 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 @@ -641,6 +642,8 @@ class SupplierProposal extends CommonObject $this->line->fk_product = $fk_product; $this->line->remise_percent = $remise_percent; $this->line->subprice = (float) $pu_ht; + // Persist the original entry mode of the line so updateline() can preserve it later. + $this->line->subprice_ttc = ($price_base_type === 'TTC') ? (float) $pu_ttc : 0; $this->line->rang = $ranktouse; $this->line->info_bits = $info_bits; $this->line->total_ht = (float) $total_ht; @@ -825,11 +828,6 @@ class SupplierProposal extends CommonObject $multicurrency_total_ttc = $tabprice[18]; $pu_ht_devise = $tabprice[19]; - $pu = $pu_ht; - if ($price_base_type == 'TTC') { - $pu = $pu_ttc; - } - // Fetch current line from the database and then clone the object and set it in $oldline property $line = new SupplierProposalLine($this->db); $line->fetch($rowid); @@ -863,7 +861,9 @@ class SupplierProposal extends CommonObject $this->line->localtax1_type = empty($localtaxes_type[0]) ? '' : $localtaxes_type[0]; $this->line->localtax2_type = empty($localtaxes_type[2]) ? '' : $localtaxes_type[2]; $this->line->remise_percent = $remise_percent; - $this->line->subprice = (float) $pu; + $this->line->subprice = (float) $pu_ht; + // Persist the original entry mode of the line so a no-op edit can preserve it later. + $this->line->subprice_ttc = ($price_base_type === 'TTC') ? (float) $pu_ttc : 0; $this->line->info_bits = $info_bits; $this->line->total_ht = (float) $total_ht; $this->line->total_tva = (float) $total_tva; @@ -1097,17 +1097,19 @@ class SupplierProposal extends CommonObject $fk_parent_line = 0; } + // Preserve the original entry mode of the line so the total is computed from the typed value (no rounding drift). + $line_price_base_type = $this->lines[$i]->getPriceBaseType(); $result = $this->addline( $this->lines[$i]->desc, - $this->lines[$i]->subprice, + (float) $this->lines[$i]->subprice, $this->lines[$i]->qty, $this->lines[$i]->tva_tx, $this->lines[$i]->localtax1_tx, $this->lines[$i]->localtax2_tx, $this->lines[$i]->fk_product, $this->lines[$i]->remise_percent, - 'HT', - 0, + $line_price_base_type, + (float) $this->lines[$i]->subprice_ttc, 0, $this->lines[$i]->product_type, $this->lines[$i]->rang, @@ -1383,7 +1385,7 @@ class SupplierProposal extends CommonObject $this->lines = array(); // Lines of supplier proposals - $sql = "SELECT d.rowid, d.fk_supplier_proposal, d.fk_parent_line, d.label as custom_label, d.description, d.price, d.tva_tx, d.localtax1_tx, d.localtax2_tx, d.qty, d.fk_remise_except, d.remise_percent, d.subprice, d.fk_product,"; + $sql = "SELECT d.rowid, d.fk_supplier_proposal, d.fk_parent_line, d.label as custom_label, d.description, d.price, d.tva_tx, d.localtax1_tx, d.localtax2_tx, d.qty, d.fk_remise_except, d.remise_percent, d.subprice, d.subprice_ttc, d.fk_product,"; $sql .= " d.info_bits, d.total_ht, d.total_tva, d.total_localtax1, d.total_localtax2, d.total_ttc, d.fk_product_fournisseur_price as fk_fournprice, d.buy_price_ht as pa_ht, d.special_code, d.rang, d.product_type,"; $sql .= ' p.ref as product_ref, p.description as product_desc, p.fk_product_type, p.label as product_label,'; $sql .= ' d.ref_fourn as ref_produit_fourn, d.extraparams,'; @@ -1415,6 +1417,7 @@ class SupplierProposal extends CommonObject $line->localtax1_tx = $objp->localtax1_tx; $line->localtax2_tx = $objp->localtax2_tx; $line->subprice = $objp->subprice; + $line->subprice_ttc = $objp->subprice_ttc; $line->fk_remise_except = $objp->fk_remise_except; $line->remise_percent = $objp->remise_percent; diff --git a/test/phpunit/AllTests.php b/test/phpunit/AllTests.php index 3dc678f1537..7113ba2363e 100644 --- a/test/phpunit/AllTests.php +++ b/test/phpunit/AllTests.php @@ -3,6 +3,7 @@ * Copyright (C) 2011-2012 Regis Houssin * Copyright (C) 2024-2026 MDW * Copyright (C) 2024-2026 Frédéric France + * Copyright (C) 2026 Lionel Vessiller * * 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 @@ -224,6 +225,8 @@ class AllTests $suite->addTestSuite('FactureRecTest'); require_once dirname(__FILE__).'/FactureTestRounding.php'; $suite->addTestSuite('FactureTestRounding'); + require_once dirname(__FILE__).'/TtcRoundingTest.php'; + $suite->addTestSuite('TtcRoundingTest'); require_once dirname(__FILE__).'/PaiementTest.php'; $suite->addTestSuite('PaiementTest'); require_once dirname(__FILE__).'/FactureFournisseurTest.php'; diff --git a/test/phpunit/TtcRoundingTest.php b/test/phpunit/TtcRoundingTest.php new file mode 100644 index 00000000000..c7833c9ad3e --- /dev/null +++ b/test/phpunit/TtcRoundingTest.php @@ -0,0 +1,1840 @@ + + * + * 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/TtcRoundingTest.php + * \ingroup test + * \brief PHPUnit test for unit price entered including tax (price_base_type='TTC'). + * \remarks To run this script as CLI: phpunit filename.php + * + * When a line is entered with the unit price including tax, the line total AND the persisted + * "TTC entry mode" (subprice_ttc) must be kept consistent so that no 0.01 rounding drift appears, + * including after a no-op edit or a bulk action (discount on all lines). + * + * Full workflow covered per object (like a real quote): a subtotal title line, a product line and + * a free line, all entered in TTC mode, then a no-op update, then a global discount on all lines. + * + * Scenario per priced line: qty = 680, VAT = 20%, unit price TTC = 3.35 + * - on create: total_ht = 1898.33, total_tva = 379.67, total_ttc = 2278.00 + * - after 10% discount: total_ht = 1708.50, total_tva = 341.70, total_ttc = 2050.20 + * - the line always keeps subprice_ttc = 3.35 (the value typed by the user) + * + * All customer and supplier objects persist the TTC entry mode: customer proposal, order, invoice, + * contract, supplier proposal, supplier order and supplier invoice. This test guards against a + * regression of that behaviour. Contract lines have no subtotals; each per-object test is skipped + * when its module is disabled. + */ + +global $conf,$user,$langs,$db; +//define('TEST_DB_FORCE_TYPE','mysql'); // This is to force using mysql driver +require_once dirname(__FILE__).'/../../htdocs/master.inc.php'; +require_once dirname(__FILE__).'/../../htdocs/societe/class/societe.class.php'; +require_once dirname(__FILE__).'/../../htdocs/product/class/product.class.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__).'/../../htdocs/contrat/class/contrat.class.php'; +require_once dirname(__FILE__).'/../../htdocs/contrat/class/contratligne.class.php'; +require_once dirname(__FILE__).'/../../htdocs/fourn/class/fournisseur.commande.class.php'; +require_once dirname(__FILE__).'/../../htdocs/fourn/class/fournisseur.facture.class.php'; +require_once dirname(__FILE__).'/../../htdocs/supplier_proposal/class/supplier_proposal.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; + + +/** + * Class for PHPUnit tests + * + * @backupGlobals disabled + * @backupStaticAttributes enabled + * @remarks backupGlobals must be disabled to have db,conf,user and lang not erased. + */ +class TtcRoundingTest extends CommonClassTest +{ + // Common scenario: a unit price typed including tax that does not divide evenly once converted to HT. + const QTY = 680; + const VAT = 20; + const PU_TTC = 3.35; + const PU_HT = 2.79167; // 3.35 / 1.2 rounded at MAIN_MAX_DECIMALS_UNIT=5 - the stored HT unit price + const REMISE = 10; + + // Expected per priced line, entered in TTC mode (calibrated, MAIN_MAX_DECIMALS_UNIT=5 / _TOT=2). + const LINE_HT = 1898.33; + const LINE_TVA = 379.67; + const LINE_TTC = 2278.00; + + // Expected per priced line after a 10% discount, TTC mode preserved. + const LINE_HT_REMISE = 1708.50; + const LINE_TVA_REMISE = 341.70; + const LINE_TTC_REMISE = 2050.20; + + // Expected per priced line entered in HT mode (pu_ht = PU_HT). subprice_ttc must be 0 (not marked as + // TTC entry), so HT lines are never hijacked by the TTC preservation and the total keeps its HT rounding. + const LINE_HT_HTMODE = 1898.34; + const LINE_TVA_HTMODE = 379.66; + const LINE_TTC_HTMODE = 2278.00; + + /** @var int Shared thirdparty (customer and supplier) created once for the whole class */ + protected static $socid; + + /** @var int Shared product created once for the whole class */ + protected static $productid; + + /** + * Create the shared fixtures (one thirdparty and one product) once for the whole class. + * They live inside the class transaction and are rolled back in tearDownAfterClass(). + * + * @return void + */ + public static function setUpBeforeClass(): void + { + global $user,$db; + + parent::setUpBeforeClass(); + + $soc = new Societe($db); + $soc->name = 'Test TTC rounding '.uniqid(); + $soc->client = 1; + $soc->fournisseur = 1; + $soc->country_id = 1; + $soc->code_client = -1; + $soc->code_fournisseur = -1; + self::$socid = $soc->create($user); + self::assertGreaterThan(0, self::$socid, 'Create shared thirdparty: '.$soc->error); + + // A dedicated simple product (no packaging) so a product line behaves deterministically. + $prod = new Product($db); + $prod->ref = 'TTCTEST'.substr(uniqid(), -8); + $prod->label = 'TTC test product'; + $prod->type = 0; + $prod->status = 1; + $prod->status_buy = 1; + $prod->price_base_type = 'HT'; + $prod->price = 10; + $prod->tva_tx = self::VAT; + self::$productid = $prod->create($user); + self::assertGreaterThan(0, self::$productid, 'Create shared product: '.$prod->error); + } + + /** + * Restore globals saved by CommonClassTest and force a deterministic rounding mode. + * + * @return void + */ + private function restoreGlobals() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $conf->global->MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND = 0; + $conf->global->MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND_SUPPLIER = 0; + } + + /** + * Return only the priced lines (exclude subtotal title/section lines). + * + * @param CommonObject $object Object with lines loaded + * @return CommonObjectLine[] + */ + private function pricedLines($object) + { + $out = array(); + foreach ($object->lines as $line) { + if (defined('SUBTOTALS_SPECIAL_CODE') && $line->special_code == SUBTOTALS_SPECIAL_CODE) { + continue; + } + $out[] = $line; + } + return $out; + } + + /** + * Assert a priced line entered in TTC keeps its TTC value and has the expected totals. + * + * @param CommonObjectLine $line Reloaded line + * @param string $tag Message prefix + * @param bool $discounted true to check the post-discount totals + * @return void + */ + private function assertPricedLine($line, $tag, $discounted = false) + { + $this->assertEquals(self::PU_TTC, (float) ($line->subprice_ttc ?? 0), $tag.' subprice_ttc (TTC entry mode kept)'); + $this->assertEquals(self::PU_HT, (float) $line->subprice, $tag.' subprice (HT unit price kept)'); + if ($discounted) { + $this->assertEquals(self::LINE_HT_REMISE, (float) $line->total_ht, $tag.' total_ht (after discount)'); + $this->assertEquals(self::LINE_TVA_REMISE, (float) $line->total_tva, $tag.' total_tva (after discount)'); + $this->assertEquals(self::LINE_TTC_REMISE, (float) $line->total_ttc, $tag.' total_ttc (after discount)'); + } else { + $this->assertEquals(self::LINE_HT, (float) $line->total_ht, $tag.' total_ht'); + $this->assertEquals(self::LINE_TVA, (float) $line->total_tva, $tag.' total_tva'); + $this->assertEquals(self::LINE_TTC, (float) $line->total_ttc, $tag.' total_ttc'); + } + } + + /** + * Assert both priced lines (product + free) of a reloaded object. + * + * @param CommonObject $object Reloaded object (lines loaded) + * @param string $tag Message prefix + * @param bool $discounted true to check post-discount totals + * @return void + */ + private function assertBothPricedLines($object, $tag, $discounted = false) + { + $priced = $this->pricedLines($object); + $this->assertCount(2, $priced, $tag.' must have 2 priced lines'); + $this->assertPricedLine($priced[0], $tag.' product line', $discounted); + $this->assertPricedLine($priced[1], $tag.' free line', $discounted); + } + + /** + * Assert both priced lines of a credit note built by inverting an invoice: the TTC entry mode + * is kept with the inverted sign so there is no rounding drift. + * + * @param CommonObject $object Reloaded credit note (lines loaded) + * @param string $tag Message prefix + * @return void + */ + private function assertBothPricedLinesInverted($object, $tag) + { + $priced = $this->pricedLines($object); + $this->assertCount(2, $priced, $tag.' must have 2 priced lines'); + foreach ($priced as $k => $line) { + $this->assertEquals(-self::PU_TTC, (float) ($line->subprice_ttc ?? 0), $tag." line $k subprice_ttc (inverted TTC entry mode kept)"); + $this->assertEquals(-self::PU_HT, (float) $line->subprice, $tag." line $k subprice (inverted HT unit price)"); + $this->assertEquals(-self::LINE_HT, (float) $line->total_ht, $tag." line $k total_ht (inverted)"); + $this->assertEquals(-self::LINE_TVA, (float) $line->total_tva, $tag." line $k total_tva (inverted)"); + $this->assertEquals(-self::LINE_TTC, (float) $line->total_ttc, $tag." line $k total_ttc (inverted)"); + } + } + + /** + * Assert both priced lines were entered in HT mode: subprice_ttc must be 0 (not a TTC entry marker), + * subprice = PU_HT and the total keeps its HT-mode rounding (never hijacked by TTC preservation). + * + * @param CommonObject $object Reloaded object (lines loaded) + * @param string $tag Message prefix + * @return void + */ + private function assertBothHtLines($object, $tag) + { + $priced = $this->pricedLines($object); + $this->assertCount(2, $priced, $tag.' must have 2 priced lines'); + foreach ($priced as $k => $line) { + $this->assertEquals(0, (float) ($line->subprice_ttc ?? 0), $tag." line $k subprice_ttc must be 0 (HT entry mode, not marked TTC)"); + $this->assertEquals(self::PU_HT, (float) $line->subprice, $tag." line $k subprice (HT unit price)"); + $this->assertEquals(self::LINE_HT_HTMODE, (float) $line->total_ht, $tag." line $k total_ht (HT mode)"); + $this->assertEquals(self::LINE_TTC_HTMODE, (float) $line->total_ttc, $tag." line $k total_ttc (HT mode)"); + } + } + + /** + * Snapshot the priced lines (subprice, subprice_ttc and totals) for later stability comparison. + * Used for the "rounding of total" mode where absolute totals are not deterministic, so we assert + * a no-op / clone does not change the stored values rather than hardcoding them. + * + * @param CommonObject $object Reloaded object (lines loaded) + * @return array> + */ + private function snapshotPricedLines($object) + { + $snap = array(); + foreach ($this->pricedLines($object) as $line) { + $snap[] = array( + 'subprice' => (float) $line->subprice, + 'subprice_ttc' => (float) ($line->subprice_ttc ?? 0), + 'total_ht' => (float) $line->total_ht, + 'total_tva' => (float) $line->total_tva, + 'total_ttc' => (float) $line->total_ttc, + ); + } + return $snap; + } + + /** + * Assert two priced-line snapshots are identical (no rounding drift on a no-op / clone). + * + * @param array> $before Snapshot before the operation + * @param array> $after Snapshot after the operation + * @param string $tag Message prefix + * @return void + */ + private function assertLinesUnchanged($before, $after, $tag) + { + $this->assertCount(count($before), $after, $tag.' same number of priced lines'); + foreach ($before as $k => $b) { + foreach (array('subprice', 'subprice_ttc', 'total_ht', 'total_tva', 'total_ttc') as $f) { + $this->assertEquals($b[$f], $after[$k][$f], $tag." line $k $f stable"); + } + } + } + + /** + * Assert the mixed 4-line layout kept each entry mode: exactly 2 HT lines (subprice_ttc = 0, subprice = + * PU_HT) and 2 TTC lines (subprice_ttc = PU_TTC), whatever the round-of-total totals are. + * + * @param CommonObject $object Reloaded object (lines loaded) + * @param string $tag Message prefix + * @return void + */ + private function assertMixedSubpriceTtc($object, $tag) + { + $priced = $this->pricedLines($object); + $this->assertCount(4, $priced, $tag.' must have 4 priced lines'); + $htCount = 0; + $ttcCount = 0; + foreach ($priced as $k => $line) { + $stt = (float) ($line->subprice_ttc ?? 0); + if ($stt == 0.0) { + $htCount++; + $this->assertEquals(self::PU_HT, (float) $line->subprice, $tag." line $k HT subprice (PU_HT kept)"); + } else { + $ttcCount++; + $this->assertEquals(self::PU_TTC, $stt, $tag." line $k TTC subprice_ttc (PU_TTC kept)"); + } + } + $this->assertEquals(2, $htCount, $tag.' must have 2 HT-entered lines'); + $this->assertEquals(2, $ttcCount, $tag.' must have 2 TTC-entered lines'); + } + + /** + * Whether the object supports subtotal lines (subtotals module enabled and object type wired). + * Kept portable across versions: supplier objects gained subtotals only in later Dolibarr releases. + * + * @param CommonObject $object Object to test + * @return bool + */ + private function subtotalsSupported($object) + { + return defined('SUBTOTALS_SPECIAL_CODE') && method_exists($object, 'addSubtotalLine'); + } + + /** + * Add a subtotal title line when the object supports it (no-op otherwise, for portability). + * + * @param CommonObject $object Object to add the title to + * @param Translate $langs Language object + * @return void + */ + private function addSubtotalTitle($object, $langs) + { + if ($this->subtotalsSupported($object)) { + $object->addSubtotalLine($langs, 'Section 1', 1); + } + } + + /** + * Assert that a subtotal title line is present, when the object supports subtotals. + * + * @param CommonObject $object Reloaded object (lines loaded) + * @param string $tag Message prefix + * @return void + */ + private function assertSubtotalLinePresent($object, $tag) + { + if (!$this->subtotalsSupported($object)) { + return; + } + $found = false; + foreach ($object->lines as $line) { + if ($line->special_code == SUBTOTALS_SPECIAL_CODE) { + $found = true; + break; + } + } + $this->assertTrue($found, $tag.' must have a subtotal line'); + } + + /** + * Customer proposal - reference object, fix already applied. Full workflow, expected green. + * + * @return void + */ + public function testCustomerProposalTtcWorkflow() + { + global $user,$langs,$db; + $this->restoreGlobals(); + if (!isModEnabled('propal')) { + $this->markTestSkipped('Module propal disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Propal($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Customer proposal create'); + + $this->addSubtotalTitle($object, $langs); + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, 'TTC', self::PU_TTC); + + // Create state + $reloaded = new Propal($db); + $reloaded->fetch($id); + if (method_exists($reloaded, 'fetch_lines')) { + $reloaded->fetch_lines(); + } + $this->assertSubtotalLinePresent($reloaded, 'Customer proposal'); + $this->assertBothPricedLines($reloaded, 'Customer proposal create'); + + // No-op update, preserving TTC mode (mirrors card.php using wasEnteredIncludingTax()/subprice_ttc) + foreach ($this->pricedLines($reloaded) as $line) { + $object->updateline($line->id, (float) $line->subprice_ttc, $line->qty, $line->remise_percent, $line->tva_tx, 0, 0, $line->desc, 'TTC'); + } + $afterUpdate = new Propal($db); + $afterUpdate->fetch($id); + if (method_exists($afterUpdate, 'fetch_lines')) { + $afterUpdate->fetch_lines(); + } + $this->assertBothPricedLines($afterUpdate, 'Customer proposal no-op update'); + + // Discount for all lines + foreach ($this->pricedLines($afterUpdate) as $line) { + $object->updateline($line->id, (float) $line->subprice_ttc, $line->qty, self::REMISE, $line->tva_tx, 0, 0, $line->desc, 'TTC'); + } + $afterRemise = new Propal($db); + $afterRemise->fetch($id); + if (method_exists($afterRemise, 'fetch_lines')) { + $afterRemise->fetch_lines(); + } + $this->assertBothPricedLines($afterRemise, 'Customer proposal discount', true); + print __METHOD__." id=".$id." total_ht=".$afterRemise->total_ht."\n"; + } + + /** + * Customer proposal - clone must preserve the TTC entry mode (subprice_ttc) so the cloned + * line keeps the same totals with no rounding drift. + * + * @return void + */ + public function testCustomerProposalCloneTtc() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('propal')) { + $this->markTestSkipped('Module propal disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Propal($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCCLONE'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Customer proposal clone create source'); + + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, 'TTC', self::PU_TTC); + + $source = new Propal($db); + $source->fetch($id); + $clonedId = $source->createFromClone($user, $socid); + $this->assertGreaterThan(0, $clonedId, 'Customer proposal createFromClone'); + + $clone = new Propal($db); + $clone->fetch($clonedId); + if (method_exists($clone, 'fetch_lines')) { + $clone->fetch_lines(); + } + $this->assertBothPricedLines($clone, 'Customer proposal clone'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } + + /** + * Customer order - full workflow. subprice_ttc persistence to be added -> red. + * + * @return void + */ + public function testCustomerOrderTtcWorkflow() + { + global $user,$langs,$db; + $this->restoreGlobals(); + if (!isModEnabled('order')) { + $this->markTestSkipped('Module order disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Commande($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Customer order create'); + + $this->addSubtotalTitle($object, $langs); + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, 0, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, 0, 0, 'TTC', self::PU_TTC); + + $reloaded = new Commande($db); + $reloaded->fetch($id); + if (method_exists($reloaded, 'fetch_lines')) { + $reloaded->fetch_lines(); + } + $this->assertSubtotalLinePresent($reloaded, 'Customer order'); + $this->assertBothPricedLines($reloaded, 'Customer order create'); + + foreach ($this->pricedLines($reloaded) as $line) { + $object->updateline($line->id, $line->desc, (float) $line->subprice_ttc, $line->qty, $line->remise_percent, $line->tva_tx, 0, 0, 'TTC'); + } + $afterUpdate = new Commande($db); + $afterUpdate->fetch($id); + if (method_exists($afterUpdate, 'fetch_lines')) { + $afterUpdate->fetch_lines(); + } + $this->assertBothPricedLines($afterUpdate, 'Customer order no-op update'); + + foreach ($this->pricedLines($afterUpdate) as $line) { + $object->updateline($line->id, $line->desc, (float) $line->subprice_ttc, $line->qty, self::REMISE, $line->tva_tx, 0, 0, 'TTC'); + } + $afterRemise = new Commande($db); + $afterRemise->fetch($id); + if (method_exists($afterRemise, 'fetch_lines')) { + $afterRemise->fetch_lines(); + } + $this->assertBothPricedLines($afterRemise, 'Customer order discount', true); + print __METHOD__." id=".$id."\n"; + } + + /** + * Customer order - clone must preserve the TTC entry mode (subprice_ttc). + * + * @return void + */ + public function testCustomerOrderCloneTtc() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('order')) { + $this->markTestSkipped('Module order disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Commande($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCCLONE'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Customer order clone create source'); + + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, 0, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, 0, 0, 'TTC', self::PU_TTC); + + $source = new Commande($db); + $source->fetch($id); + $clonedId = $source->createFromClone($user, $socid); + $this->assertGreaterThan(0, $clonedId, 'Customer order createFromClone'); + + $clone = new Commande($db); + $clone->fetch($clonedId); + if (method_exists($clone, 'fetch_lines')) { + $clone->fetch_lines(); + } + $this->assertBothPricedLines($clone, 'Customer order clone'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } + + /** + * Customer invoice - full workflow. subprice_ttc persistence to be added -> red. + * + * @return void + */ + public function testCustomerInvoiceTtcWorkflow() + { + global $user,$langs,$db; + $this->restoreGlobals(); + if (!isModEnabled('invoice')) { + $this->markTestSkipped('Module invoice disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Facture($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Customer invoice create'); + + $this->addSubtotalTitle($object, $langs); + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, '', '', 0, 0, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, '', '', 0, 0, 0, 'TTC', self::PU_TTC); + + $reloaded = new Facture($db); + $reloaded->fetch($id); + if (method_exists($reloaded, 'fetch_lines')) { + $reloaded->fetch_lines(); + } + $this->assertSubtotalLinePresent($reloaded, 'Customer invoice'); + $this->assertBothPricedLines($reloaded, 'Customer invoice create'); + + foreach ($this->pricedLines($reloaded) as $line) { + $object->updateline($line->id, $line->desc, (float) $line->subprice_ttc, $line->qty, $line->remise_percent, $line->date_start, $line->date_end, $line->tva_tx, 0, 0, 'TTC'); + } + $afterUpdate = new Facture($db); + $afterUpdate->fetch($id); + if (method_exists($afterUpdate, 'fetch_lines')) { + $afterUpdate->fetch_lines(); + } + $this->assertBothPricedLines($afterUpdate, 'Customer invoice no-op update'); + + foreach ($this->pricedLines($afterUpdate) as $line) { + $object->updateline($line->id, $line->desc, (float) $line->subprice_ttc, $line->qty, self::REMISE, $line->date_start, $line->date_end, $line->tva_tx, 0, 0, 'TTC'); + } + $afterRemise = new Facture($db); + $afterRemise->fetch($id); + if (method_exists($afterRemise, 'fetch_lines')) { + $afterRemise->fetch_lines(); + } + $this->assertBothPricedLines($afterRemise, 'Customer invoice discount', true); + print __METHOD__." id=".$id."\n"; + } + + /** + * Customer invoice - clone must preserve the TTC entry mode (subprice_ttc). + * + * @return void + */ + public function testCustomerInvoiceCloneTtc() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('invoice')) { + $this->markTestSkipped('Module invoice disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Facture($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCCLONE'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Customer invoice clone create source'); + + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, '', '', 0, 0, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, '', '', 0, 0, 0, 'TTC', self::PU_TTC); + + $source = new Facture($db); + $source->fetch($id); + $clonedId = $source->createFromClone($user, $id); + $this->assertGreaterThan(0, $clonedId, 'Customer invoice createFromClone'); + + $clone = new Facture($db); + $clone->fetch($clonedId); + if (method_exists($clone, 'fetch_lines')) { + $clone->fetch_lines(); + } + $this->assertBothPricedLines($clone, 'Customer invoice clone'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } + + /** + * Customer contract - full workflow (no subtotals on contracts) -> red. + * + * @return void + */ + public function testCustomerContractTtcWorkflow() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('contract')) { + $this->markTestSkipped('Module contract disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Contrat($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Customer contract create'); + + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, '', '', 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, '', '', 'TTC', self::PU_TTC); + + $reloaded = new Contrat($db); + $reloaded->fetch($id); + if (method_exists($reloaded, 'fetch_lines')) { + $reloaded->fetch_lines(); + } + $this->assertBothPricedLines($reloaded, 'Customer contract create'); + + foreach ($this->pricedLines($reloaded) as $line) { + $object->updateline($line->id, $line->desc, (float) $line->subprice_ttc, $line->qty, $line->remise_percent, $line->date_start, $line->date_end, $line->tva_tx, 0, 0, '', '', 'TTC'); + } + $afterUpdate = new Contrat($db); + $afterUpdate->fetch($id); + if (method_exists($afterUpdate, 'fetch_lines')) { + $afterUpdate->fetch_lines(); + } + $this->assertBothPricedLines($afterUpdate, 'Customer contract no-op update'); + + foreach ($this->pricedLines($afterUpdate) as $line) { + $object->updateline($line->id, $line->desc, (float) $line->subprice_ttc, $line->qty, self::REMISE, $line->date_start, $line->date_end, $line->tva_tx, 0, 0, '', '', 'TTC'); + } + $afterRemise = new Contrat($db); + $afterRemise->fetch($id); + if (method_exists($afterRemise, 'fetch_lines')) { + $afterRemise->fetch_lines(); + } + $this->assertBothPricedLines($afterRemise, 'Customer contract discount', true); + print __METHOD__." id=".$id."\n"; + } + + /** + * Customer contract - line edit through ContratLigne::update() (the HT-only UI edit path, distinct + * from Contrat::updateline()). A no-op edit must keep the TTC total; changing the HT unit price + * drops the TTC entry mode. + * + * @return void + */ + public function testCustomerContractLineUpdateTtc() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('contract')) { + $this->markTestSkipped('Module contract disabled'); + return; + } + + $object = new Contrat($db); + $object->initAsSpecimen(); + $object->socid = self::$socid; + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Contrat create'); + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, self::$productid, 0, '', '', 'TTC', self::PU_TTC); + + $reloaded = new Contrat($db); + $reloaded->fetch($id); + if (method_exists($reloaded, 'fetch_lines')) { + $reloaded->fetch_lines(); + } + $lineid = $reloaded->lines[0]->id; + + // No-op edit (price unchanged): TTC total must be preserved, no rounding drift. + $line = new ContratLigne($db); + $line->fetch($lineid); + $line->oldcopy = dol_clone($line, 2); + $line->tva_tx = self::VAT; + $line->update($user); + + $afterNoop = new ContratLigne($db); + $afterNoop->fetch($lineid); + $this->assertEquals(self::PU_TTC, (float) $afterNoop->subprice_ttc, 'Contrat line no-op: subprice_ttc kept'); + $this->assertEquals(self::LINE_HT, (float) $afterNoop->total_ht, 'Contrat line no-op: total_ht preserved (no drift)'); + + // Edit that actually changes the HT unit price: TTC entry mode dropped, HT-based total. + $newHt = 2.50; + $line2 = new ContratLigne($db); + $line2->fetch($lineid); + $line2->oldcopy = dol_clone($line2, 2); + $line2->subprice = $newHt; + // Mirror contrat/card.php: drop the TTC mode when the HT unit price actually changed. + if ((float) $line2->subprice != (float) $line2->oldcopy->subprice) { + $line2->subprice_ttc = 0; + } + $line2->tva_tx = self::VAT; + $line2->update($user); + + $afterHt = new ContratLigne($db); + $afterHt->fetch($lineid); + $this->assertEquals(0, (float) $afterHt->subprice_ttc, 'Contrat line HT edit: subprice_ttc reset'); + $this->assertEquals((float) price2num($newHt * self::QTY, 'MT'), (float) $afterHt->total_ht, 'Contrat line HT edit: HT-based total'); + print __METHOD__." id=".$id."\n"; + } + + /** + * Customer contract - clone must preserve the TTC entry mode (subprice_ttc). + * + * @return void + */ + public function testCustomerContractCloneTtc() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('contract')) { + $this->markTestSkipped('Module contract disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Contrat($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCCLONE'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Customer contract clone create source'); + + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, '', '', 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, '', '', 'TTC', self::PU_TTC); + + $source = new Contrat($db); + $source->fetch($id); + $clonedId = $source->createFromClone($user, $socid); + $this->assertGreaterThan(0, $clonedId, 'Customer contract createFromClone'); + + $clone = new Contrat($db); + $clone->fetch($clonedId); + if (method_exists($clone, 'fetch_lines')) { + $clone->fetch_lines(); + } + $this->assertBothPricedLines($clone, 'Customer contract clone'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } + + /** + * Supplier proposal - full workflow. subprice_ttc already handled upstream. + * Same addline()/updateline() signatures as the customer proposal. + * + * @return void + */ + public function testSupplierProposalTtcWorkflow() + { + global $user,$langs,$db; + $this->restoreGlobals(); + if (!isModEnabled('supplier_proposal')) { + $this->markTestSkipped('Module supplier_proposal disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new SupplierProposal($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Supplier proposal create'); + + $this->addSubtotalTitle($object, $langs); + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, 'TTC', self::PU_TTC); + + $reloaded = new SupplierProposal($db); + $reloaded->fetch($id); + if (method_exists($reloaded, 'fetch_lines')) { + $reloaded->fetch_lines(); + } + $this->assertSubtotalLinePresent($reloaded, 'Supplier proposal'); + $this->assertBothPricedLines($reloaded, 'Supplier proposal create'); + + foreach ($this->pricedLines($reloaded) as $line) { + $object->updateline($line->id, (float) $line->subprice_ttc, $line->qty, $line->remise_percent, $line->tva_tx, 0, 0, $line->desc, 'TTC'); + } + $afterUpdate = new SupplierProposal($db); + $afterUpdate->fetch($id); + if (method_exists($afterUpdate, 'fetch_lines')) { + $afterUpdate->fetch_lines(); + } + $this->assertBothPricedLines($afterUpdate, 'Supplier proposal no-op update'); + + foreach ($this->pricedLines($afterUpdate) as $line) { + $object->updateline($line->id, (float) $line->subprice_ttc, $line->qty, self::REMISE, $line->tva_tx, 0, 0, $line->desc, 'TTC'); + } + $afterRemise = new SupplierProposal($db); + $afterRemise->fetch($id); + if (method_exists($afterRemise, 'fetch_lines')) { + $afterRemise->fetch_lines(); + } + $this->assertBothPricedLines($afterRemise, 'Supplier proposal discount', true); + print __METHOD__." id=".$id."\n"; + } + + /** + * Supplier proposal - clone must preserve the TTC entry mode (subprice_ttc). + * + * @return void + */ + public function testSupplierProposalCloneTtc() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('supplier_proposal')) { + $this->markTestSkipped('Module supplier_proposal disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new SupplierProposal($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCCLONE'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Supplier proposal clone create source'); + + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, 'TTC', self::PU_TTC); + + $source = new SupplierProposal($db); + $source->fetch($id); + $clonedId = $source->createFromClone($user, $socid); + $this->assertGreaterThan(0, $clonedId, 'Supplier proposal createFromClone'); + + $clone = new SupplierProposal($db); + $clone->fetch($clonedId); + if (method_exists($clone, 'fetch_lines')) { + $clone->fetch_lines(); + } + $this->assertBothPricedLines($clone, 'Supplier proposal clone'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } + + /** + * Supplier order - full workflow. subprice_ttc already handled upstream. + * + * @return void + */ + public function testSupplierOrderTtcWorkflow() + { + global $user,$langs,$db; + $this->restoreGlobals(); + if (!isModEnabled('fournisseur') && !isModEnabled('supplier_order')) { + $this->markTestSkipped('Module supplier order disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new CommandeFournisseur($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Supplier order create'); + + $this->addSubtotalTitle($object, $langs); + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, '', 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, '', 0, 'TTC', self::PU_TTC); + + $reloaded = new CommandeFournisseur($db); + $reloaded->fetch($id); + if (method_exists($reloaded, 'fetch_lines')) { + $reloaded->fetch_lines(); + } + $this->assertSubtotalLinePresent($reloaded, 'Supplier order'); + $this->assertBothPricedLines($reloaded, 'Supplier order create'); + + foreach ($this->pricedLines($reloaded) as $line) { + $object->updateline($line->id, $line->desc, (float) $line->subprice_ttc, $line->qty, $line->remise_percent, $line->tva_tx, 0, 0, 'TTC'); + } + $afterUpdate = new CommandeFournisseur($db); + $afterUpdate->fetch($id); + if (method_exists($afterUpdate, 'fetch_lines')) { + $afterUpdate->fetch_lines(); + } + $this->assertBothPricedLines($afterUpdate, 'Supplier order no-op update'); + + foreach ($this->pricedLines($afterUpdate) as $line) { + $object->updateline($line->id, $line->desc, (float) $line->subprice_ttc, $line->qty, self::REMISE, $line->tva_tx, 0, 0, 'TTC'); + } + $afterRemise = new CommandeFournisseur($db); + $afterRemise->fetch($id); + if (method_exists($afterRemise, 'fetch_lines')) { + $afterRemise->fetch_lines(); + } + $this->assertBothPricedLines($afterRemise, 'Supplier order discount', true); + print __METHOD__." id=".$id."\n"; + } + + /** + * Supplier order - clone must preserve the TTC entry mode (subprice_ttc). + * + * @return void + */ + public function testSupplierOrderCloneTtc() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('fournisseur') && !isModEnabled('supplier_order')) { + $this->markTestSkipped('Module supplier order disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new CommandeFournisseur($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Supplier order clone create source'); + + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, '', 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, '', 0, 'TTC', self::PU_TTC); + + $source = new CommandeFournisseur($db); + $source->fetch($id); + $clonedId = $source->createFromClone($user, $socid); + $this->assertGreaterThan(0, $clonedId, 'Supplier order createFromClone'); + + $clone = new CommandeFournisseur($db); + $clone->fetch($clonedId); + if (method_exists($clone, 'fetch_lines')) { + $clone->fetch_lines(); + } + $this->assertBothPricedLines($clone, 'Supplier order clone'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } + + /** + * Supplier invoice - full workflow. subprice_ttc already handled upstream. + * Note the different addline()/updateline() parameter order (pu in position 2, no pu_ttc: in TTC + * mode the $pu argument carries the price including tax). + * + * @return void + */ + public function testSupplierInvoiceTtcWorkflow() + { + global $user,$langs,$db; + $this->restoreGlobals(); + if (!isModEnabled('fournisseur') && !isModEnabled('supplier_invoice')) { + $this->markTestSkipped('Module supplier invoice disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new FactureFournisseur($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Supplier invoice create'); + + $this->addSubtotalTitle($object, $langs); + $object->addline('Product TTC', self::PU_TTC, self::VAT, 0, 0, self::QTY, $pid, 0, 0, 0, 0, 0, 'TTC'); + $object->addline('Free TTC', self::PU_TTC, self::VAT, 0, 0, self::QTY, 0, 0, 0, 0, 0, 0, 'TTC'); + + $reloaded = new FactureFournisseur($db); + $reloaded->fetch($id); + if (method_exists($reloaded, 'fetch_lines')) { + $reloaded->fetch_lines(); + } + $this->assertSubtotalLinePresent($reloaded, 'Supplier invoice'); + $this->assertBothPricedLines($reloaded, 'Supplier invoice create'); + + foreach ($this->pricedLines($reloaded) as $line) { + $object->updateline($line->id, $line->desc, (float) $line->subprice_ttc, $line->tva_tx, 0, 0, $line->qty, 0, 'TTC', 0, 0, $line->remise_percent); + } + $afterUpdate = new FactureFournisseur($db); + $afterUpdate->fetch($id); + if (method_exists($afterUpdate, 'fetch_lines')) { + $afterUpdate->fetch_lines(); + } + $this->assertBothPricedLines($afterUpdate, 'Supplier invoice no-op update'); + + foreach ($this->pricedLines($afterUpdate) as $line) { + $object->updateline($line->id, $line->desc, (float) $line->subprice_ttc, $line->tva_tx, 0, 0, $line->qty, 0, 'TTC', 0, 0, self::REMISE); + } + $afterRemise = new FactureFournisseur($db); + $afterRemise->fetch($id); + if (method_exists($afterRemise, 'fetch_lines')) { + $afterRemise->fetch_lines(); + } + $this->assertBothPricedLines($afterRemise, 'Supplier invoice discount', true); + print __METHOD__." id=".$id."\n"; + } + + /** + * Supplier invoice - clone must preserve the TTC entry mode (subprice_ttc). + * + * @return void + */ + public function testSupplierInvoiceCloneTtc() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('fournisseur') && !isModEnabled('supplier_invoice')) { + $this->markTestSkipped('Module supplier invoice disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new FactureFournisseur($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref_supplier = 'TTCCLONE'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Supplier invoice clone create source'); + + $object->addline('Product TTC', self::PU_TTC, self::VAT, 0, 0, self::QTY, $pid, 0, 0, 0, 0, 0, 'TTC'); + $object->addline('Free TTC', self::PU_TTC, self::VAT, 0, 0, self::QTY, 0, 0, 0, 0, 0, 0, 'TTC'); + + // createFromClone() takes the new supplier ref from $this (fallback "CopyOf "): call it on a + // fresh object so the cloned supplier invoice gets a unique ref_supplier. + $cloner = new FactureFournisseur($db); + $clonedId = $cloner->createFromClone($user, $id); + $this->assertGreaterThan(0, $clonedId, 'Supplier invoice createFromClone'); + + $clone = new FactureFournisseur($db); + $clone->fetch($clonedId); + if (method_exists($clone, 'fetch_lines')) { + $clone->fetch_lines(); + } + $this->assertBothPricedLines($clone, 'Supplier invoice clone'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } + + /** + * Customer proposal -> order (Commande::createFromProposal) must preserve the TTC entry mode. + * + * @return void + */ + public function testCustomerProposalToOrderTtc() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('propal') || !isModEnabled('order')) { + $this->markTestSkipped('Module propal or order disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Propal($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCORIG'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Proposal create for origin'); + + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, 'TTC', self::PU_TTC); + + $source = new Propal($db); + $source->fetch($id); + if (method_exists($source, 'fetch_lines')) { + $source->fetch_lines(); + } + + $order = new Commande($db); + $orderId = $order->createFromProposal($source, $user); + $this->assertGreaterThan(0, $orderId, 'createFromProposal'); + + $reloaded = new Commande($db); + $reloaded->fetch($orderId); + if (method_exists($reloaded, 'fetch_lines')) { + $reloaded->fetch_lines(); + } + $this->assertBothPricedLines($reloaded, 'Proposal to order'); + print __METHOD__." propal=".$id." order=".$orderId."\n"; + } + + /** + * Customer order -> invoice (Facture::createFromOrder) must preserve the TTC entry mode. + * + * @return void + */ + public function testCustomerOrderToInvoiceTtc() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('order') || !isModEnabled('invoice')) { + $this->markTestSkipped('Module order or invoice disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Commande($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCORIG'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Order create for origin'); + + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, 0, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, 0, 0, 'TTC', self::PU_TTC); + + $source = new Commande($db); + $source->fetch($id); + if (method_exists($source, 'fetch_lines')) { + $source->fetch_lines(); + } + + // createFromOrder() returns 1 on success; the new invoice id is in $invoice->id. + $invoice = new Facture($db); + $this->assertGreaterThan(0, $invoice->createFromOrder($source, $user), 'createFromOrder'); + $invoiceId = $invoice->id; + + $reloaded = new Facture($db); + $reloaded->fetch($invoiceId); + if (method_exists($reloaded, 'fetch_lines')) { + $reloaded->fetch_lines(); + } + $this->assertBothPricedLines($reloaded, 'Order to invoice'); + print __METHOD__." order=".$id." invoice=".$invoiceId."\n"; + } + + /** + * Customer contract -> invoice (Facture::createFromContract) must preserve the TTC entry mode. + * + * @return void + */ + public function testCustomerContractToInvoiceTtc() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('contract') || !isModEnabled('invoice')) { + $this->markTestSkipped('Module contract or invoice disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Contrat($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCORIG'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Contract create for origin'); + + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, '', '', 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, '', '', 'TTC', self::PU_TTC); + + $source = new Contrat($db); + $source->fetch($id); + if (method_exists($source, 'fetch_lines')) { + $source->fetch_lines(); + } + + // createFromContract() returns 1 on success; the new invoice id is in $invoice->id. + $invoice = new Facture($db); + $this->assertGreaterThan(0, $invoice->createFromContract($source, $user), 'createFromContract'); + $invoiceId = $invoice->id; + + $reloaded = new Facture($db); + $reloaded->fetch($invoiceId); + if (method_exists($reloaded, 'fetch_lines')) { + $reloaded->fetch_lines(); + } + $this->assertBothPricedLines($reloaded, 'Contract to invoice'); + print __METHOD__." contract=".$id." invoice=".$invoiceId."\n"; + } + + /** + * Customer invoice -> credit note (Facture::createFromCurrent with invertdetail) must keep the TTC + * entry mode with the inverted sign. + * + * @return void + */ + public function testCustomerInvoiceToCreditNoteTtc() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('invoice')) { + $this->markTestSkipped('Module invoice disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Facture($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCORIG'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Invoice create for credit note'); + + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, '', '', 0, 0, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, '', '', 0, 0, 0, 'TTC', self::PU_TTC); + + $source = new Facture($db); + $source->fetch($id); + if (method_exists($source, 'fetch_lines')) { + $source->fetch_lines(); + } + + // Credit note = invoice with all amounts inverted (invertdetail = 1). + $creditId = $source->createFromCurrent($user, 1); + $this->assertGreaterThan(0, $creditId, 'createFromCurrent invertdetail'); + + $credit = new Facture($db); + $credit->fetch($creditId); + if (method_exists($credit, 'fetch_lines')) { + $credit->fetch_lines(); + } + $this->assertBothPricedLinesInverted($credit, 'Invoice to credit note'); + print __METHOD__." invoice=".$id." creditnote=".$creditId."\n"; + } + + /** + * Supplier order - a line entered in HT must keep subprice_ttc = 0 (not marked TTC) on create and on + * clone, so it is never hijacked by the TTC preservation. Guards the supplier socle gating. + * + * @return void + */ + public function testSupplierOrderHtLineClone() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('fournisseur') && !isModEnabled('supplier_order')) { + $this->markTestSkipped('Module supplier order disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new CommandeFournisseur($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCHT'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Supplier order HT create source'); + + $object->addline('Product HT', self::PU_HT, self::QTY, self::VAT, 0, 0, $pid, 0, '', 0, 'HT', 0); + $object->addline('Free HT', self::PU_HT, self::QTY, self::VAT, 0, 0, 0, 0, '', 0, 'HT', 0); + + $reloaded = new CommandeFournisseur($db); + $reloaded->fetch($id); + if (method_exists($reloaded, 'fetch_lines')) { + $reloaded->fetch_lines(); + } + $this->assertBothHtLines($reloaded, 'Supplier order HT create'); + + $source = new CommandeFournisseur($db); + $source->fetch($id); + $clonedId = $source->createFromClone($user, $socid); + $this->assertGreaterThan(0, $clonedId, 'Supplier order HT createFromClone'); + + $clone = new CommandeFournisseur($db); + $clone->fetch($clonedId); + if (method_exists($clone, 'fetch_lines')) { + $clone->fetch_lines(); + } + $this->assertBothHtLines($clone, 'Supplier order HT clone'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } + + /** + * Supplier invoice - a line entered in HT must keep subprice_ttc = 0 on create (via + * SupplierInvoiceLine::insert) and on clone. Guards the supplier invoice socle gating. + * + * @return void + */ + public function testSupplierInvoiceHtLineClone() + { + global $user,$db; + $this->restoreGlobals(); + if (!isModEnabled('fournisseur') && !isModEnabled('supplier_invoice')) { + $this->markTestSkipped('Module supplier invoice disabled'); + return; + } + + $socid = self::$socid; + $pid = self::$productid; + + $object = new FactureFournisseur($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref_supplier = 'TTCHT'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Supplier invoice HT create source'); + + $object->addline('Product HT', self::PU_HT, self::VAT, 0, 0, self::QTY, $pid, 0, 0, 0, 0, 0, 'HT'); + $object->addline('Free HT', self::PU_HT, self::VAT, 0, 0, self::QTY, 0, 0, 0, 0, 0, 0, 'HT'); + + $reloaded = new FactureFournisseur($db); + $reloaded->fetch($id); + if (method_exists($reloaded, 'fetch_lines')) { + $reloaded->fetch_lines(); + } + $this->assertBothHtLines($reloaded, 'Supplier invoice HT create'); + + $cloner = new FactureFournisseur($db); + $clonedId = $cloner->createFromClone($user, $id); + $this->assertGreaterThan(0, $clonedId, 'Supplier invoice HT createFromClone'); + + $clone = new FactureFournisseur($db); + $clone->fetch($clonedId); + if (method_exists($clone, 'fetch_lines')) { + $clone->fetch_lines(); + } + $this->assertBothHtLines($clone, 'Supplier invoice HT clone'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } + + /** + * Customer proposal - with MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND = 1 (rounding of total), a mixed order + * (2 lines entered HT + 2 lines entered TTC, like customer proposal id=205) must survive create / + * no-op / clone with no drift, and a discount keeps each entry mode. + * + * @return void + */ + public function testCustomerProposalRoundOfTotalStable() + { + global $conf,$user,$db; + $this->restoreGlobals(); + if (!isModEnabled('propal')) { + $this->markTestSkipped('Module propal disabled'); + return; + } + $conf->global->MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND = 1; + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Propal($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCROT'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Customer proposal ROT create'); + $object->addline('Product HT', self::PU_HT, self::QTY, self::VAT, 0, 0, $pid, 0, 'HT', 0); + $object->addline('Free HT', self::PU_HT, self::QTY, self::VAT, 0, 0, 0, 0, 'HT', 0); + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, 'TTC', self::PU_TTC); + + $reloaded = new Propal($db); + $reloaded->fetch($id); + $reloaded->fetch_lines(); + $this->assertMixedSubpriceTtc($reloaded, 'Customer proposal ROT create'); + $s0 = $this->snapshotPricedLines($reloaded); + + foreach ($this->pricedLines($reloaded) as $line) { + $ttc = $line->wasEnteredIncludingTax(); + $pu = $ttc ? (float) $line->subprice_ttc : (float) $line->subprice; + $object->updateline($line->id, $pu, $line->qty, $line->remise_percent, $line->tva_tx, 0, 0, $line->desc, $ttc ? 'TTC' : 'HT'); + } + $afterNoop = new Propal($db); + $afterNoop->fetch($id); + $afterNoop->fetch_lines(); + $this->assertLinesUnchanged($s0, $this->snapshotPricedLines($afterNoop), 'Customer proposal ROT no-op'); + + $source = new Propal($db); + $source->fetch($id); + $clonedId = $source->createFromClone($user, $socid); + $this->assertGreaterThan(0, $clonedId, 'Customer proposal ROT clone'); + $clone = new Propal($db); + $clone->fetch($clonedId); + $clone->fetch_lines(); + $this->assertLinesUnchanged($s0, $this->snapshotPricedLines($clone), 'Customer proposal ROT clone'); + + foreach ($this->pricedLines($afterNoop) as $line) { + $ttc = $line->wasEnteredIncludingTax(); + $pu = $ttc ? (float) $line->subprice_ttc : (float) $line->subprice; + $object->updateline($line->id, $pu, $line->qty, self::REMISE, $line->tva_tx, 0, 0, $line->desc, $ttc ? 'TTC' : 'HT'); + } + $afterRemise = new Propal($db); + $afterRemise->fetch($id); + $afterRemise->fetch_lines(); + $this->assertMixedSubpriceTtc($afterRemise, 'Customer proposal ROT discount'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } + + /** + * Customer order - mixed HT/TTC order, MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND = 1. + * + * @return void + */ + public function testCustomerOrderRoundOfTotalStable() + { + global $conf,$user,$db; + $this->restoreGlobals(); + if (!isModEnabled('order')) { + $this->markTestSkipped('Module order disabled'); + return; + } + $conf->global->MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND = 1; + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Commande($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCROT'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Customer order ROT create'); + $object->addline('Product HT', self::PU_HT, self::QTY, self::VAT, 0, 0, $pid, 0, 0, 0, 'HT', 0); + $object->addline('Free HT', self::PU_HT, self::QTY, self::VAT, 0, 0, 0, 0, 0, 0, 'HT', 0); + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, 0, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, 0, 0, 'TTC', self::PU_TTC); + + $reloaded = new Commande($db); + $reloaded->fetch($id); + $reloaded->fetch_lines(); + $this->assertMixedSubpriceTtc($reloaded, 'Customer order ROT create'); + $s0 = $this->snapshotPricedLines($reloaded); + + foreach ($this->pricedLines($reloaded) as $line) { + $ttc = $line->wasEnteredIncludingTax(); + $pu = $ttc ? (float) $line->subprice_ttc : (float) $line->subprice; + $object->updateline($line->id, $line->desc, $pu, $line->qty, $line->remise_percent, $line->tva_tx, 0, 0, $ttc ? 'TTC' : 'HT'); + } + $afterNoop = new Commande($db); + $afterNoop->fetch($id); + $afterNoop->fetch_lines(); + $this->assertLinesUnchanged($s0, $this->snapshotPricedLines($afterNoop), 'Customer order ROT no-op'); + + $source = new Commande($db); + $source->fetch($id); + $clonedId = $source->createFromClone($user, $socid); + $this->assertGreaterThan(0, $clonedId, 'Customer order ROT clone'); + $clone = new Commande($db); + $clone->fetch($clonedId); + $clone->fetch_lines(); + $this->assertLinesUnchanged($s0, $this->snapshotPricedLines($clone), 'Customer order ROT clone'); + + foreach ($this->pricedLines($afterNoop) as $line) { + $ttc = $line->wasEnteredIncludingTax(); + $pu = $ttc ? (float) $line->subprice_ttc : (float) $line->subprice; + $object->updateline($line->id, $line->desc, $pu, $line->qty, self::REMISE, $line->tva_tx, 0, 0, $ttc ? 'TTC' : 'HT'); + } + $afterRemise = new Commande($db); + $afterRemise->fetch($id); + $afterRemise->fetch_lines(); + $this->assertMixedSubpriceTtc($afterRemise, 'Customer order ROT discount'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } + + /** + * Customer invoice - mixed HT/TTC order, MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND = 1. + * + * @return void + */ + public function testCustomerInvoiceRoundOfTotalStable() + { + global $conf,$user,$db; + $this->restoreGlobals(); + if (!isModEnabled('invoice')) { + $this->markTestSkipped('Module invoice disabled'); + return; + } + $conf->global->MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND = 1; + + $socid = self::$socid; + $pid = self::$productid; + + $object = new Facture($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCROT'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Customer invoice ROT create'); + $object->addline('Product HT', self::PU_HT, self::QTY, self::VAT, 0, 0, $pid, 0, '', '', 0, 0, 0, 'HT', 0); + $object->addline('Free HT', self::PU_HT, self::QTY, self::VAT, 0, 0, 0, 0, '', '', 0, 0, 0, 'HT', 0); + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, '', '', 0, 0, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, '', '', 0, 0, 0, 'TTC', self::PU_TTC); + + $reloaded = new Facture($db); + $reloaded->fetch($id); + $reloaded->fetch_lines(); + $this->assertMixedSubpriceTtc($reloaded, 'Customer invoice ROT create'); + $s0 = $this->snapshotPricedLines($reloaded); + + foreach ($this->pricedLines($reloaded) as $line) { + $ttc = $line->wasEnteredIncludingTax(); + $pu = $ttc ? (float) $line->subprice_ttc : (float) $line->subprice; + $object->updateline($line->id, $line->desc, $pu, $line->qty, $line->remise_percent, $line->date_start, $line->date_end, $line->tva_tx, 0, 0, $ttc ? 'TTC' : 'HT'); + } + $afterNoop = new Facture($db); + $afterNoop->fetch($id); + $afterNoop->fetch_lines(); + $this->assertLinesUnchanged($s0, $this->snapshotPricedLines($afterNoop), 'Customer invoice ROT no-op'); + + $source = new Facture($db); + $source->fetch($id); + $clonedId = $source->createFromClone($user, $id); + $this->assertGreaterThan(0, $clonedId, 'Customer invoice ROT clone'); + $clone = new Facture($db); + $clone->fetch($clonedId); + $clone->fetch_lines(); + $this->assertLinesUnchanged($s0, $this->snapshotPricedLines($clone), 'Customer invoice ROT clone'); + + foreach ($this->pricedLines($afterNoop) as $line) { + $ttc = $line->wasEnteredIncludingTax(); + $pu = $ttc ? (float) $line->subprice_ttc : (float) $line->subprice; + $object->updateline($line->id, $line->desc, $pu, $line->qty, self::REMISE, $line->date_start, $line->date_end, $line->tva_tx, 0, 0, $ttc ? 'TTC' : 'HT'); + } + $afterRemise = new Facture($db); + $afterRemise->fetch($id); + $afterRemise->fetch_lines(); + $this->assertMixedSubpriceTtc($afterRemise, 'Customer invoice ROT discount'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } + + /** + * Supplier proposal - mixed HT/TTC order, MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND_SUPPLIER = 1. + * + * @return void + */ + public function testSupplierProposalRoundOfTotalStable() + { + global $conf,$user,$db; + $this->restoreGlobals(); + if (!isModEnabled('supplier_proposal')) { + $this->markTestSkipped('Module supplier_proposal disabled'); + return; + } + $conf->global->MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND_SUPPLIER = 1; + + $socid = self::$socid; + $pid = self::$productid; + + $object = new SupplierProposal($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCROT'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Supplier proposal ROT create'); + $object->addline('Product HT', self::PU_HT, self::QTY, self::VAT, 0, 0, $pid, 0, 'HT', 0); + $object->addline('Free HT', self::PU_HT, self::QTY, self::VAT, 0, 0, 0, 0, 'HT', 0); + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, 'TTC', self::PU_TTC); + + $reloaded = new SupplierProposal($db); + $reloaded->fetch($id); + if (method_exists($reloaded, 'fetch_lines')) { + $reloaded->fetch_lines(); + } + $this->assertMixedSubpriceTtc($reloaded, 'Supplier proposal ROT create'); + $s0 = $this->snapshotPricedLines($reloaded); + + foreach ($this->pricedLines($reloaded) as $line) { + $ttc = $line->wasEnteredIncludingTax(); + $pu = $ttc ? (float) $line->subprice_ttc : (float) $line->subprice; + $object->updateline($line->id, $pu, $line->qty, $line->remise_percent, $line->tva_tx, 0, 0, $line->desc, $ttc ? 'TTC' : 'HT'); + } + $afterNoop = new SupplierProposal($db); + $afterNoop->fetch($id); + if (method_exists($afterNoop, 'fetch_lines')) { + $afterNoop->fetch_lines(); + } + $this->assertLinesUnchanged($s0, $this->snapshotPricedLines($afterNoop), 'Supplier proposal ROT no-op'); + + $source = new SupplierProposal($db); + $source->fetch($id); + $clonedId = $source->createFromClone($user, $socid); + $this->assertGreaterThan(0, $clonedId, 'Supplier proposal ROT clone'); + $clone = new SupplierProposal($db); + $clone->fetch($clonedId); + if (method_exists($clone, 'fetch_lines')) { + $clone->fetch_lines(); + } + $this->assertLinesUnchanged($s0, $this->snapshotPricedLines($clone), 'Supplier proposal ROT clone'); + + foreach ($this->pricedLines($afterNoop) as $line) { + $ttc = $line->wasEnteredIncludingTax(); + $pu = $ttc ? (float) $line->subprice_ttc : (float) $line->subprice; + $object->updateline($line->id, $pu, $line->qty, self::REMISE, $line->tva_tx, 0, 0, $line->desc, $ttc ? 'TTC' : 'HT'); + } + $afterRemise = new SupplierProposal($db); + $afterRemise->fetch($id); + if (method_exists($afterRemise, 'fetch_lines')) { + $afterRemise->fetch_lines(); + } + $this->assertMixedSubpriceTtc($afterRemise, 'Supplier proposal ROT discount'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } + + /** + * Supplier order - mixed HT/TTC order, MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND_SUPPLIER = 1. + * + * @return void + */ + public function testSupplierOrderRoundOfTotalStable() + { + global $conf,$user,$db; + $this->restoreGlobals(); + if (!isModEnabled('fournisseur') && !isModEnabled('supplier_order')) { + $this->markTestSkipped('Module supplier order disabled'); + return; + } + $conf->global->MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND_SUPPLIER = 1; + + $socid = self::$socid; + $pid = self::$productid; + + $object = new CommandeFournisseur($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref = 'TTCROT'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Supplier order ROT create'); + $object->addline('Product HT', self::PU_HT, self::QTY, self::VAT, 0, 0, $pid, 0, '', 0, 'HT', 0); + $object->addline('Free HT', self::PU_HT, self::QTY, self::VAT, 0, 0, 0, 0, '', 0, 'HT', 0); + $object->addline('Product TTC', 0, self::QTY, self::VAT, 0, 0, $pid, 0, '', 0, 'TTC', self::PU_TTC); + $object->addline('Free TTC', 0, self::QTY, self::VAT, 0, 0, 0, 0, '', 0, 'TTC', self::PU_TTC); + + $reloaded = new CommandeFournisseur($db); + $reloaded->fetch($id); + $reloaded->fetch_lines(); + $this->assertMixedSubpriceTtc($reloaded, 'Supplier order ROT create'); + $s0 = $this->snapshotPricedLines($reloaded); + + foreach ($this->pricedLines($reloaded) as $line) { + $ttc = $line->wasEnteredIncludingTax(); + $pu = $ttc ? (float) $line->subprice_ttc : (float) $line->subprice; + $object->updateline($line->id, $line->desc, $pu, $line->qty, $line->remise_percent, $line->tva_tx, 0, 0, $ttc ? 'TTC' : 'HT'); + } + $afterNoop = new CommandeFournisseur($db); + $afterNoop->fetch($id); + $afterNoop->fetch_lines(); + $this->assertLinesUnchanged($s0, $this->snapshotPricedLines($afterNoop), 'Supplier order ROT no-op'); + + $source = new CommandeFournisseur($db); + $source->fetch($id); + $clonedId = $source->createFromClone($user, $socid); + $this->assertGreaterThan(0, $clonedId, 'Supplier order ROT clone'); + $clone = new CommandeFournisseur($db); + $clone->fetch($clonedId); + $clone->fetch_lines(); + $this->assertLinesUnchanged($s0, $this->snapshotPricedLines($clone), 'Supplier order ROT clone'); + + foreach ($this->pricedLines($afterNoop) as $line) { + $ttc = $line->wasEnteredIncludingTax(); + $pu = $ttc ? (float) $line->subprice_ttc : (float) $line->subprice; + $object->updateline($line->id, $line->desc, $pu, $line->qty, self::REMISE, $line->tva_tx, 0, 0, $ttc ? 'TTC' : 'HT'); + } + $afterRemise = new CommandeFournisseur($db); + $afterRemise->fetch($id); + $afterRemise->fetch_lines(); + $this->assertMixedSubpriceTtc($afterRemise, 'Supplier order ROT discount'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } + + /** + * Supplier invoice - mixed HT/TTC order, MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND_SUPPLIER = 1. + * + * @return void + */ + public function testSupplierInvoiceRoundOfTotalStable() + { + global $conf,$user,$db; + $this->restoreGlobals(); + if (!isModEnabled('fournisseur') && !isModEnabled('supplier_invoice')) { + $this->markTestSkipped('Module supplier invoice disabled'); + return; + } + $conf->global->MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND_SUPPLIER = 1; + + $socid = self::$socid; + $pid = self::$productid; + + $object = new FactureFournisseur($db); + $object->initAsSpecimen(); + $object->socid = $socid; + $object->ref_supplier = 'TTCROT'.substr(uniqid(), -8); + $object->lines = array(); + $id = $object->create($user); + $this->assertGreaterThan(0, $id, 'Supplier invoice ROT create'); + $object->addline('Product HT', self::PU_HT, self::VAT, 0, 0, self::QTY, $pid, 0, 0, 0, 0, 0, 'HT'); + $object->addline('Free HT', self::PU_HT, self::VAT, 0, 0, self::QTY, 0, 0, 0, 0, 0, 0, 'HT'); + $object->addline('Product TTC', self::PU_TTC, self::VAT, 0, 0, self::QTY, $pid, 0, 0, 0, 0, 0, 'TTC'); + $object->addline('Free TTC', self::PU_TTC, self::VAT, 0, 0, self::QTY, 0, 0, 0, 0, 0, 0, 'TTC'); + + $reloaded = new FactureFournisseur($db); + $reloaded->fetch($id); + $reloaded->fetch_lines(); + $this->assertMixedSubpriceTtc($reloaded, 'Supplier invoice ROT create'); + $s0 = $this->snapshotPricedLines($reloaded); + + foreach ($this->pricedLines($reloaded) as $line) { + $ttc = $line->wasEnteredIncludingTax(); + $pu = $ttc ? (float) $line->subprice_ttc : (float) $line->subprice; + $object->updateline($line->id, $line->desc, $pu, $line->tva_tx, 0, 0, $line->qty, 0, $ttc ? 'TTC' : 'HT', 0, 0, $line->remise_percent); + } + $afterNoop = new FactureFournisseur($db); + $afterNoop->fetch($id); + $afterNoop->fetch_lines(); + $this->assertLinesUnchanged($s0, $this->snapshotPricedLines($afterNoop), 'Supplier invoice ROT no-op'); + + $cloner = new FactureFournisseur($db); + $clonedId = $cloner->createFromClone($user, $id); + $this->assertGreaterThan(0, $clonedId, 'Supplier invoice ROT clone'); + $clone = new FactureFournisseur($db); + $clone->fetch($clonedId); + $clone->fetch_lines(); + $this->assertLinesUnchanged($s0, $this->snapshotPricedLines($clone), 'Supplier invoice ROT clone'); + + foreach ($this->pricedLines($afterNoop) as $line) { + $ttc = $line->wasEnteredIncludingTax(); + $pu = $ttc ? (float) $line->subprice_ttc : (float) $line->subprice; + $object->updateline($line->id, $line->desc, $pu, $line->tva_tx, 0, 0, $line->qty, 0, $ttc ? 'TTC' : 'HT', 0, 0, self::REMISE); + } + $afterRemise = new FactureFournisseur($db); + $afterRemise->fetch($id); + $afterRemise->fetch_lines(); + $this->assertMixedSubpriceTtc($afterRemise, 'Supplier invoice ROT discount'); + print __METHOD__." id=".$id." clone=".$clonedId."\n"; + } +}