Standard-Shop fuer jeden Berater: Kundenbestellung ohne Shop crashte nicht mehr
Berater ohne UserShop liefen bei Kundenbestellungen (abo-ot-customer) in "Attempt to read property id on null". Shops werden jetzt bei Freischaltung und bei Bedarf automatisch angelegt (Name aus Vor-/Nachname, bei Belegung -2, -3, ...). Nachtrag fuer Bestandsberater: php artisan user:ensure-shops. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2381bbe3b9
commit
083d643220
7 changed files with 306 additions and 7 deletions
53
app/Console/Commands/UserEnsureShops.php
Normal file
53
app/Console/Commands/UserEnsureShops.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\UserShopService;
|
||||
use App\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class UserEnsureShops extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'user:ensure-shops {--dry-run : Nur anzeigen, keine Shops anlegen}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Legt für aktive Berater ohne Shop einen Standard-Shop an';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$users = User::query()
|
||||
->where('active', 1)
|
||||
->where('admin', 0)
|
||||
->whereDoesntHave('shop')
|
||||
->with('account')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$rows = [];
|
||||
foreach ($users as $user) {
|
||||
$name = $dryRun
|
||||
? UserShopService::generateDefaultName($user, array_column($rows, 2))
|
||||
: UserShopService::ensureForUser($user)->name;
|
||||
|
||||
$rows[] = [$user->id, $user->email, $name];
|
||||
}
|
||||
|
||||
$this->table(['User', 'E-Mail', 'Shop'], $rows);
|
||||
$this->info(($dryRun ? '[DRY-RUN] ' : '').count($rows).' Berater ohne Shop'.($dryRun ? ' gefunden.' : ' mit Standard-Shop versorgt.'));
|
||||
|
||||
if (! $dryRun && count($rows)) {
|
||||
\Log::channel('cleanup')->info('COMMAND [user:ensure-shops] created '.count($rows).' shops.', [
|
||||
'user_ids' => array_column($rows, 0),
|
||||
]);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ use App\Models\ShoppingOrderItem;
|
|||
use App\Models\ShoppingUser;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\Services\Shop;
|
||||
use App\Services\UserShopService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -638,7 +639,7 @@ class ShoppingUserController extends Controller
|
|||
'auth_user_id' => $shopping_user->auth_user_id,
|
||||
'country_id' => Yard::instance('shopping')->getShippingCountryId(),
|
||||
'language' => \App::getLocale(),
|
||||
'user_shop_id' => auth()->user()->user_sponsor->shop->id,
|
||||
'user_shop_id' => UserShopService::ensureForUser(auth()->user()->user_sponsor)->id,
|
||||
'payment_for' => 7,
|
||||
'member_id' => $shopping_user->member_id,
|
||||
'total' => Yard::instance('shopping')->total(2, '.', ''),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use App\Repositories\UserRepository;
|
|||
use App\Services\HTMLHelper;
|
||||
use App\Services\SysLog;
|
||||
use App\Services\UserService;
|
||||
use App\Services\UserShopService;
|
||||
use App\User;
|
||||
use Auth;
|
||||
use Carbon\Carbon;
|
||||
|
|
@ -344,6 +345,7 @@ class LeadController extends Controller
|
|||
$user->confirmation_code_to = null;
|
||||
$user->confirmation_code_remider = 0;
|
||||
$user->save();
|
||||
UserShopService::ensureForUser($user);
|
||||
|
||||
// mail with code to user?
|
||||
Mail::to($user->email)->locale($user->getLocale())->send(new MailAccountActive($user));
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ use App\Services\Payment;
|
|||
use App\Services\ProductOrderContext;
|
||||
use App\Services\Shop;
|
||||
use App\Services\UserService;
|
||||
use App\Services\UserShopService;
|
||||
use App\Services\Util;
|
||||
use App\User;
|
||||
use Auth;
|
||||
|
|
@ -306,11 +307,12 @@ class OrderController extends Controller
|
|||
private function processCustomerPayment($user, $identifier, $data, $id, $for)
|
||||
{
|
||||
$shopping_user = ShoppingUser::find($id);
|
||||
$user_shop = UserShopService::ensureForUser($user);
|
||||
$shopping_instance = ShoppingInstance::create([
|
||||
'identifier' => $identifier,
|
||||
'user_shop_id' => $user->shop->id,
|
||||
'user_shop_id' => $user_shop->id,
|
||||
'payment' => 6, // Berater Shop to Customer Shop
|
||||
'subdomain' => $user->shop->getSubdomain(),
|
||||
'subdomain' => $user_shop->getSubdomain(),
|
||||
'country_id' => Yard::instance('shopping')->getShippingCountryId(),
|
||||
'language' => $shopping_user->getLocale(), // hier wird die Sprache des Kunden verwendet
|
||||
'amount' => Yard::instance('shopping')->totalWithShipping(2, '.', ''),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use App\Repositories\ContractPDFRepository;
|
|||
use App\Repositories\FileRepository;
|
||||
use App\Repositories\UserRepository;
|
||||
use App\Services\UserService;
|
||||
use App\Services\UserShopService;
|
||||
use App\Services\Util;
|
||||
use App\User;
|
||||
use Auth;
|
||||
|
|
@ -416,6 +417,7 @@ class WizardController extends Controller
|
|||
$user->confirmation_code_to = null;
|
||||
$user->confirmation_code_remider = 0;
|
||||
$user->save();
|
||||
UserShopService::ensureForUser($user);
|
||||
|
||||
// mail with code to user?
|
||||
if ($user->isTestMode()) {
|
||||
|
|
@ -604,7 +606,7 @@ class WizardController extends Controller
|
|||
if ($product->images->count()) {
|
||||
$image = $product->images->first()->slug;
|
||||
}
|
||||
$cartItem = Yard::instance('shopping')->add($product->id, $product->getLang('name'), 1, $product->getPriceWith(\App\Services\UserService::getTaxFree(), false, \App\Services\UserService::$user_country), false, false, ['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight, 'points' => $product->points, 'no_commission' => $product->no_commission, 'no_free_shipping' => $product->no_free_shipping, 'free_shipping_consultant' => $product->free_shipping_consultant, 'show_on' => $product->show_on]);
|
||||
$cartItem = Yard::instance('shopping')->add($product->id, $product->getLang('name'), 1, $product->getPriceWith(UserService::getTaxFree(), false, UserService::$user_country), false, false, ['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight, 'points' => $product->points, 'no_commission' => $product->no_commission, 'no_free_shipping' => $product->no_free_shipping, 'free_shipping_consultant' => $product->free_shipping_consultant, 'show_on' => $product->show_on]);
|
||||
if ($cartItem->qty > 1) {
|
||||
Yard::instance('shopping')->update($cartItem->rowId, 1);
|
||||
}
|
||||
|
|
@ -613,10 +615,10 @@ class WizardController extends Controller
|
|||
Yard::instance('shopping')->remove($existingItem->rowId);
|
||||
}
|
||||
}
|
||||
if (\App\Services\UserService::getTaxFree()) {
|
||||
if (UserService::getTaxFree()) {
|
||||
Yard::setTax($cartItem->rowId, 0);
|
||||
} else {
|
||||
Yard::setTax($cartItem->rowId, $product->getTaxWith(\App\Services\UserService::$user_country));
|
||||
Yard::setTax($cartItem->rowId, $product->getTaxWith(UserService::$user_country));
|
||||
}
|
||||
|
||||
do {
|
||||
|
|
@ -626,7 +628,7 @@ class WizardController extends Controller
|
|||
$data = [];
|
||||
$data['is_from'] = 'wizard';
|
||||
$data['is_for'] = 'me';
|
||||
$data['user_price_infos'] = \App\Services\UserService::getUserPriceInfos();
|
||||
$data['user_price_infos'] = UserService::getUserPriceInfos();
|
||||
|
||||
ShoppingInstance::create([
|
||||
'identifier' => $identifier,
|
||||
|
|
|
|||
102
app/Services/UserShopService.php
Normal file
102
app/Services/UserShopService.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\UserShop;
|
||||
use App\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserShopService
|
||||
{
|
||||
private const NAME_MIN_LENGTH = 4;
|
||||
|
||||
private const NAME_MAX_LENGTH = 20;
|
||||
|
||||
private const MAX_COUNTER = 999;
|
||||
|
||||
/**
|
||||
* Jeder Berater hat einen Shop als Verkaufskanal. Fehlt er, wird ein aktiver Standard-Shop
|
||||
* angelegt; den Namen (Subdomain) kann der Berater später unter "Mein Shop" ändern.
|
||||
*/
|
||||
public static function ensureForUser(User $user): UserShop
|
||||
{
|
||||
$existing = $user->shop()->first();
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$userShop = UserShop::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => self::generateDefaultName($user),
|
||||
'active' => true,
|
||||
'active_date' => now(),
|
||||
]);
|
||||
|
||||
$user->setRelation('shop', $userShop);
|
||||
|
||||
return $userShop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Belegte Namen (auch von gelöschten Shops) bekommen eine fortlaufende Endung: -2, -3, ...
|
||||
*
|
||||
* @param array<int, string> $reserved Namen, die zusätzlich als belegt gelten (z. B. im Dry-Run)
|
||||
*/
|
||||
public static function generateDefaultName(User $user, array $reserved = []): string
|
||||
{
|
||||
$account = $user->account;
|
||||
$base = $account ? Str::slug($account->first_name.' '.$account->last_name, '-', 'de') : '';
|
||||
$base = self::limitAtWordBoundary($base, self::NAME_MAX_LENGTH);
|
||||
if (strlen($base) < self::NAME_MIN_LENGTH) {
|
||||
$base = 'berater';
|
||||
}
|
||||
|
||||
if (self::isNameAvailable($base, $reserved)) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
for ($counter = 2; $counter <= self::MAX_COUNTER; $counter++) {
|
||||
$suffix = '-'.$counter;
|
||||
$candidate = self::limitAtWordBoundary($base, self::NAME_MAX_LENGTH - strlen($suffix)).$suffix;
|
||||
|
||||
if (self::isNameAvailable($candidate, $reserved)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return 'berater-'.$user->id;
|
||||
}
|
||||
|
||||
private static function limitAtWordBoundary(string $slug, int $maxLength): string
|
||||
{
|
||||
if (strlen($slug) <= $maxLength) {
|
||||
return $slug;
|
||||
}
|
||||
|
||||
$boundary = strrpos(substr($slug, 0, $maxLength + 1), '-');
|
||||
|
||||
return rtrim($boundary ? substr($slug, 0, $boundary) : substr($slug, 0, $maxLength), '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $reserved
|
||||
*/
|
||||
private static function isNameAvailable(string $name, array $reserved = []): bool
|
||||
{
|
||||
if (strlen($name) < self::NAME_MIN_LENGTH || strlen($name) > self::NAME_MAX_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array($name, $reserved, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array($name, config('profanity.full_word_check', []), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! UserShop::withTrashed()
|
||||
->where(fn ($query) => $query->where('name', $name)->orWhere('slug', $name))
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
137
tests/Feature/UserShopServiceTest.php
Normal file
137
tests/Feature/UserShopServiceTest.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Jeder Berater bekommt einen Standard-Shop, damit Kundenbestellungen (payment 6)
|
||||
* nicht mehr an "$user->shop->id on null" scheitern.
|
||||
*/
|
||||
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserShop;
|
||||
use App\Services\UserShopService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function makeShopUser(string $firstName = 'Melanie', string $lastName = 'Rauch', array $attributes = []): User
|
||||
{
|
||||
$account = UserAccount::create([
|
||||
'salutation' => 'Frau',
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
'address' => 'Musterstr. 1',
|
||||
'zipcode' => '12345',
|
||||
'city' => 'Musterstadt',
|
||||
'phone' => '123456',
|
||||
'same_as_billing' => 1,
|
||||
'language' => 'de',
|
||||
]);
|
||||
|
||||
return User::forceCreate(array_merge([
|
||||
'email' => 'shop-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'account_id' => $account->id,
|
||||
'active' => 1,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('legt einen aktiven Standard-Shop aus dem Namen an', function () {
|
||||
$user = makeShopUser('Jürgen', 'Müller');
|
||||
|
||||
$shop = UserShopService::ensureForUser($user);
|
||||
|
||||
expect($shop->name)->toBe('juergen-mueller')
|
||||
->and($shop->slug)->toBe('juergen-mueller')
|
||||
->and((bool) $shop->active)->toBeTrue()
|
||||
->and($shop->active_date)->not->toBeNull()
|
||||
->and($user->shop->id)->toBe($shop->id);
|
||||
});
|
||||
|
||||
it('gibt einen vorhandenen Shop unverändert zurück', function () {
|
||||
$user = makeShopUser();
|
||||
$existing = UserShop::create(['user_id' => $user->id, 'name' => 'meinshop', 'active' => true]);
|
||||
|
||||
expect(UserShopService::ensureForUser($user)->id)->toBe($existing->id)
|
||||
->and(UserShop::where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('hängt bei belegtem Namen eine fortlaufende Nummer an', function () {
|
||||
$first = UserShopService::ensureForUser(makeShopUser());
|
||||
$second = UserShopService::ensureForUser(makeShopUser());
|
||||
$third = UserShopService::ensureForUser(makeShopUser());
|
||||
|
||||
expect($first->name)->toBe('melanie-rauch')
|
||||
->and($second->name)->toBe('melanie-rauch-2')
|
||||
->and($third->name)->toBe('melanie-rauch-3');
|
||||
});
|
||||
|
||||
it('kürzt lange Namen vor der Nummer an der Wortgrenze', function () {
|
||||
$other = makeShopUser('Katrin', 'Gutknecht-Stoll');
|
||||
UserShop::create(['user_id' => $other->id, 'name' => 'katrin-gutknecht', 'active' => true]);
|
||||
|
||||
$name = UserShopService::ensureForUser(makeShopUser('Katrin', 'Gutknecht-Stoll'))->name;
|
||||
|
||||
expect($name)->toBe('katrin-gutknecht-2')
|
||||
->and(strlen($name))->toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
it('berücksichtigt reservierte Namen im Dry-Run', function () {
|
||||
expect(UserShopService::generateDefaultName(makeShopUser(), ['melanie-rauch']))->toBe('melanie-rauch-2');
|
||||
});
|
||||
|
||||
it('berücksichtigt auch gelöschte Shops als belegt', function () {
|
||||
$other = makeShopUser();
|
||||
UserShop::create(['user_id' => $other->id, 'name' => 'melanie-rauch', 'active' => true])->delete();
|
||||
$user = makeShopUser();
|
||||
|
||||
expect(UserShopService::ensureForUser($user)->name)->toBe('melanie-rauch-2');
|
||||
});
|
||||
|
||||
it('kürzt lange Namen auf 20 Zeichen', function () {
|
||||
$user = makeShopUser('Maximiliane-Theresa', 'Schmidt-Oberhausen');
|
||||
|
||||
$name = UserShopService::ensureForUser($user)->name;
|
||||
|
||||
expect(strlen($name))->toBeLessThanOrEqual(20)
|
||||
->and($name)->toBe('maximiliane-theresa');
|
||||
});
|
||||
|
||||
it('kürzt an der Wortgrenze statt mitten im Nachnamen', function () {
|
||||
$user = makeShopUser('Theresia', 'Schifferegger');
|
||||
|
||||
expect(UserShopService::ensureForUser($user)->name)->toBe('theresia');
|
||||
});
|
||||
|
||||
it('fällt bei zu kurzem Namen auf berater zurück', function () {
|
||||
UserShopService::ensureForUser(makeShopUser('A', ''));
|
||||
|
||||
expect(UserShopService::ensureForUser(makeShopUser('B', ''))->name)->toBe('berater-2');
|
||||
});
|
||||
|
||||
it('nutzt berater ohne Account', function () {
|
||||
$user = User::forceCreate([
|
||||
'email' => 'no-account-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
|
||||
expect(UserShopService::ensureForUser($user)->name)->toBe('berater');
|
||||
});
|
||||
|
||||
it('legt per Command nur für aktive Berater ohne Shop Shops an', function () {
|
||||
$withoutShop = makeShopUser('Anna', 'Beispiel');
|
||||
$withShop = makeShopUser('Bernd', 'Beispiel');
|
||||
UserShop::create(['user_id' => $withShop->id, 'name' => 'bernd-shop', 'active' => true]);
|
||||
$inactive = makeShopUser('Carla', 'Beispiel', ['active' => 0]);
|
||||
|
||||
$this->artisan('user:ensure-shops', ['--dry-run' => true])->assertSuccessful();
|
||||
expect(UserShop::count())->toBe(1);
|
||||
|
||||
$this->artisan('user:ensure-shops')->assertSuccessful();
|
||||
|
||||
expect($withoutShop->fresh()->shop->name)->toBe('anna-beispiel')
|
||||
->and($withShop->fresh()->shop->name)->toBe('bernd-shop')
|
||||
->and($inactive->fresh()->shop)->toBeNull();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue