- 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>
128 lines
4.6 KiB
PHP
128 lines
4.6 KiB
PHP
<?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();
|
||
}
|
||
}
|