Version 1.8.0: Rücknahmen und Einkaufspreise in Rechnungsübernahme

- Rücknahmen (returned) werden bei Rechnungsübertragung abgezogen
  - Mehraufwand: Matching über fk_product bzw. MD5 der description
  - Auftragspositionen: Matching über fk_commandedet
  - Effektive Menge ≤ 0 → Produkt wird nicht übernommen
- Einkaufspreise (buy_price_ht/pa_ht) werden automatisch gesetzt
  - Priorität: cost_price > Lieferanten-Stückpreis > PMP
  - Lieferanten-unitprice statt PMP verhindert falsche Margen bei Meterware

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-23 18:01:51 +01:00
parent a69e308a68
commit 6257713e5b
3 changed files with 102 additions and 7 deletions

View file

@ -1,6 +1,6 @@
# Stundenzettel Modul für Dolibarr
**Version:** 1.7.0
**Version:** 1.8.0
**Autor:** Data IT Solution
**Kompatibilität:** Dolibarr 16.0+
**Lizenz:** GPL v3
@ -117,6 +117,16 @@ Sie können beim Kunden (unter **Kunden > Kundenkarte**) eine Standard-Leistung
## Changelog
### Version 1.8.0
- **Rücknahmen in Rechnungsübernahme**: Zurückgenommene Produkte (origin='returned') werden nicht mehr in die Rechnung übertragen
- Mehraufwand: Rücknahmen werden von der Rechnungsmenge abgezogen (Matching über fk_product oder description)
- Auftragspositionen: Rücknahmen werden über fk_commandedet abgezogen
- Produkte mit effektiver Menge ≤ 0 werden komplett ausgelassen
- **Einkaufspreise in Rechnungsübernahme**: buy_price_ht (pa_ht) wird jetzt automatisch gesetzt
- Priorität: 1. cost_price (manuell gesetzt), 2. bester Lieferanten-Stückpreis, 3. PMP
- Ermöglicht korrekte Margenberechnung in der Rechnung
- Freitext-Produkte ohne Produktverknüpfung bleiben bei EK=0
### Version 1.7.0
- **Rücknahme-Anzeige verbessert**: Rücknahmen werden mit rotem Badge (-X) angezeigt
- **Rücknahme beeinflusst Beauftragt**: Zurückgenommene Mengen werden von der Zielmenge abgezogen (soll nicht wieder verbaut werden)

2
core/modules/modStundenzettel.class.php Normal file → Executable file
View file

@ -53,7 +53,7 @@ class modStundenzettel extends DolibarrModules
$this->descriptionlong = "Verwaltet Stundenzettel für Kundenaufträge. Ermöglicht die Dokumentation von Arbeitszeiten, verbrauchten Materialien und Notizen. Integration mit SubtotalTitle für Produktgruppen-Unterstützung.";
// Version
$this->version = '1.7.0';
$this->version = '1.8.0';
// Autor
$this->editor_name = 'Data IT Solution';

95
stundenzettel_commande.php Normal file → Executable file
View file

@ -380,6 +380,37 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
$facture_id = $facture->create($user);
if ($facture_id > 0) {
// Einkaufspreise aller Produkte vorladen
// Priorität: 1. cost_price (manuell gesetzt), 2. bester Lieferanten-Stückpreis, 3. PMP
$buyPrices = array(); // fk_product => buy_price
$sqlBuyPrices = "SELECT p.rowid, p.cost_price, p.pmp,";
// Bester Lieferanten-Stückpreis (günstigster unitprice)
$sqlBuyPrices .= " (SELECT MIN(pfp.unitprice) FROM ".MAIN_DB_PREFIX."product_fournisseur_price pfp";
$sqlBuyPrices .= " WHERE pfp.fk_product = p.rowid AND pfp.unitprice > 0) as best_supplier_unitprice";
$sqlBuyPrices .= " FROM ".MAIN_DB_PREFIX."product p";
$sqlBuyPrices .= " WHERE p.rowid IN (";
$sqlBuyPrices .= " SELECT DISTINCT cd.fk_product FROM ".MAIN_DB_PREFIX."commandedet cd WHERE cd.fk_commande = ".((int)$order->id)." AND cd.fk_product IS NOT NULL";
$sqlBuyPrices .= " UNION";
$sqlBuyPrices .= " SELECT DISTINCT sp.fk_product FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlBuyPrices .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlBuyPrices .= " WHERE s.fk_commande = ".((int)$order->id)." AND sp.fk_product IS NOT NULL";
$sqlBuyPrices .= " )";
$resqlBuyPrices = $db->query($sqlBuyPrices);
if ($resqlBuyPrices) {
while ($objBp = $db->fetch_object($resqlBuyPrices)) {
// Priorität: cost_price > Lieferanten-Stückpreis > PMP
if (!empty($objBp->cost_price) && $objBp->cost_price > 0) {
$buyPrices[(int)$objBp->rowid] = (float)$objBp->cost_price;
} elseif (!empty($objBp->best_supplier_unitprice) && $objBp->best_supplier_unitprice > 0) {
$buyPrices[(int)$objBp->rowid] = (float)$objBp->best_supplier_unitprice;
} elseif (!empty($objBp->pmp) && $objBp->pmp > 0) {
$buyPrices[(int)$objBp->rowid] = (float)$objBp->pmp;
} else {
$buyPrices[(int)$objBp->rowid] = 0;
}
}
}
// Sammle alle Produkt-Mengen aus Stundenzetteln (gruppiert nach fk_commandedet)
$productQtys = array();
$sqlQty = "SELECT sp.fk_commandedet, SUM(sp.qty_done) as total_qty";
@ -397,6 +428,44 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
}
}
// Rücknahmen laden für Abzug bei Mehraufwand und Auftragspositionen
// (wird vor beiden Bereichen geladen, da beide Rücknahmen berücksichtigen müssen)
$returnedQtys = array(); // key => qty (für Mehraufwand: prod_ID oder desc_MD5)
$returnedByCommandedet = array(); // fk_commandedet => qty (für Auftragspositionen)
$sqlReturned = "SELECT sp.fk_product, sp.fk_commandedet, sp.product_label, sp.description, SUM(sp.qty_done) as qty_returned";
$sqlReturned .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlReturned .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlReturned .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlReturned .= " AND sp.origin = 'returned'";
$sqlReturned .= " GROUP BY sp.fk_product, sp.fk_commandedet, sp.product_label, sp.description";
$resqlReturned = $db->query($sqlReturned);
if ($resqlReturned) {
while ($objRet = $db->fetch_object($resqlReturned)) {
// Für Mehraufwand-Matching (fk_product oder description)
if ($objRet->fk_product > 0) {
$retKey = 'prod_'.$objRet->fk_product;
} else {
$retKey = 'desc_'.md5(trim(strip_tags($objRet->description)));
}
$returnedQtys[$retKey] = (isset($returnedQtys[$retKey]) ? $returnedQtys[$retKey] : 0) + (float)$objRet->qty_returned;
// Für Auftragspositionen-Matching (fk_commandedet)
if (!empty($objRet->fk_commandedet)) {
$returnedByCommandedet[$objRet->fk_commandedet] = (isset($returnedByCommandedet[$objRet->fk_commandedet]) ? $returnedByCommandedet[$objRet->fk_commandedet] : 0) + (float)$objRet->qty_returned;
}
}
}
// Rücknahmen von Auftragspositionen abziehen
foreach ($returnedByCommandedet as $cmdDetId => $retQty) {
if (isset($productQtys[$cmdDetId])) {
$productQtys[$cmdDetId] -= $retQty;
if ($productQtys[$cmdDetId] <= 0) {
unset($productQtys[$cmdDetId]);
}
}
}
// Sammle Mehraufwand und zusätzlich verbaute Produkte
// (origin='additional' = beauftragt, origin='added' ohne fk_commandedet = verbaut ohne Auftragszeile)
$additionalProducts = array();
@ -414,7 +483,19 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
if ($resqlAdd) {
while ($objAdd = $db->fetch_object($resqlAdd)) {
// Für die Rechnung zählt die verbaute Menge (added), nicht nur beauftragte (additional)
$objAdd->invoice_qty = floatval($objAdd->qty_added) > 0 ? floatval($objAdd->qty_added) : floatval($objAdd->qty_additional);
$invoiceQty = floatval($objAdd->qty_added) > 0 ? floatval($objAdd->qty_added) : floatval($objAdd->qty_additional);
// Rücknahmen abziehen
if ($objAdd->fk_product > 0) {
$retKey = 'prod_'.$objAdd->fk_product;
} else {
$retKey = 'desc_'.md5(trim(strip_tags($objAdd->description)));
}
if (isset($returnedQtys[$retKey])) {
$invoiceQty -= $returnedQtys[$retKey];
}
$objAdd->invoice_qty = $invoiceQty;
if ($objAdd->invoice_qty > 0) {
$additionalProducts[] = $objAdd;
}
@ -513,6 +594,7 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
$qtyToInvoice = isset($productQtys[$prod->fk_commandedet]) ? $productQtys[$prod->fk_commandedet] : 0;
if ($qtyToInvoice > 0) {
$prodBuyPrice = isset($buyPrices[(int)$prod->fk_product]) ? $buyPrices[(int)$prod->fk_product] : 0;
$result = $facture->addline(
$prod->description, // 1: desc
$prod->subprice, // 2: pu_ht
@ -536,7 +618,7 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
0, // 20: origin_id
0, // 21: fk_parent_line
null, // 22: fk_fournprice
0, // 23: pa_ht
$prodBuyPrice, // 23: pa_ht (Einkaufspreis)
'', // 24: label
array(), // 25: array_options
100, // 26: situation_percent
@ -631,6 +713,7 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
$qtyToInvoice = isset($productQtys[$prod->fk_commandedet]) ? $productQtys[$prod->fk_commandedet] : 0;
if ($qtyToInvoice > 0) {
$prodBuyPrice = isset($buyPrices[(int)$prod->fk_product]) ? $buyPrices[(int)$prod->fk_product] : 0;
$result = $facture->addline(
$prod->description, // 1: desc
$prod->subprice, // 2: pu_ht
@ -654,7 +737,7 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
0, // 20: origin_id
0, // 21: fk_parent_line
null, // 22: fk_fournprice
0, // 23: pa_ht
$prodBuyPrice, // 23: pa_ht (Einkaufspreis)
'', // 24: label
array(), // 25: array_options
100, // 26: situation_percent
@ -732,6 +815,7 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
$usePrice = $customerPriceInfo['price'];
$useTvaTx = $customerPriceInfo['tva_tx'];
$addBuyPrice = isset($buyPrices[(int)$addProd->fk_product]) ? $buyPrices[(int)$addProd->fk_product] : 0;
$productResult = $facture->addline(
$product->label, // 1: desc
$usePrice, // 2: pu_ht - kundenspezifischer Preis
@ -755,7 +839,7 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
0, // 20: origin_id
0, // 21: fk_parent_line
null, // 22: fk_fournprice
0, // 23: pa_ht
$addBuyPrice, // 23: pa_ht (Einkaufspreis)
'', // 24: label
array(), // 25: array_options
100, // 26: situation_percent
@ -932,6 +1016,7 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
}
// Rechnungszeile hinzufügen
$serviceBuyPrice = isset($buyPrices[$productId]) ? $buyPrices[$productId] : 0;
$hoursResult = $facture->addline(
$lineDesc, // 1: desc
$hourlyPrice, // 2: pu_ht
@ -955,7 +1040,7 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
0, // 20: origin_id
0, // 21: fk_parent_line
null, // 22: fk_fournprice
0, // 23: pa_ht
$serviceBuyPrice, // 23: pa_ht (Einkaufspreis)
'', // 24: label
array(), // 25: array_options
100, // 26: situation_percent