- Etikettgröße im Lieferschein korrekt parsen ("40#3,5" ergab 3 statt 3,5 cm)
- Produktkopie optional inkl. Kundenzuordnungen und Logo-Dateien (zweiter Kopier-Link)
- whitelabel:transfer-assignments: gezielte, wiederholbare Übertragung ohne Dubletten
- whitelabel:migrate-colors: Zuordnungen anhand der Bildfarbe auf die Farbprodukte übertragen (Dry-Run ohne --execute)
- whitelabel:report: rein lesende Bestandsprüfung (fehlende Zuordnungen, Farbwidersprüche, Dateien, Dubletten)
- Migration für attributes.value (Schema-Drift, auf Live No-op)
- Analyse/Entwicklungsplan um Gegenprüfung, systemischen Befund und neue Paketreihenfolge ergänzt
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
209 lines
9.1 KiB
PHP
209 lines
9.1 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Models\Attribute;
|
||
use App\Models\Product;
|
||
use App\Models\ProductImage;
|
||
use App\Models\UserWhitelabelProduct;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* WL-01: Rein lesende Bestandsprüfung der White-Label-Daten.
|
||
* Meldet Konflikte mit IDs, ändert nichts.
|
||
*/
|
||
class WhitelabelReport extends Command
|
||
{
|
||
protected $signature = 'whitelabel:report
|
||
{--since=2026-07-27 : Bestellungen ab diesem Datum prüfen (Anlage der Farbprodukte)}';
|
||
|
||
protected $description = 'Bestandsprüfung White Label: fehlende Kundenzuordnungen, Farbwidersprüche, fehlende Dateien, Dubletten (nur lesend)';
|
||
|
||
/** @var array<string, string> */
|
||
private array $attributeColors = [];
|
||
|
||
public function handle(): int
|
||
{
|
||
$this->attributeColors = Attribute::query()->where('attribute_type_id', 1)->get()
|
||
->mapWithKeys(fn (Attribute $a) => [(string) $a->id => $this->normalizeColor(strtok((string) $a->name, ' ') ?: '')])
|
||
->all();
|
||
|
||
$findings = 0;
|
||
$findings += $this->ordersWithoutAssignment((string) $this->option('since'));
|
||
$findings += $this->colorMismatches();
|
||
$findings += $this->customerImagesWithoutMatch();
|
||
$findings += $this->missingFiles();
|
||
$findings += $this->duplicates();
|
||
$findings += $this->productsWithoutOrMultipleStandardLabels();
|
||
|
||
$this->newLine();
|
||
$this->info($findings === 0 ? 'Keine Befunde.' : $findings.' Befund(e). Es wurde nichts geändert.');
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
/** Bestellungen mit White-Label-Produkt ohne Kundenzuordnung, obwohl der Partner anderswo Logos hat. */
|
||
private function ordersWithoutAssignment(string $since): int
|
||
{
|
||
$rows = DB::table('shopping_orders as so')
|
||
->join('shopping_order_items as soi', 'soi.shopping_order_id', '=', 'so.id')
|
||
->join('products as p', 'p.id', '=', 'soi.product_id')
|
||
->where('p.whitelabel', true)
|
||
->where('so.created_at', '>=', $since)
|
||
->whereNull('soi.deleted_at')
|
||
->whereNotNull('so.auth_user_id')
|
||
->whereIn('so.auth_user_id', fn ($q) => $q->select('user_id')->from('user_whitelabel_products'))
|
||
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('user_whitelabel_products as uwp')
|
||
->whereColumn('uwp.user_id', 'so.auth_user_id')
|
||
->whereColumn('uwp.product_id', 'soi.product_id'))
|
||
->select('so.id as order_id', 'so.auth_user_id as user_id', 'soi.product_id', 'p.name', 'so.user_white_label', 'so.created_at')
|
||
->distinct()
|
||
->orderBy('so.id')
|
||
->get();
|
||
|
||
$this->section('A) Bestellungen ab '.$since.' ohne Kundenzuordnung (Partner hat Logos bei anderen Produkten)', $rows->count());
|
||
if ($rows->isNotEmpty()) {
|
||
$this->table(['Bestellung', 'Partner', 'Produkt', 'Name', 'user_white_label', 'Datum'],
|
||
$rows->map(fn ($r) => [$r->order_id, $r->user_id, $r->product_id, $r->name, $r->user_white_label ? 'ja' : 'nein', substr((string) $r->created_at, 0, 10)])->all());
|
||
}
|
||
|
||
return $rows->count();
|
||
}
|
||
|
||
/** Farbe am Kundenbild (Attribut) widerspricht der Farbe im Produktnamen "(Tiegel X)". */
|
||
private function colorMismatches(): int
|
||
{
|
||
$rows = [];
|
||
foreach (UserWhitelabelProduct::with(['product', 'whitelabel_images'])->orderBy('id')->get() as $assignment) {
|
||
$productColor = $this->productColor($assignment->product);
|
||
if ($productColor === null) {
|
||
continue;
|
||
}
|
||
foreach ($assignment->whitelabel_images as $image) {
|
||
$imageColors = collect((array) $image->attributes)
|
||
->map(fn ($id) => $this->attributeColors[(string) $id] ?? null)
|
||
->filter()->unique()->values();
|
||
if ($imageColors->isNotEmpty() && ! $imageColors->contains($productColor)) {
|
||
$rows[] = [$assignment->id, $assignment->user_id, $assignment->product_id, $assignment->product->name, $image->id, $imageColors->join(', ')];
|
||
}
|
||
}
|
||
}
|
||
|
||
$this->section('B) Farbwiderspruch: Kundenbild-Variante passt nicht zur Produktfarbe', count($rows));
|
||
if ($rows !== []) {
|
||
$this->table(['Zuordnung', 'Partner', 'Produkt', 'Produktname', 'Bild', 'Bildfarbe(n)'], $rows);
|
||
}
|
||
|
||
return count($rows);
|
||
}
|
||
|
||
/** Kundenbilder, die im Lieferschein nie greifen: ohne Variante oder ohne Überschneidung mit dem Standardetikett. */
|
||
private function customerImagesWithoutMatch(): int
|
||
{
|
||
$rows = [];
|
||
foreach (UserWhitelabelProduct::with(['product.whitelabel_images', 'whitelabel_images'])->orderBy('id')->get() as $assignment) {
|
||
if (! $assignment->product) {
|
||
$rows[] = [$assignment->id, $assignment->user_id, $assignment->product_id, '-', 'Produkt existiert nicht'];
|
||
|
||
continue;
|
||
}
|
||
$standardAttributes = $assignment->product->whitelabel_images
|
||
->flatMap(fn ($img) => (array) $img->attributes)->map(fn ($v) => (string) $v)->unique();
|
||
|
||
foreach ($assignment->whitelabel_images as $image) {
|
||
$own = collect((array) $image->attributes)->map(fn ($v) => (string) $v);
|
||
if ($own->isEmpty()) {
|
||
$rows[] = [$assignment->id, $assignment->user_id, $assignment->product_id, $image->id, 'keine Variante am Kundenbild'];
|
||
} elseif ($standardAttributes->isEmpty()) {
|
||
$rows[] = [$assignment->id, $assignment->user_id, $assignment->product_id, $image->id, 'Produkt hat kein Standardetikett'];
|
||
} elseif ($own->intersect($standardAttributes)->isEmpty()) {
|
||
$rows[] = [$assignment->id, $assignment->user_id, $assignment->product_id, $image->id, 'keine Überschneidung mit Standardetikett ('.$own->join(',').' vs. '.$standardAttributes->join(',').')'];
|
||
}
|
||
}
|
||
if ($assignment->whitelabel_images->isEmpty()) {
|
||
$rows[] = [$assignment->id, $assignment->user_id, $assignment->product_id, '-', 'Zuordnung ohne Kundenbild'];
|
||
}
|
||
}
|
||
|
||
$this->section('C) Kundenzuordnungen, die im Lieferschein nicht greifen', count($rows));
|
||
if ($rows !== []) {
|
||
$this->table(['Zuordnung', 'Partner', 'Produkt', 'Bild', 'Grund'], $rows);
|
||
}
|
||
|
||
return count($rows);
|
||
}
|
||
|
||
private function missingFiles(): int
|
||
{
|
||
$rows = [];
|
||
foreach (ProductImage::query()->whereIn('type', ['uwllogo', 'wllogo'])->orderBy('id')->get() as $image) {
|
||
if (! file_exists($image->getBaseImagePath())) {
|
||
$rows[] = [$image->id, $image->type, $image->product_id ?? '-', $image->user_wl_product_id ?? '-', $image->getImagePath()];
|
||
}
|
||
}
|
||
|
||
$this->section('D) Etikettbilder ohne Datei', count($rows));
|
||
if ($rows !== []) {
|
||
$this->table(['Bild', 'Typ', 'Produkt', 'Zuordnung', 'Pfad'], $rows);
|
||
}
|
||
|
||
return count($rows);
|
||
}
|
||
|
||
private function duplicates(): int
|
||
{
|
||
$rows = DB::table('user_whitelabel_products')
|
||
->select('user_id', 'product_id', DB::raw('count(*) as c'))
|
||
->groupBy('user_id', 'product_id')->having('c', '>', 1)->get();
|
||
|
||
$this->section('E) Dubletten (user_id, product_id)', $rows->count());
|
||
if ($rows->isNotEmpty()) {
|
||
$this->table(['Partner', 'Produkt', 'Anzahl'], $rows->map(fn ($r) => [$r->user_id, $r->product_id, $r->c])->all());
|
||
}
|
||
|
||
return $rows->count();
|
||
}
|
||
|
||
private function productsWithoutOrMultipleStandardLabels(): int
|
||
{
|
||
$rows = [];
|
||
foreach (Product::query()->where('whitelabel', true)->withCount('whitelabel_images')->orderBy('id')->get() as $product) {
|
||
if ($product->whitelabel_images_count === 0) {
|
||
$rows[] = [$product->id, $product->name, 'kein Standardetikett'];
|
||
} elseif ($product->whitelabel_images_count > 1) {
|
||
$rows[] = [$product->id, $product->name, $product->whitelabel_images_count.' Standardetiketten (Mehrfachetikett prüfen)'];
|
||
}
|
||
if ($this->productColor($product) === null) {
|
||
$rows[] = [$product->id, $product->name, 'keine Farbe im Produktnamen erkennbar'];
|
||
}
|
||
}
|
||
|
||
$this->section('F) White-Label-Produkte mit fehlendem/mehrfachem Standardetikett oder ohne erkennbare Farbe', count($rows));
|
||
if ($rows !== []) {
|
||
$this->table(['Produkt', 'Name', 'Befund'], $rows);
|
||
}
|
||
|
||
return count($rows);
|
||
}
|
||
|
||
private function productColor(?Product $product): ?string
|
||
{
|
||
if (! $product || ! preg_match('/\(Tiegel\s+([^)]+)\)/iu', (string) $product->name, $m)) {
|
||
return null;
|
||
}
|
||
|
||
return $this->normalizeColor($m[1]);
|
||
}
|
||
|
||
private function normalizeColor(string $value): string
|
||
{
|
||
return str_replace(['ß', 'ss'], 'ss', mb_strtolower(trim($value)));
|
||
}
|
||
|
||
private function section(string $title, int $count): void
|
||
{
|
||
$this->newLine();
|
||
$this->line('<comment>'.$title.'</comment> – '.$count);
|
||
}
|
||
}
|