mivita/app/Console/Commands/RepairMissingAboFromOrders.php
2026-04-10 17:15:27 +02:00

220 lines
7.3 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Services\AboHelper;
use App\Services\Incentive\IncentiveTracker;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RepairMissingAboFromOrders extends Command
{
protected $signature = 'abo:repair-missing
{--fix : Reparatur ausfuehren (ohne: nur Abgleich/Vorschau)}
{--force : Mit --fix: ohne Rueckfrage (Skripte/CI)}
{--since= : Nur Bestellungen mit created_at >= (Y-m-d)}
{--until= : Nur Bestellungen mit created_at <= Ende dieses Tages (Y-m-d)}
{--order= : Komma-getrennte shopping_order IDs (Filter)}
{--mode=live : Modus: live, test, dev oder all}
{--stats : Zusaetzliche Statistik: bezahlte Abo-Bestellungen vs. mit/ohne UserAboOrder}';
protected $description = 'Abgleich und Reparatur: bezahlte Abo-Bestellungen (Checkout) ohne Verknuepfung user_abo_orders — z. B. nach Payone-Callback vor Erfolgs-Redirect';
public function handle(): int
{
$missing = $this->queryMissingOrders()->orderBy('id')->get();
$this->info('Abgleich: Bestellungen mit is_abo, abo_interval>0, als bezahlt markiert, ohne user_abo_orders-Eintrag.');
$this->newLine();
if ($this->option('stats')) {
$this->printStats();
$this->newLine();
}
$this->info('Treffer (fehlende Verknuepfung): '.$missing->count());
if ($missing->isEmpty()) {
$this->info('Keine Diskrepanz — nichts zu tun.');
return self::SUCCESS;
}
$this->table(
['ID', 'shopping_user_id', 'mode', 'txaction', 'paid', 'created_at'],
$missing->take(200)->map(fn (ShoppingOrder $o) => [
$o->id,
$o->shopping_user_id,
$o->mode,
$o->txaction,
$o->paid ? '1' : '0',
$o->created_at?->format('Y-m-d H:i'),
])
);
if ($missing->count() > 200) {
$this->warn('… und weitere '.($missing->count() - 200).' Eintraege (Ausgabe gekuerzt).');
}
if (! $this->option('fix')) {
$this->newLine();
$this->warn('Trockenlauf. Nutze --fix zur Reparatur (mit Bestaetigung).');
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->confirm('Wirklich '.$missing->count().' Bestellung(en) reparieren?')) {
return self::SUCCESS;
}
$ok = 0;
$fail = 0;
$bar = $this->output->createProgressBar($missing->count());
$bar->start();
foreach ($missing as $order) {
try {
DB::transaction(function () use ($order) {
$this->repairSingleOrder($order);
});
$ok++;
} catch (\Throwable $e) {
$fail++;
$this->newLine();
$this->error("Order #{$order->id}: {$e->getMessage()}");
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("Fertig: {$ok} repariert, {$fail} Fehler.");
return $fail > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return \Illuminate\Database\Eloquent\Builder<ShoppingOrder>
*/
private function queryMissingOrders(): \Illuminate\Database\Eloquent\Builder
{
$q = ShoppingOrder::query()
->where('is_abo', true)
->where('abo_interval', '>', 0)
->where(function ($sub) {
$sub->where('paid', true)
->orWhere('paid', 1);
})
->whereIn('txaction', ['paid', 'invoice_paid', 'extern_paid'])
->whereNotNull('shopping_user_id')
->whereHas('shopping_payments')
->whereNotExists(function ($sub) {
$sub->select(DB::raw('1'))
->from('user_abo_orders')
->whereColumn('user_abo_orders.shopping_order_id', 'shopping_orders.id');
});
if ($ids = $this->parseOrderIds()) {
$q->whereIn('id', $ids);
}
if ($since = $this->option('since')) {
$q->where('created_at', '>=', $since.' 00:00:00');
}
if ($until = $this->option('until')) {
$q->where('created_at', '<=', $until.' 23:59:59');
}
$mode = (string) $this->option('mode');
if ($mode !== 'all') {
$q->where('mode', $mode);
}
return $q;
}
/**
* @return list<int>
*/
private function parseOrderIds(): array
{
$raw = $this->option('order');
if ($raw === null || $raw === '') {
return [];
}
return array_values(array_filter(array_map('intval', explode(',', (string) $raw))));
}
private function printStats(): void
{
$mode = (string) $this->option('mode');
$base = ShoppingOrder::query()
->where('is_abo', true)
->where('abo_interval', '>', 0)
->where(function ($sub) {
$sub->where('paid', true)->orWhere('paid', 1);
})
->whereIn('txaction', ['paid', 'invoice_paid', 'extern_paid']);
if ($since = $this->option('since')) {
$base->where('created_at', '>=', $since.' 00:00:00');
}
if ($until = $this->option('until')) {
$base->where('created_at', '<=', $until.' 23:59:59');
}
if ($mode !== 'all') {
$base->where('mode', $mode);
}
if ($ids = $this->parseOrderIds()) {
$base->whereIn('id', $ids);
}
$totalPaidAbo = (clone $base)->count();
$withLink = (clone $base)->whereExists(function ($sub) {
$sub->select(DB::raw('1'))
->from('user_abo_orders')
->whereColumn('user_abo_orders.shopping_order_id', 'shopping_orders.id');
})->count();
$this->table(
['Kennzahl', 'Anzahl'],
[
['Bezahlte Abo-Bestellungen (Filter)', $totalPaidAbo],
['Davon mit user_abo_orders', $withLink],
['Davon ohne user_abo_orders', max(0, $totalPaidAbo - $withLink)],
]
);
}
private function repairSingleOrder(ShoppingOrder $order): void
{
$payment = ShoppingPayment::query()
->where('shopping_order_id', $order->id)
->orderByDesc('id')
->first();
if (! $payment) {
throw new \RuntimeException('Kein ShoppingPayment zur Bestellung.');
}
$order->loadMissing(['shopping_user', 'shopping_order_items']);
$payment->loadMissing(['payment_transactions']);
$payment->setRelation('shopping_order', $order);
AboHelper::createNewAbo($payment);
$order->refresh();
if (! $order->getUserAbo()) {
throw new \RuntimeException('createNewAbo hat kein UserAbo erzeugt (pruefen: abo_interval, Bestellpositionen, ShoppingPayment.abo_interval).');
}
AboHelper::setAboActive($order, 2, true);
IncentiveTracker::trackAboActivated($order);
}
}