White Label WL-02: verbindliche Tiegelfarbe, Etikettposition und gemeinsamer Resolver

- products.whitelabel_variant_id, product_images.position/size_cm mit Backfill aus Produktnamen
- WhiteLabelResolver: Farbe vom Produkt, Kundenlogo je Position, Fehler statt stillem Rückfall; Altlogik für Produkte ohne Variante
- Attribute: Variantenwert-Parsing und Farbname ohne Größe
- Produkt- und Kundenformular um Variante, Position und Größe ergänzt
- Lieferschein noch nicht umgestellt (WL-03, wartet auf K6/K7)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-17 12:33:44 +00:00
parent 0dc3dea85e
commit cff749b133
14 changed files with 1159 additions and 280 deletions

View file

@ -3,22 +3,27 @@
namespace App\Http\Controllers;
use App\Exports\ExcelExport;
use Request;
use App\User;
use Validator;
use App\Services\SysLog;
use App\Models\UserAccount;
use App\Models\UserHistory;
use Maatwebsite\Excel\Facades\Excel;
use App\Services\UserService;
use App\Mail\MailAccountActive;
use App\Mail\MailCustomMessage;
use App\Mail\MailVerifyAccount;
use App\Mail\MailVerifyContact;
use App\Models\UserAccount;
use App\Models\UserHistory;
use App\Models\UserWhitelabelProduct;
use App\Repositories\UserRepository;
use Illuminate\Support\Facades\Mail;
use App\Repositories\ContractPDFRepository;
use App\Repositories\UserRepository;
use App\Services\ProductImage;
use App\Services\SysLog;
use App\Services\UserService;
use App\User;
use Illuminate\Contracts\View\Factory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Mail;
use Illuminate\View\View;
use Maatwebsite\Excel\Facades\Excel;
use Request;
use Validator;
class LeadController extends Controller
{
@ -32,7 +37,7 @@ class LeadController extends Controller
}
/**
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
* @return Factory|View
*/
public function index()
{
@ -41,26 +46,26 @@ class LeadController extends Controller
'values' => User::where('admin', '=', 0)->where('confirmation_code_remider', '!=', 2)->get(),
];
return view('admin.lead.index', $data);
}
/**
* @param $id
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
* @return Factory|View
*/
public function edit($id)
{
if($id === "new"){
$user = new User();
$user->account = new UserAccount();
if ($id === 'new') {
$user = new User;
$user->account = new UserAccount;
$user->account->same_as_billing = 1;
$user->account->country_id = 1;
$user->account->shipping_country_id = 1;
$user->id = "new";
}else{
$user->id = 'new';
} else {
$user = User::findOrFail($id);
if(!$user->account){
$user->account = new UserAccount();
if (! $user->account) {
$user->account = new UserAccount;
}
}
$data = [
@ -70,39 +75,39 @@ class LeadController extends Controller
'm_data_load' => false,
'm_data_error' => false,
];
return view('admin.lead.edit', $data);
}
/**
* @param $id
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
* @return Factory|View
*/
public function editPost($id)
{
$m_data_load = false;
$m_data_error = false;
$data = Request::all();
if(!isset($data['edit_m_data_key']) || $data['edit_m_data_key'] !== config('main.edit_data_pass')){
$m_data_error = "Das Passwort ist falsch.";
}else{
if (! isset($data['edit_m_data_key']) || $data['edit_m_data_key'] !== config('main.edit_data_pass')) {
$m_data_error = 'Das Passwort ist falsch.';
} else {
$m_data_load = true;
}
if($id === "new"){
$user = new User();
$user->account = new UserAccount();
if ($id === 'new') {
$user = new User;
$user->account = new UserAccount;
$user->account->same_as_billing = 1;
$user->account->country_id = 1;
$user->account->shipping_country_id = 1;
$user->id = "new";
}else{
$user->id = 'new';
} else {
$user = User::findOrFail($id);
if(!$user->account){
$user->account = new UserAccount();
if (! $user->account) {
$user->account = new UserAccount;
}
}
$next_account_id = UserAccount::withTrashed()->max('m_account') +1;
if($user->account->m_account === null){
$next_account_id = UserAccount::withTrashed()->max('m_account') + 1;
if ($user->account->m_account === null) {
$user->account->m_account = $next_account_id;
}
@ -112,14 +117,14 @@ class LeadController extends Controller
'm_data_load' => $m_data_load,
'm_data_error' => $m_data_error,
'can_change_mail' => true,
'next_account_id' => $next_account_id
'next_account_id' => $next_account_id,
];
return view('admin.lead.edit', $data);
}
/**
* @param Request $request
* @return \Illuminate\Contracts\View\Factory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Illuminate\View\View
* @return Factory|RedirectResponse|Redirector|View
*/
public function store(Request $request)
{
@ -127,153 +132,157 @@ class LeadController extends Controller
$data = Request::all();
$show = Request::get('show');
if(isset($data['action']) && $data['action'] == "reverse_charge_validate" && isset($data['user_id'])){
if (isset($data['action']) && $data['action'] == 'reverse_charge_validate' && isset($data['user_id'])) {
$user = User::findOrFail($data['user_id']);
return $this->userRepo->reverse_charge_validate($data, $user, route('admin_lead_edit', [$user->id]));
}
if(isset($data['action']) && $data['action'] == "reverse_charge_delete" && isset($data['user_id'])){
if (isset($data['action']) && $data['action'] == 'reverse_charge_delete' && isset($data['user_id'])) {
$user = User::findOrFail($data['user_id']);
return $this->userRepo->reverse_charge_delete($data, $user, route('admin_lead_edit', [$user->id]));
}
if ($data['user_id'] === "new" || $data['user_id'] == 0) {
$rules = array(
return $this->userRepo->reverse_charge_delete($data, $user, route('admin_lead_edit', [$user->id]));
}
if ($data['user_id'] === 'new' || $data['user_id'] == 0) {
$rules = [
'salutation' => 'required',
'first_name'=>'required',
'last_name'=>'required',
'first_name' => 'required',
'last_name' => 'required',
'email' => 'required|string|email|max:255|unique:users',
'email-confirm' => 'required|same:email',
);
}else{
$rules = array(
];
} else {
$rules = [
'salutation' => 'required',
'first_name'=>'required',
'last_name'=>'required',
'address'=>'required',
'zipcode'=>'required',
'first_name' => 'required',
'last_name' => 'required',
'address' => 'required',
'zipcode' => 'required',
'city' => 'required',
'email' => 'required|string|email|max:255|exists:users,email',
'email-confirm' => 'required|same:email',
);
if(!Request::get('same_as_billing')){
];
if (! Request::get('same_as_billing')) {
$rules = array_merge($rules, [
'shipping_firstname'=>'required',
'shipping_lastname'=>'required',
'shipping_address'=>'required',
'shipping_zipcode'=>'required',
'shipping_firstname' => 'required',
'shipping_lastname' => 'required',
'shipping_address' => 'required',
'shipping_zipcode' => 'required',
'shipping_city' => 'required',
'shipping_salutation' => 'required'
'shipping_salutation' => 'required',
]);
}
}
if(isset($data['m_account']) && $data['m_account']){
if (isset($data['m_account']) && $data['m_account']) {
$user = User::findOrFail($data['user_id']);
$rules['m_account'] = 'unique:user_accounts,m_account,'.$user->account->id.',id';
}
$validator = Validator::make(Request::all(), $rules);
if ($validator->fails()) {
if ($data['user_id'] === "new" || $data['user_id'] == 0) {
$user_id = "new";
}else{
if ($data['user_id'] === 'new' || $data['user_id'] == 0) {
$user_id = 'new';
} else {
$user = User::findOrFail($data['user_id']);
$user_id = $user->id;
}
return redirect(route('admin_lead_edit', [$user_id])."?show=".$show)->withErrors($validator)->withRequest(Request::all());
return redirect(route('admin_lead_edit', [$user_id]).'?show='.$show)->withErrors($validator)->withRequest(Request::all());
}
if ($data['user_id'] === "new" || $data['user_id'] == 0) {
$user = new User();
$user->id = "new";
$user->account = new UserAccount();
if ($data['user_id'] === 'new' || $data['user_id'] == 0) {
$user = new User;
$user->id = 'new';
$user->account = new UserAccount;
}else {
$user = User::findOrFail($data['user_id']);
if(!$user->account){
$user->account = new UserAccount();
}
} else {
$user = User::findOrFail($data['user_id']);
if (! $user->account) {
$user->account = new UserAccount;
}
}
$this->userRepo->update($data);
$this->userRepo->update($data);
if(isset($data['m_data_edit']) && $data['m_data_edit'] === "TSOK"){
$user = $this->userRepo->getModel();
$user->m_level = isset($data['m_level']) ? $data['m_level'] : NULL;
$user->lead_type_id = isset($data['lead_type_id']) ? $data['lead_type_id'] : NULL;
$user->m_sponsor = isset($data['m_sponsor']) ? $data['m_sponsor'] : NULL;
$user->save();
}
if (isset($data['m_data_edit']) && $data['m_data_edit'] === 'TSOK') {
$user = $this->userRepo->getModel();
$user->m_level = isset($data['m_level']) ? $data['m_level'] : null;
$user->lead_type_id = isset($data['lead_type_id']) ? $data['lead_type_id'] : null;
$user->m_sponsor = isset($data['m_sponsor']) ? $data['m_sponsor'] : null;
$user->save();
}
if(isset($data['contact_verify'])){
if (isset($data['contact_verify'])) {
$user = $this->userRepo->getModel();
$user = $this->userRepo->getModel();
$confirmation_code = UserService::createConfirmationCode();
$confirmation_code = UserService::createConfirmationCode();
$user->lang = $user->getLandByCountry();
$user->confirmation_code = $confirmation_code;
//10 == start wizard form create Lead
$user->wizard = 10;
$user->save();
Mail::to($user->email)->bcc(config('app.info_mail'))->send(new MailVerifyContact($confirmation_code, $user));
\Session()->flash('alert-save', true);
return redirect(route('admin_leads'));
}
$user->lang = $user->getLandByCountry();
$user->confirmation_code = $confirmation_code;
// 10 == start wizard form create Lead
$user->wizard = 10;
$user->save();
Mail::to($user->email)->bcc(config('app.info_mail'))->send(new MailVerifyContact($confirmation_code, $user));
\Session()->flash('alert-save', true);
return redirect(route('admin_lead_edit', [$user->id])."?show=".$show);
return redirect(route('admin_leads'));
}
\Session()->flash('alert-save', true);
return redirect(route('admin_lead_edit', [$user->id]).'?show='.$show);
}
public function update()
{
$data = Request::all();
$show = Request::get('show');
if(!isset($data['user_id']) || $data['user_id'] === "new" || $data['user_id'] == 0){
if (! isset($data['user_id']) || $data['user_id'] === 'new' || $data['user_id'] == 0) {
abort(404, 'User not found');
}
$user = User::findOrFail($data['user_id']);
if(isset($data['action'])){
if (isset($data['action'])) {
//add whitelabel products
if($data['action'] === 'add_whitelabel_products'){
if(isset($data['whitelabel_products']) && is_array($data['whitelabel_products'])){
foreach ($data['whitelabel_products'] as $product_id){
// add whitelabel products
if ($data['action'] === 'add_whitelabel_products') {
if (isset($data['whitelabel_products']) && is_array($data['whitelabel_products'])) {
foreach ($data['whitelabel_products'] as $product_id) {
$user->whitelabel_products()->create(['product_id' => $product_id]);
}
}
\Session()->flash('alert-save', true);
}
//remove whitelabel products
if($data['action'] === 'remove_whitelabel_products'){
if(isset($data['whitelabel_products']) && is_array($data['whitelabel_products'])){
// remove whitelabel products
if ($data['action'] === 'remove_whitelabel_products') {
if (isset($data['whitelabel_products']) && is_array($data['whitelabel_products'])) {
$user->whitelabel_products()->whereIn('product_id', $data['whitelabel_products'])->delete();
}
\Session()->flash('alert-success', "Produkt gelöscht");
\Session()->flash('alert-success', 'Produkt gelöscht');
}
if($data['action'] === 'upload_white_label_image'){
//id == user_whitelabel_product_id
$whitelabel_product = UserWhitelabelProduct::findOrFail($data['id']);
if($whitelabel_product && $user->id == $whitelabel_product->user_id){
return \App\Services\ProductImage::imageUpload('user_wl_product', $whitelabel_product, Request::get('upload_type'));
}
if ($data['action'] === 'upload_white_label_image') {
// id == user_whitelabel_product_id
$whitelabel_product = UserWhitelabelProduct::findOrFail($data['id']);
if ($whitelabel_product && $user->id == $whitelabel_product->user_id) {
return ProductImage::imageUpload('user_wl_product', $whitelabel_product, Request::get('upload_type'));
}
}
//update whitelabel products
if($data['action'] === 'update_whitelabel_products'){
if(isset($data['image_wl_attributes']) && is_array($data['image_wl_attributes'])){
foreach ( $user->whitelabel_products as $wl_product){
// update whitelabel products
if ($data['action'] === 'update_whitelabel_products') {
$positions = isset($data['image_wl_position']) && is_array($data['image_wl_position']) ? $data['image_wl_position'] : [];
if (isset($data['image_wl_attributes']) && is_array($data['image_wl_attributes'])) {
foreach ($user->whitelabel_products as $wl_product) {
foreach ($wl_product->whitelabel_images as $wl_image) {
$position = $positions[$wl_image->id] ?? null;
$wl_image->update([
'attributes' => isset($data['image_wl_attributes'][$wl_image->id]) ? $data['image_wl_attributes'][$wl_image->id] : NULL,
'attributes' => isset($data['image_wl_attributes'][$wl_image->id]) ? $data['image_wl_attributes'][$wl_image->id] : null,
'position' => array_key_exists($position, \App\Models\ProductImage::POSITIONS) ? $position : ($wl_image->position ?: 'deckel'),
]);
}
}
@ -281,86 +290,95 @@ class LeadController extends Controller
\Session()->flash('alert-save', true);
}
}
return redirect(route('admin_lead_edit', [$user->id])."?show=".$show);
return redirect(route('admin_lead_edit', [$user->id]).'?show='.$show);
}
public function remove($action, $user_id, $id, $mid=null){
public function remove($action, $user_id, $id, $mid = null)
{
$show = Request::get('show');
$user = User::findOrFail($user_id);
if($action === 'remove_whitelabel_label'){
if ($action === 'remove_whitelabel_label') {
$model = $user->whitelabel_products()->where('id', $id)->first();
return \App\Services\ProductImage::imageDelete('user_wl_image', $model, $mid);
return ProductImage::imageDelete('user_wl_image', $model, $mid);
}
if($action === 'remove_whitelabel_product'){
if ($action === 'remove_whitelabel_product') {
$model = $user->whitelabel_products()->where('id', $id)->first();
//remove images
foreach ($model->whitelabel_images as $image){
\App\Services\ProductImage::imageDelete('user_wl_image', $model, $image->id);
// remove images
foreach ($model->whitelabel_images as $image) {
ProductImage::imageDelete('user_wl_image', $model, $image->id);
}
$model->delete();
\Session()->flash('alert-success', "White Label Produkt entfernt");
\Session()->flash('alert-success', 'White Label Produkt entfernt');
return redirect(route('admin_lead_edit', [$user->id])."?show=$show");
}
}
public function download(){
if(Request::get('action') === "export"){
}
public function download()
{
if (Request::get('action') === 'export') {
$query = $this->initSearch();
$columns = [];
$filename = "GS-VP-export-".date("d-m-Y");
$headers = array(
'ID',
'Email',
'Firma',
'Anrede',
'Vorname',
'Nachname',
'Mitglied',
$filename = 'GS-VP-export-'.date('d-m-Y');
$headers = [
'ID',
'Email',
'Firma',
'Anrede',
'Vorname',
'Nachname',
'Mitglied',
'Bis',
'Art',
);
];
$objects = $query->get();
if($objects){
foreach ($objects as $obj){
$columns[] = array(
'ID' => $obj->id,
'Email' => $obj->email,
'Firma' => $obj->account ? $obj->account->company : '',
'Anrede' => $obj->account ? ($obj->account->salutation == 'mr' ? 'Herr' : 'Frau') : '-',
'Vorname' => $obj->account ? $obj->account->first_name : '',
'Nachname' => $obj->account ? $obj->account->last_name : '',
'Mitglied' => $obj->payment_account ? ($obj->isActiveAccount() ? 'JA' : 'Abgelaufen') : "Nein",
'Bis' => $obj->payment_account ? $obj->getPaymentAccountDateFormat(false) : '-',
'Art' => $obj->lead_type ? $obj->lead_type->name : '-',
);
if ($objects) {
foreach ($objects as $obj) {
$columns[] = [
'ID' => $obj->id,
'Email' => $obj->email,
'Firma' => $obj->account ? $obj->account->company : '',
'Anrede' => $obj->account ? ($obj->account->salutation == 'mr' ? 'Herr' : 'Frau') : '-',
'Vorname' => $obj->account ? $obj->account->first_name : '',
'Nachname' => $obj->account ? $obj->account->last_name : '',
'Mitglied' => $obj->payment_account ? ($obj->isActiveAccount() ? 'JA' : 'Abgelaufen') : 'Nein',
'Bis' => $obj->payment_account ? $obj->getPaymentAccountDateFormat(false) : '-',
'Art' => $obj->lead_type ? $obj->lead_type->name : '-',
];
}
}
return Excel::download(new ExcelExport($columns, $headers), $filename.'.xls');
}
}
private function setFilterVars(){
if(!session('filter_lead_type_id')){
private function setFilterVars()
{
if (! session('filter_lead_type_id')) {
session(['filter_lead_type_id' => 'all']);
}
if(Request::get('filter_lead_type_id')){
if (Request::get('filter_lead_type_id')) {
session(['filter_lead_type_id' => Request::get('filter_lead_type_id')]);
}
}
private function initSearch(){
private function initSearch()
{
$this->setFilterVars();
$query = User::with('account')->select('users.*')->where('users.deleted_at', '=', null)->where('users.admin', "<", 4);
if(session('filter_lead_type_id') && session('filter_lead_type_id') !== null && session('filter_lead_type_id') !== "all"){
$query = User::with('account')->select('users.*')->where('users.deleted_at', '=', null)->where('users.admin', '<', 4);
if (session('filter_lead_type_id') && session('filter_lead_type_id') !== null && session('filter_lead_type_id') !== 'all') {
$query->where('lead_type_id', session('filter_lead_type_id'));
}
return $query;
}
@ -385,7 +403,7 @@ class LeadController extends Controller
return $user->lead_type ? $user->lead_type->name : '';
})
->addColumn('id', function (User $user) {
return '<a href="' . route('admin_lead_edit', [$user->id]) . '" class="btn icon-btn btn-sm btn-primary"><span class="far fa-edit"></span></a>';
return '<a href="'.route('admin_lead_edit', [$user->id]).'" class="btn icon-btn btn-sm btn-primary"><span class="far fa-edit"></span></a>';
})
->addColumn('confirmed', function (User $user) {
return $user->confirmed ? '<span class="badge badge-pill badge-success"><i class="fa fa-check"></i></span>' : '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span>';
@ -400,7 +418,7 @@ class LeadController extends Controller
return $user->payment_account ? ' <span class="badge badge-pill badge-success"><i class="fa fa-check"></i></span>' : '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span>';
})
->addColumn('payment_account_date', function (User $user) {
return $user->payment_account ? $user->getPaymentAccountDateFormat(false) : "-";
return $user->payment_account ? $user->getPaymentAccountDateFormat(false) : '-';
})
/*->addColumn('payment_shop', function (User $user) {
return $user->payment_shop ? ' <span class="badge badge-pill badge-success"><i class="fa fa-check"></i></span>' : '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span>';
@ -409,10 +427,10 @@ class LeadController extends Controller
return $user->payment_shop ? $user->getPaymentShopDateFormat(false) : "-";
})*/
->addColumn('turnover', function (User $user) {
return "-";
return '-';
})
->addColumn('sales_total', function (User $user) {
return "-";
return '-';
})
->orderColumn('id', 'id $1')
@ -421,16 +439,17 @@ class LeadController extends Controller
->orderColumn('lead_type', 'lead_type_id $1')
->orderColumn('agreement', 'agreement $1')
->orderColumn('payment_account', 'payment_account $1')
//->orderColumn('payment_shop', 'payment_shop $1')
// ->orderColumn('payment_shop', 'payment_shop $1')
->rawColumns(['id', 'confirmed', 'active', 'agreement', 'payment_account'])
->make(true);
}
private function activeAccountPayment($user){
private function activeAccountPayment($user)
{
if($user->user_level){
if($user->user_level->payment_year){
//if true = payments
if ($user->user_level) {
if ($user->user_level->payment_year) {
// if true = payments
$user->wizard = 20;
$user->active = 1;
$user->active_date = now();
@ -438,8 +457,8 @@ class LeadController extends Controller
$user->confirmation_code_to = null;
$user->confirmation_code_remider = 0;
$user->save();
}else{
//if false = no payments for 1 year
} else {
// if false = no payments for 1 year
$user->wizard = 100;
$user->payment_account = \Carbon::now()->modify('1 year');
$user->active = 1;
@ -449,37 +468,38 @@ class LeadController extends Controller
$user->confirmation_code_remider = 0;
$user->save();
}
}
}
}
//user released when register is complete
public function released($action, $id){
// user released when register is complete
public function released($action, $id)
{
$user = User::findOrFail($id);
if($action === 'completed'){
if ($action === 'completed') {
$validator = Validator::make(Request::all(), []);
/*if(!$user->m_sponsor){
$validator->errors()->add('m_sponsor', __('Vertriebspartner hat keinen Sponsor.'));
}*/
if(!$user->account->m_first_name){
if (! $user->account->m_first_name) {
$validator->errors()->add('m_first_name', __('Vertriebspartner hat keinen Vornamen.'));
}
if(!$user->account->m_last_name){
if (! $user->account->m_last_name) {
$validator->errors()->add('m_last_name', __('Vertriebspartner hat keinen Nachnamen.'));
}
if(!$user->m_level){
if (! $user->m_level) {
$validator->errors()->add('m_level', __('Vertriebspartner hat keine Rolle'));
}
if ($validator->errors()->count()) {
return back()->withErrors($validator)->withRequest(Request::all());
}
if(!$user->account->m_account){
$user->account->m_account = UserAccount::withTrashed()->max('m_account') +1;
if (! $user->account->m_account) {
$user->account->m_account = UserAccount::withTrashed()->max('m_account') + 1;
$user->account->save();
}
//create PDF
// create PDF
$pdf = new ContractPDFRepository($user);
$pdf->_set('disk', 'user');
$pdf->_set('dir', '/'.$user->id.'/documents/');
@ -487,14 +507,13 @@ class LeadController extends Controller
$pdf->_set('identifier', 'contract');
$pdf->createContractPDF();
//set wizard to payments / activate
$this->activeAccountPayment($user);
// set wizard to payments / activate
$this->activeAccountPayment($user);
//mail with code to user?
// mail with code to user?
try {
Mail::to($user->email)->bcc(config('app.info_mail'))->send(new MailAccountActive($user));
}
catch(\Exception $e){
} catch (\Exception $e) {
SysLog::action('released', 'admin_lead', 5)
->setUserId($user->id)
->setModel($user->id, User::class)
@ -502,12 +521,12 @@ class LeadController extends Controller
->save();
}
UserHistory::create(['user_id' => $user->id, 'action'=>'released_completed', 'status'=>0]);
\Session()->flash('alert-success', "Vertriebspartner freigeschaltet!");
UserHistory::create(['user_id' => $user->id, 'action' => 'released_completed', 'status' => 0]);
\Session()->flash('alert-success', 'Vertriebspartner freigeschaltet!');
}
if($action === 'incomplete'){
//reset release
if ($action === 'incomplete') {
// reset release
$confirmation_code = UserService::createConfirmationCode();
$user->confirmation_code = $confirmation_code;
$user->confirmation_code_to = date('Y-m-d H:i:s', strtotime('+1 week'));
@ -524,55 +543,53 @@ class LeadController extends Controller
];
try {
Mail::to($user->email)->bcc(config('app.info_mail'))->send(new MailCustomMessage($user, $data, \Auth::user(), true));
}
catch(\Exception $e){
} catch (\Exception $e) {
SysLog::action('released_incomplete', 'admin_lead', 5)
->setUserId($user->id)
->setModel($user->id, User::class)
->setMessage('Error send released_incomplete E-Mail: '.$e->getMessage())
->save();
}
UserHistory::create(['user_id' => $user->id, 'action'=>'released_incomplete', 'status'=>0]);
\Session()->flash('alert-success', "E-Mail an Vertriebspartner gesendet.");
UserHistory::create(['user_id' => $user->id, 'action' => 'released_incomplete', 'status' => 0]);
\Session()->flash('alert-success', 'E-Mail an Vertriebspartner gesendet.');
}
if($action === 'reset_switch'){
if ($action === 'reset_switch') {
$user->wizard = 3;
$user->save();
UserHistory::create(['user_id' => $user->id, 'action'=>'reset_switch', 'status'=>0]);
\Session()->flash('alert-success', "Vertriebspartner zurückgesetzt!");
UserHistory::create(['user_id' => $user->id, 'action' => 'reset_switch', 'status' => 0]);
\Session()->flash('alert-success', 'Vertriebspartner zurückgesetzt!');
}
if($action === 'unlock'){
if ($action === 'unlock') {
$validator = Validator::make(Request::all(), []);
/*if(!$user->m_sponsor){
$validator->errors()->add('m_sponsor', __('Vertriebspartner hat keinen Sponsor.'));
}*/
if(!$user->account->m_first_name){
if (! $user->account->m_first_name) {
$validator->errors()->add('m_first_name', __('Vertriebspartner hat keinen Vornamen.'));
}
if(!$user->account->m_last_name){
if (! $user->account->m_last_name) {
$validator->errors()->add('m_last_name', __('Vertriebspartner hat keinen Nachnamen.'));
}
if(!$user->m_level){
if (! $user->m_level) {
$validator->errors()->add('m_level', __('Vertriebspartner hat keine Rolle'));
}
if ($validator->errors()->count()) {
return back()->withErrors($validator)->withRequest(Request::all());
}
if(!$user->account->m_account){
$user->account->m_account = UserAccount::withTrashed()->max('m_account') +1;
if (! $user->account->m_account) {
$user->account->m_account = UserAccount::withTrashed()->max('m_account') + 1;
$user->account->save();
}
//set wizard to payments / activate
// set wizard to payments / activate
$this->activeAccountPayment($user);
//mail with code to user?
// mail with code to user?
try {
Mail::to($user->email)->bcc(config('app.info_mail'))->send(new MailAccountActive($user));
}
catch(\Exception $e){
} catch (\Exception $e) {
SysLog::action('unlock', 'admin_lead', 5)
->setUserId($user->id)
->setModel($user->id, User::class)
@ -580,55 +597,56 @@ class LeadController extends Controller
->save();
}
UserHistory::create(['user_id' => $user->id, 'action'=>'unlock_completed', 'status'=>0]);
\Session()->flash('alert-success', "Vertriebspartner erneut freigeschaltet!");
UserHistory::create(['user_id' => $user->id, 'action' => 'unlock_completed', 'status' => 0]);
\Session()->flash('alert-success', 'Vertriebspartner erneut freigeschaltet!');
}
return redirect(route('admin_lead_edit', [$user->id]));
return redirect(route('admin_lead_edit', [$user->id]));
}
// send new verfified mail to user
/* public function newMailVerified($id){
//send new verfified mail to user
/* public function newMailVerified($id){
User Register sind in der DB UserRegister, erst bei bestätigung wird es in die User DB übertragen
User Register sind in der DB UserRegister, erst bei bestätigung wird es in die User DB übertragen
$user = User::findOrFail($id);
$user = User::findOrFail($id);
$confirmation_code = UserService::createConfirmationCode();
$user->confirmation_code = $confirmation_code;
$user->confirmation_code_to = date('Y-m-d H:i:s', strtotime('+1 week'));
$user->confirmation_code_remider = 0;
$user->save();
$confirmation_code = UserService::createConfirmationCode();
$user->confirmation_code = $confirmation_code;
$user->confirmation_code_to = date('Y-m-d H:i:s', strtotime('+1 week'));
$user->confirmation_code_remider = 0;
$user->save();
try {
Mail::to($user->email)->bcc(config('app.info_mail'))->send(new MailVerifyAccount($confirmation_code, $user));
}
catch(\Exception $e){
SysLog::action('new_mail_verified', 'admin_lead', 5)
->setUserId($user->id)
->setModel($user->id, User::class)
->setMessage('Error send new_mail_verified E-Mail: '.$e->getMessage())
->save();
}
UserHistory::create(['user_id' => $user->id, 'action'=>'new_mail_verified', 'status'=>0]);
try {
Mail::to($user->email)->bcc(config('app.info_mail'))->send(new MailVerifyAccount($confirmation_code, $user));
}
catch(\Exception $e){
SysLog::action('new_mail_verified', 'admin_lead', 5)
->setUserId($user->id)
->setModel($user->id, User::class)
->setMessage('Error send new_mail_verified E-Mail: '.$e->getMessage())
->save();
}
UserHistory::create(['user_id' => $user->id, 'action'=>'new_mail_verified', 'status'=>0]);
\Session()->flash('alert-success', "E-Mail erneut gesendet");
return redirect(route('admin_lead_edit', [$user->id]));
\Session()->flash('alert-success', "E-Mail erneut gesendet");
return redirect(route('admin_lead_edit', [$user->id]));
}*/
}*/
public function deleteFile($user_id, $file_id, $relation)
{
public function deleteFile($user_id, $file_id, $relation){
if($relation === 'upload'){
if ($relation === 'upload') {
$user = User::findOrFail($user_id);
$file = $user->files()->findOrFail($file_id);
//remove file
// remove file
\Storage::disk('user')->delete($file->dir.$file->filename);
$file->delete();
\Session()->flash('alert-success', "Datei gelöscht");
\Session()->flash('alert-success', 'Datei gelöscht');
}
return back();
}
}
}

View file

@ -3,7 +3,9 @@
namespace App\Models;
use App\Services\Util;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* App\Models\Attribute
@ -14,10 +16,11 @@ use Illuminate\Database\Eloquent\Model;
* @property array|null $trans_name
* @property int|null $pos
* @property int $active
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\Attribute[] $childrens
* @property-read \App\Models\Attribute|null $parent
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Collection|Attribute[] $childrens
* @property-read Attribute|null $parent
*
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Attribute whereActive($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Attribute whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Attribute whereId($value)
@ -26,17 +29,22 @@ use Illuminate\Database\Eloquent\Model;
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Attribute wherePos($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Attribute whereTransName($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Attribute whereUpdatedAt($value)
*
* @property string|null $slug
*
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Attribute whereSlug($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Attribute newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Attribute newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Attribute query()
*
* @property-read int|null $childrens_count
* @property int $attribute_type_id
* @property string|null $value
* @property-read \App\Models\AttributeType $attribute_type
* @property-read AttributeType $attribute_type
*
* @method static \Illuminate\Database\Eloquent\Builder|Attribute whereAttributeTypeId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Attribute whereValue($value)
*
* @mixin \Eloquent
*/
class Attribute extends Model
@ -45,28 +53,61 @@ class Attribute extends Model
protected $casts = [
'parent_id' => 'int',
'pos' => 'int',
'active' => 'bool',
'trans_name' => 'array',
'pos' => 'int',
'active' => 'bool',
'trans_name' => 'array',
];
protected $fillable = [
'parent_id',
protected $fillable = [
'parent_id',
'attribute_type_id',
'name',
'name',
'value',
'trans_name',
'pos',
'active',
'slug'
];
'trans_name',
'pos',
'active',
'slug',
];
public function attribute_type()
{
return $this->belongsTo(AttributeType::class, 'attribute_type_id');
}
/**
* Variantenwert "Volumen#Größe" (z. B. "40#3,5") in Volumen (ml) und Etikettgröße (cm) zerlegen.
* Die Größe steht mit deutschem Komma im Bestand; ein reiner (float)-Cast würde "3,5" zu 3.0 kürzen.
*
* @return array{0: float, 1: float}
*/
public static function parseVariantValue(?string $value): array
{
$parts = explode('#', (string) $value);
return [
(float) reFormatNumber($parts[0] ?? ''),
(float) reFormatNumber($parts[1] ?? ''),
];
}
public function variantVolume(): float
{
return self::parseVariantValue($this->value)[0];
}
public function variantSize(): float
{
return self::parseVariantValue($this->value)[1];
}
/**
* Farbname ohne Größenangabe: "Schwarz 40 ml" => "Schwarz". Namen ohne Volumen bleiben unverändert.
*/
public function colorName(): string
{
return trim((string) preg_replace('/\s+\d+([,.]\d+)?\s*ml\s*$/iu', '', (string) $this->name));
}
public function parent()
{
return $this->belongsTo(Attribute::class, 'parent_id');
@ -77,15 +118,16 @@ class Attribute extends Model
return $this->hasMany(Attribute::class, 'parent_id', 'id');
}
public function setPosAttribute($value){
public function setPosAttribute($value)
{
$this->attributes['pos'] = is_numeric($value) ? $value : null;
}
public function getFormattedValue()
{
// return isset($this->attributes['value']) ? Util::formatNumber($this->attributes['value']) : "";
// return isset($this->attributes['value']) ? Util::formatNumber($this->attributes['value']) : "";
}
public function getLang($key)
{
$lang = \App::getLocale();
@ -93,27 +135,28 @@ class Attribute extends Model
return $this->{$key};
}
$trans = $this->getTrans($key, $lang);
if (!$trans || $trans == '') {
if (! $trans || $trans == '') {
return $this->{$key};
}
return $trans;
}
public function getTrans($key, $lang)
{
$key = 'trans_' . $key;
if (!empty($this->{$key}[$lang])) {
$key = 'trans_'.$key;
if (! empty($this->{$key}[$lang])) {
return $this->{$key}[$lang];
}
}
public function getTranNames()
{
$ret = "";
foreach ((array) $this->trans_name as $value){
$ret = '';
foreach ((array) $this->trans_name as $value) {
$ret .= $value.', ';
}
return rtrim($ret, ', ');
}
}

View file

@ -204,6 +204,7 @@ class Product extends Model
'max_buy' => 'bool',
'max_buy_num' => 'int',
'whitelabel' => 'bool',
'whitelabel_variant_id' => 'int',
'no_recipe_required' => 'bool',
'is_set' => 'bool',
'main_product_id' => 'int',
@ -224,6 +225,7 @@ class Product extends Model
'name',
'whitelabel',
'whitelabel_name',
'whitelabel_variant_id',
'title',
'copy',
'short_copy',
@ -348,6 +350,12 @@ class Product extends Model
return $this->hasMany(UserWhitelabelProduct::class, 'product_id', 'id');
}
/** Verbindliche Tiegelfarbe/Größe des White-Label-Produkts (WL-02). */
public function whitelabel_variant()
{
return $this->belongsTo(Attribute::class, 'whitelabel_variant_id');
}
public function country_prices()
{
return $this->hasMany(CountryPrice::class, 'product_id');

View file

@ -48,6 +48,8 @@ use Illuminate\Support\Carbon;
* @property int|null $user_wl_product_id
* @property string|null $type
* @property object|null $attributes
* @property string|null $position
* @property float|null $size_cm
* @property-read UserWhitelabelProduct|null $user_wl_product
*
* @method static \Illuminate\Database\Eloquent\Builder|ProductImage whereAttributes($value)
@ -62,12 +64,16 @@ class ProductImage extends Model
protected $table = 'product_images';
public const POSITIONS = ['deckel' => 'Deckel', 'seite' => 'Seite', 'boden' => 'Boden'];
protected $casts = [
'attributes' => 'object',
'size_cm' => 'float',
];
protected $fillable = [
'product_id', 'user_wl_product_id', 'type', 'filename', 'original_name', 'ext', 'mine', 'size', 'pos', 'active', 'attributes',
'position', 'size_cm',
];
public function sluggable(): array

View file

@ -363,18 +363,10 @@ class InvoiceRepository extends BaseRepository
}
/**
* Attributwert "Volumen#Größe" (z. B. "40#3,5") in Volumen (ml) und Etikettgröße (cm) zerlegen.
* Die Größe steht mit deutschem Komma im Bestand; ein reiner (float)-Cast würde "3,5" zu 3.0 kürzen.
*
* @return array{0: float, 1: float}
*/
public static function parseVariantValue(?string $value): array
{
$parts = explode('#', (string) $value);
return [
(float) reFormatNumber($parts[0] ?? ''),
(float) reFormatNumber($parts[1] ?? ''),
];
return Attribute::parseVariantValue($value);
}
}

View file

@ -35,6 +35,10 @@ class ProductRepository extends BaseRepository
$data['amount_commission'] = isset($data['amount_commission']) ? 1 : 0;
$data['exclude_stats_sales'] = isset($data['exclude_stats_sales']) ? 1 : 0;
$data['whitelabel'] = isset($data['whitelabel']) ? 1 : 0;
// WL-02: verbindliche Tiegelfarbe/Größe (leer => null, Altlogik).
$data['whitelabel_variant_id'] = isset($data['whitelabel_variant_id']) && $data['whitelabel_variant_id'] !== ''
? (int) $data['whitelabel_variant_id']
: null;
$data['shipping_addon'] = isset($data['shipping_addon']) ? 1 : 0;
$data['max_buy'] = isset($data['max_buy']) ? 1 : 0;
$data['no_recipe_required'] = isset($data['no_recipe_required']) ? 1 : 0;
@ -115,6 +119,7 @@ class ProductRepository extends BaseRepository
$this->updateWLVariants(isset($data['whitelabel_variants']) ? $data['whitelabel_variants'] : []);
$this->updateWLImageAttributs(isset($data['image_wl_attributes']) ? $data['image_wl_attributes'] : [], isset($data['whitelabel_variants']) ? $data['whitelabel_variants'] : []);
$this->updateWLImagePositions(isset($data['image_wl_position']) ? $data['image_wl_position'] : [], isset($data['image_wl_size']) ? $data['image_wl_size'] : []);
if ($this->model->is_set) {
// Sets haben keine eigene Rezeptur/Verpackung und werden nicht produziert.
@ -597,6 +602,26 @@ class ProductRepository extends BaseRepository
return true;
}
/**
* WL-02: Etikettposition und Größe (cm) je Standardetikett.
*
* @param array<int, string> $positions Bild-ID => Position
* @param array<int, string> $sizes Bild-ID => Größe in cm (deutsche Schreibweise erlaubt)
*/
public function updateWLImagePositions(array $positions = [], array $sizes = []): bool
{
foreach ($this->model->whitelabel_images as $image) {
$position = $positions[$image->id] ?? null;
$size = isset($sizes[$image->id]) && trim((string) $sizes[$image->id]) !== '' ? (float) reFormatNumber($sizes[$image->id]) : null;
$image->update([
'position' => array_key_exists($position, ProductImage::POSITIONS) ? $position : ($image->position ?: 'deckel'),
'size_cm' => $size !== null && $size > 0 ? $size : null,
]);
}
return true;
}
public function updateCountryPrices($data)
{
if (! isset($data['country_prices']) || ! is_array($data['country_prices'])) {

View file

@ -0,0 +1,92 @@
<?php
namespace App\Services\WhiteLabel;
use App\Models\Product;
use App\Models\ShoppingOrderItem;
/**
* Auflösungsergebnis einer Bestellposition: Tiegelfarbe, Etiketten je Position, Fehler und Hinweise (WL-02).
* Fehler verhindern die Freigabe des Lieferscheins, Hinweise nicht.
*/
final class ResolvedItem
{
public const COLOR_SOURCE_VARIANT = 'variant';
public const COLOR_SOURCE_LEGACY = 'legacy';
public const COLOR_SOURCE_NONE = 'none';
public ?string $color = null;
public ?int $variantId = null;
public float $volumeMl = 0.0;
public float $sizeCm = 0.0;
public string $colorSource = self::COLOR_SOURCE_NONE;
/** @var array<string, ResolvedLabel> Position => Etikett */
public array $labels = [];
/** @var list<string> */
public array $errors = [];
/** @var list<string> */
public array $warnings = [];
public function __construct(
public readonly ShoppingOrderItem $item,
public readonly Product $product,
) {}
public function hasCustomerLogo(): bool
{
foreach ($this->labels as $label) {
if ($label->isCustomer()) {
return true;
}
}
return false;
}
public function isComplete(): bool
{
return $this->errors === [] && $this->color !== null && $this->labels !== [];
}
/** Text für den Lieferschein, z. B. "Tiegel: Schwarz". */
public function colorLine(): string
{
return 'Tiegel: '.($this->color ?? 'unbekannt');
}
/** Text für den Lieferschein, z. B. "Etikett: Kundenlogo". */
public function labelSourceLine(): string
{
return 'Etikett: '.($this->hasCustomerLogo() ? 'Kundenlogo' : 'Standardetikett');
}
/** Größtes Etikett in cm; Sortierschlüssel für den Lieferschein (kleine zuerst). */
public function maxLabelSize(): float
{
$max = 0.0;
foreach ($this->labels as $label) {
$max = max($max, $label->sizeCm);
}
return $max;
}
public function addError(string $message): void
{
$this->errors[] = $message;
}
public function addWarning(string $message): void
{
$this->warnings[] = $message;
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Services\WhiteLabel;
use App\Models\ProductImage;
use Illuminate\Support\Facades\Storage;
/**
* Ein aufgelöstes Etikett für eine Position (WL-02).
*/
final class ResolvedLabel
{
public const SOURCE_CUSTOMER = 'customer';
public const SOURCE_STANDARD = 'standard';
public function __construct(
public readonly ProductImage $image,
public readonly string $source,
public readonly string $position,
public readonly float $sizeCm,
public readonly float $volumeMl,
public readonly ?int $attributeId = null,
) {}
public function isCustomer(): bool
{
return $this->source === self::SOURCE_CUSTOMER;
}
public function sourceName(): string
{
return $this->isCustomer() ? 'Kundenlogo' : 'Standardetikett';
}
public function fileExists(): bool
{
return Storage::disk('public')->exists(ltrim($this->image->getImagePath(), '/'));
}
}

View file

@ -0,0 +1,254 @@
<?php
namespace App\Services\WhiteLabel;
use App\Models\Attribute;
use App\Models\ProductImage;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\UserWhitelabelProduct;
use App\User;
use Illuminate\Support\Collection;
/**
* Gemeinsame Auflösung von Tiegelfarbe und Etiketten je Bestellposition (WL-02, Abschnitt 4.2 des Plans).
*
* Grundregel: Das Produkt bestimmt die Farbe (whitelabel_variant), Kunde + Produkt bestimmen die
* Kundenetiketten, die Position bestimmt, welches Standardetikett ersetzt wird.
* Produkte ohne verbindliche Variante (Klärung K5) laufen über die Altlogik (Attributüberschneidung).
*
* Der Resolver liest nur; er speichert keine Bestellung und erzeugt keine PDF.
*/
class WhiteLabelResolver
{
public const DEFAULT_POSITION = 'deckel';
/**
* @return Collection<int, ResolvedItem> Bestellpositions-ID => Ergebnis (nur White-Label-Positionen)
*/
public function resolveOrder(ShoppingOrder $order): Collection
{
$partner = $order->auth_user;
$results = collect();
foreach ($order->shopping_order_items as $item) {
$resolved = $this->resolveItem($item, $partner);
if ($resolved) {
$results->put($item->id, $resolved);
}
}
return $results;
}
public function resolveItem(ShoppingOrderItem $item, ?User $partner): ?ResolvedItem
{
$product = $item->product;
if (! $product || ! $product->whitelabel) {
return null;
}
$result = new ResolvedItem($item, $product);
$variant = $product->whitelabel_variant;
if ($variant) {
$result->color = $variant->colorName();
$result->variantId = $variant->id;
$result->volumeMl = $variant->variantVolume();
$result->sizeCm = $variant->variantSize();
$result->colorSource = ResolvedItem::COLOR_SOURCE_VARIANT;
} else {
$result->addWarning('Produkt ohne verbindliche Tiegelfarbe; Farbe wird aus dem Etikett abgeleitet (Altlogik).');
}
$standard = $this->groupByPosition($product->whitelabel_images, $result, 'Standardetikett');
$assignment = $this->findAssignment($partner, $product->id);
$customer = $assignment
? $this->groupByPosition($assignment->whitelabel_images, $result, 'Kundenlogo')
: [];
if (! $assignment && $partner && $this->partnerHasAssignments($partner)) {
$result->addWarning('Für dieses Produkt keine Kundenzuordnung vorhanden; Partner hat Logos bei anderen Produkten.');
}
$positions = array_unique(array_merge(array_keys($standard), array_keys($customer)));
foreach ($positions as $position) {
$label = $variant
? $this->resolveWithVariant($position, $standard[$position] ?? null, $customer[$position] ?? null, $result)
: $this->resolveLegacy($position, $standard[$position] ?? null, $customer[$position] ?? null, $product, $result);
if ($label) {
$result->labels[$position] = $label;
}
}
if ($result->labels === [] && $result->errors === []) {
$result->addError('Kein Etikett vorhanden (weder Standardetikett noch Kundenlogo).');
}
if ($variant === null && $result->color === null && $result->labels !== []) {
$result->color = 'Standard';
$result->colorSource = ResolvedItem::COLOR_SOURCE_LEGACY;
}
if ($result->color === null) {
$result->addError('Tiegelfarbe nicht bestimmbar.');
}
ksort($result->labels);
return $result;
}
/**
* Variantenmodus: Position entscheidet, Farbe kommt vom Produkt.
*/
private function resolveWithVariant(string $position, ?ProductImage $standard, ?ProductImage $customer, ResolvedItem $result): ?ResolvedLabel
{
if ($customer) {
$imageAttributes = array_map('intval', (array) $customer->attributes);
if ($imageAttributes !== [] && ! in_array($result->variantId, $imageAttributes, true)) {
$colors = collect($imageAttributes)
->map(fn ($id) => Attribute::find($id)?->colorName())
->filter()->unique()->join(', ');
$result->addError('Kundenlogo (Bild '.$customer->id.', Position '.$position.') ist für '.$colors.' hinterlegt, Produktfarbe ist '.$result->color.'. Zuordnung prüfen.');
return null;
}
$size = $this->sizeFor($customer, $result->sizeCm, $standard);
if ($size <= 0) {
$result->addError('Kundenlogo (Bild '.$customer->id.', Position '.$position.'): Etikettgröße nicht bestimmbar.');
return null;
}
$label = new ResolvedLabel($customer, ResolvedLabel::SOURCE_CUSTOMER, $position, $size, $result->volumeMl, $result->variantId);
if (! $label->fileExists()) {
$result->addError('Kundenlogo (Bild '.$customer->id.', Position '.$position.'): Datei fehlt. Kein Rückfall auf das Standardetikett.');
return null;
}
return $label;
}
if ($standard) {
$size = $this->sizeFor($standard, $result->sizeCm, null);
if ($size <= 0) {
$result->addError('Standardetikett (Bild '.$standard->id.', Position '.$position.'): Etikettgröße nicht bestimmbar.');
return null;
}
$label = new ResolvedLabel($standard, ResolvedLabel::SOURCE_STANDARD, $position, $size, $result->volumeMl, $result->variantId);
if (! $label->fileExists()) {
$result->addError('Standardetikett (Bild '.$standard->id.', Position '.$position.'): Datei fehlt.');
return null;
}
return $label;
}
return null;
}
/**
* Altlogik für Produkte ohne verbindliche Variante: Kundenlogo ersetzt das Standardetikett nur bei
* Attributüberschneidung; Farbe und Größe kommen aus dem getroffenen Attribut.
*/
private function resolveLegacy(string $position, ?ProductImage $standard, ?ProductImage $customer, $product, ResolvedItem $result): ?ResolvedLabel
{
$standardAttributes = $standard ? array_map('intval', (array) $standard->attributes) : [];
$customerAttributes = $customer ? array_map('intval', (array) $customer->attributes) : [];
$overlap = array_values(array_intersect($customerAttributes, $standardAttributes));
if ($customer && $overlap !== []) {
$attribute = Attribute::find($overlap[0]);
$label = new ResolvedLabel($customer, ResolvedLabel::SOURCE_CUSTOMER, $position, $customer->size_cm ?: $attribute?->variantSize() ?: 0.0, $attribute?->variantVolume() ?? 0.0, $attribute?->id);
if (! $label->fileExists()) {
$result->addError('Kundenlogo (Bild '.$customer->id.', Position '.$position.'): Datei fehlt. Kein Rückfall auf das Standardetikett.');
return null;
}
if ($attribute) {
$result->color = $attribute->colorName();
$result->variantId = $attribute->id;
$result->volumeMl = $attribute->variantVolume();
$result->sizeCm = $attribute->variantSize();
$result->colorSource = ResolvedItem::COLOR_SOURCE_LEGACY;
}
return $label;
}
if ($customer) {
$result->addWarning('Kundenlogo (Bild '.$customer->id.', Position '.$position.') greift nicht: keine Variantenüberschneidung mit dem Standardetikett.');
}
if (! $standard) {
return null;
}
// Standard: erstes Attribut des Etiketts, sonst Produktvarianten (wie bisher)
$attribute = $standardAttributes !== [] ? Attribute::find($standardAttributes[0]) : null;
if (! $attribute) {
$attribute = $product->attribute_variants->first()?->attribute;
}
$size = $standard->size_cm ?: ($attribute?->variantSize() ?? 0.0);
if ($size <= 0) {
$result->addError('Standardetikett (Bild '.$standard->id.', Position '.$position.'): Etikettgröße nicht bestimmbar.');
return null;
}
$label = new ResolvedLabel($standard, ResolvedLabel::SOURCE_STANDARD, $position, $size, $attribute?->variantVolume() ?? 0.0, $attribute?->id);
if (! $label->fileExists()) {
$result->addError('Standardetikett (Bild '.$standard->id.', Position '.$position.'): Datei fehlt.');
return null;
}
$result->volumeMl = $label->volumeMl;
$result->sizeCm = $size;
return $label;
}
private function sizeFor(ProductImage $image, float $variantSize, ?ProductImage $fallback): float
{
if ($image->size_cm) {
return (float) $image->size_cm;
}
if ($variantSize > 0) {
return $variantSize;
}
return $fallback && $fallback->size_cm ? (float) $fallback->size_cm : 0.0;
}
/**
* @param iterable<ProductImage> $images
* @return array<string, ProductImage>
*/
private function groupByPosition(iterable $images, ResolvedItem $result, string $kind): array
{
$byPosition = [];
foreach ($images as $image) {
$position = $image->position ?: self::DEFAULT_POSITION;
if (isset($byPosition[$position])) {
$result->addError($kind.': mehrere Bilder für Position "'.$position.'" (Bilder '.$byPosition[$position]->id.' und '.$image->id.'). Keine Auswahl nach Reihenfolge.');
continue;
}
$byPosition[$position] = $image;
}
return $byPosition;
}
private function findAssignment(?User $partner, int $productId): ?UserWhitelabelProduct
{
if (! $partner) {
return null;
}
return $partner->whitelabel_products()->where('product_id', $productId)->where('active', true)->first();
}
private function partnerHasAssignments(User $partner): bool
{
return $partner->whitelabel_products()->where('active', true)->exists();
}
}

View file

@ -0,0 +1,80 @@
<?php
use App\Models\Attribute;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* WL-02: verbindliche Tiegelfarbe/Größe je White-Label-Produkt und Etikettposition/Größe je Etikettbild.
*
* Backfill:
* - products.whitelabel_variant_id aus dem Farbnamen "(Tiegel X)" + Variante des Produkts mit gleicher Farbe.
* Produkte ohne erkennbare Farbe bleiben leer (Klärung K5) und laufen weiter über die Altlogik.
* - product_images.position = 'deckel' (im Bestand hat kein Produkt mehr als ein Etikett),
* size_cm bei Standardetiketten aus dem Variantenwert "Volumen#Größe".
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->unsignedInteger('whitelabel_variant_id')->nullable()->after('whitelabel_name');
$table->foreign('whitelabel_variant_id')->references('id')->on('attributes')->nullOnDelete();
});
Schema::table('product_images', function (Blueprint $table) {
$table->string('position', 20)->nullable()->after('attributes');
$table->decimal('size_cm', 5, 2)->nullable()->after('position');
});
$this->backfill();
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropForeign(['whitelabel_variant_id']);
$table->dropColumn('whitelabel_variant_id');
});
Schema::table('product_images', function (Blueprint $table) {
$table->dropColumn(['position', 'size_cm']);
});
}
private function backfill(): void
{
DB::table('product_images')->whereIn('type', ['wllogo', 'uwllogo'])->whereNull('position')->update(['position' => 'deckel']);
$attributes = DB::table('attributes')->where('attribute_type_id', 1)->get()->keyBy('id');
$normalize = fn (string $v): string => str_replace('ß', 'ss', mb_strtolower(trim($v)));
foreach (DB::table('products')->where('whitelabel', true)->get() as $product) {
if (! preg_match('/\(Tiegel\s+([^)]+)\)/iu', (string) $product->name, $m)) {
continue;
}
$color = $normalize($m[1]);
$variantIds = DB::table('product_attributes')->where('product_id', $product->id)->where('type_id', 1)->pluck('attribute_id');
$match = $variantIds->first(function ($id) use ($attributes, $normalize, $color) {
$attribute = $attributes->get($id);
return $attribute && $normalize(strtok((string) $attribute->name, ' ') ?: '') === $color;
});
if ($match) {
DB::table('products')->where('id', $product->id)->update(['whitelabel_variant_id' => $match]);
}
}
foreach (DB::table('product_images')->where('type', 'wllogo')->whereNull('size_cm')->get() as $image) {
$ids = json_decode((string) $image->attributes, true);
$first = is_array($ids) && $ids !== [] ? $attributes->get((int) reset($ids)) : null;
if ($first && $first->value) {
[, $size] = Attribute::parseVariantValue($first->value);
if ($size > 0) {
DB::table('product_images')->where('id', $image->id)->update(['size_cm' => $size]);
}
}
}
}
};

View file

@ -2,7 +2,7 @@
Stand: 17.09.2026 (ergänzt nach Gegenprüfung gegen Code und Datenbank am selben Tag). Grundlage: Prüfung des vorhandenen Codes, der angeschlossenen Datenbank und des gespeicherten Lieferscheins zur Bestellung 2050.
**Status:** Analyse und Entwicklungsplan. Das Sofortpaket WL-00 und die Bestandsprüfung WL-01 sind umgesetzt (siehe Abschnitt 5 und 8). Die Pakete WL-02 bis WL-05 sind noch nicht implementiert.
**Status:** Analyse und Entwicklungsplan. WL-00, WL-01 und WL-02 sind umgesetzt (siehe Abschnitt 5 und 8). WL-03 bis WL-05 sind noch nicht implementiert; WL-03 hängt an den Klärungen K6 und K7 (Abschnitt 9).
## 1. Ergebnis der Untersuchung
@ -172,7 +172,7 @@ Alte Bestellungen nicht pauschal mit heutigen Stammdaten überschreiben. Bestehe
| --- | --- | --- | --- |
| WL-00: Sofortpaket | Etikettgröße korrekt parsen (Schwachstelle 10). Produktkopie mit ausdrücklicher Option „Kundenzuordnungen übernehmen“. Befehl `whitelabel:transfer-assignments` zur wiederholbaren Übertragung von Kundenzuordnungen zwischen Produkten (Abschnitt 6). | Kopie und Übertragung erzeugen keine Dubletten; 40-ml-Etiketten sind 3,5 cm groß. | umgesetzt 17.09.2026 |
| WL-01: Bestandsprüfung | Befehl `whitelabel:report`: Bestellungen ohne Kundenzuordnung bei Partnern mit Logos, Farbwidersprüche zwischen Kundenbild und Produkt, fehlende Dateien, Dubletten, Mehrfachetiketten, Bilder ohne Variantenzuordnung. Rein lesend. | Jeder Konflikt ist mit betroffenen IDs nachvollziehbar; keine automatische Bereinigung. | umgesetzt 17.09.2026 |
| WL-02: Datenmodell und Auflösung | Eindeutige Produktvariante, Etikettposition/Größe am Bild, Zuordnungsregeln und gemeinsamen Resolver ergänzen. Eindeutige Altdaten übernehmen, Konflikte zur Prüfung vorlegen. `attribute_variants` stilllegen. | Gleiche Eingaben ergeben dieselbe Farbe und dieselben Etiketten; keine Auswahl nach Reihenfolge. | offen |
| WL-02: Datenmodell und Auflösung | Eindeutige Produktvariante, Etikettposition/Größe am Bild, Zuordnungsregeln und gemeinsamen Resolver ergänzen. Eindeutige Altdaten übernehmen, Konflikte zur Prüfung vorlegen. `attribute_variants` stilllegen. | Gleiche Eingaben ergeben dieselbe Farbe und dieselben Etiketten; keine Auswahl nach Reihenfolge. | umgesetzt 17.09.2026 (Stilllegung `attribute_variants` wartet auf K6) |
| WL-03: Bestellung und Lieferschein | Datenstand je Position speichern, Resolver anbinden, Farbe und Etikettquelle getrennt ausgeben, Kennzeichen im Bestellablauf ableiten, Hinweis „keine Kundenzuordnung“ in Bestelldetail und WaWi-Auftrag. | Vorschau und PDF stimmen überein; bestehende Dokumente ändern sich nicht unbemerkt. | offen |
| WL-04: Verwaltungsbereich | Navigation „White Label“, beide Ansichten, Anlage/Bearbeitung, Vorschau und Übertragung in der Oberfläche. Bestehende Admin-Berechtigungen und `user`-Guard verwenden. | Kundenlogos und Farbprodukte sind eindeutig erkennbar und zentral pflegbar. Alte Bearbeitungswege verwenden dieselben Regeln oder verweisen auf den neuen Bereich. | offen |
| WL-05: Migration und Abnahme | Bestandskonflikte fachlich auflösen, Regressionstests und manuelle PDF-Prüfung. | Standardfälle bleiben korrekt; alle Befunde aus `whitelabel:report` sind entweder behoben oder bewusst akzeptiert. | offen |
@ -251,6 +251,16 @@ Diese Korrektur löst die konkreten Fälle, ersetzt aber nicht die Bereinigung d
**Auf Live noch auszuführen:** `php artisan migrate`, `whitelabel:migrate-colors --execute` nach Backup und Freigabe, danach Lieferschein 2050 über „Rechnung erstellen“ neu erzeugen (ohne Mailversand).
## 8a. Umsetzung WL-02 (17.09.2026)
- Migration `2026_09_17_110000_add_whitelabel_variant_and_label_position`: `products.whitelabel_variant_id` (FK auf `attributes`, nullOnDelete), `product_images.position` (deckel/seite/boden) und `product_images.size_cm`. Backfill: Variante aus „(Tiegel X)“ im Produktnamen + gleichfarbige Produktvariante (alle 23 Tattoocreme-Produkte belegt, 7 Produkte ohne Farbe bleiben leer → K5); alle Etikettbilder auf Position `deckel`; Standardetiketten mit Größe aus dem Variantenwert.
- `Attribute::parseVariantValue()`, `variantVolume()`, `variantSize()`, `colorName()` („Schwarz 40 ml“ → „Schwarz“); `InvoiceRepository::parseVariantValue()` delegiert dorthin.
- `App\Services\WhiteLabel\WhiteLabelResolver` mit `ResolvedItem`/`ResolvedLabel`: liest nur, liefert je Bestellposition Farbe, Etikett je Position, Quelle (Kundenlogo/Standard), Fehler und Hinweise; Regeln aus Abschnitt 4.2. Produkte ohne `whitelabel_variant_id` laufen über die Altlogik (Attributüberschneidung), damit K5-Produkte weiter funktionieren.
- Ergebnis gegen Live-Daten: 2050 vollständig (Kundenlogo, Schwarz, 3,5 cm / 2 cm); 1880 Standard mit Hinweis „keine Kundenzuordnung“; 1729 (alte Bestellung auf 37/84) meldet den GOLD/Schwarz-Konflikt als Fehler → genau die Entscheidung K6.
- Produktformular: Feld „Tiegelfarbe / Größe (verbindlich)“, je Standardetikett Position und Größe (cm). Kundenformular: Position je Kundenlogo, Anzeige der Produktfarbe. `attribute_variants` bleibt bis K6 sichtbar.
- Noch **nicht** angebunden: Der Lieferschein nutzt weiterhin `prepairForDelivery()`. Die Umstellung ist WL-03 und braucht K6 (Umgang mit Konflikten auf den alten Artikeln: blockieren oder nur warnen) und K7 (Textformat).
- Tests: `tests/Feature/WhiteLabelResolverTest.php` (12 Tests: alle Fälle aus „Erforderliche Prüfungen“ außer PDF-Sichtprüfung).
## 9. Offene Klärungen mit dem Kunden
Stand 17.09.2026, abgeleitet aus `whitelabel:report` und `whitelabel:migrate-colors` (Dry-Run). Ohne diese Antworten werden keine Daten auf Live geändert.

View file

@ -43,7 +43,10 @@
<div class="float-right">
<a href="{{ route('admin_lead_remove', ['remove_whitelabel_product', $user->id, $wlp->id]) }}?show={{ $show }}" class="btn btn-sm btn-danger mt-2 mb-2" onclick="return confirm('Produkt wirklich entfernen?');">Produkt entfernen</a>
</div>
<h4>{{ $wlp->product->getWhiteLableName() }}</h4>
<h4>{{ $wlp->product->name }} <small class="text-muted">{{ $wlp->product->wp_number }}</small></h4>
@if($wlp->product->whitelabel_variant)
<div class="mb-2"><span class="badge badge-dark">Tiegel: {{ $wlp->product->whitelabel_variant->colorName() }}</span> <small class="text-muted">Farbe kommt vom Produkt; das Logo wird an seiner Position eingesetzt.</small></div>
@endif
</div>
@ -60,6 +63,16 @@
<select class="selectpicker" name="image_wl_attributes[{{ $wlp_image->id }}][]" id="image_wl_attributes_{{ $wlp_image->id }}" data-style="btn-light" data-live-search="true" multiple>
{!! HTMLHelper::getProductAttributesOptions($wlp->product->attribute_variants, $wlp_image->attributes, false, 1) !!}
</select>
<div class="form-row mt-2">
<div class="form-group col-md-4">
<label class="form-label" for="image_wl_position_{{ $wlp_image->id }}">Etikettposition</label>
<select class="form-control" name="image_wl_position[{{ $wlp_image->id }}]" id="image_wl_position_{{ $wlp_image->id }}">
@foreach (\App\Models\ProductImage::POSITIONS as $key => $label)
<option value="{{ $key }}" {{ ($wlp_image->position ?: 'deckel') === $key ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
</div>
<a href="{{ route('admin_lead_remove', ['remove_whitelabel_label', $user->id, $wlp->id, $wlp_image->id]) }}?show={{ $show }}" class="btn btn-sm btn-danger mt-2 mb-2" onclick="return confirm('Etikett wirklich löschen?');">Etikett löschen</a>
</div>
@endforeach

View file

@ -465,6 +465,15 @@
<label class="form-label" for="whitelabel_name">{{ __('White-Label Bezeichnung') }}</label>
{{ Form::text('whitelabel_name', $product->whitelabel_name, ['placeholder' => __('Bezeichnung'), 'class' => 'form-control', 'id' => 'whitelabel_name']) }}
</div>
<div class="form-group col-md-6">
<label class="form-label" for="whitelabel_variant_id">Tiegelfarbe / Größe (verbindlich)</label>
<select class="selectpicker" name="whitelabel_variant_id" id="whitelabel_variant_id" data-style="btn-light" data-live-search="true">
{!! HTMLHelper::getAttributesOptions($product->whitelabel_variant_id ? [$product->whitelabel_variant_id] : [], true, 1) !!}
</select>
<div class="mt-2">
<em>Bestimmt die Farbe auf dem Lieferschein („Tiegel: Schwarz“). Ohne Auswahl wird die Farbe wie bisher aus dem Etikett abgeleitet.</em>
</div>
</div>
<div class="form-group col-md-6">
<label class="form-label" for="whitelabel_variants">Mögliche Varianten (<a
href="{{ route('admin_product_attributes') }}">Anlage unter Attribute</a>)</label>
@ -497,6 +506,20 @@
multiple>
{!! HTMLHelper::getProductAttributesOptions($product->attribute_variants, $image->attributes, false, 1) !!}
</select>
<div class="form-row mt-2">
<div class="form-group col-md-4">
<label class="form-label" for="image_wl_position_{{ $image->id }}">Etikettposition</label>
<select class="form-control" name="image_wl_position[{{ $image->id }}]" id="image_wl_position_{{ $image->id }}">
@foreach (\App\Models\ProductImage::POSITIONS as $key => $label)
<option value="{{ $key }}" {{ ($image->position ?: 'deckel') === $key ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
<div class="form-group col-md-4">
<label class="form-label" for="image_wl_size_{{ $image->id }}">Etikettgröße (cm)</label>
{{ Form::text('image_wl_size['.$image->id.']', $image->size_cm !== null ? formatNumber($image->size_cm, 1) : '', ['class' => 'form-control', 'id' => 'image_wl_size_'.$image->id, 'placeholder' => 'aus Variante']) }}
</div>
</div>
<a href="{{ route('admin_product_image_delete', [$image->id, $product->id]) }}"
class="btn btn-sm btn-danger mt-2 mb-2"
onclick="return confirm('Etikett wirklich löschen?');">Etikett löschen</a>

View file

@ -0,0 +1,275 @@
<?php
use App\Models\Attribute;
use App\Models\AttributeType;
use App\Models\Country;
use App\Models\Product;
use App\Models\ProductAttribute;
use App\Models\ProductImage;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Models\UserWhitelabelProduct;
use App\Repositories\ProductRepository;
use App\Services\WhiteLabel\ResolvedItem;
use App\Services\WhiteLabel\WhiteLabelResolver;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function wlrUser(int $admin = 0): User
{
$user = User::query()->create(['email' => uniqid('wlr_', true).'@test.example', 'password' => bcrypt('password')]);
$user->forceFill(['admin' => $admin, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
return $user->fresh();
}
function wlrVariant(string $name, string $value): Attribute
{
$type = AttributeType::query()->firstOrCreate(['id' => 1], ['name' => 'Variante', 'slug' => 'variante']);
return Attribute::query()->create(['attribute_type_id' => $type->id, 'name' => $name, 'value' => $value, 'slug' => uniqid('v_'), 'active' => true, 'pos' => 0]);
}
function wlrProduct(string $name, ?Attribute $variant, array $possibleVariants = []): Product
{
$product = Product::query()->create(['name' => $name, 'title' => $name, 'active' => true, 'is_set' => false, 'whitelabel' => true, 'whitelabel_variant_id' => $variant?->id]);
foreach ($possibleVariants as $attribute) {
ProductAttribute::create(['product_id' => $product->id, 'type_id' => 1, 'attribute_id' => $attribute->id]);
}
return $product;
}
function wlrStandardLabel(Product $product, array $attributes, ?float $sizeCm = null, string $position = 'deckel', bool $withFile = true): ProductImage
{
$name = uniqid('std_').'.png';
if ($withFile) {
Storage::disk('public')->put('images/product/'.$product->id.'/'.$name, 'png');
}
return ProductImage::create(['product_id' => $product->id, 'type' => 'wllogo', 'filename' => $name, 'original_name' => $name, 'ext' => 'png', 'mine' => 'image/png', 'size' => 3, 'pos' => 0, 'active' => true, 'attributes' => $attributes, 'position' => $position, 'size_cm' => $sizeCm]);
}
function wlrAssignment(User $user, Product $product, array $attributes, string $position = 'deckel', bool $withFile = true): UserWhitelabelProduct
{
$assignment = UserWhitelabelProduct::firstOrCreate(['user_id' => $user->id, 'product_id' => $product->id], ['active' => true]);
$name = uniqid('logo_').'.png';
if ($withFile) {
Storage::disk('public')->put('images/user_product/'.$assignment->id.'/'.$name, 'png');
}
ProductImage::create(['user_wl_product_id' => $assignment->id, 'type' => 'uwllogo', 'filename' => $name, 'original_name' => $name, 'ext' => 'png', 'mine' => 'image/png', 'size' => 3, 'pos' => 0, 'active' => true, 'attributes' => $attributes, 'position' => $position]);
return $assignment->fresh();
}
function wlrOrder(User $partner, array $products): ShoppingOrder
{
$country = Country::query()->firstOrCreate(['code' => 'DE'], ['en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Germanija']);
$shoppingUser = ShoppingUser::query()->forceCreate(['billing_country_id' => $country->id, 'shipping_country_id' => $country->id, 'billing_firstname' => 'Test', 'billing_lastname' => 'Partner', 'billing_address' => 'Weg 1', 'billing_zipcode' => '11111', 'billing_city' => 'Stadt', 'same_as_billing' => true]);
$shipping = Shipping::query()->firstOrCreate(['name' => 'Standard']);
$shippingCountry = ShippingCountry::query()->firstOrCreate(['shipping_id' => $shipping->id, 'country_id' => $country->id]);
$order = ShoppingOrder::query()->forceCreate(['shopping_user_id' => $shoppingUser->id, 'auth_user_id' => $partner->id, 'country_id' => $shippingCountry->id, 'shipped' => 0, 'txaction' => 'paid', 'total' => 10, 'user_white_label' => false]);
foreach ($products as $product) {
ShoppingOrderItem::query()->forceCreate(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 1, 'price' => 10]);
}
return $order->fresh();
}
beforeEach(function () {
Storage::fake('public');
$this->schwarz40 = wlrVariant('Schwarz 40 ml', '40#3,5');
$this->gold40 = wlrVariant('Gold 40 ml', '40#3,5');
$this->schwarz15 = wlrVariant('Schwarz 15 ml', '15#2');
});
test('kundenlogo mit vorhandener datei ergibt kundenlogo und produktfarbe', function () {
$partner = wlrUser();
$black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', $this->schwarz40);
wlrStandardLabel($black, [(string) $this->schwarz40->id], 3.5);
wlrAssignment($partner, $black, [(string) $this->schwarz40->id]);
$result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$black]))->first();
expect($result)->toBeInstanceOf(ResolvedItem::class)
->and($result->isComplete())->toBeTrue()
->and($result->color)->toBe('Schwarz')
->and($result->colorSource)->toBe(ResolvedItem::COLOR_SOURCE_VARIANT)
->and($result->hasCustomerLogo())->toBeTrue()
->and($result->labels['deckel']->sizeCm)->toBe(3.5)
->and($result->labels['deckel']->volumeMl)->toBe(40.0)
->and($result->colorLine())->toBe('Tiegel: Schwarz')
->and($result->labelSourceLine())->toBe('Etikett: Kundenlogo');
});
test('standardetikett auf schwarzem produkt behaelt die produktfarbe und meldet fehlende zuordnung nur als hinweis', function () {
$partner = wlrUser();
$black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', $this->schwarz40);
$other = wlrProduct('Creme 15 ml (Tiegel SCHWARZ)', $this->schwarz15);
wlrStandardLabel($black, [(string) $this->schwarz40->id], 3.5);
wlrAssignment($partner, $other, [(string) $this->schwarz15->id]);
$result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$black]))->first();
expect($result->isComplete())->toBeTrue()
->and($result->color)->toBe('Schwarz')
->and($result->hasCustomerLogo())->toBeFalse()
->and($result->labelSourceLine())->toBe('Etikett: Standardetikett')
->and($result->warnings)->toHaveCount(1)
->and($result->warnings[0])->toContain('keine Kundenzuordnung');
});
test('kundenlogo ohne standardetikett ist zulaessig wenn die groesse aus der variante kommt', function () {
$partner = wlrUser();
$black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', $this->schwarz40);
wlrAssignment($partner, $black, []);
$result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$black]))->first();
expect($result->isComplete())->toBeTrue()
->and($result->hasCustomerLogo())->toBeTrue()
->and($result->labels['deckel']->sizeCm)->toBe(3.5);
});
test('fehlende logodatei ist ein fehler ohne rueckfall auf das standardetikett', function () {
$partner = wlrUser();
$black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', $this->schwarz40);
wlrStandardLabel($black, [(string) $this->schwarz40->id], 3.5);
wlrAssignment($partner, $black, [(string) $this->schwarz40->id], 'deckel', false);
$result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$black]))->first();
expect($result->isComplete())->toBeFalse()
->and($result->labels)->toBe([])
->and($result->errors[0])->toContain('Datei fehlt');
});
test('farbwiderspruch zwischen kundenbild und produkt ist ein fehler', function () {
$partner = wlrUser();
$gold = wlrProduct('Creme 40 ml (Tiegel GOLD)', $this->gold40);
wlrStandardLabel($gold, [(string) $this->gold40->id], 3.5);
wlrAssignment($partner, $gold, [(string) $this->schwarz40->id]);
$result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$gold]))->first();
expect($result->isComplete())->toBeFalse()
->and($result->errors)->toHaveCount(1)
->and($result->errors[0])->toContain('für Schwarz hinterlegt, Produktfarbe ist Gold');
});
test('mehrere kundenlogos auf derselben position sind ein konflikt ohne auswahl nach reihenfolge', function () {
$partner = wlrUser();
$black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', $this->schwarz40);
wlrStandardLabel($black, [(string) $this->schwarz40->id], 3.5);
wlrAssignment($partner, $black, [(string) $this->schwarz40->id]);
wlrAssignment($partner, $black, [(string) $this->schwarz40->id]);
$result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$black]))->first();
expect($result->isComplete())->toBeFalse()
->and($result->errors[0])->toContain('mehrere Bilder für Position "deckel"');
});
test('positionen bleiben getrennt: kundenlogo am deckel ersetzt nicht das bodenetikett', function () {
$partner = wlrUser();
$black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', $this->schwarz40);
wlrStandardLabel($black, [(string) $this->schwarz40->id], 3.5, 'deckel');
$boden = wlrStandardLabel($black, [], 4.7, 'boden');
wlrAssignment($partner, $black, [(string) $this->schwarz40->id], 'deckel');
$result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$black]))->first();
expect($result->isComplete())->toBeTrue()
->and($result->labels)->toHaveCount(2)
->and($result->labels['deckel']->isCustomer())->toBeTrue()
->and($result->labels['boden']->isCustomer())->toBeFalse()
->and($result->labels['boden']->image->id)->toBe($boden->id)
->and($result->labels['boden']->sizeCm)->toBe(4.7)
->and($result->maxLabelSize())->toBe(4.7);
});
test('altlogik ohne verbindliche variante: kundenlogo greift nur bei attributueberschneidung', function () {
$partner = wlrUser();
$legacy = wlrProduct('Tattoobalm 30 ml', null, [$this->schwarz40, $this->gold40]);
wlrStandardLabel($legacy, [(string) $this->schwarz40->id, (string) $this->gold40->id]);
$noOverlap = wlrAssignment($partner, $legacy, [(string) $this->schwarz15->id]);
$result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$legacy]))->first();
expect($result->isComplete())->toBeTrue()
->and($result->hasCustomerLogo())->toBeFalse()
->and($result->color)->toBe('Standard')
->and($result->colorSource)->toBe(ResolvedItem::COLOR_SOURCE_LEGACY)
->and($result->labels['deckel']->sizeCm)->toBe(3.5)
->and(collect($result->warnings)->first(fn ($w) => str_contains($w, 'greift nicht')))->not->toBeNull();
$noOverlap->whitelabel_images()->update(['attributes' => json_encode([(string) $this->schwarz40->id])]);
$result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$legacy]))->first();
expect($result->hasCustomerLogo())->toBeTrue()
->and($result->color)->toBe('Schwarz')
->and($result->volumeMl)->toBe(40.0);
});
test('resolver schreibt weder bestellung noch kennzeichen', function () {
$partner = wlrUser();
$black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', $this->schwarz40);
wlrStandardLabel($black, [(string) $this->schwarz40->id], 3.5);
wlrAssignment($partner, $black, [(string) $this->schwarz40->id]);
$order = wlrOrder($partner, [$black]);
(new WhiteLabelResolver)->resolveOrder($order);
expect($order->fresh()->user_white_label)->toBeFalse();
});
test('nicht-white-label-positionen werden nicht aufgeloest', function () {
$partner = wlrUser();
$plain = Product::query()->create(['name' => 'Seife', 'title' => 'Seife', 'active' => true, 'is_set' => false, 'whitelabel' => false]);
expect((new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$plain])))->toHaveCount(0);
});
test('produktformular speichert verbindliche variante sowie position und groesse je etikett', function () {
$black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', null);
$label = wlrStandardLabel($black, [(string) $this->schwarz40->id]);
(new ProductRepository($black))->update([
'id' => (string) $black->id,
'name' => $black->name,
'title' => $black->title,
'active' => '1',
'whitelabel' => '1',
'whitelabel_variant_id' => (string) $this->schwarz40->id,
'whitelabel_variants' => [(string) $this->schwarz40->id],
'image_wl_attributes' => [$label->id => [(string) $this->schwarz40->id]],
'image_wl_position' => [$label->id => 'boden'],
'image_wl_size' => [$label->id => '4,7'],
]);
$black->refresh();
$label->refresh();
expect($black->whitelabel_variant_id)->toBe($this->schwarz40->id)
->and($black->whitelabel_variant->colorName())->toBe('Schwarz')
->and($label->position)->toBe('boden')
->and($label->size_cm)->toBe(4.7);
$this->actingAs(wlrUser(9), 'user');
$this->get(route('admin_product_edit', $black->id))
->assertOk()
->assertSee('Tiegelfarbe / Größe (verbindlich)')
->assertSee('Etikettposition');
});
test('attribut-farbname trennt die groesse ab', function () {
expect($this->schwarz40->colorName())->toBe('Schwarz')
->and(wlrVariant('Braunglasflasche 30 ml', '30#5')->colorName())->toBe('Braunglasflasche')
->and(wlrVariant('Bodenetikett', '40#3,5')->colorName())->toBe('Bodenetikett')
->and($this->schwarz40->variantSize())->toBe(3.5)
->and($this->schwarz40->variantVolume())->toBe(40.0);
});