mivita/app/Console/Commands/BackfillShippedAt.php
Kevin d01b9bca3a 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>
2026-09-23 15:42:39 +00:00

128 lines
4.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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();
}
}