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

@ -0,0 +1,128 @@
<?php
namespace App\Console\Commands;
use Acme\Dhl\Models\DhlShipment;
use App\Models\ShoppingOrder;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Traegt das Versanddatum fuer Altbestellungen nach, damit die Absatzauswertung
* "nach Versand" auch rueckwirkend funktioniert.
*
* Quellen, in dieser Reihenfolge:
* 1. Datum des ersten ausgehenden DHL-Labels – exakt, verfuegbar ab 23.10.2025.
* 2. `updated_at` der Bestellung – Schaetzung. Gegengeprueft an Bestellungen MIT
* Label liegt sie in rund 93 % innerhalb von 24 Stunden und in rund 97 % im
* richtigen Monat am tatsaechlichen Versanddatum.
*
* Die Quelle wird in `shipped_at_source` festgehalten, damit geschaetzte Monate
* in der Auswertung als solche ausgewiesen werden koennen.
*/
class BackfillShippedAt extends Command
{
protected $signature = 'sales:backfill-shipped-at
{--fix : Tatsaechlich schreiben (ohne Flag nur Vorschau)}
{--since= : Nur Bestellungen ab diesem Datum (Y-m-d)}
{--chunk=500 : Bestellungen je Verarbeitungsblock}';
protected $description = 'Traegt shipped_at fuer bereits versendete Altbestellungen nach (DHL-Label oder Schaetzung)';
public function handle(): int
{
$fix = (bool) $this->option('fix');
$since = $this->option('since');
$chunk = max(50, (int) $this->option('chunk'));
$query = ShoppingOrder::query()
->whereIn('shipped', ShoppingOrder::SHIPPED_STATES)
->whereNull('shipped_at')
->when($since, fn ($q) => $q->where('created_at', '>=', $since))
->orderBy('id');
$total = (clone $query)->count();
if ($total === 0) {
$this->info('Keine Bestellungen ohne Versanddatum gefunden.');
return self::SUCCESS;
}
$this->info("Bestellungen ohne Versanddatum: {$total}");
$counts = [ShoppingOrder::SHIPPED_AT_SOURCE_DHL => 0, ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED => 0];
$byMonth = [];
$bar = $this->output->createProgressBar($total);
$query->chunkById($chunk, function ($orders) use ($fix, &$counts, &$byMonth, $bar) {
$labels = $this->getLabelDates($orders->pluck('id')->all());
foreach ($orders as $order) {
$shippedAt = $labels[$order->id] ?? null;
$source = ShoppingOrder::SHIPPED_AT_SOURCE_DHL;
if ($shippedAt === null) {
$shippedAt = $order->updated_at ?: $order->created_at;
$source = ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED;
}
if ($shippedAt === null) {
continue;
}
$counts[$source]++;
$month = $shippedAt->format('Y-m');
$byMonth[$month][$source] = ($byMonth[$month][$source] ?? 0) + 1;
if ($fix) {
ShoppingOrder::withoutTimestamps(fn () => ShoppingOrder::query()
->whereKey($order->id)
->update(['shipped_at' => $shippedAt, 'shipped_at_source' => $source]));
}
$bar->advance();
}
});
$bar->finish();
$this->newLine(2);
ksort($byMonth);
$rows = [];
foreach ($byMonth as $month => $sources) {
$dhl = $sources[ShoppingOrder::SHIPPED_AT_SOURCE_DHL] ?? 0;
$estimated = $sources[ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED] ?? 0;
$sum = $dhl + $estimated;
$rows[] = [$month, $dhl, $estimated, $sum > 0 ? round($estimated / $sum * 100, 1).' %' : '-'];
}
$this->table(['Versandmonat', 'aus DHL-Label', 'geschaetzt', 'Anteil geschaetzt'], $rows);
$this->info('Exakt aus DHL-Label: '.$counts[ShoppingOrder::SHIPPED_AT_SOURCE_DHL]);
$this->info('Geschaetzt aus updated_at: '.$counts[ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED]);
if (! $fix) {
$this->warn('Trockenlauf! Nutze --fix um die Werte zu schreiben.');
}
return self::SUCCESS;
}
/**
* Datum des ersten ausgehenden DHL-Labels je Bestellung.
*
* @param array<int, int> $orderIds
* @return array<int, Carbon>
*/
private function getLabelDates(array $orderIds): array
{
return DhlShipment::query()
->whereIn('order_id', $orderIds)
->where('type', 'outbound')
->orderBy('created_at')
->get(['order_id', 'created_at'])
->groupBy('order_id')
->map(fn ($shipments) => $shipments->first()->created_at)
->all();
}
}