Absatzmengen: nach Warenausgang (Versanddatum) oder nach Rechnung auswertbar

- shopping_orders.shipped_at (+ Herkunft live/dhl/geschaetzt): gesetzt im
  Model, sobald der Versandstatus auf versendet wechselt, egal an welcher
  Stelle der Status gesetzt wird. sales:backfill-shipped-at traegt
  Altbestellungen aus dem DHL-Label nach oder schaetzt.
- SalesVolumeService rechnet Absatzmengen je Produkt wahlweise nach
  Versand (inkl. externer/API-Bestellungen, Stornos im Monat des
  Stornobelegs) oder nach Rechnung. Sets werden fuer die zweite Liste in
  Einzelartikel aufgeloest.
- Admin-Seite: Umschalter Versand/Rechnung, Datenqualitaet (Anteil
  geschaetzter Versanddaten), Export Produkte und Einzelartikel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-23 15:42:39 +00:00
parent a21bebc932
commit d01b9bca3a
7 changed files with 1139 additions and 432 deletions

View file

@ -142,7 +142,7 @@ class ShoppingOrder extends Model
use SoftDeletes;
protected $dates = ['deleted_at'];
protected $dates = ['deleted_at', 'shipped_at'];
protected $fillable = [
'shopping_user_id',
@ -177,6 +177,8 @@ class ShoppingOrder extends Model
'customer_order_source_comment',
'shipped',
'tracking',
'shipped_at',
'shipped_at_source',
];
protected $casts = [
@ -187,6 +189,7 @@ class ShoppingOrder extends Model
'is_abo' => 'bool',
'recovery_user_abo_id' => 'int',
'points' => 'float',
'shipped_at' => 'datetime',
];
public const CUSTOMER_ORDER_SOURCE_OPTIONS = [
@ -199,6 +202,49 @@ class ShoppingOrder extends Model
'other' => 'Sonstiges',
];
/**
* Versandstatus, die als "ist rausgegangen" gelten ({@see self::$shippedTypes}).
*/
public const SHIPPED_STATES = [2, 3, 4];
public const SHIPPED_AT_SOURCE_LIVE = 'live';
public const SHIPPED_AT_SOURCE_DHL = 'dhl';
public const SHIPPED_AT_SOURCE_ESTIMATED = 'geschaetzt';
/**
* Setzt den Versandzeitpunkt, sobald der Versandstatus auf "versendet" wechselt.
* Der Hook sitzt bewusst im Model, weil der Status an mehreren Stellen gesetzt
* wird (Admin-Liste, Abo-Fulfillment, API) und keine davon uebersehen werden darf.
*/
protected static function booted(): void
{
static::saving(function (self $order): void {
if ($order->shipped_at !== null) {
return;
}
if (! in_array((int) $order->shipped, self::SHIPPED_STATES, true)) {
return;
}
if ($order->exists && ! $order->isDirty('shipped')) {
return;
}
$order->shipped_at = now();
$order->shipped_at_source = self::SHIPPED_AT_SOURCE_LIVE;
});
}
/**
* Bestellungen, die im angegebenen Zeitraum versendet wurden.
*/
public function scopeShippedBetween(Builder $query, Carbon $from, Carbon $to): Builder
{
return $query->whereIn('shipped', self::SHIPPED_STATES)
->whereBetween('shipped_at', [$from, $to]);
}
/**
* @return array<string, string>
*/