feat: MySQL data import and parallel test fixes
- Import Cannabrands data from MySQL to PostgreSQL (strains, categories, companies, locations, contacts, products, images, invoices) - Make migrations idempotent for parallel test execution - Add ParallelTesting setup for separate test databases per process - Update product type constraint for imported data - Keep MysqlImport seeders for reference (data already in PG)
This commit is contained in:
@@ -13,6 +13,7 @@ use Filament\Forms;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
@@ -154,47 +155,77 @@ class BusinessResource extends Resource
|
||||
]),
|
||||
]),
|
||||
|
||||
Tab::make('Addresses')
|
||||
Tab::make('Locations')
|
||||
->schema([
|
||||
Section::make('Physical Address')
|
||||
Repeater::make('locations')
|
||||
->relationship('locations')
|
||||
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
|
||||
$data['slug'] = $data['slug'] ?? \Illuminate\Support\Str::slug($data['name'] ?? 'location');
|
||||
|
||||
return $data;
|
||||
})
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextInput::make('physical_address')
|
||||
TextInput::make('name')
|
||||
->label('Location Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Select::make('location_type')
|
||||
->label('Type')
|
||||
->options([
|
||||
'physical' => 'Physical',
|
||||
'billing' => 'Billing',
|
||||
'delivery' => 'Delivery',
|
||||
])
|
||||
->default('physical'),
|
||||
TextInput::make('license_number')
|
||||
->label('License #')
|
||||
->maxLength(255),
|
||||
]),
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextInput::make('address')
|
||||
->label('Street Address')
|
||||
->maxLength(255)
|
||||
->columnSpan(2),
|
||||
TextInput::make('physical_city')
|
||||
TextInput::make('unit')
|
||||
->label('Unit/Suite')
|
||||
->maxLength(255),
|
||||
TextInput::make('city')
|
||||
->label('City')
|
||||
->maxLength(255),
|
||||
TextInput::make('physical_state')
|
||||
]),
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextInput::make('state')
|
||||
->label('State')
|
||||
->maxLength(255),
|
||||
TextInput::make('physical_zipcode')
|
||||
->label('ZIP Code')
|
||||
->maxLength(2),
|
||||
TextInput::make('zipcode')
|
||||
->label('ZIP')
|
||||
->maxLength(10),
|
||||
TextInput::make('phone')
|
||||
->label('Phone')
|
||||
->tel()
|
||||
->maxLength(20),
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->maxLength(255),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Billing Address')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('billing_address')
|
||||
->label('Billing Street Address')
|
||||
->maxLength(255)
|
||||
->columnSpan(2),
|
||||
TextInput::make('billing_city')
|
||||
->label('Billing City')
|
||||
->maxLength(255),
|
||||
TextInput::make('billing_state')
|
||||
->label('Billing State')
|
||||
->maxLength(255),
|
||||
TextInput::make('billing_zipcode')
|
||||
->label('Billing ZIP Code')
|
||||
->maxLength(255),
|
||||
Toggle::make('is_primary')
|
||||
->label('Primary Location'),
|
||||
Toggle::make('is_billing')
|
||||
->label('Billing Location'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->itemLabel(fn (array $state): ?string => $state['name'] ?? 'New Location')
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->addActionLabel('Add Location')
|
||||
->defaultItems(0),
|
||||
]),
|
||||
|
||||
Tab::make('Users & Access')
|
||||
@@ -205,29 +236,53 @@ class BusinessResource extends Resource
|
||||
->schema([
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextInput::make('first_name')
|
||||
->label('First Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('last_name')
|
||||
->label('Last Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('phone')
|
||||
->label('Phone')
|
||||
->tel()
|
||||
->maxLength(255),
|
||||
Select::make('contact_type')
|
||||
->label('Role/Type')
|
||||
->required()
|
||||
->options(Contact::CONTACT_TYPES)
|
||||
->default('staff')
|
||||
->searchable(),
|
||||
Hidden::make('id'),
|
||||
Select::make('user_id')
|
||||
->label('Link Existing User')
|
||||
->options(function ($get, $livewire) {
|
||||
$business = $livewire->getRecord();
|
||||
$currentUserIds = $business ? $business->users()->pluck('users.id')->toArray() : [];
|
||||
$currentId = $get('id');
|
||||
|
||||
return \App\Models\User::query()
|
||||
->with('businesses')
|
||||
->where(function ($query) use ($currentUserIds, $currentId) {
|
||||
$query->whereNotIn('id', $currentUserIds);
|
||||
if ($currentId) {
|
||||
$query->orWhere('id', $currentId);
|
||||
}
|
||||
})
|
||||
->where('user_type', '!=', 'admin')
|
||||
->orderBy('first_name')
|
||||
->get()
|
||||
->mapWithKeys(function ($user) {
|
||||
$businesses = $user->businesses->pluck('name')->join(', ');
|
||||
$label = $user->full_name.' ('.$user->email.')';
|
||||
if ($businesses) {
|
||||
$label .= ' - '.$businesses;
|
||||
}
|
||||
|
||||
return [$user->id => $label];
|
||||
});
|
||||
})
|
||||
->searchable()
|
||||
->preload()
|
||||
->live()
|
||||
->dehydrated(false)
|
||||
->afterStateUpdated(function ($state, callable $set) {
|
||||
if ($state) {
|
||||
$user = \App\Models\User::find($state);
|
||||
if ($user) {
|
||||
$set('id', $user->id);
|
||||
$set('first_name', $user->first_name);
|
||||
$set('last_name', $user->last_name);
|
||||
$set('email', $user->email);
|
||||
$set('phone', $user->phone);
|
||||
}
|
||||
}
|
||||
})
|
||||
->helperText('Search and select an existing user, or leave empty to create new')
|
||||
->columnSpan(2),
|
||||
Toggle::make('is_primary')
|
||||
->label(new \Illuminate\Support\HtmlString(
|
||||
'<span style="text-decoration: underline dotted; cursor: help;" title="Only one primary user allowed - clicking will immediately switch the primary user">Primary</span>'
|
||||
@@ -266,6 +321,31 @@ class BusinessResource extends Resource
|
||||
return false;
|
||||
})
|
||||
->inline(false),
|
||||
TextInput::make('first_name')
|
||||
->label('First Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('last_name')
|
||||
->label('Last Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->disabled(fn ($get) => ! empty($get('id')))
|
||||
->helperText(fn ($get) => ! empty($get('id')) ? 'Email cannot be changed for existing users' : 'New user will be created with this email'),
|
||||
TextInput::make('phone')
|
||||
->label('Phone')
|
||||
->tel()
|
||||
->maxLength(255),
|
||||
Select::make('contact_type')
|
||||
->label('Role/Type')
|
||||
->required()
|
||||
->options(Contact::CONTACT_TYPES)
|
||||
->default('staff')
|
||||
->searchable(),
|
||||
]),
|
||||
])
|
||||
->saveRelationshipsUsing(function ($component, $state, $record) {
|
||||
@@ -274,22 +354,54 @@ class BusinessResource extends Resource
|
||||
}
|
||||
$syncData = [];
|
||||
foreach ($state as $item) {
|
||||
$email = $item['email'] ?? null;
|
||||
if (! $email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if user exists by ID or email
|
||||
$user = null;
|
||||
if (isset($item['id'])) {
|
||||
$user = \App\Models\User::find($item['id']);
|
||||
if ($user) {
|
||||
$user->update([
|
||||
'first_name' => $item['first_name'] ?? null,
|
||||
'last_name' => $item['last_name'] ?? null,
|
||||
'email' => $item['email'] ?? null,
|
||||
'phone' => $item['phone'] ?? null,
|
||||
]);
|
||||
}
|
||||
$syncData[$item['id']] = [
|
||||
'contact_type' => $item['contact_type'] ?? 'staff',
|
||||
'is_primary' => $item['is_primary'] ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
// If no user found by ID, try to find by email
|
||||
if (! $user) {
|
||||
$user = \App\Models\User::where('email', $email)->first();
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
// Update existing user
|
||||
$user->update([
|
||||
'first_name' => $item['first_name'] ?? $user->first_name,
|
||||
'last_name' => $item['last_name'] ?? $user->last_name,
|
||||
'phone' => $item['phone'] ?? $user->phone,
|
||||
]);
|
||||
} else {
|
||||
// Create new user
|
||||
$user = \App\Models\User::create([
|
||||
'first_name' => $item['first_name'] ?? '',
|
||||
'last_name' => $item['last_name'] ?? '',
|
||||
'email' => $email,
|
||||
'phone' => $item['phone'] ?? null,
|
||||
'password' => bcrypt(\Illuminate\Support\Str::random(16)),
|
||||
'user_type' => $record->business_type === 'retailer' ? 'buyer' : 'seller',
|
||||
]);
|
||||
}
|
||||
|
||||
$syncData[$user->id] = [
|
||||
'contact_type' => $item['contact_type'] ?? 'staff',
|
||||
'is_primary' => $item['is_primary'] ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
// Auto-set first user as primary if no primary is set
|
||||
$hasPrimary = collect($syncData)->contains(fn ($data) => $data['is_primary']);
|
||||
if (! $hasPrimary && ! empty($syncData)) {
|
||||
$firstUserId = array_key_first($syncData);
|
||||
$syncData[$firstUserId]['is_primary'] = true;
|
||||
}
|
||||
|
||||
$record->users()->sync($syncData);
|
||||
})
|
||||
->itemLabel(fn (array $state): ?string => trim(($state['first_name'] ?? '').' '.($state['last_name'] ?? '')) ?:
|
||||
@@ -1660,23 +1772,27 @@ class BusinessResource extends Resource
|
||||
default => 'gray',
|
||||
})
|
||||
->sortable(),
|
||||
TextColumn::make('owner.full_name')
|
||||
TextColumn::make('primary_user')
|
||||
->label('Account Owner')
|
||||
->getStateUsing(function (Business $record): ?string {
|
||||
$owner = $record->owner;
|
||||
if ($owner) {
|
||||
$name = trim($owner->first_name.' '.$owner->last_name);
|
||||
// Use the primary user from the pivot table
|
||||
$primaryUser = $record->users->first();
|
||||
if ($primaryUser) {
|
||||
$name = trim($primaryUser->first_name.' '.$primaryUser->last_name);
|
||||
|
||||
return $name.' ('.$owner->email.')';
|
||||
return $name.' ('.$primaryUser->email.')';
|
||||
}
|
||||
|
||||
return 'N/A';
|
||||
})
|
||||
->searchable(query: function ($query, $search) {
|
||||
return $query->whereHas('owner', function ($q) use ($search) {
|
||||
$q->where('first_name', 'like', "%{$search}%")
|
||||
->orWhere('last_name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
return $query->whereHas('users', function ($q) use ($search) {
|
||||
$q->wherePivot('is_primary', true)
|
||||
->where(function ($q2) use ($search) {
|
||||
$q2->where('first_name', 'like', "%{$search}%")
|
||||
->orWhere('last_name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
})
|
||||
->sortable(),
|
||||
|
||||
@@ -13,6 +13,11 @@ class EditBusiness extends EditRecord
|
||||
{
|
||||
protected static string $resource = BusinessResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'Edit '.$this->record->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Livewire listeners for audit trail integration.
|
||||
*/
|
||||
|
||||
@@ -9,12 +9,13 @@ class CustomerController extends Controller
|
||||
/**
|
||||
* Customers entry point - smart gateway to CRM Accounts.
|
||||
*
|
||||
* If CRM is enabled: redirect to /s/{business}/crm/accounts
|
||||
* If CRM is enabled (via Sales Suite or CRM feature): redirect to /s/{business}/crm/accounts
|
||||
* If CRM is disabled: show feature-disabled view
|
||||
*/
|
||||
public function index(Business $business)
|
||||
{
|
||||
if ($business->has_crm) {
|
||||
// CRM is included in Sales Suite or can be enabled as standalone feature
|
||||
if ($business->hasCrmAccess()) {
|
||||
return redirect()->route('seller.business.crm.accounts.index', $business);
|
||||
}
|
||||
|
||||
@@ -34,12 +35,13 @@ class CustomerController extends Controller
|
||||
/**
|
||||
* Individual customer view - redirect to CRM Account detail.
|
||||
*
|
||||
* If CRM is enabled: redirect to the account detail page
|
||||
* If CRM is enabled (via Sales Suite or CRM feature): redirect to the account detail page
|
||||
* If CRM is disabled: show feature-disabled view
|
||||
*/
|
||||
public function show(Business $business, $customer)
|
||||
{
|
||||
if ($business->has_crm) {
|
||||
// CRM is included in Sales Suite or can be enabled as standalone feature
|
||||
if ($business->hasCrmAccess()) {
|
||||
// Redirect to CRM Account detail - $customer is the account ID
|
||||
return redirect()->route('seller.business.crm.accounts.show', [$business, $customer]);
|
||||
}
|
||||
|
||||
@@ -250,10 +250,14 @@ class BrandController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Load products for this brand (newest first)
|
||||
$products = $brand->products()
|
||||
// Load products for this brand (newest first) with pagination
|
||||
$perPage = $request->get('per_page', 50);
|
||||
$productsPaginator = $brand->products()
|
||||
->with('images')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get()
|
||||
->paginate($perPage);
|
||||
|
||||
$products = $productsPaginator->getCollection()
|
||||
->map(function ($product) use ($business, $brand) {
|
||||
// Set brand relationship so getImageUrl() can fall back to brand logo
|
||||
$product->setRelation('brand', $brand);
|
||||
@@ -273,6 +277,16 @@ class BrandController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
// Pagination info for the view
|
||||
$productsPagination = [
|
||||
'current_page' => $productsPaginator->currentPage(),
|
||||
'last_page' => $productsPaginator->lastPage(),
|
||||
'per_page' => $productsPaginator->perPage(),
|
||||
'total' => $productsPaginator->total(),
|
||||
'from' => $productsPaginator->firstItem(),
|
||||
'to' => $productsPaginator->lastItem(),
|
||||
];
|
||||
|
||||
return view('seller.brands.dashboard', array_merge($stats, [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
@@ -286,6 +300,8 @@ class BrandController extends Controller
|
||||
'recommendations' => $recommendations,
|
||||
'menus' => $menus,
|
||||
'products' => $products,
|
||||
'productsPagination' => $productsPagination,
|
||||
'productsPaginator' => $productsPaginator,
|
||||
'collections' => collect(), // Placeholder for future collections feature
|
||||
]));
|
||||
}
|
||||
@@ -293,31 +309,31 @@ class BrandController extends Controller
|
||||
/**
|
||||
* Preview the brand as it would appear to buyers
|
||||
*/
|
||||
public function preview(Business $business, Brand $brand)
|
||||
public function preview(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// Load relationships including active products with images, strain, unit, and product line
|
||||
// Only load parent products (exclude varieties from top level) and eager load their varieties
|
||||
$brand->load([
|
||||
'business',
|
||||
'products' => function ($query) {
|
||||
$query->where('is_active', true)
|
||||
->whereNull('parent_product_id') // Only parent products
|
||||
->with([
|
||||
'images',
|
||||
'strain',
|
||||
'unit',
|
||||
'productLine',
|
||||
'varieties' => function ($q) {
|
||||
$q->where('is_active', true)
|
||||
->with(['images', 'strain', 'unit'])
|
||||
->orderBy('name');
|
||||
},
|
||||
])
|
||||
->orderBy('name');
|
||||
},
|
||||
]);
|
||||
// Load brand with business relationship
|
||||
$brand->load('business');
|
||||
|
||||
// Paginate products (50 per page) instead of loading all
|
||||
$perPage = $request->get('per_page', 50);
|
||||
$productsPaginator = $brand->products()
|
||||
->where('is_active', true)
|
||||
->whereNull('parent_product_id') // Only parent products
|
||||
->with([
|
||||
'images',
|
||||
'strain',
|
||||
'unit',
|
||||
'productLine',
|
||||
'varieties' => function ($q) {
|
||||
$q->where('is_active', true)
|
||||
->with(['images', 'strain', 'unit'])
|
||||
->orderBy('name');
|
||||
},
|
||||
])
|
||||
->orderBy('name')
|
||||
->paginate($perPage);
|
||||
|
||||
// Get other brands from the same business
|
||||
$otherBrands = Brand::where('business_id', $brand->business_id)
|
||||
@@ -325,15 +341,15 @@ class BrandController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Group products by product line
|
||||
$productsByLine = $brand->products->groupBy(function ($product) {
|
||||
// Group paginated products by product line
|
||||
$productsByLine = $productsPaginator->getCollection()->groupBy(function ($product) {
|
||||
return $product->productLine->name ?? 'Uncategorized';
|
||||
});
|
||||
|
||||
// Allow viewing as buyer with ?as=buyer query parameter (for testing)
|
||||
$isSeller = request()->query('as') !== 'buyer';
|
||||
|
||||
return view('seller.brands.preview', compact('business', 'brand', 'otherBrands', 'productsByLine', 'isSeller'));
|
||||
return view('seller.brands.preview', compact('business', 'brand', 'otherBrands', 'productsByLine', 'productsPaginator', 'isSeller'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmEvent;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\SalesOpportunity;
|
||||
use App\Models\SendMenuLog;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountController extends Controller
|
||||
@@ -27,13 +32,84 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function show(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$account->load(['contacts', 'orders' => function ($q) use ($business) {
|
||||
$q->whereHas('items.product.brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
})->latest()->limit(10);
|
||||
}]);
|
||||
$account->load(['contacts']);
|
||||
|
||||
return view('seller.crm.accounts.show', compact('business', 'account'));
|
||||
// Get orders for this account from this seller
|
||||
$orders = $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Get opportunities for this account from this seller
|
||||
// SalesOpportunity uses business_id for the buyer
|
||||
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->with(['stage', 'brand'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Get tasks related to this account
|
||||
// CrmTask uses business_id for the buyer
|
||||
$tasks = CrmTask::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->whereNull('completed_at')
|
||||
->with('assignee')
|
||||
->orderBy('due_at')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Get conversation events for this account
|
||||
$conversationEvents = CrmEvent::where('seller_business_id', $business->id)
|
||||
->where('buyer_business_id', $account->id)
|
||||
->latest('occurred_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Get menu send history for this account
|
||||
$sendHistory = SendMenuLog::where('business_id', $business->id)
|
||||
->where('customer_id', $account->id)
|
||||
->with(['menu', 'brand'])
|
||||
->latest('sent_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Get activity log for this account
|
||||
$activities = Activity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->with(['causer'])
|
||||
->latest()
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Compute stats for this account (orders from this seller)
|
||||
$ordersQuery = $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
});
|
||||
|
||||
$pipelineValue = $opportunities->where('status', 'open')->sum('value');
|
||||
|
||||
$stats = [
|
||||
'total_orders' => $ordersQuery->count(),
|
||||
'total_revenue' => $ordersQuery->sum('total') ?? 0,
|
||||
'open_opportunities' => $opportunities->where('status', 'open')->count(),
|
||||
'pipeline_value' => $pipelineValue ?? 0,
|
||||
];
|
||||
|
||||
return view('seller.crm.accounts.show', compact(
|
||||
'business',
|
||||
'account',
|
||||
'stats',
|
||||
'orders',
|
||||
'opportunities',
|
||||
'tasks',
|
||||
'conversationEvents',
|
||||
'sendHistory',
|
||||
'activities'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,4 +153,27 @@ class AccountController extends Controller
|
||||
{
|
||||
return view('seller.crm.accounts.tasks', compact('business', 'account'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a note for an account
|
||||
*/
|
||||
public function storeNote(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$request->validate([
|
||||
'note' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
CrmEvent::log(
|
||||
sellerBusinessId: $business->id,
|
||||
eventType: 'note_added',
|
||||
summary: $request->input('note'),
|
||||
buyerBusinessId: $account->id,
|
||||
userId: auth()->id(),
|
||||
channel: 'system'
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
|
||||
->with('success', 'Note added successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class MessagingController extends Controller
|
||||
public function index(Request $request, \App\Models\Business $business)
|
||||
{
|
||||
// If CRM is enabled, use the enhanced CRM inbox
|
||||
if ($business->has_crm) {
|
||||
if ($business->hasCrmAccess()) {
|
||||
return app(CrmInboxController::class)->index($request, $business);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ class MessagingController extends Controller
|
||||
public function show(Request $request, \App\Models\Business $business, Conversation $conversation)
|
||||
{
|
||||
// If CRM is enabled, use the enhanced CRM inbox view
|
||||
if ($business->has_crm) {
|
||||
if ($business->hasCrmAccess()) {
|
||||
return app(CrmInboxController::class)->show($request, $business, $conversation);
|
||||
}
|
||||
|
||||
|
||||
@@ -704,7 +704,7 @@ class SettingsController extends Controller
|
||||
$validated['manual_order_emails_internal_only'] = $request->has('manual_order_emails_internal_only');
|
||||
|
||||
// CRM notification checkbox values
|
||||
if ($business->has_crm) {
|
||||
if ($business->hasCrmAccess()) {
|
||||
$validated['crm_task_reminder_enabled'] = $request->has('crm_task_reminder_enabled');
|
||||
$validated['crm_event_reminder_enabled'] = $request->has('crm_event_reminder_enabled');
|
||||
$validated['crm_daily_digest_enabled'] = $request->has('crm_daily_digest_enabled');
|
||||
|
||||
@@ -52,10 +52,8 @@ class EnsureBusinessHasCrm
|
||||
// This relies on route model binding: Route::prefix('{business}')
|
||||
$business = $request->route('business');
|
||||
|
||||
// Check if business has Sales Suite or CRM feature
|
||||
// CRM is included in the Sales Suite
|
||||
$hasAccess = $business
|
||||
&& ($business->hasSalesSuite() || $business->hasSuiteFeature('crm'));
|
||||
// Check if business has CRM access (via Sales Suite or standalone CRM feature)
|
||||
$hasAccess = $business && $business->hasCrmAccess();
|
||||
|
||||
if (! $hasAccess) {
|
||||
return response()->view('seller.crm.feature-disabled', [
|
||||
|
||||
@@ -67,9 +67,9 @@ class ProcessCrmCommandJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if business has CRM enabled
|
||||
if (! $business->has_crm) {
|
||||
Log::debug('CRM command job: Business does not have CRM enabled', [
|
||||
// Check if business has CRM access (via Sales Suite or standalone feature)
|
||||
if (! $business->hasCrmAccess()) {
|
||||
Log::debug('CRM command job: Business does not have CRM access', [
|
||||
'business_id' => $business->id,
|
||||
]);
|
||||
|
||||
|
||||
@@ -869,6 +869,17 @@ class Business extends Model implements AuditableContract
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if business has CRM access.
|
||||
*
|
||||
* CRM is included in the Sales Suite, or can be enabled as a standalone feature.
|
||||
* This method replaces the deprecated `has_crm` flag.
|
||||
*/
|
||||
public function hasCrmAccess(): bool
|
||||
{
|
||||
return $this->hasSalesSuite() || $this->hasSuiteFeature('crm');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if business is on Enterprise Plan (usage limits bypassed).
|
||||
*
|
||||
|
||||
@@ -21,6 +21,7 @@ class Contact extends Model
|
||||
'primary' => 'Primary Contact',
|
||||
'owner' => 'Owner/Executive',
|
||||
'manager' => 'General Manager',
|
||||
'brand_manager' => 'Brand Manager',
|
||||
'buyer' => 'Buyer/Purchasing Manager',
|
||||
'accounts_payable' => 'Accounts Payable',
|
||||
'accounts_receivable' => 'Accounts Receivable',
|
||||
|
||||
284
app/Models/Crm/CrmEvent.php
Normal file
284
app/Models/Crm/CrmEvent.php
Normal file
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Crm;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CrmEvent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'crm_events';
|
||||
|
||||
// Event types
|
||||
public const TYPES = [
|
||||
'menu_sent' => 'Menu Sent',
|
||||
'email_sent' => 'Email Sent',
|
||||
'email_received' => 'Email Received',
|
||||
'call_logged' => 'Call Logged',
|
||||
'call_scheduled' => 'Call Scheduled',
|
||||
'meeting_scheduled' => 'Meeting Scheduled',
|
||||
'meeting_completed' => 'Meeting Completed',
|
||||
'note_added' => 'Note Added',
|
||||
'sms_sent' => 'SMS Sent',
|
||||
'sms_received' => 'SMS Received',
|
||||
'quote_sent' => 'Quote Sent',
|
||||
'order_placed' => 'Order Placed',
|
||||
'opportunity_created' => 'Opportunity Created',
|
||||
'opportunity_won' => 'Opportunity Won',
|
||||
'opportunity_lost' => 'Opportunity Lost',
|
||||
'task_created' => 'Task Created',
|
||||
'task_completed' => 'Task Completed',
|
||||
];
|
||||
|
||||
// Channel options
|
||||
public const CHANNELS = [
|
||||
'email' => 'Email',
|
||||
'sms' => 'SMS',
|
||||
'phone' => 'Phone',
|
||||
'whatsapp' => 'WhatsApp',
|
||||
'in_person' => 'In Person',
|
||||
'system' => 'System',
|
||||
];
|
||||
|
||||
// Icon mapping for each event type
|
||||
public const TYPE_ICONS = [
|
||||
'menu_sent' => 'icon-[heroicons--paper-airplane]',
|
||||
'email_sent' => 'icon-[heroicons--envelope]',
|
||||
'email_received' => 'icon-[heroicons--envelope-open]',
|
||||
'call_logged' => 'icon-[heroicons--phone]',
|
||||
'call_scheduled' => 'icon-[heroicons--phone-arrow-up-right]',
|
||||
'meeting_scheduled' => 'icon-[heroicons--calendar]',
|
||||
'meeting_completed' => 'icon-[heroicons--calendar-days]',
|
||||
'note_added' => 'icon-[heroicons--document-text]',
|
||||
'sms_sent' => 'icon-[heroicons--chat-bubble-left]',
|
||||
'sms_received' => 'icon-[heroicons--chat-bubble-left-ellipsis]',
|
||||
'quote_sent' => 'icon-[heroicons--document-currency-dollar]',
|
||||
'order_placed' => 'icon-[heroicons--shopping-cart]',
|
||||
'opportunity_created' => 'icon-[heroicons--sparkles]',
|
||||
'opportunity_won' => 'icon-[heroicons--trophy]',
|
||||
'opportunity_lost' => 'icon-[heroicons--x-circle]',
|
||||
'task_created' => 'icon-[heroicons--clipboard-document-check]',
|
||||
'task_completed' => 'icon-[heroicons--check-circle]',
|
||||
];
|
||||
|
||||
// Color mapping for each event type
|
||||
public const TYPE_COLORS = [
|
||||
'menu_sent' => 'text-primary',
|
||||
'email_sent' => 'text-info',
|
||||
'email_received' => 'text-info',
|
||||
'call_logged' => 'text-secondary',
|
||||
'call_scheduled' => 'text-secondary',
|
||||
'meeting_scheduled' => 'text-warning',
|
||||
'meeting_completed' => 'text-success',
|
||||
'note_added' => 'text-base-content/60',
|
||||
'sms_sent' => 'text-accent',
|
||||
'sms_received' => 'text-accent',
|
||||
'quote_sent' => 'text-primary',
|
||||
'order_placed' => 'text-success',
|
||||
'opportunity_created' => 'text-primary',
|
||||
'opportunity_won' => 'text-success',
|
||||
'opportunity_lost' => 'text-error',
|
||||
'task_created' => 'text-info',
|
||||
'task_completed' => 'text-success',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'seller_business_id',
|
||||
'buyer_business_id',
|
||||
'contact_id',
|
||||
'brand_id',
|
||||
'user_id',
|
||||
'event_type',
|
||||
'channel',
|
||||
'summary',
|
||||
'payload',
|
||||
'occurred_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'occurred_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
/**
|
||||
* Get the seller business that owns this event
|
||||
*/
|
||||
public function sellerBusiness(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'seller_business_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the buyer business this event is related to
|
||||
*/
|
||||
public function buyerBusiness(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'buyer_business_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the contact this event is related to
|
||||
*/
|
||||
public function contact(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Contact::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the brand this event is related to
|
||||
*/
|
||||
public function brand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user who triggered this event
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
/**
|
||||
* Scope by seller business
|
||||
*/
|
||||
public function scopeForSellerBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('seller_business_id', $businessId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope by buyer business
|
||||
*/
|
||||
public function scopeForBuyerBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('buyer_business_id', $businessId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope by contact
|
||||
*/
|
||||
public function scopeForContact($query, int $contactId)
|
||||
{
|
||||
return $query->where('contact_id', $contactId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope by event type
|
||||
*/
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('event_type', $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope by channel
|
||||
*/
|
||||
public function scopeViaChannel($query, string $channel)
|
||||
{
|
||||
return $query->where('channel', $channel);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
/**
|
||||
* Get the icon class for this event type
|
||||
*/
|
||||
public function getIconClass(): string
|
||||
{
|
||||
return self::TYPE_ICONS[$this->event_type] ?? 'icon-[heroicons--document]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color class for this event type
|
||||
*/
|
||||
public function getColorClass(): string
|
||||
{
|
||||
return self::TYPE_COLORS[$this->event_type] ?? 'text-base-content/60';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display label for this event type
|
||||
*/
|
||||
public function getTypeLabel(): string
|
||||
{
|
||||
return self::TYPES[$this->event_type] ?? ucfirst(str_replace('_', ' ', $this->event_type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channel label
|
||||
*/
|
||||
public function getChannel(): ?string
|
||||
{
|
||||
return $this->channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted date
|
||||
*/
|
||||
public function getFormattedDate(): string
|
||||
{
|
||||
$date = $this->occurred_at ?? $this->created_at;
|
||||
|
||||
return $date->format('M j, Y g:i A');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time ago string
|
||||
*/
|
||||
public function getTimeAgo(): string
|
||||
{
|
||||
$date = $this->occurred_at ?? $this->created_at;
|
||||
|
||||
return $date->diffForHumans();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get menu ID from payload (for menu_sent events)
|
||||
*/
|
||||
public function getMenuId(): ?int
|
||||
{
|
||||
return $this->payload['menu_id'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a CRM event
|
||||
*/
|
||||
public static function log(
|
||||
int $sellerBusinessId,
|
||||
string $eventType,
|
||||
string $summary,
|
||||
?int $buyerBusinessId = null,
|
||||
?int $contactId = null,
|
||||
?int $brandId = null,
|
||||
?int $userId = null,
|
||||
?string $channel = null,
|
||||
?array $payload = null,
|
||||
?\DateTimeInterface $occurredAt = null
|
||||
): self {
|
||||
return self::create([
|
||||
'seller_business_id' => $sellerBusinessId,
|
||||
'buyer_business_id' => $buyerBusinessId,
|
||||
'contact_id' => $contactId,
|
||||
'brand_id' => $brandId,
|
||||
'user_id' => $userId ?? auth()->id(),
|
||||
'event_type' => $eventType,
|
||||
'channel' => $channel,
|
||||
'summary' => $summary,
|
||||
'payload' => $payload,
|
||||
'occurred_at' => $occurredAt ?? now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ class MessageObserver
|
||||
$conversation->load('brand.business');
|
||||
|
||||
$business = $conversation->brand?->business;
|
||||
if (! $business || ! $business->has_crm) {
|
||||
if (! $business || ! $business->hasCrmAccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\ParallelTesting;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
@@ -44,6 +45,16 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// Configure parallel testing to use separate databases per process
|
||||
// This prevents migration race conditions when running tests in parallel
|
||||
ParallelTesting::setUpProcess(function (int $token) {
|
||||
// Each parallel process gets its own database: testing_1, testing_2, etc.
|
||||
$baseDatabase = config('database.connections.pgsql.database');
|
||||
config([
|
||||
'database.connections.pgsql.database' => $baseDatabase.'_test_'.$token,
|
||||
]);
|
||||
});
|
||||
|
||||
// Force HTTPS for all generated URLs in non-local environments
|
||||
// This is required because:
|
||||
// 1. SSL terminates at the K8s ingress, so PHP sees HTTP
|
||||
@@ -162,7 +173,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
View::composer('components.seller-sidebar', function ($view) {
|
||||
$business = auth()->user()?->primaryBusiness();
|
||||
|
||||
if (! $business || ! $business->has_crm) {
|
||||
if (! $business || ! $business->hasCrmAccess()) {
|
||||
$view->with('crmStats', null);
|
||||
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
@@ -15,39 +16,79 @@ return new class extends Migration
|
||||
// Make lab_id nullable for backward compatibility
|
||||
$table->foreignId('lab_id')->nullable()->change();
|
||||
|
||||
// Lab/Test identification fields
|
||||
$table->string('test_id')->nullable()->after('batch_number');
|
||||
$table->string('lot_number')->nullable()->after('test_id');
|
||||
$table->string('lab_name')->nullable()->after('lot_number');
|
||||
$table->string('lab_license_number')->nullable()->after('lab_name');
|
||||
// Lab/Test identification fields (check if columns exist for parallel test safety)
|
||||
if (! Schema::hasColumn('batches', 'test_id')) {
|
||||
$table->string('test_id')->nullable()->after('batch_number');
|
||||
}
|
||||
if (! Schema::hasColumn('batches', 'lot_number')) {
|
||||
$table->string('lot_number')->nullable()->after('test_id');
|
||||
}
|
||||
if (! Schema::hasColumn('batches', 'lab_name')) {
|
||||
$table->string('lab_name')->nullable()->after('lot_number');
|
||||
}
|
||||
if (! Schema::hasColumn('batches', 'lab_license_number')) {
|
||||
$table->string('lab_license_number')->nullable()->after('lab_name');
|
||||
}
|
||||
|
||||
// Cannabinoid percentages
|
||||
$table->decimal('thc_percentage', 5, 2)->nullable()->after('test_date');
|
||||
$table->decimal('thca_percentage', 5, 2)->nullable()->after('thc_percentage');
|
||||
$table->decimal('cbd_percentage', 5, 2)->nullable()->after('thca_percentage');
|
||||
$table->decimal('cbda_percentage', 5, 2)->nullable()->after('cbd_percentage');
|
||||
$table->decimal('cbg_percentage', 5, 2)->nullable()->after('cbda_percentage');
|
||||
$table->decimal('cbn_percentage', 5, 2)->nullable()->after('cbg_percentage');
|
||||
$table->decimal('thcv_percentage', 5, 2)->nullable()->after('cbn_percentage');
|
||||
$table->decimal('cbdv_percentage', 5, 2)->nullable()->after('thcv_percentage');
|
||||
$table->decimal('delta_9_percentage', 5, 2)->nullable()->after('cbdv_percentage');
|
||||
if (! Schema::hasColumn('batches', 'thc_percentage')) {
|
||||
$table->decimal('thc_percentage', 5, 2)->nullable()->after('test_date');
|
||||
}
|
||||
if (! Schema::hasColumn('batches', 'thca_percentage')) {
|
||||
$table->decimal('thca_percentage', 5, 2)->nullable()->after('thc_percentage');
|
||||
}
|
||||
if (! Schema::hasColumn('batches', 'cbd_percentage')) {
|
||||
$table->decimal('cbd_percentage', 5, 2)->nullable()->after('thca_percentage');
|
||||
}
|
||||
if (! Schema::hasColumn('batches', 'cbda_percentage')) {
|
||||
$table->decimal('cbda_percentage', 5, 2)->nullable()->after('cbd_percentage');
|
||||
}
|
||||
if (! Schema::hasColumn('batches', 'cbg_percentage')) {
|
||||
$table->decimal('cbg_percentage', 5, 2)->nullable()->after('cbda_percentage');
|
||||
}
|
||||
if (! Schema::hasColumn('batches', 'cbn_percentage')) {
|
||||
$table->decimal('cbn_percentage', 5, 2)->nullable()->after('cbg_percentage');
|
||||
}
|
||||
if (! Schema::hasColumn('batches', 'thcv_percentage')) {
|
||||
$table->decimal('thcv_percentage', 5, 2)->nullable()->after('cbn_percentage');
|
||||
}
|
||||
if (! Schema::hasColumn('batches', 'cbdv_percentage')) {
|
||||
$table->decimal('cbdv_percentage', 5, 2)->nullable()->after('thcv_percentage');
|
||||
}
|
||||
if (! Schema::hasColumn('batches', 'delta_9_percentage')) {
|
||||
$table->decimal('delta_9_percentage', 5, 2)->nullable()->after('cbdv_percentage');
|
||||
}
|
||||
|
||||
// Calculated/total cannabinoid values
|
||||
$table->decimal('total_thc', 5, 2)->nullable()->after('delta_9_percentage');
|
||||
$table->decimal('total_cbd', 5, 2)->nullable()->after('total_thc');
|
||||
$table->decimal('total_cannabinoids', 5, 2)->nullable()->after('total_cbd');
|
||||
$table->decimal('total_terps_percentage', 5, 2)->nullable()->after('total_cannabinoids');
|
||||
if (! Schema::hasColumn('batches', 'total_thc')) {
|
||||
$table->decimal('total_thc', 5, 2)->nullable()->after('delta_9_percentage');
|
||||
}
|
||||
if (! Schema::hasColumn('batches', 'total_cbd')) {
|
||||
$table->decimal('total_cbd', 5, 2)->nullable()->after('total_thc');
|
||||
}
|
||||
if (! Schema::hasColumn('batches', 'total_cannabinoids')) {
|
||||
$table->decimal('total_cannabinoids', 5, 2)->nullable()->after('total_cbd');
|
||||
}
|
||||
if (! Schema::hasColumn('batches', 'total_terps_percentage')) {
|
||||
$table->decimal('total_terps_percentage', 5, 2)->nullable()->after('total_cannabinoids');
|
||||
}
|
||||
|
||||
// Terpene profile (JSON array)
|
||||
$table->json('terpenes')->nullable()->after('total_terps_percentage');
|
||||
if (! Schema::hasColumn('batches', 'terpenes')) {
|
||||
$table->json('terpenes')->nullable()->after('total_terps_percentage');
|
||||
}
|
||||
|
||||
// Additional notes field (from Lab model)
|
||||
// Note: batches table already has 'notes', so we'll skip adding it again
|
||||
|
||||
// Add indexes for common search fields
|
||||
$table->index('test_id');
|
||||
$table->index('lot_number');
|
||||
});
|
||||
|
||||
// Add indexes separately using raw SQL with IF NOT EXISTS pattern
|
||||
if (Schema::hasColumn('batches', 'test_id')) {
|
||||
DB::statement('CREATE INDEX IF NOT EXISTS batches_test_id_index ON batches (test_id)');
|
||||
}
|
||||
if (Schema::hasColumn('batches', 'lot_number')) {
|
||||
DB::statement('CREATE INDEX IF NOT EXISTS batches_lot_number_index ON batches (lot_number)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
@@ -11,79 +12,151 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// All column additions check for existence to support parallel test execution
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
// Foreign Keys
|
||||
$table->foreignId('packaging_id')->nullable()->after('strain_id')->constrained('product_packagings')->onDelete('set null');
|
||||
$table->foreignId('unit_id')->nullable()->after('packaging_id')->constrained('units')->onDelete('set null');
|
||||
if (! Schema::hasColumn('products', 'packaging_id')) {
|
||||
$table->foreignId('packaging_id')->nullable()->after('strain_id')->constrained('product_packagings')->onDelete('set null');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'unit_id')) {
|
||||
$table->foreignId('unit_id')->nullable()->after('packaging_id')->constrained('units')->onDelete('set null');
|
||||
}
|
||||
|
||||
// Metadata
|
||||
$table->string('product_line')->nullable()->after('category');
|
||||
$table->text('product_link')->nullable()->after('product_line'); // External URL
|
||||
$table->text('creatives')->nullable()->after('product_link'); // Marketing assets
|
||||
// barcode column already added by 2025_10_30_000006_add_barcode_to_products_table
|
||||
$table->integer('brand_display_order')->nullable()->after('sort_order');
|
||||
if (! Schema::hasColumn('products', 'product_line')) {
|
||||
$table->string('product_line')->nullable()->after('category');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'product_link')) {
|
||||
$table->text('product_link')->nullable()->after('product_line');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'creatives')) {
|
||||
$table->text('creatives')->nullable()->after('product_link');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'brand_display_order')) {
|
||||
$table->integer('brand_display_order')->nullable()->after('sort_order');
|
||||
}
|
||||
|
||||
// Configuration Flags
|
||||
$table->boolean('has_varieties')->default(false)->after('is_raw_material');
|
||||
$table->boolean('sell_multiples')->default(false)->after('has_varieties');
|
||||
$table->boolean('fractional_quantities')->default(false)->after('sell_multiples');
|
||||
$table->boolean('allow_sample')->default(false)->after('fractional_quantities');
|
||||
$table->boolean('is_fpr')->default(false)->after('allow_sample'); // Finished Product Ready
|
||||
$table->boolean('is_sellable')->default(false)->after('is_fpr');
|
||||
if (! Schema::hasColumn('products', 'has_varieties')) {
|
||||
$table->boolean('has_varieties')->default(false)->after('is_raw_material');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'sell_multiples')) {
|
||||
$table->boolean('sell_multiples')->default(false)->after('has_varieties');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'fractional_quantities')) {
|
||||
$table->boolean('fractional_quantities')->default(false)->after('sell_multiples');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'allow_sample')) {
|
||||
$table->boolean('allow_sample')->default(false)->after('fractional_quantities');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'is_fpr')) {
|
||||
$table->boolean('is_fpr')->default(false)->after('allow_sample');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'is_sellable')) {
|
||||
$table->boolean('is_sellable')->default(false)->after('is_fpr');
|
||||
}
|
||||
|
||||
// Case/Box Packaging
|
||||
$table->boolean('is_case')->default(false)->after('units_per_case');
|
||||
$table->integer('cased_qty')->default(0)->after('is_case');
|
||||
$table->boolean('is_box')->default(false)->after('cased_qty');
|
||||
$table->integer('boxed_qty')->default(0)->after('is_box');
|
||||
if (! Schema::hasColumn('products', 'is_case')) {
|
||||
$table->boolean('is_case')->default(false)->after('units_per_case');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'cased_qty')) {
|
||||
$table->integer('cased_qty')->default(0)->after('is_case');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'is_box')) {
|
||||
$table->boolean('is_box')->default(false)->after('cased_qty');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'boxed_qty')) {
|
||||
$table->integer('boxed_qty')->default(0)->after('is_box');
|
||||
}
|
||||
|
||||
// Dates
|
||||
$table->date('launch_date')->nullable()->after('test_date');
|
||||
if (! Schema::hasColumn('products', 'launch_date')) {
|
||||
$table->date('launch_date')->nullable()->after('test_date');
|
||||
}
|
||||
|
||||
// Inventory Management
|
||||
$table->integer('inventory_manage_pct')->nullable()->after('reorder_point'); // 0-100%
|
||||
$table->integer('min_order_qty')->nullable()->after('inventory_manage_pct');
|
||||
$table->integer('max_order_qty')->nullable()->after('min_order_qty');
|
||||
$table->integer('low_stock_threshold')->nullable()->after('max_order_qty');
|
||||
$table->boolean('low_stock_alert_enabled')->default(false)->after('low_stock_threshold');
|
||||
if (! Schema::hasColumn('products', 'inventory_manage_pct')) {
|
||||
$table->integer('inventory_manage_pct')->nullable()->after('reorder_point');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'min_order_qty')) {
|
||||
$table->integer('min_order_qty')->nullable()->after('inventory_manage_pct');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'max_order_qty')) {
|
||||
$table->integer('max_order_qty')->nullable()->after('min_order_qty');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'low_stock_threshold')) {
|
||||
$table->integer('low_stock_threshold')->nullable()->after('max_order_qty');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'low_stock_alert_enabled')) {
|
||||
$table->boolean('low_stock_alert_enabled')->default(false)->after('low_stock_threshold');
|
||||
}
|
||||
|
||||
// Strain Value
|
||||
$table->decimal('strain_value', 8, 2)->nullable()->after('cbd_content_mg');
|
||||
if (! Schema::hasColumn('products', 'strain_value')) {
|
||||
$table->decimal('strain_value', 8, 2)->nullable()->after('cbd_content_mg');
|
||||
}
|
||||
|
||||
// Arizona Compliance
|
||||
$table->decimal('arz_total_weight', 10, 3)->nullable()->after('license_number');
|
||||
$table->decimal('arz_usable_mmj', 10, 3)->nullable()->after('arz_total_weight');
|
||||
if (! Schema::hasColumn('products', 'arz_total_weight')) {
|
||||
$table->decimal('arz_total_weight', 10, 3)->nullable()->after('license_number');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'arz_usable_mmj')) {
|
||||
$table->decimal('arz_usable_mmj', 10, 3)->nullable()->after('arz_total_weight');
|
||||
}
|
||||
|
||||
// Extended Descriptions
|
||||
$table->text('long_description')->nullable()->after('description');
|
||||
$table->text('ingredients')->nullable()->after('long_description');
|
||||
$table->text('effects')->nullable()->after('ingredients');
|
||||
$table->text('dosage_guidelines')->nullable()->after('effects');
|
||||
if (! Schema::hasColumn('products', 'long_description')) {
|
||||
$table->text('long_description')->nullable()->after('description');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'ingredients')) {
|
||||
$table->text('ingredients')->nullable()->after('long_description');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'effects')) {
|
||||
$table->text('effects')->nullable()->after('ingredients');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'dosage_guidelines')) {
|
||||
$table->text('dosage_guidelines')->nullable()->after('effects');
|
||||
}
|
||||
|
||||
// Visibility
|
||||
$table->boolean('show_inventory_to_buyers')->default(false)->after('is_featured');
|
||||
if (! Schema::hasColumn('products', 'show_inventory_to_buyers')) {
|
||||
$table->boolean('show_inventory_to_buyers')->default(false)->after('is_featured');
|
||||
}
|
||||
|
||||
// Threshold Automation
|
||||
$table->integer('decreasing_qty_threshold')->nullable()->after('low_stock_alert_enabled');
|
||||
$table->string('decreasing_qty_action')->nullable()->after('decreasing_qty_threshold');
|
||||
$table->integer('increasing_qty_threshold')->nullable()->after('decreasing_qty_action');
|
||||
$table->string('increasing_qty_action')->nullable()->after('increasing_qty_threshold');
|
||||
if (! Schema::hasColumn('products', 'decreasing_qty_threshold')) {
|
||||
$table->integer('decreasing_qty_threshold')->nullable()->after('low_stock_alert_enabled');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'decreasing_qty_action')) {
|
||||
$table->string('decreasing_qty_action')->nullable()->after('decreasing_qty_threshold');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'increasing_qty_threshold')) {
|
||||
$table->integer('increasing_qty_threshold')->nullable()->after('decreasing_qty_action');
|
||||
}
|
||||
if (! Schema::hasColumn('products', 'increasing_qty_action')) {
|
||||
$table->string('increasing_qty_action')->nullable()->after('increasing_qty_threshold');
|
||||
}
|
||||
|
||||
// Enhanced Status (update from boolean to enum)
|
||||
$table->enum('status', ['available', 'archived', 'sample', 'backorder', 'internal', 'unavailable'])
|
||||
->default('available')
|
||||
->after('is_active');
|
||||
if (! Schema::hasColumn('products', 'status')) {
|
||||
$table->enum('status', ['available', 'archived', 'sample', 'backorder', 'internal', 'unavailable'])
|
||||
->default('available')
|
||||
->after('is_active');
|
||||
}
|
||||
|
||||
// MSRP for retail pricing
|
||||
$table->decimal('msrp', 10, 2)->nullable()->after('wholesale_price');
|
||||
|
||||
// Add indexes for new foreign keys and frequently queried fields
|
||||
$table->index('packaging_id');
|
||||
$table->index('unit_id');
|
||||
$table->index('status');
|
||||
$table->index('is_sellable');
|
||||
$table->index('launch_date');
|
||||
if (! Schema::hasColumn('products', 'msrp')) {
|
||||
$table->decimal('msrp', 10, 2)->nullable()->after('wholesale_price');
|
||||
}
|
||||
});
|
||||
|
||||
// Add indexes using IF NOT EXISTS for parallel test safety
|
||||
DB::statement('CREATE INDEX IF NOT EXISTS products_packaging_id_index ON products (packaging_id)');
|
||||
DB::statement('CREATE INDEX IF NOT EXISTS products_unit_id_index ON products (unit_id)');
|
||||
DB::statement('CREATE INDEX IF NOT EXISTS products_status_index ON products (status)');
|
||||
DB::statement('CREATE INDEX IF NOT EXISTS products_is_sellable_index ON products (is_sellable)');
|
||||
DB::statement('CREATE INDEX IF NOT EXISTS products_launch_date_index ON products (launch_date)');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Drop existing constraint
|
||||
DB::statement('ALTER TABLE products DROP CONSTRAINT IF EXISTS products_type_check');
|
||||
|
||||
// Add new constraint with updated types
|
||||
DB::statement("ALTER TABLE products ADD CONSTRAINT products_type_check CHECK (type IN (
|
||||
'accessories',
|
||||
'additives',
|
||||
'bulk',
|
||||
'cartridges',
|
||||
'concentrates',
|
||||
'edibles',
|
||||
'flower',
|
||||
'lab_testing',
|
||||
'packaging',
|
||||
'pre_roll',
|
||||
'topicals'
|
||||
))");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Drop new constraint
|
||||
DB::statement('ALTER TABLE products DROP CONSTRAINT IF EXISTS products_type_check');
|
||||
|
||||
// Restore original constraint
|
||||
DB::statement("ALTER TABLE products ADD CONSTRAINT products_type_check CHECK (type IN (
|
||||
'flower',
|
||||
'pre_roll',
|
||||
'concentrate',
|
||||
'edible',
|
||||
'vape',
|
||||
'topical',
|
||||
'tincture',
|
||||
'accessory',
|
||||
'raw_material',
|
||||
'assembly'
|
||||
))");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('crm_events', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('seller_business_id')->constrained('businesses')->cascadeOnDelete();
|
||||
$table->foreignId('buyer_business_id')->nullable()->constrained('businesses')->nullOnDelete();
|
||||
$table->foreignId('contact_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('brand_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
|
||||
$table->string('event_type'); // menu_sent, email_sent, call_logged, etc.
|
||||
$table->string('channel')->nullable(); // email, sms, phone, whatsapp, in_person, system
|
||||
$table->text('summary'); // Human-readable description of what happened
|
||||
$table->json('payload')->nullable(); // Additional structured data (menu_id, etc.)
|
||||
$table->timestamp('occurred_at')->nullable(); // When the event actually occurred
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes for common queries
|
||||
$table->index(['seller_business_id', 'buyer_business_id', 'occurred_at']);
|
||||
$table->index(['seller_business_id', 'contact_id', 'occurred_at']);
|
||||
$table->index(['event_type', 'occurred_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('crm_events');
|
||||
}
|
||||
};
|
||||
@@ -2,697 +2,214 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* BrandsTableSeeder - Seeds Cannabrands LLC brands using slug-based lookup.
|
||||
*
|
||||
* This seeder is IDEMPOTENT - it uses updateOrCreate with slug as the unique key.
|
||||
* IDs and hashids are auto-generated, ensuring consistency across environments.
|
||||
*
|
||||
* Run with: php artisan db:seed --class=BrandsTableSeeder
|
||||
*/
|
||||
class BrandsTableSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Auto generated seed file
|
||||
*
|
||||
* @return void
|
||||
* Brand configurations keyed by slug.
|
||||
* The slug is the stable identifier used for lookups.
|
||||
*/
|
||||
public function run()
|
||||
private array $brands = [
|
||||
'canna-rso' => [
|
||||
'name' => 'Canna RSO',
|
||||
'description' => 'Rick Simpson Oil cannabis extracts',
|
||||
'tagline' => 'Pure Cannabis Extract',
|
||||
'sku_prefix' => 'CRSO',
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => true,
|
||||
'sort_order' => 1,
|
||||
],
|
||||
'thunder-bud' => [
|
||||
'name' => 'Thunder Bud',
|
||||
'description' => 'High-potency flower and pre-rolls that hit hard and fast.',
|
||||
'tagline' => 'Strike Like Thunder',
|
||||
'sku_prefix' => 'TBUD',
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => true,
|
||||
'sort_order' => 2,
|
||||
],
|
||||
'doobz' => [
|
||||
'name' => 'Doobz',
|
||||
'description' => 'Premium pre-rolled cannabis for the modern consumer.',
|
||||
'tagline' => 'Roll With The Best',
|
||||
'sku_prefix' => 'DOBZ',
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => true,
|
||||
'sort_order' => 3,
|
||||
],
|
||||
'high-expectations' => [
|
||||
'name' => 'High Expectations',
|
||||
'description' => 'Live Hash Rosin Hash Hole Prerolls',
|
||||
'tagline' => 'Elevated Prerolls',
|
||||
'sku_prefix' => 'HIEX',
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 4,
|
||||
],
|
||||
'twisties' => [
|
||||
'name' => 'Twisties',
|
||||
'description' => 'Unique cannabis creations with a twist.',
|
||||
'tagline' => 'Get Twisted',
|
||||
'sku_prefix' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 4,
|
||||
],
|
||||
'hash-factory' => [
|
||||
'name' => 'Hash Factory',
|
||||
'description' => 'Premium cannabis concentrates and traditional hash.',
|
||||
'tagline' => 'Crafted Excellence',
|
||||
'sku_prefix' => 'HFAC',
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => true,
|
||||
'sort_order' => 5,
|
||||
],
|
||||
'just-vape' => [
|
||||
'name' => 'Just Vape',
|
||||
'description' => '0.5G Live Hash Rosin Vape Cartridges',
|
||||
'tagline' => 'Pure Rosin Vapes',
|
||||
'sku_prefix' => 'JVAP',
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 5,
|
||||
],
|
||||
'dairy2dank' => [
|
||||
'name' => 'Dairy2Dank',
|
||||
'description' => 'Creamy, smooth cannabis with rich terpene profiles.',
|
||||
'tagline' => 'From Farm to Flame',
|
||||
'sku_prefix' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => true,
|
||||
'sort_order' => 6,
|
||||
],
|
||||
'aloha-tymemachine' => [
|
||||
'name' => 'Aloha TymeMachine',
|
||||
'description' => 'Cannabis-Infused Beverages',
|
||||
'tagline' => 'Liquid Refreshment',
|
||||
'sku_prefix' => 'ALOH',
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 7,
|
||||
],
|
||||
'outlaw-cannabis' => [
|
||||
'name' => 'Outlaw Cannabis',
|
||||
'description' => '1G All Flower Prerolls',
|
||||
'tagline' => 'Pure Flower Rolls',
|
||||
'sku_prefix' => 'OUTL',
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 8,
|
||||
],
|
||||
'nuvata' => [
|
||||
'name' => 'Nuvata',
|
||||
'description' => '1g All-in-One Vapes',
|
||||
'tagline' => 'Powerful Portability',
|
||||
'sku_prefix' => 'NUVA',
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 10,
|
||||
],
|
||||
'doinks' => [
|
||||
'name' => 'Doinks',
|
||||
'description' => '2G Hash Infused Hemp Wrap Blunts',
|
||||
'tagline' => 'Big Smoke Energy',
|
||||
'sku_prefix' => 'DINK',
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 11,
|
||||
],
|
||||
'proper-cock' => [
|
||||
'name' => 'Proper Cock',
|
||||
'description' => 'Premium solventless concentrates',
|
||||
'tagline' => null,
|
||||
'sku_prefix' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 12,
|
||||
],
|
||||
'blitzd' => [
|
||||
'name' => "Blitz'd",
|
||||
'description' => 'High-intensity cannabis products',
|
||||
'tagline' => null,
|
||||
'sku_prefix' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 13,
|
||||
],
|
||||
'white-label-canna' => [
|
||||
'name' => 'White Label Canna',
|
||||
'description' => 'Custom cannabis manufacturing solutions',
|
||||
'tagline' => null,
|
||||
'sku_prefix' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 14,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->info('Seeding brands...');
|
||||
|
||||
\DB::table('brands')->delete();
|
||||
// Get Cannabrands LLC business (the parent company for all brands)
|
||||
$cannabrands = Business::where('slug', 'cannabrands')->first();
|
||||
|
||||
\DB::table('brands')->insert([
|
||||
0 => [
|
||||
'id' => 3,
|
||||
'business_id' => 4,
|
||||
'name' => 'Hash Factory',
|
||||
'slug' => 'hash-factory',
|
||||
'description' => 'At Hash Factory, we are dedicated to delivering a hash experience that embodies the essence of euphoria. With our meticulously handcrafted hash, you unlock a world of flavors, effects, and craftsmanship that elevate your cannabis journey.',
|
||||
'tagline' => 'Manufacturing Happiness',
|
||||
'logo_path' => 'businesses/cannabrands/brands/hash-factory/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => true,
|
||||
'sort_order' => 3,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 19:42:23',
|
||||
'updated_at' => '2025-11-24 00:03:41',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '55ak4',
|
||||
'sku_prefix' => 'HFAC',
|
||||
'long_description' => 'At Hash Factory, we are dedicated to delivering a hash experience that embodies the essence of euphoria. With our meticulously handcrafted hash, you unlock a world of flavors, effects, and craftsmanship that elevate your cannabis journey. Embrace the artistry and excellence that define our premium hash, and embark on a sensory adventure that captivates the mind, body, and soul. Choose Hash Factory for an unforgettable hash experience and discover the true essence of euphoria today.',
|
||||
'banner_path' => 'businesses/cannabrands/brands/hash-factory/branding/banner.png',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
1 => [
|
||||
'id' => 2,
|
||||
'business_id' => 4,
|
||||
'name' => 'Doobz',
|
||||
'slug' => 'doobz',
|
||||
'description' => 'Doobz Infused Prerolls are crafted for those who value quality, innovation, and community.',
|
||||
'tagline' => null,
|
||||
'logo_path' => 'businesses/cannabrands/brands/doobz/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => true,
|
||||
'sort_order' => 2,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 19:42:23',
|
||||
'updated_at' => '2025-11-24 00:03:41',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '93xm5',
|
||||
'sku_prefix' => 'DOBZ',
|
||||
'long_description' => 'Doobz Infused Prerolls are crafted for those who value quality, innovation, and community. Made with premium, locally-grown flower and infused with top-tier solventless concentrates, every preroll delivers bold potency and clean, rich flavor. Doobz leads with advanced infusion techniques and a commitment to sustainability offering more than just a smoke, but a movement rooted in integrity and elevated experiences.',
|
||||
'banner_path' => 'businesses/cannabrands/brands/doobz/branding/banner.jpg',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
2 => [
|
||||
'id' => 4,
|
||||
'business_id' => 4,
|
||||
'name' => 'High Expectations',
|
||||
'slug' => 'high-expectations',
|
||||
'description' => 'Rolled to Perfection.',
|
||||
'tagline' => 'Setting High Expectations',
|
||||
'logo_path' => 'businesses/cannabrands/brands/high-expectations/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 4,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 19:42:23',
|
||||
'updated_at' => '2025-11-24 00:03:41',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '38sv8',
|
||||
'sku_prefix' => 'HIEX',
|
||||
'long_description' => 'Rolled to Perfection. Your taste buds\' new best friend.',
|
||||
'banner_path' => 'businesses/cannabrands/brands/high-expectations/branding/banner.jpg',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
3 => [
|
||||
'id' => 5,
|
||||
'business_id' => 4,
|
||||
'name' => 'Just Vape',
|
||||
'slug' => 'just-vape',
|
||||
'description' => 'Just Vape is a commitment to clean, effective, and responsible vaping.',
|
||||
'tagline' => 'Elevating Your Vaping Experience',
|
||||
'logo_path' => 'businesses/cannabrands/brands/just-vape/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 5,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 19:42:23',
|
||||
'updated_at' => '2025-11-24 00:04:00',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '91wk2',
|
||||
'sku_prefix' => 'JVAP',
|
||||
'long_description' => 'Just Vape is a commitment to clean, effective, and responsible vaping. We use only high-quality ingredients and all-ceramic, heavy-metal-free hardware for a pure experience you can trust. Backed by innovation and transparency, our products are rigorously tested and crafted for those who care about what they inhale. Just Vape isn\'t just a brand it\'s a promise of integrity, quality, and progress.',
|
||||
'banner_path' => 'businesses/cannabrands/brands/just-vape/branding/banner.jpg',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
4 => [
|
||||
'id' => 16,
|
||||
'business_id' => 4,
|
||||
'name' => 'Proper Cock',
|
||||
'slug' => 'proper-cock',
|
||||
'description' => 'Proper Cock delivers top-tier, solventless cannabis concentrates.',
|
||||
'tagline' => null,
|
||||
'logo_path' => 'businesses/cannabrands/brands/proper-cock/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 0,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 23:09:47',
|
||||
'updated_at' => '2025-11-24 00:04:00',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '5462e',
|
||||
'sku_prefix' => null,
|
||||
'long_description' => 'Proper Cock delivers top-tier, solventless cannabis concentrates like hash buttons, rosin jams, and diamonds crafted for purity, potency, and flavor. Their artisanal products are 100% plant-based and chemical-free, offering a balanced, high-quality experience for both creative daytime use and relaxed evenings.',
|
||||
'banner_path' => 'businesses/cannabrands/brands/proper-cock/branding/banner.jpg',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
5 => [
|
||||
'id' => 6,
|
||||
'business_id' => 4,
|
||||
'name' => 'Thunder Bud',
|
||||
'slug' => 'thunder-bud',
|
||||
'description' => 'Unique strains for enhanced experience.',
|
||||
'tagline' => 'Big Highs, Small Price',
|
||||
'logo_path' => 'businesses/cannabrands/brands/thunder-bud/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 6,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 19:42:23',
|
||||
'updated_at' => '2025-11-24 00:04:00',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '14jb8',
|
||||
'sku_prefix' => 'TBUD',
|
||||
'long_description' => 'Unique strains for enhanced experience: Our trim plus flower prerolls offer a unique blend of premium flower and high-quality trim (we offer ice water hash infused options too!), providing a richer, more balanced smoking experience. Unlike other prerolls that use lower-grade materials, our blend ensures a smooth burn, consistent potency, and a fuller flavor profile. This combination not only maximizes the use of the entire plant but also delivers a superior and cost-effective option for discerning cannabis enthusiasts.',
|
||||
'banner_path' => 'businesses/cannabrands/brands/thunder-bud/branding/banner.jpg',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
6 => [
|
||||
'id' => 9,
|
||||
'business_id' => 4,
|
||||
'name' => 'Twisites',
|
||||
'slug' => 'twisites',
|
||||
'description' => 'Twisties offers a premium cannabis pre-roll experience.',
|
||||
'tagline' => 'Unwind with Twisties',
|
||||
'logo_path' => 'businesses/cannabrands/brands/twisites/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 9,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 19:42:23',
|
||||
'updated_at' => '2025-11-24 00:04:18',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '31ns8',
|
||||
'sku_prefix' => 'TWIS',
|
||||
'long_description' => 'Twisties offers a premium cannabis pre-roll experience with our small-batch, hand-crafted products made from quality flower. With a focus on quality and attention to detail, Twisties sets a high standard for those seeking a top-notch pre-roll experience.',
|
||||
'banner_path' => 'businesses/cannabrands/brands/twisites/branding/banner.jpg',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
7 => [
|
||||
'id' => 1,
|
||||
'business_id' => 4,
|
||||
'name' => 'Canna RSO',
|
||||
'slug' => 'canna-rso',
|
||||
'description' => 'Experience the breakthrough extract backed by nature!',
|
||||
'tagline' => 'Pure by Design',
|
||||
'logo_path' => 'businesses/cannabrands/brands/canna-rso/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => true,
|
||||
'sort_order' => 1,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 19:42:23',
|
||||
'updated_at' => '2025-11-24 00:04:18',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '24zv3',
|
||||
'sku_prefix' => 'CRSO',
|
||||
'long_description' => 'Experience the breakthrough extract backed by nature!',
|
||||
'banner_path' => 'businesses/cannabrands/brands/canna-rso/branding/banner.jpg',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
8 => [
|
||||
'id' => 11,
|
||||
'business_id' => 4,
|
||||
'name' => 'Doinks',
|
||||
'slug' => 'doinks',
|
||||
'description' => 'Doinks isn\'t just a product, it\'s a statement.',
|
||||
'tagline' => 'This Ain\'t a Joint. It\'s a DOINK.',
|
||||
'logo_path' => 'businesses/cannabrands/brands/doinks/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 11,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 19:42:23',
|
||||
'updated_at' => '2025-11-24 00:04:18',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '41he7',
|
||||
'sku_prefix' => 'DINK',
|
||||
'long_description' => 'Doinks isn\'t just a product, it\'s a statement. We craft 2-gram hemp-wrapped blunts infused with premium ice water hash and packed with 100% top-shelf flower. No fillers. No shortcuts. Just potent, terp-rich smoke that delivers every time. Every Doink is rolled with intention: bold flavor profiles, smooth glass tips, and a heavy-hitting high designed for heads who know what quality really means. Doinks bring the fire; loud, clean, and always handcrafted in small batches. Because this ain\'t a joint. It\'s a DOINK.',
|
||||
'banner_path' => 'businesses/cannabrands/brands/doinks/branding/banner.jpg',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
9 => [
|
||||
'id' => 8,
|
||||
'business_id' => 4,
|
||||
'name' => 'Outlaw',
|
||||
'slug' => 'outlaw-cannabis',
|
||||
'description' => 'Exclusive Drops. Legendary Quality. Limited Supply.',
|
||||
'tagline' => 'Exclusive Drops. Legendary Quality.',
|
||||
'logo_path' => 'businesses/cannabrands/brands/outlaw-cannabis/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 8,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 19:42:23',
|
||||
'updated_at' => '2025-11-24 00:04:56',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '83qe3',
|
||||
'sku_prefix' => 'OUTL',
|
||||
'long_description' => 'Exclusive Drops. Legendary Quality. Limited Supply.',
|
||||
'banner_path' => 'businesses/cannabrands/brands/outlaw-cannabis/branding/banner.png',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
10 => [
|
||||
'id' => 14,
|
||||
'business_id' => 4,
|
||||
'name' => 'Dairy to Dank',
|
||||
'slug' => 'dairy-to-dank',
|
||||
'description' => 'Journey with us from Dairy to Dank, a brand that is as spirited, industrious, and triumphant as the dairymen, oilmen, golfers, and entrepreneurs of New Mexico, Texas, and Arizona.',
|
||||
'tagline' => null,
|
||||
'logo_path' => 'businesses/cannabrands/brands/dairy-to-dank/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 0,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 23:09:18',
|
||||
'updated_at' => '2025-11-24 00:04:56',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '88a2e',
|
||||
'sku_prefix' => null,
|
||||
'long_description' => 'Our products take root in the extraordinary transition of the Greathouse family from the dairy industry to the cannabis landscape, embodying our dedication to quality, innovation, and natural wellness. Embrace the legacy of growth and excellence with Dairy to Dank – a proud testament to our past, a beacon for our future, and a commitment to our fellow cultivators of success.',
|
||||
'banner_path' => 'businesses/cannabrands/brands/dairy-to-dank/branding/banner.jpg',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
11 => [
|
||||
'id' => 15,
|
||||
'business_id' => 4,
|
||||
'name' => 'Blitzd',
|
||||
'slug' => 'blitzd',
|
||||
'description' => 'Too much in too little. BLITZ\'D is not your chill smoke.',
|
||||
'tagline' => 'Your New Favorite Bad Idea',
|
||||
'logo_path' => 'businesses/cannabrands/brands/blitzd/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 0,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 23:09:35',
|
||||
'updated_at' => '2025-11-24 00:04:38',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '4f53f',
|
||||
'sku_prefix' => null,
|
||||
'long_description' => 'Too much in too little. BLITZ\'D is not your chill smoke. It\'s the joint you light when you shouldn\'t but do anyway. We\'re talking 0.5g of pure chaos: infused with flower, hash, and THC oil, packed so tight it practically dares you to finish it. High-THC. High-risk. High-reward. Each pre-roll is a tiny timebomb dressed up in a clear tube. No fluff. No filters. Just heavy hits in a compact format that goes way too hard. We don\'t do mids. We don\'t do moderation. BLITZ\'D is for the ones who know better and still spark it. This isn\'t for everyone. It\'s for you.',
|
||||
'banner_path' => 'businesses/cannabrands/brands/blitzd/branding/banner.png',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
12 => [
|
||||
'id' => 17,
|
||||
'business_id' => 4,
|
||||
'name' => 'White Label Canna',
|
||||
'slug' => 'white-label-canna',
|
||||
'description' => 'White Label Canna empowers cannabis brands with turnkey, high-quality manufacturing.',
|
||||
'tagline' => 'Your Partner for Cannabis Success',
|
||||
'logo_path' => 'businesses/cannabrands/brands/white-label-canna/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 0,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 23:10:00',
|
||||
'updated_at' => '2025-11-24 00:04:38',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '92f87',
|
||||
'sku_prefix' => null,
|
||||
'long_description' => 'White Label Canna empowers cannabis brands with turnkey, high-quality manufacturing combining innovative solventless extraction, tailored product development, and polished packaging solutions to help you launch or scale quickly with confidence.',
|
||||
'banner_path' => 'businesses/cannabrands/brands/white-label-canna/branding/banner.jpg',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
13 => [
|
||||
'id' => 10,
|
||||
'business_id' => 4,
|
||||
'name' => 'Nuvata',
|
||||
'slug' => 'nuvata',
|
||||
'description' => 'Whether you\'re looking for an aid to wellness and mindfulness or simply to add a little flavor to life, Nuvata\'s premium vaporizers go beyond expectation.',
|
||||
'tagline' => null,
|
||||
'logo_path' => 'businesses/cannabrands/brands/nuvata/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 10,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 19:42:23',
|
||||
'updated_at' => '2025-11-24 00:04:56',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '44gf9',
|
||||
'sku_prefix' => 'NUVA',
|
||||
'long_description' => 'Our process of combining cannabinoids and terpenoids, paired with our understanding of the "entourage effect", allows us to create enhanced states of mind, body, and everywhere in between. Terpenoid research is the frontier of cannabis innovation, and through it, we customize effects to provide a personalized experience. From there, our focus lies on crafting flavorful profiles to complement these effects as flavors represent how we celebrate, how we nurture, and how culture is passed down through taste buds worldwide.',
|
||||
'banner_path' => 'businesses/cannabrands/brands/nuvata/branding/banner.jpg',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
14 => [
|
||||
'id' => 7,
|
||||
'business_id' => 4,
|
||||
'name' => 'Aloha TymeMachine',
|
||||
'slug' => 'aloha-tymemachine',
|
||||
'description' => 'Uncommon in every way! Infused cold brew, lemonades and teas.',
|
||||
'tagline' => 'Uncommon In Every Way',
|
||||
'logo_path' => 'businesses/cannabrands/brands/aloha-tymemachine/branding/logo.png',
|
||||
'website_url' => null,
|
||||
'colors' => null,
|
||||
'instagram_handle' => null,
|
||||
'facebook_url' => null,
|
||||
'twitter_handle' => null,
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'is_featured' => false,
|
||||
'sort_order' => 7,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'created_at' => '2025-11-23 19:42:23',
|
||||
'updated_at' => '2025-11-25 03:22:51',
|
||||
'deleted_at' => null,
|
||||
'hashid' => '22dt7',
|
||||
'sku_prefix' => 'ALOH',
|
||||
'long_description' => 'Uncommon in every way! Infused cold brew, lemonades and teas...organic, gluten-free, raw and unprocessed ingredients. 100mg/12.5 ounces active in under 10 minutes, lasting 4-6 hours.',
|
||||
'banner_path' => 'businesses/cannabrands/brands/aloha-tymemachine/branding/banner.jpg',
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'youtube_url' => null,
|
||||
'unit_number' => null,
|
||||
'city' => null,
|
||||
'state' => null,
|
||||
'zip_code' => null,
|
||||
'brand_announcement' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
'brand_voice' => null,
|
||||
'brand_voice_custom' => null,
|
||||
'sales_email' => null,
|
||||
'support_email' => null,
|
||||
'wholesale_email' => null,
|
||||
'pr_email' => null,
|
||||
'inbound_email' => null,
|
||||
'sms_number' => null,
|
||||
],
|
||||
]);
|
||||
if (! $cannabrands) {
|
||||
$this->command->error('Cannabrands LLC business not found! Run BusinessesTableSeeder first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
|
||||
foreach ($this->brands as $slug => $data) {
|
||||
$brand = Brand::updateOrCreate(
|
||||
['slug' => $slug], // Lookup by slug (stable identifier)
|
||||
array_merge($data, [
|
||||
'business_id' => $cannabrands->id,
|
||||
// hashid will be auto-generated by HasHashid trait if not set
|
||||
])
|
||||
);
|
||||
|
||||
if ($brand->wasRecentlyCreated) {
|
||||
$created++;
|
||||
$this->command->info(" Created: {$brand->name} (slug: {$slug}, hashid: {$brand->hashid})");
|
||||
} else {
|
||||
$updated++;
|
||||
$this->command->line(" Updated: {$brand->name} (slug: {$slug})");
|
||||
}
|
||||
}
|
||||
|
||||
$this->command->info("Brands seeding complete: {$created} created, {$updated} updated.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use Illuminate\Database\Seeder;
|
||||
/**
|
||||
* BusinessConfigSeeder - Sets business configurations and suite assignments.
|
||||
*
|
||||
* Auto-generated on: 2025-12-04 00:38:39
|
||||
* Auto-generated on: 2025-12-04 09:24:53
|
||||
* Generated by: php artisan export:business-config-seeder
|
||||
*
|
||||
* This seeder is IDEMPOTENT - it updates existing records without deleting data.
|
||||
@@ -22,14 +22,85 @@ class BusinessConfigSeeder extends Seeder
|
||||
*/
|
||||
private array $configs = [
|
||||
[
|
||||
'slug' => 'cannabrands-1',
|
||||
'slug' => 'all-greens',
|
||||
'name' => 'All Greens',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'aloha-tymemachine',
|
||||
'name' => 'Aloha TymeMachine',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'anc-arizona-natural-concepts',
|
||||
'name' => 'ANC - Arizona Natural Concepts',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'apache-county-dispensary-llc-curagreen-llc',
|
||||
'name' => 'Apache County Dispensary LLC - Curagreen LLC',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'arizona-cannabis-society-inc',
|
||||
'name' => 'Arizona Cannabis Society Inc',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'arizona-organix',
|
||||
'name' => 'Arizona Organix',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'arizona-wellness-center',
|
||||
'name' => 'Arizona Wellness Center',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'asdasd',
|
||||
'name' => 'asdasd',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'bb-manufacturing-company-llc-baked-bros',
|
||||
'name' => 'BB Manufacturing Company LLC / Baked Bros',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'best-dispensary',
|
||||
'name' => 'Best Dispensary',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'botanica',
|
||||
'name' => 'Botanica',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'broken-arrow-herbal-center',
|
||||
'name' => 'Broken Arrow Herbal Center',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'cannabist',
|
||||
'name' => 'Cannabist',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'cannabist-tempe',
|
||||
'name' => 'Cannabist - Tempe',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'cannabrands-2',
|
||||
'name' => 'Cannabrands',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'cannabrands',
|
||||
'name' => 'Cannabrands LLC',
|
||||
'suites' => [],
|
||||
'suites' => ['sales'],
|
||||
'is_enterprise_plan' => true,
|
||||
],
|
||||
[
|
||||
'slug' => 'canopy',
|
||||
@@ -37,28 +108,345 @@ class BusinessConfigSeeder extends Seeder
|
||||
'suites' => ['sales', 'manufacturing', 'delivery', 'inventory'],
|
||||
'has_manufacturing' => true,
|
||||
],
|
||||
[
|
||||
'slug' => 'clifton-bakery-dispensary',
|
||||
'name' => 'Clifton Bakery Dispensary',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'curagreen-1',
|
||||
'name' => 'Curagreen',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'curagreen',
|
||||
'name' => 'Curagreen LLC',
|
||||
'suites' => [],
|
||||
'suites' => ['processing'],
|
||||
'is_enterprise_plan' => true,
|
||||
'has_processing_suite' => true,
|
||||
],
|
||||
[
|
||||
'slug' => 'curaleaf',
|
||||
'name' => 'Curaleaf',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'deeply-rooted',
|
||||
'name' => 'Deeply Rooted',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'deeply-rooted-boutique-cannabis-company',
|
||||
'name' => 'Deeply Rooted Boutique Cannabis Company',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'desert-bloom-re-leaf',
|
||||
'name' => 'Desert Bloom Re-Leaf',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'diablo',
|
||||
'name' => 'Diablo',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'earths-healing',
|
||||
'name' => 'Earth\'s Healing',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'envy-farms',
|
||||
'name' => 'Envy Farms',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'farm-fresh',
|
||||
'name' => 'Farm Fresh',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'giving-tree-dispensary',
|
||||
'name' => 'Giving Tree Dispensary',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'goldsmith-extracts',
|
||||
'name' => 'Goldsmith Extracts',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'greenleaf-dispensary',
|
||||
'name' => 'GreenLeaf Dispensary',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'greenmed',
|
||||
'name' => 'GreenMed',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'green-medicine-saints',
|
||||
'name' => 'Green Medicine - SAINTS',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'greenpharms',
|
||||
'name' => 'GreenPharms',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'greens-goddess-products-inc-dba-aeriz',
|
||||
'name' => 'Greens Goddess Products Inc DBA Aeriz',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'hana-dispensary',
|
||||
'name' => 'Hana Dispensary',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'hana-meds',
|
||||
'name' => 'Hana Meds',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'health-for-life-dba-of-soothing-options',
|
||||
'name' => 'Health for Life DBA of Soothing Options',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'high-mountain-health',
|
||||
'name' => 'High Mountain Health',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'high-valley-cannabis',
|
||||
'name' => 'High Valley Cannabis',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'hub-tester',
|
||||
'name' => 'Hub Tester',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'jareds-dispensary-test',
|
||||
'name' => 'Jareds dispensary test',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'jars',
|
||||
'name' => 'JARS',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'key-cannabis',
|
||||
'name' => 'Key Cannabis',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'kind-meds',
|
||||
'name' => 'Kind Meds',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'kompo',
|
||||
'name' => 'Kompo',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'leopard-az',
|
||||
'name' => 'Leopard AZ LLC',
|
||||
'suites' => [],
|
||||
'suites' => ['sales', 'processing', 'manufacturing', 'delivery'],
|
||||
'is_enterprise_plan' => true,
|
||||
'has_processing_suite' => true,
|
||||
],
|
||||
[
|
||||
'slug' => 'life-changers-investments-llc',
|
||||
'name' => 'Life Changers Investments LLC',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'lunchbox-sonoita-az',
|
||||
'name' => 'Lunchbox - Sonoita AZ',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'marigold',
|
||||
'name' => 'Marigold',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'medusa-farms',
|
||||
'name' => 'Medusa Farms',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'aliquam-laudantium-recusandae',
|
||||
'name' => 'Morissette Ltd',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'natural-remedy',
|
||||
'name' => 'Natural Remedy',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'nature-med-inc',
|
||||
'name' => 'Nature Med, Inc.',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'natures-wonder',
|
||||
'name' => 'Nature\'s Wonder',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'nirvana-cannabis',
|
||||
'name' => 'Nirvana Cannabis',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'ocotillo-vista-inc-dba-globe-cannabis-company',
|
||||
'name' => 'Ocotillo Vista Inc DBA Globe Cannabis Company',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'primeleaf',
|
||||
'name' => 'Primeleaf',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'rch-wellness-center-dba-noble-herb',
|
||||
'name' => 'RCH Wellness Center DBA Noble Herb',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'saints',
|
||||
'name' => 'SAINTS',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'cannabrands-1',
|
||||
'name' => 'Seller Sales Business',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'sol-flower',
|
||||
'name' => 'Sol Flower',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'soothing-ponderosa',
|
||||
'name' => 'Soothing Ponderosa',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'sticky-saguaro',
|
||||
'name' => 'Sticky Saguaro',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'story',
|
||||
'name' => 'Story',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'sunday-goods',
|
||||
'name' => 'Sunday Goods',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'test',
|
||||
'name' => 'test',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'test-company',
|
||||
'name' => 'test_company',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'the-downtown-dispensary',
|
||||
'name' => 'The Downtown Dispensary',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'the-flower-shop',
|
||||
'name' => 'The Flower Shop',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'the-mint-dispensary',
|
||||
'name' => 'The Mint Dispensary',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'the-superior-dispensary',
|
||||
'name' => 'The Superior Dispensary',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'trubliss',
|
||||
'name' => 'TruBliss',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'trulieve',
|
||||
'name' => 'Trulieve',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'trumed',
|
||||
'name' => 'TruMed',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'valley-healing-group-inc-dba-the-good-dispensary',
|
||||
'name' => 'Valley Healing Group Inc DBA The Good Dispensary',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'valley-of-the-sun',
|
||||
'name' => 'Valley of the Sun',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'verano',
|
||||
'name' => 'Verano',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'wam',
|
||||
'name' => 'WAM',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'white-label-canna',
|
||||
'name' => 'White Label Canna',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'white-mountain-health-center',
|
||||
'name' => 'White Mountain Health Center',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'yeltsin-test',
|
||||
'name' => 'Yeltsin Test',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'yilo-superstore',
|
||||
'name' => 'YiLo Superstore',
|
||||
'suites' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'yuma-dispensary',
|
||||
'name' => 'Yuma Dispensary',
|
||||
'suites' => [],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,6 +58,10 @@ class DatabaseSeeder extends Seeder
|
||||
$this->call(OrchestratorMarketingConfigSeeder::class);
|
||||
$this->call(OrchestratorMessageVariantsSeeder::class);
|
||||
$this->call(BrandOrchestratorProfilesSeeder::class);
|
||||
|
||||
// NOTE: Cannabrands MySQL import seeders are in database/seeders/MysqlImport/
|
||||
// These were used for one-time data migration and are kept for reference.
|
||||
// Data is now in PostgreSQL - do not run these seeders again.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
115
database/seeders/MysqlImport/CompaniesImportSeeder.php
Normal file
115
database/seeders/MysqlImport/CompaniesImportSeeder.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders\MysqlImport;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* Import companies from MySQL hub_cannabrands database as buyer businesses.
|
||||
*
|
||||
* Idempotent: Skips existing businesses by name.
|
||||
*/
|
||||
class CompaniesImportSeeder extends Seeder
|
||||
{
|
||||
protected string $mysqlConnection = 'mysql_import';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->info('Importing companies from MySQL as buyer businesses...');
|
||||
|
||||
$companies = DB::connection($this->mysqlConnection)
|
||||
->table('companies')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$this->command->info("Found {$companies->count()} companies in MySQL.");
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($companies as $company) {
|
||||
$name = trim($company->name);
|
||||
|
||||
if (! $name) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if name already exists
|
||||
if (DB::table('businesses')->where('name', $name)->exists()) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
$baseSlug = Str::slug($name);
|
||||
$slug = $baseSlug;
|
||||
$counter = 1;
|
||||
while (DB::table('businesses')->where('slug', $slug)->exists()) {
|
||||
$slug = $baseSlug.'-'.$counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
// Split delivery contact name into first/last
|
||||
$deliveryFirstName = null;
|
||||
$deliveryLastName = null;
|
||||
if ($company->delivery_contact_name) {
|
||||
$parts = explode(' ', trim($company->delivery_contact_name), 2);
|
||||
$deliveryFirstName = $parts[0] ?? null;
|
||||
$deliveryLastName = $parts[1] ?? null;
|
||||
}
|
||||
|
||||
DB::table('businesses')->insert([
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'uuid' => substr((string) Uuid::uuid7(), 0, 18),
|
||||
'type' => 'buyer',
|
||||
'status' => 'approved',
|
||||
'is_active' => true,
|
||||
'business_phone' => $company->phone,
|
||||
'business_email' => $company->email,
|
||||
'description' => $company->description,
|
||||
'license_number' => $company->license_no,
|
||||
'license_type' => $company->license_type,
|
||||
'physical_address' => $company->cop_address,
|
||||
'physical_city' => $company->cop_city,
|
||||
'physical_state' => $company->cop_state,
|
||||
'physical_zipcode' => $company->cop_zip,
|
||||
'billing_address' => $company->billing_address,
|
||||
'billing_city' => $company->billing_city,
|
||||
'billing_state' => $company->billing_state,
|
||||
'billing_zipcode' => $company->billing_zip,
|
||||
'shipping_address' => $company->ship_address,
|
||||
'shipping_city' => $company->ship_city,
|
||||
'shipping_state' => $company->ship_state,
|
||||
'shipping_zipcode' => $company->ship_zip,
|
||||
'delivery_preferences' => $company->delivery_preferences,
|
||||
'delivery_directions' => $company->delivery_directions,
|
||||
'delivery_schedule' => $company->delivery_schedule,
|
||||
'delivery_contact_first_name' => $deliveryFirstName,
|
||||
'delivery_contact_last_name' => $deliveryLastName,
|
||||
'delivery_contact_phone' => $company->delivery_contact_phone,
|
||||
'delivery_contact_email' => $company->delivery_contact_email,
|
||||
'delivery_contact_sms' => $company->delivery_contact_sms,
|
||||
'buyer_name' => $company->buyer_name,
|
||||
'buyer_phone' => $company->buyer_phone,
|
||||
'buyer_email' => $company->buyer_email,
|
||||
'payment_method' => $company->payment_method,
|
||||
'tin_ein' => $company->tin,
|
||||
'dba_name' => $company->dba_name,
|
||||
'created_at' => $company->created_at,
|
||||
'updated_at' => $company->updated_at,
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
}
|
||||
|
||||
$this->command->info("Imported {$imported} companies as businesses, skipped {$skipped}.");
|
||||
}
|
||||
}
|
||||
164
database/seeders/MysqlImport/ContactsImportSeeder.php
Normal file
164
database/seeders/MysqlImport/ContactsImportSeeder.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders\MysqlImport;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Import company contacts from MySQL hub_cannabrands database.
|
||||
*
|
||||
* Idempotent: Skips existing contacts by business_id + email (if email exists)
|
||||
* or business_id + first_name + last_name (if no email).
|
||||
* Skips soft-deleted contacts.
|
||||
*/
|
||||
class ContactsImportSeeder extends Seeder
|
||||
{
|
||||
protected string $mysqlConnection = 'mysql_import';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->info('Importing company contacts from MySQL...');
|
||||
|
||||
// Build company_id to business_id mapping
|
||||
$companyMapping = $this->buildCompanyMapping();
|
||||
$this->command->info('Built mapping for '.count($companyMapping).' companies.');
|
||||
|
||||
// Build location mapping (MySQL location_id -> PG location_id)
|
||||
$locationMapping = $this->buildLocationMapping($companyMapping);
|
||||
$this->command->info('Built mapping for '.count($locationMapping).' locations.');
|
||||
|
||||
$contacts = DB::connection($this->mysqlConnection)
|
||||
->table('company_contacts')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$this->command->info("Found {$contacts->count()} active contacts in MySQL.");
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($contacts as $contact) {
|
||||
// Look up business by company_id
|
||||
if (! isset($companyMapping[$contact->company_id])) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$businessId = $companyMapping[$contact->company_id];
|
||||
$firstName = trim($contact->first_name ?? '');
|
||||
|
||||
if (! $firstName) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$lastName = trim($contact->last_name ?? '');
|
||||
$email = trim($contact->email ?? '') ?: null;
|
||||
|
||||
// Check for duplicate - by email if exists, otherwise by name
|
||||
$existsQuery = DB::table('contacts')->where('business_id', $businessId);
|
||||
if ($email) {
|
||||
$existsQuery->where('email', $email);
|
||||
} else {
|
||||
$existsQuery->where('first_name', $firstName)->where('last_name', $lastName);
|
||||
}
|
||||
|
||||
if ($existsQuery->exists()) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look up location if set
|
||||
$locationId = null;
|
||||
if ($contact->location_id && isset($locationMapping[$contact->location_id])) {
|
||||
$locationId = $locationMapping[$contact->location_id];
|
||||
}
|
||||
|
||||
DB::table('contacts')->insert([
|
||||
'business_id' => $businessId,
|
||||
'location_id' => $locationId,
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
'email' => $email,
|
||||
'phone' => $contact->phone,
|
||||
'title' => $contact->title,
|
||||
'position' => $contact->position,
|
||||
'notes' => $contact->description,
|
||||
'is_active' => (bool) ($contact->account_status ?? 1),
|
||||
'created_at' => $contact->created_at,
|
||||
'updated_at' => $contact->updated_at,
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
}
|
||||
|
||||
$this->command->info("Imported {$imported} contacts, skipped {$skipped}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build mapping from MySQL company_id to PostgreSQL business_id.
|
||||
*/
|
||||
protected function buildCompanyMapping(): array
|
||||
{
|
||||
$companies = DB::connection($this->mysqlConnection)
|
||||
->table('companies')
|
||||
->select('id', 'name')
|
||||
->get();
|
||||
|
||||
$mapping = [];
|
||||
|
||||
foreach ($companies as $company) {
|
||||
$name = trim($company->name);
|
||||
if (! $name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$businessId = DB::table('businesses')->where('name', $name)->value('id');
|
||||
|
||||
if ($businessId) {
|
||||
$mapping[$company->id] = $businessId;
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build mapping from MySQL location_id to PostgreSQL location_id.
|
||||
*/
|
||||
protected function buildLocationMapping(array $companyMapping): array
|
||||
{
|
||||
$mysqlLocations = DB::connection($this->mysqlConnection)
|
||||
->table('company_locations')
|
||||
->whereNull('deleted_at')
|
||||
->select('id', 'company_id', 'name')
|
||||
->get();
|
||||
|
||||
$mapping = [];
|
||||
|
||||
foreach ($mysqlLocations as $loc) {
|
||||
if (! isset($companyMapping[$loc->company_id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$businessId = $companyMapping[$loc->company_id];
|
||||
$name = trim($loc->name);
|
||||
|
||||
$pgLocationId = DB::table('locations')
|
||||
->where('business_id', $businessId)
|
||||
->where('name', $name)
|
||||
->value('id');
|
||||
|
||||
if ($pgLocationId) {
|
||||
$mapping[$loc->id] = $pgLocationId;
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
}
|
||||
293
database/seeders/MysqlImport/InvoicesImportSeeder.php
Normal file
293
database/seeders/MysqlImport/InvoicesImportSeeder.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders\MysqlImport;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Import invoices from MySQL hub_cannabrands database.
|
||||
*
|
||||
* NOTE: This seeder is ONLY for Cannabrands business data migration.
|
||||
*
|
||||
* Creates:
|
||||
* 1. Order (mirrored from invoice, with ORD- prefix)
|
||||
* 2. Order Items (from invoice_lines)
|
||||
* 3. Invoice (linked to order)
|
||||
*
|
||||
* Idempotent: Skips existing orders by order_number.
|
||||
* Skips invoices with NULL organisation_id (no buyer).
|
||||
*/
|
||||
class InvoicesImportSeeder extends Seeder
|
||||
{
|
||||
protected string $mysqlConnection = 'mysql_import';
|
||||
|
||||
// Status mapping: MySQL -> PostgreSQL
|
||||
// Valid PG statuses: new, accepted, in_progress, ready_for_approval, ready_for_delivery,
|
||||
// approved_for_delivery, out_for_delivery, delivered, completed, invoiced, paid,
|
||||
// partially_rejected, rejected, cancelled
|
||||
protected array $statusMapping = [
|
||||
'new' => 'new',
|
||||
'draft' => 'new',
|
||||
'ready_for_approval' => 'ready_for_approval',
|
||||
'buyer_modified' => 'new',
|
||||
'seller_modified' => 'new',
|
||||
'accepted' => 'accepted',
|
||||
'ready_for_delivery' => 'ready_for_delivery',
|
||||
'create_manifest' => 'in_progress',
|
||||
'ready_for_invoice' => 'invoiced',
|
||||
'invoiced' => 'invoiced',
|
||||
'delivered' => 'delivered',
|
||||
'cancelled' => 'cancelled',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->info('Importing invoices from MySQL...');
|
||||
|
||||
// Get Cannabrands business (always the seller)
|
||||
$cannabrandsBusiness = DB::table('businesses')->where('slug', 'cannabrands')->first();
|
||||
if (! $cannabrandsBusiness) {
|
||||
$this->command->error('Cannabrands business not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Build company -> business mapping (for buyers)
|
||||
$companyMapping = $this->buildCompanyMapping();
|
||||
$this->command->info('Built mapping for '.count($companyMapping).' companies.');
|
||||
|
||||
// Build product mapping (MySQL product_id -> PG product with details)
|
||||
$productMapping = $this->buildProductMapping();
|
||||
$this->command->info('Built mapping for '.count($productMapping).' products.');
|
||||
|
||||
$invoices = DB::connection($this->mysqlConnection)
|
||||
->table('invoices')
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('organisation_id')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$this->command->info("Found {$invoices->count()} invoices with buyers in MySQL.");
|
||||
|
||||
$importedOrders = 0;
|
||||
$importedInvoices = 0;
|
||||
$importedLines = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
// Map buyer business from organisation_id -> companies -> businesses
|
||||
if (! isset($companyMapping[$invoice->organisation_id])) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$buyerBusinessId = $companyMapping[$invoice->organisation_id];
|
||||
|
||||
// Generate order number (INV-1000 -> ORD-1000)
|
||||
$orderNumber = str_replace('INV-', 'ORD-', $invoice->invoice_id);
|
||||
|
||||
// Skip if order already exists
|
||||
if (DB::table('orders')->where('order_number', $orderNumber)->exists()) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map status (default to 'new' if not mapped)
|
||||
$status = $this->statusMapping[$invoice->status ?? ''] ?? 'new';
|
||||
|
||||
// Create Order
|
||||
$orderId = DB::table('orders')->insertGetId([
|
||||
'order_number' => $orderNumber,
|
||||
'business_id' => $buyerBusinessId,
|
||||
'seller_business_id' => $cannabrandsBusiness->id,
|
||||
'subtotal' => ($invoice->subtotal ?? 0) / 100,
|
||||
'tax' => ($invoice->tax ?? 0) / 100,
|
||||
'total' => ($invoice->total ?? 0) / 100,
|
||||
'surcharge' => 0,
|
||||
'status' => $status,
|
||||
'due_date' => $invoice->due_date,
|
||||
'notes' => $invoice->comments,
|
||||
'workorder_status' => 0,
|
||||
'created_by' => 'seller',
|
||||
'was_promo_driven' => false,
|
||||
'created_at' => $invoice->created_at,
|
||||
'updated_at' => $invoice->updated_at,
|
||||
]);
|
||||
|
||||
$importedOrders++;
|
||||
|
||||
// Create Order Items from invoice_lines
|
||||
$lines = DB::connection($this->mysqlConnection)
|
||||
->table('invoice_lines')
|
||||
->where('invoice_id', $invoice->id)
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Get product info
|
||||
$product = $productMapping[$line->product_id] ?? null;
|
||||
|
||||
if (! $product) {
|
||||
// Skip line if product not found
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('order_items')->insert([
|
||||
'order_id' => $orderId,
|
||||
'product_id' => $product['pg_id'],
|
||||
'quantity' => (int) $line->quantity,
|
||||
'unit_price' => $line->price ?? 0,
|
||||
'line_total' => ($line->amount ?? 0) / 100,
|
||||
'product_name' => $product['name'],
|
||||
'product_sku' => $product['sku'],
|
||||
'brand_name' => $product['brand_name'],
|
||||
'picked_qty' => (int) ($line->picked_qty ?? 0),
|
||||
'accepted_qty' => (int) $line->quantity,
|
||||
'rejected_qty' => 0,
|
||||
'promo_discount_amount' => 0,
|
||||
'created_at' => $line->created_at,
|
||||
'updated_at' => $line->updated_at,
|
||||
]);
|
||||
|
||||
$importedLines++;
|
||||
}
|
||||
|
||||
// Create Invoice
|
||||
$amountPaid = ($invoice->amount_paid ?? 0) / 100;
|
||||
$amountDue = ($invoice->amount_due ?? $invoice->total ?? 0) / 100;
|
||||
$paymentStatus = $amountDue <= 0 ? 'paid' : 'unpaid';
|
||||
|
||||
DB::table('invoices')->insert([
|
||||
'invoice_number' => $invoice->invoice_id,
|
||||
'order_id' => $orderId,
|
||||
'business_id' => $buyerBusinessId,
|
||||
'subtotal' => ($invoice->subtotal ?? 0) / 100,
|
||||
'tax' => ($invoice->tax ?? 0) / 100,
|
||||
'total' => ($invoice->total ?? 0) / 100,
|
||||
'amount_paid' => $amountPaid,
|
||||
'amount_due' => $amountDue,
|
||||
'payment_status' => $paymentStatus,
|
||||
'invoice_date' => $invoice->issue_date ?? $invoice->created_at,
|
||||
'due_date' => $invoice->due_date ?? $invoice->issue_date ?? $invoice->created_at,
|
||||
'notes' => $invoice->comments,
|
||||
'created_at' => $invoice->created_at,
|
||||
'updated_at' => $invoice->updated_at,
|
||||
]);
|
||||
|
||||
$importedInvoices++;
|
||||
|
||||
if ($importedOrders % 50 === 0) {
|
||||
$this->command->info("Imported {$importedOrders} orders...");
|
||||
}
|
||||
}
|
||||
|
||||
$this->command->info("Imported {$importedOrders} orders, {$importedInvoices} invoices, {$importedLines} line items. Skipped {$skipped}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build mapping from MySQL company_id (organisation_id) to PostgreSQL business_id.
|
||||
*/
|
||||
protected function buildCompanyMapping(): array
|
||||
{
|
||||
$companies = DB::connection($this->mysqlConnection)
|
||||
->table('companies')
|
||||
->select('id', 'name')
|
||||
->get();
|
||||
|
||||
$mapping = [];
|
||||
|
||||
foreach ($companies as $company) {
|
||||
$name = trim($company->name);
|
||||
if (! $name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$businessId = DB::table('businesses')->where('name', $name)->value('id');
|
||||
|
||||
if ($businessId) {
|
||||
$mapping[$company->id] = $businessId;
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build mapping from MySQL product_id to PostgreSQL product with details.
|
||||
*/
|
||||
protected function buildProductMapping(): array
|
||||
{
|
||||
$mysqlProducts = DB::connection($this->mysqlConnection)
|
||||
->table('products')
|
||||
->select('id', 'brand_id', 'code', 'name')
|
||||
->get();
|
||||
|
||||
// Brand mapping (same as ProductsImportSeeder)
|
||||
$brandMapping = [
|
||||
1 => 3, // Hash Factory
|
||||
2 => 2, // Doobz
|
||||
3 => 4, // High Expectations
|
||||
4 => 5, // Just Vape
|
||||
5 => 34, // Proper Cock
|
||||
6 => 6, // Thunder Bud
|
||||
7 => 14, // Twisties
|
||||
8 => 1, // Canna RSO
|
||||
9 => 11, // Doinks
|
||||
10 => 36, // White Label Canna
|
||||
11 => 7, // Aloha TymeMachine
|
||||
12 => 35, // Blitz'd
|
||||
13 => 36, // White Label Canna
|
||||
14 => 8, // Outlaw Cannabis
|
||||
15 => 10, // Nuvata
|
||||
16 => 15, // Dairy2Dank
|
||||
];
|
||||
$defaultBrandId = 36; // White Label Canna
|
||||
|
||||
// Cache PG brands
|
||||
$pgBrands = DB::table('brands')->select('id', 'name')->get()->keyBy('id');
|
||||
|
||||
$mapping = [];
|
||||
|
||||
foreach ($mysqlProducts as $product) {
|
||||
$pgBrandId = $product->brand_id
|
||||
? ($brandMapping[$product->brand_id] ?? null)
|
||||
: $defaultBrandId;
|
||||
|
||||
if (! $pgBrandId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim($product->name ?? '');
|
||||
$sku = trim($product->code ?? '');
|
||||
|
||||
if (! $name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find PG product by brand_id + sku (or name)
|
||||
$query = DB::table('products')->where('brand_id', $pgBrandId);
|
||||
if ($sku) {
|
||||
$query->where('sku', $sku);
|
||||
} else {
|
||||
$query->where('name', $name);
|
||||
}
|
||||
|
||||
$pgProduct = $query->first();
|
||||
|
||||
if ($pgProduct) {
|
||||
$brandName = $pgBrands->get($pgBrandId)?->name ?? '';
|
||||
$mapping[$product->id] = [
|
||||
'pg_id' => $pgProduct->id,
|
||||
'name' => $pgProduct->name,
|
||||
'sku' => $pgProduct->sku,
|
||||
'brand_name' => $brandName,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
}
|
||||
145
database/seeders/MysqlImport/LocationsImportSeeder.php
Normal file
145
database/seeders/MysqlImport/LocationsImportSeeder.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders\MysqlImport;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Import company locations from MySQL hub_cannabrands database.
|
||||
*
|
||||
* Idempotent: Skips existing locations by business_id + name.
|
||||
* Skips soft-deleted locations.
|
||||
*/
|
||||
class LocationsImportSeeder extends Seeder
|
||||
{
|
||||
protected string $mysqlConnection = 'mysql_import';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->info('Importing company locations from MySQL...');
|
||||
|
||||
// Build company_id to business mapping
|
||||
$companyMapping = $this->buildCompanyMapping();
|
||||
$this->command->info('Built mapping for '.count($companyMapping).' companies.');
|
||||
|
||||
$locations = DB::connection($this->mysqlConnection)
|
||||
->table('company_locations')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$this->command->info("Found {$locations->count()} active locations in MySQL.");
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
$skippedReasons = [];
|
||||
|
||||
foreach ($locations as $location) {
|
||||
// Look up business by company_id
|
||||
if (! isset($companyMapping[$location->company_id])) {
|
||||
$skipped++;
|
||||
$skippedReasons[] = "Location '{$location->name}' - company_id {$location->company_id} not found";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$businessId = $companyMapping[$location->company_id];
|
||||
$name = trim($location->name);
|
||||
|
||||
if (! $name) {
|
||||
$skipped++;
|
||||
$skippedReasons[] = "Location id {$location->id} - empty name";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if location with same name already exists for this business
|
||||
if (DB::table('locations')
|
||||
->where('business_id', $businessId)
|
||||
->where('name', $name)
|
||||
->exists()) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
$baseSlug = Str::slug($name);
|
||||
$slug = $baseSlug ?: 'location';
|
||||
$counter = 1;
|
||||
while (DB::table('locations')->where('business_id', $businessId)->where('slug', $slug)->exists()) {
|
||||
$slug = $baseSlug.'-'.$counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
DB::table('locations')->insert([
|
||||
'business_id' => $businessId,
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'location_type' => $location->location_type ?? 'physical',
|
||||
'license_number' => $location->license_no,
|
||||
'is_primary' => (bool) $location->is_primary,
|
||||
'is_billing' => (bool) $location->is_billing,
|
||||
'is_active' => true,
|
||||
'address' => $location->address,
|
||||
'unit' => $location->unit,
|
||||
'city' => $location->city,
|
||||
'state' => $location->state,
|
||||
'zipcode' => $location->zip,
|
||||
'phone' => $location->phone,
|
||||
'email' => $location->email,
|
||||
'open_hours' => $location->open_hours,
|
||||
'delivery_preferences' => $location->delivery_preferences,
|
||||
'delivery_directions' => $location->delivery_directions,
|
||||
'delivery_schedule' => $location->delivery_schedule,
|
||||
'delivery_contact_name' => $location->delivery_contact_name,
|
||||
'delivery_contact_phone' => $location->delivery_contact_phone,
|
||||
'delivery_contact_email' => $location->delivery_contact_email,
|
||||
'location_files' => $location->location_files,
|
||||
'created_at' => $location->created_at,
|
||||
'updated_at' => $location->updated_at,
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
}
|
||||
|
||||
$this->command->info("Imported {$imported} locations, skipped {$skipped}.");
|
||||
|
||||
if (count($skippedReasons) > 0 && count($skippedReasons) <= 10) {
|
||||
foreach ($skippedReasons as $reason) {
|
||||
$this->command->warn(" - {$reason}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build mapping from MySQL company_id to PostgreSQL business_id.
|
||||
*/
|
||||
protected function buildCompanyMapping(): array
|
||||
{
|
||||
$companies = DB::connection($this->mysqlConnection)
|
||||
->table('companies')
|
||||
->select('id', 'name')
|
||||
->get();
|
||||
|
||||
$mapping = [];
|
||||
|
||||
foreach ($companies as $company) {
|
||||
$name = trim($company->name);
|
||||
if (! $name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look up business by name
|
||||
$businessId = DB::table('businesses')->where('name', $name)->value('id');
|
||||
|
||||
if ($businessId) {
|
||||
$mapping[$company->id] = $businessId;
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders\MysqlImport;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Import product categories from MySQL hub_cannabrands database.
|
||||
*
|
||||
* Idempotent: Skips existing categories by id.
|
||||
* Skips soft-deleted categories (deleted_at IS NOT NULL).
|
||||
*/
|
||||
class ProductCategoriesImportSeeder extends Seeder
|
||||
{
|
||||
protected string $mysqlConnection = 'mysql_import';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->info('Importing product categories from MySQL...');
|
||||
|
||||
// Get Cannabrands business_id
|
||||
$businessId = DB::table('businesses')->where('slug', 'cannabrands')->value('id');
|
||||
|
||||
if (! $businessId) {
|
||||
$this->command->error('Cannabrands business not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$categories = DB::connection($this->mysqlConnection)
|
||||
->table('product_categories')
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$this->command->info("Found {$categories->count()} active categories in MySQL.");
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
|
||||
// Import in correct order: parents before children
|
||||
// First pass: top-level categories (no parent)
|
||||
// Then recursively import children
|
||||
$this->importCategoriesRecursively($categories, null, $businessId, $imported, $skipped);
|
||||
|
||||
// Reset sequence to max id + 1
|
||||
$maxId = DB::table('product_categories')->max('id');
|
||||
if ($maxId) {
|
||||
DB::statement("SELECT setval('product_categories_id_seq', ?)", [$maxId]);
|
||||
}
|
||||
|
||||
$this->command->info("Imported {$imported} categories, skipped {$skipped} existing.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively import categories - parents first, then children.
|
||||
*/
|
||||
protected function importCategoriesRecursively($allCategories, $parentId, $businessId, &$imported, &$skipped): void
|
||||
{
|
||||
// Get categories with this parent_id
|
||||
$children = $allCategories->filter(fn ($cat) => $cat->parent_id === $parentId);
|
||||
|
||||
foreach ($children as $category) {
|
||||
// Skip if id already exists
|
||||
if (DB::table('product_categories')->where('id', $category->id)->exists()) {
|
||||
$skipped++;
|
||||
} else {
|
||||
DB::table('product_categories')->insert([
|
||||
'id' => $category->id,
|
||||
'business_id' => $businessId,
|
||||
'parent_id' => $category->parent_id,
|
||||
'name' => $category->name,
|
||||
'slug' => Str::slug($category->name).'-'.$category->id,
|
||||
'description' => $category->description,
|
||||
'is_active' => (bool) $category->public,
|
||||
'sort_order' => 0,
|
||||
'created_at' => $category->created_at,
|
||||
'updated_at' => $category->updated_at,
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
}
|
||||
|
||||
// Import children of this category
|
||||
$this->importCategoriesRecursively($allCategories, $category->id, $businessId, $imported, $skipped);
|
||||
}
|
||||
}
|
||||
}
|
||||
202
database/seeders/MysqlImport/ProductImagesImportSeeder.php
Normal file
202
database/seeders/MysqlImport/ProductImagesImportSeeder.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders\MysqlImport;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
/**
|
||||
* Import product images from MySQL hub_cannabrands database.
|
||||
*
|
||||
* MySQL stores images as binary blobs in the `image` column.
|
||||
* This seeder extracts the binary data, processes with Intervention Image,
|
||||
* saves to MinIO, and updates product image_path.
|
||||
*
|
||||
* Storage path structure:
|
||||
* businesses/{business_slug}/brands/{brand_slug}/products/{product_sku}/images/{filename}
|
||||
*
|
||||
* Example: businesses/cannabrands/brands/thunder-bud/products/TB-BM-AZ1G/images/black-maple.png
|
||||
*
|
||||
* Idempotent: Skips products that already have an image_path set.
|
||||
*/
|
||||
class ProductImagesImportSeeder extends Seeder
|
||||
{
|
||||
protected string $mysqlConnection = 'mysql_import';
|
||||
|
||||
// MySQL brand_id => PostgreSQL brand_id (same mapping as ProductsImportSeeder)
|
||||
protected array $brandMapping = [
|
||||
1 => 3, // Hash Factory
|
||||
2 => 2, // Doobz
|
||||
3 => 4, // High Expectations
|
||||
4 => 5, // Just Vape
|
||||
5 => 34, // Proper Cock
|
||||
6 => 6, // Thunder Bud
|
||||
7 => 14, // Twisties
|
||||
8 => 1, // Canna RSO
|
||||
9 => 11, // Doinks
|
||||
10 => 36, // White Label Canna (was Cannabrands)
|
||||
11 => 7, // Aloha TymeMachine
|
||||
12 => 35, // Blitz'd
|
||||
13 => 36, // White Label Canna
|
||||
14 => 8, // Outlaw Cannabis
|
||||
15 => 10, // Nuvata
|
||||
16 => 15, // Dairy2Dank
|
||||
];
|
||||
|
||||
// Default brand for NULL brand_id (components)
|
||||
protected int $defaultBrandId = 36; // White Label Canna
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->info('Importing product images from MySQL...');
|
||||
|
||||
// Get Cannabrands business
|
||||
$business = DB::table('businesses')->where('slug', 'cannabrands')->first();
|
||||
if (! $business) {
|
||||
$this->command->error('Cannabrands business not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Build brand cache for path lookups
|
||||
$brands = DB::table('brands')
|
||||
->join('businesses', 'brands.business_id', '=', 'businesses.id')
|
||||
->select('brands.id', 'brands.slug as brand_slug', 'businesses.slug as business_slug')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Get MySQL products with images (binary blob not null)
|
||||
$mysqlProducts = DB::connection($this->mysqlConnection)
|
||||
->table('products')
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('product_image')
|
||||
->select('id', 'brand_id', 'code', 'name', 'product_image')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$this->command->info("Found {$mysqlProducts->count()} products with images in MySQL.");
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($mysqlProducts as $mysqlProduct) {
|
||||
// Map brand_id
|
||||
$pgBrandId = $mysqlProduct->brand_id
|
||||
? ($this->brandMapping[$mysqlProduct->brand_id] ?? null)
|
||||
: $this->defaultBrandId;
|
||||
|
||||
if (! $pgBrandId) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim($mysqlProduct->name ?? '');
|
||||
$sku = trim($mysqlProduct->code ?? '');
|
||||
|
||||
if (! $name) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find matching PG product by brand_id + sku (or name if no sku)
|
||||
$query = DB::table('products')->where('brand_id', $pgBrandId);
|
||||
if ($sku) {
|
||||
$query->where('sku', $sku);
|
||||
} else {
|
||||
$query->where('name', $name);
|
||||
}
|
||||
|
||||
$pgProduct = $query->first();
|
||||
|
||||
if (! $pgProduct) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if product already has an image_path
|
||||
if ($pgProduct->image_path) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get brand info for path construction
|
||||
$brand = $brands->get($pgBrandId);
|
||||
if (! $brand) {
|
||||
$this->command->warn("Brand ID {$pgBrandId} not found for product '{$name}'");
|
||||
$errors++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process image with Intervention Image
|
||||
$imageData = $mysqlProduct->product_image;
|
||||
|
||||
try {
|
||||
$manager = new ImageManager(new Driver);
|
||||
$image = $manager->read($imageData);
|
||||
|
||||
// Get original format info
|
||||
$origin = $image->origin();
|
||||
$mimeType = $origin->mediaType();
|
||||
|
||||
$extension = match ($mimeType) {
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
default => 'jpg', // fallback to jpg
|
||||
};
|
||||
|
||||
// Generate filename from product name
|
||||
$filename = Str::slug($name).'.'.$extension;
|
||||
|
||||
// Construct MinIO path:
|
||||
// businesses/{business_slug}/brands/{brand_slug}/products/{product_sku}/images/{filename}
|
||||
$productSku = $pgProduct->sku ?: Str::slug($name);
|
||||
$storagePath = sprintf(
|
||||
'businesses/%s/brands/%s/products/%s/images/%s',
|
||||
$brand->business_slug,
|
||||
$brand->brand_slug,
|
||||
$productSku,
|
||||
$filename
|
||||
);
|
||||
|
||||
// Encode image maintaining original format
|
||||
$encoded = match ($extension) {
|
||||
'png' => $image->toPng(),
|
||||
'gif' => $image->toGif(),
|
||||
'webp' => $image->toWebp(quality: 90),
|
||||
default => $image->toJpeg(quality: 90),
|
||||
};
|
||||
|
||||
// Save to MinIO (default disk)
|
||||
Storage::put($storagePath, (string) $encoded);
|
||||
|
||||
// Update product with image_path
|
||||
DB::table('products')
|
||||
->where('id', $pgProduct->id)
|
||||
->update(['image_path' => $storagePath]);
|
||||
|
||||
$imported++;
|
||||
|
||||
if ($imported % 50 === 0) {
|
||||
$this->command->info("Imported {$imported} images...");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->command->error("Failed to process image for '{$name}': ".$e->getMessage());
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->command->info("Imported {$imported} product images, skipped {$skipped}, errors {$errors}.");
|
||||
}
|
||||
}
|
||||
322
database/seeders/MysqlImport/ProductsImportSeeder.php
Normal file
322
database/seeders/MysqlImport/ProductsImportSeeder.php
Normal file
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders\MysqlImport;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Import products from MySQL hub_cannabrands database.
|
||||
*
|
||||
* Idempotent: Skips existing products by brand_id + sku (or brand_id + name if no sku).
|
||||
* Skips soft-deleted products.
|
||||
* Hashid is auto-generated by Product model's HasHashid trait.
|
||||
*/
|
||||
class ProductsImportSeeder extends Seeder
|
||||
{
|
||||
protected string $mysqlConnection = 'mysql_import';
|
||||
|
||||
// MySQL brand_id => PostgreSQL brand_id
|
||||
protected array $brandMapping = [
|
||||
1 => 3, // Hash Factory
|
||||
2 => 2, // Doobz
|
||||
3 => 4, // High Expectations
|
||||
4 => 5, // Just Vape
|
||||
5 => 34, // Proper Cock
|
||||
6 => 6, // Thunder Bud
|
||||
7 => 14, // Twisties
|
||||
8 => 1, // Canna RSO
|
||||
9 => 11, // Doinks
|
||||
10 => 36, // White Label Canna (was Cannabrands)
|
||||
11 => 7, // Aloha TymeMachine
|
||||
12 => 35, // Blitz'd
|
||||
13 => 36, // White Label Canna
|
||||
14 => 8, // Outlaw Cannabis
|
||||
15 => 10, // Nuvata
|
||||
16 => 15, // Dairy2Dank
|
||||
];
|
||||
|
||||
// Default brand for NULL brand_id (components)
|
||||
protected int $defaultBrandId = 36; // White Label Canna
|
||||
|
||||
// Default department for products without one
|
||||
protected int $defaultDepartmentId = 1; // General
|
||||
|
||||
// Parent category ID => type mapping
|
||||
protected array $categoryTypeMapping = [
|
||||
1 => 'accessories',
|
||||
2 => 'cartridges',
|
||||
3 => 'concentrates',
|
||||
4 => 'edibles',
|
||||
5 => 'flower',
|
||||
6 => 'pre_roll',
|
||||
7 => 'topicals',
|
||||
77 => 'concentrates',
|
||||
84 => 'packaging',
|
||||
131 => 'lab_testing',
|
||||
144 => 'additives',
|
||||
147 => 'bulk',
|
||||
];
|
||||
|
||||
// Categories to remap to different parents
|
||||
// 73 (Flower) children -> 5 (Flower)
|
||||
// 118 (Hemp Blunts) -> becomes subcategory of 6 (Pre-Rolls)
|
||||
// 132 (Edibles) children -> 4 (Edibles & Ingestibles)
|
||||
protected array $categoryRemapping = [
|
||||
73 => 5, // Flower -> Flower
|
||||
132 => 4, // Edibles -> Edibles & Ingestibles
|
||||
];
|
||||
|
||||
// Categories to skip entirely (Pre-Roll Cones - brand tracking)
|
||||
protected array $skipCategories = [114, 115, 116, 117, 127, 128];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->info('Importing products from MySQL...');
|
||||
|
||||
// Build strain mapping (MySQL id -> PG id by name)
|
||||
$strainMapping = $this->buildStrainMapping();
|
||||
$this->command->info('Built mapping for '.count($strainMapping).' strains.');
|
||||
|
||||
// Build category tree for lookups
|
||||
$categoryTree = $this->buildCategoryTree();
|
||||
$this->command->info('Built category tree.');
|
||||
|
||||
$products = DB::connection($this->mysqlConnection)
|
||||
->table('products')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$this->command->info("Found {$products->count()} active products in MySQL.");
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($products as $product) {
|
||||
// Map brand_id (NULL -> White Label Canna)
|
||||
$pgBrandId = $product->brand_id
|
||||
? ($this->brandMapping[$product->brand_id] ?? null)
|
||||
: $this->defaultBrandId;
|
||||
|
||||
if (! $pgBrandId) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim($product->name ?? '');
|
||||
if (! $name) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$sku = trim($product->code ?? '');
|
||||
|
||||
// Check for duplicate - by SKU if exists, otherwise by name
|
||||
$existsQuery = DB::table('products')->where('brand_id', $pgBrandId);
|
||||
if ($sku) {
|
||||
$existsQuery->where('sku', $sku);
|
||||
} else {
|
||||
$existsQuery->where('name', $name);
|
||||
}
|
||||
|
||||
if ($existsQuery->exists()) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate unique slug (globally unique, not per-brand)
|
||||
$baseSlug = Str::slug($name) ?: 'product';
|
||||
$slug = $baseSlug;
|
||||
$counter = 1;
|
||||
while (DB::table('products')->where('slug', $slug)->exists()) {
|
||||
$slug = $baseSlug.'-'.$counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
// Map strain_id
|
||||
$strainId = null;
|
||||
if ($product->strain_id && isset($strainMapping[$product->strain_id])) {
|
||||
$strainId = $strainMapping[$product->strain_id];
|
||||
}
|
||||
|
||||
// Resolve category, subcategory, and type
|
||||
[$categoryId, $subcategoryId, $type] = $this->resolveCategory($product->product_category_id, $categoryTree);
|
||||
|
||||
DB::table('products')->insert([
|
||||
'brand_id' => $pgBrandId,
|
||||
'department_id' => $this->defaultDepartmentId,
|
||||
'strain_id' => $strainId,
|
||||
'category_id' => $categoryId,
|
||||
'subcategory_id' => $subcategoryId,
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'sku' => $sku ?: strtoupper(Str::random(8)),
|
||||
'barcode' => $product->barcode,
|
||||
'description' => $product->description,
|
||||
'type' => $type,
|
||||
'is_assembly' => (bool) ($product->isAssembly ?? false),
|
||||
'is_raw_material' => (bool) ($product->isRaw ?? false),
|
||||
'wholesale_price' => $product->wholesale_price,
|
||||
'cost_per_unit' => $product->cost,
|
||||
'is_active' => (bool) ($product->active ?? true),
|
||||
'is_fpr' => (bool) ($product->isFPR ?? false),
|
||||
'is_sellable' => (bool) ($product->isSellable ?? true),
|
||||
'is_case' => (bool) ($product->isCase ?? false),
|
||||
'cased_qty' => $product->cased_qty,
|
||||
'is_box' => (bool) ($product->isBox ?? false),
|
||||
'boxed_qty' => $product->boxed_qty,
|
||||
'thc_percentage' => $product->QATHC,
|
||||
'cbd_percentage' => $product->QACBD,
|
||||
'brand_display_order' => $product->brand_display_order,
|
||||
'product_link' => $product->product_link,
|
||||
'creatives' => $product->creatives,
|
||||
'status' => 'available',
|
||||
'created_at' => $product->created_at,
|
||||
'updated_at' => $product->updated_at,
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
}
|
||||
|
||||
$this->command->info("Imported {$imported} products, skipped {$skipped}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build mapping from MySQL strain_id to PostgreSQL strain_id.
|
||||
*/
|
||||
protected function buildStrainMapping(): array
|
||||
{
|
||||
$mysqlStrains = DB::connection($this->mysqlConnection)
|
||||
->table('strains')
|
||||
->select('id', 'name')
|
||||
->get();
|
||||
|
||||
$pgStrains = DB::table('strains')
|
||||
->select('id', 'name')
|
||||
->get()
|
||||
->keyBy('name');
|
||||
|
||||
$mapping = [];
|
||||
|
||||
foreach ($mysqlStrains as $strain) {
|
||||
$name = trim($strain->name);
|
||||
$pgStrain = $pgStrains->get($name);
|
||||
if ($pgStrain) {
|
||||
$mapping[$strain->id] = $pgStrain->id;
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build category tree for parent lookups.
|
||||
*/
|
||||
protected function buildCategoryTree(): array
|
||||
{
|
||||
$categories = DB::table('product_categories')
|
||||
->select('id', 'name', 'parent_id')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$tree = [];
|
||||
foreach ($categories as $cat) {
|
||||
$tree[$cat->id] = [
|
||||
'id' => $cat->id,
|
||||
'name' => $cat->name,
|
||||
'parent_id' => $cat->parent_id,
|
||||
];
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve category_id, subcategory_id, and type from MySQL product_category_id.
|
||||
*
|
||||
* @return array [category_id, subcategory_id, type]
|
||||
*/
|
||||
protected function resolveCategory(?int $mysqlCategoryId, array $categoryTree): array
|
||||
{
|
||||
if (! $mysqlCategoryId || ! isset($categoryTree[$mysqlCategoryId])) {
|
||||
return [null, null, 'flower']; // default
|
||||
}
|
||||
|
||||
// Skip Pre-Roll Cones categories entirely
|
||||
if (in_array($mysqlCategoryId, $this->skipCategories)) {
|
||||
return [null, null, 'packaging'];
|
||||
}
|
||||
|
||||
$cat = $categoryTree[$mysqlCategoryId];
|
||||
|
||||
// Find root category (climb up parent chain)
|
||||
$rootId = $mysqlCategoryId;
|
||||
$parentChain = [$mysqlCategoryId];
|
||||
while (isset($categoryTree[$rootId]) && $categoryTree[$rootId]['parent_id']) {
|
||||
$rootId = $categoryTree[$rootId]['parent_id'];
|
||||
$parentChain[] = $rootId;
|
||||
}
|
||||
|
||||
// Apply remapping for 73, 132
|
||||
if (isset($this->categoryRemapping[$rootId])) {
|
||||
$rootId = $this->categoryRemapping[$rootId];
|
||||
}
|
||||
|
||||
// Special case: 118 (Hemp Blunts) becomes subcategory of 6 (Pre-Rolls)
|
||||
if ($rootId === 118) {
|
||||
// If product is directly in Hemp Blunts category
|
||||
if ($mysqlCategoryId === 118) {
|
||||
return [6, 118, 'pre_roll'];
|
||||
}
|
||||
|
||||
// If product is in a child of Hemp Blunts (3rd level) - use Hemp Blunts as subcategory
|
||||
return [6, 118, 'pre_roll'];
|
||||
}
|
||||
|
||||
// Get type from root category
|
||||
$type = $this->categoryTypeMapping[$rootId] ?? 'flower';
|
||||
|
||||
// Determine category_id and subcategory_id
|
||||
if ($cat['parent_id'] === null) {
|
||||
// Top-level category: category_id = this, subcategory_id = null
|
||||
// But check if it's one we remap (73, 132)
|
||||
if (isset($this->categoryRemapping[$mysqlCategoryId])) {
|
||||
return [$this->categoryRemapping[$mysqlCategoryId], null, $type];
|
||||
}
|
||||
|
||||
return [$mysqlCategoryId, null, $type];
|
||||
}
|
||||
|
||||
// Has parent - check depth
|
||||
$parent = $categoryTree[$cat['parent_id']] ?? null;
|
||||
|
||||
if ($parent && $parent['parent_id'] === null) {
|
||||
// 2-level: parent is root
|
||||
$parentId = $cat['parent_id'];
|
||||
// Check if parent needs remapping (73 -> 5, 132 -> 4)
|
||||
if (isset($this->categoryRemapping[$parentId])) {
|
||||
$parentId = $this->categoryRemapping[$parentId];
|
||||
}
|
||||
|
||||
return [$parentId, $mysqlCategoryId, $type];
|
||||
}
|
||||
|
||||
if ($parent && $parent['parent_id'] !== null) {
|
||||
// 3-level: use grandparent as category, parent as subcategory
|
||||
$grandparentId = $parent['parent_id'];
|
||||
// Check if grandparent needs remapping
|
||||
if (isset($this->categoryRemapping[$grandparentId])) {
|
||||
$grandparentId = $this->categoryRemapping[$grandparentId];
|
||||
}
|
||||
|
||||
return [$grandparentId, $cat['parent_id'], $type];
|
||||
}
|
||||
|
||||
return [$rootId, null, $type];
|
||||
}
|
||||
}
|
||||
84
database/seeders/MysqlImport/StrainsImportSeeder.php
Normal file
84
database/seeders/MysqlImport/StrainsImportSeeder.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders\MysqlImport;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Import strains from MySQL hub_cannabrands database.
|
||||
*
|
||||
* Idempotent: Skips existing strains by name.
|
||||
*/
|
||||
class StrainsImportSeeder extends Seeder
|
||||
{
|
||||
protected string $mysqlConnection = 'mysql_import';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->info('Importing strains from MySQL...');
|
||||
|
||||
$strains = DB::connection($this->mysqlConnection)
|
||||
->table('strains')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$this->command->info("Found {$strains->count()} strains in MySQL.");
|
||||
|
||||
// Map strain_classification to type
|
||||
$typeMap = [
|
||||
'hybrid' => 'hybrid',
|
||||
'indica' => 'indica',
|
||||
'sativa' => 'sativa',
|
||||
'indica hybrid' => 'indica_dominant',
|
||||
'sativa hybrid' => 'sativa_dominant',
|
||||
];
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($strains as $strain) {
|
||||
// Skip if name already exists
|
||||
if (DB::table('strains')->where('name', $strain->name)->exists()) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$classification = strtolower(trim($strain->strain_classification ?? 'hybrid'));
|
||||
$type = $typeMap[$classification] ?? 'hybrid';
|
||||
|
||||
// Calculate indica/sativa percentages based on type
|
||||
[$indica, $sativa] = match ($type) {
|
||||
'indica' => [100, 0],
|
||||
'sativa' => [0, 100],
|
||||
'indica_dominant' => [70, 30],
|
||||
'sativa_dominant' => [30, 70],
|
||||
default => [50, 50], // hybrid
|
||||
};
|
||||
|
||||
DB::table('strains')->insert([
|
||||
'id' => $strain->id,
|
||||
'name' => $strain->name,
|
||||
'slug' => Str::slug($strain->name),
|
||||
'type' => $type,
|
||||
'indica_percentage' => $indica,
|
||||
'sativa_percentage' => $sativa,
|
||||
'is_active' => true,
|
||||
'created_at' => $strain->created_at,
|
||||
'updated_at' => $strain->updated_at,
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
}
|
||||
|
||||
// Reset sequence to max id + 1
|
||||
$maxId = DB::table('strains')->max('id');
|
||||
if ($maxId) {
|
||||
DB::statement("SELECT setval('strains_id_seq', ?)", [$maxId]);
|
||||
}
|
||||
|
||||
$this->command->info("Imported {$imported} strains, skipped {$skipped} existing.");
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,44 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Brand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ProductsTableSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Maps legacy brand IDs (from old seeder) to brand slugs.
|
||||
* This allows products to reference brands by slug at runtime,
|
||||
* making the seeder independent of auto-generated brand IDs.
|
||||
*/
|
||||
private array $legacyBrandIdToSlug = [
|
||||
1 => 'canna-rso',
|
||||
2 => 'doobz',
|
||||
3 => 'hash-factory',
|
||||
4 => 'high-expectations',
|
||||
5 => 'just-vape',
|
||||
6 => 'thunder-bud',
|
||||
7 => 'aloha-tymemachine',
|
||||
8 => 'outlaw-cannabis',
|
||||
9 => 'twisties',
|
||||
10 => 'nuvata',
|
||||
11 => 'doinks',
|
||||
14 => 'dairy2dank',
|
||||
15 => 'blitzd',
|
||||
16 => 'proper-cock',
|
||||
17 => 'white-label-canna',
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve legacy brand ID to actual brand ID via slug lookup.
|
||||
*/
|
||||
private function resolveBrandId(int $legacyId, array $brandSlugToId): ?int
|
||||
{
|
||||
$slug = $this->legacyBrandIdToSlug[$legacyId] ?? null;
|
||||
|
||||
return $slug ? ($brandSlugToId[$slug] ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto generated seed file
|
||||
*
|
||||
@@ -13,10 +47,35 @@ class ProductsTableSeeder extends Seeder
|
||||
*/
|
||||
public function run()
|
||||
{
|
||||
// Build slug => id mapping from current brands table
|
||||
$brandSlugToId = Brand::pluck('id', 'slug')->toArray();
|
||||
|
||||
\DB::table('products')->delete();
|
||||
|
||||
\DB::table('products')->insert([
|
||||
// Get the product data and resolve brand IDs
|
||||
$products = $this->getProductData();
|
||||
|
||||
foreach ($products as &$product) {
|
||||
// Resolve legacy brand_id to actual brand_id via slug
|
||||
if (isset($product['brand_id'])) {
|
||||
$product['brand_id'] = $this->resolveBrandId($product['brand_id'], $brandSlugToId);
|
||||
}
|
||||
// Remove hardcoded id - let database auto-generate
|
||||
unset($product['id']);
|
||||
}
|
||||
|
||||
// Insert in chunks to avoid memory issues
|
||||
foreach (array_chunk($products, 50) as $chunk) {
|
||||
\DB::table('products')->insert($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the product data array.
|
||||
*/
|
||||
private function getProductData(): array
|
||||
{
|
||||
return [
|
||||
0 => [
|
||||
'id' => 2,
|
||||
'brand_id' => 1,
|
||||
@@ -29015,7 +29074,6 @@ class ProductsTableSeeder extends Seeder
|
||||
'seo_title' => null,
|
||||
'seo_description' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ services:
|
||||
TMPDIR: '/var/www/html/storage/tmp'
|
||||
volumes:
|
||||
- '.:/var/www/html'
|
||||
- './docker/sail/php.ini:/etc/php/8.4/cli/conf.d/99-sail.ini:ro'
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
|
||||
@@ -4,9 +4,10 @@ upload_max_filesize = 100M
|
||||
variables_order = EGPCS
|
||||
pcov.directory = .
|
||||
|
||||
; OPcache Configuration for Maximum Performance
|
||||
opcache.enable=1
|
||||
opcache.enable_cli=1
|
||||
; OPcache disabled for local development to avoid needing container restarts
|
||||
; Enable in production for better performance
|
||||
opcache.enable=0
|
||||
opcache.enable_cli=0
|
||||
opcache.memory_consumption=256
|
||||
opcache.interned_strings_buffer=16
|
||||
opcache.max_accelerated_files=20000
|
||||
|
||||
254
docs/MYSQL_IMPORT.md
Normal file
254
docs/MYSQL_IMPORT.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# MySQL to PostgreSQL Import
|
||||
|
||||
## Overview
|
||||
Import data from MySQL (`hub_cannabrands` on sql1.creationshop.net) to PostgreSQL.
|
||||
|
||||
**Principle:** PostgreSQL is the source of truth. MySQL data adapts to PostgreSQL schema.
|
||||
|
||||
**Idempotent:** Seeders skip existing records, safe to re-run.
|
||||
|
||||
---
|
||||
|
||||
## Import Order (Logical Dependencies)
|
||||
|
||||
1. **Brands** - Products depend on brands (already exist in PostgreSQL)
|
||||
2. **Strains** - Products reference strains
|
||||
3. **Product Categories** - Products reference categories
|
||||
4. **Companies → Businesses** - Invoices reference companies
|
||||
5. **Company Locations → Locations** - Businesses have locations
|
||||
6. **Contacts** - Contacts belong to businesses
|
||||
7. **Products** - Products belong to brands, reference strains/categories
|
||||
8. **Product Images** - Images belong to products
|
||||
9. **Invoices** - Invoices reference businesses
|
||||
10. **Invoice Lines** - Invoice lines reference invoices and products
|
||||
|
||||
---
|
||||
|
||||
## MySQL Source Data Counts
|
||||
|
||||
| Table | Count |
|
||||
|-------|-------|
|
||||
| brands | 16 |
|
||||
| products | 1,042 |
|
||||
| companies | 83 |
|
||||
| contacts | 242 |
|
||||
| invoices | 348 |
|
||||
| invoice_lines | 4,799 |
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Brand Mapping
|
||||
|
||||
MySQL brands must map to PostgreSQL brand slugs.
|
||||
|
||||
### MySQL Brands (16)
|
||||
|
||||
| brand_id | name |
|
||||
|----------|------|
|
||||
| 1 | Hash Factory |
|
||||
| 2 | Doobz |
|
||||
| 3 | High Expectations |
|
||||
| 4 | Just Vape |
|
||||
| 5 | Proper Cock |
|
||||
| 6 | Thunder Bud |
|
||||
| 7 | Twisties |
|
||||
| 8 | Canna |
|
||||
| 9 | Doinks |
|
||||
| 10 | Cannabrands |
|
||||
| 11 | Aloha TymeMachine |
|
||||
| 12 | Blitz'd |
|
||||
| 13 | White Label Canna |
|
||||
| 14 | Outlaw Cannabis |
|
||||
| 15 | Nuvata |
|
||||
| 16 | Dairy to Dank |
|
||||
|
||||
### PostgreSQL Brands (15)
|
||||
|
||||
| id | slug | name |
|
||||
|----|------|------|
|
||||
| 1 | canna-rso | Canna RSO |
|
||||
| 2 | doobz | Doobz |
|
||||
| 3 | hash-factory | Hash Factory |
|
||||
| 4 | high-expectations | High Expectations |
|
||||
| 5 | just-vape | Just Vape |
|
||||
| 6 | thunder-bud | Thunder Bud |
|
||||
| 7 | aloha-tymemachine | Aloha TymeMachine |
|
||||
| 8 | outlaw-cannabis | Outlaw |
|
||||
| 9 | twisites | Twisites |
|
||||
| 10 | nuvata | Nuvata |
|
||||
| 11 | doinks | Doinks |
|
||||
| 12 | bulk | Bulk |
|
||||
| 13 | cannabrands-brand | Cannabrands |
|
||||
| 14 | twisties | Twisties |
|
||||
| 15 | dairy-to-dank | Dairy2Dank |
|
||||
|
||||
### Brand Mapping (MySQL brand_id → PostgreSQL slug)
|
||||
|
||||
| MySQL brand_id | MySQL name | PostgreSQL slug | PostgreSQL id |
|
||||
|----------------|------------|-----------------|---------------|
|
||||
| 1 | Hash Factory | hash-factory | 3 |
|
||||
| 2 | Doobz | doobz | 2 |
|
||||
| 3 | High Expectations | high-expectations | 4 |
|
||||
| 4 | Just Vape | just-vape | 5 |
|
||||
| 5 | Proper Cock | proper-cock | 34 |
|
||||
| 6 | Thunder Bud | thunder-bud | 6 |
|
||||
| 7 | Twisties | twisties | 14 |
|
||||
| 8 | Canna | canna-rso | 1 |
|
||||
| 9 | Doinks | doinks | 11 |
|
||||
| 10 | Cannabrands | white-label-canna | 36 |
|
||||
| 11 | Aloha TymeMachine | aloha-tymemachine | 7 |
|
||||
| 12 | Blitz'd | blitzd | 35 |
|
||||
| 13 | White Label Canna | white-label-canna | 36 |
|
||||
| 14 | Outlaw Cannabis | outlaw-cannabis | 8 |
|
||||
| 15 | Nuvata | nuvata | 10 |
|
||||
| 16 | Dairy to Dank | dairy2dank | 15 |
|
||||
|
||||
**Status:** ✅ Complete
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Strains
|
||||
|
||||
**Status:** ✅ Complete
|
||||
|
||||
### MySQL Schema
|
||||
- `id`, `name`, `strain_classification`, `created_at`, `updated_at`
|
||||
- Classifications: hybrid, indica, indica hybrid, sativa, sativa hybrid
|
||||
|
||||
### Mapping
|
||||
| MySQL | PostgreSQL | Notes |
|
||||
|-------|------------|-------|
|
||||
| id | id | Preserved |
|
||||
| name | name | Direct |
|
||||
| - | slug | Generated from name |
|
||||
| strain_classification | type | hybrid→hybrid, indica→indica, sativa→sativa, indica hybrid→indica_dominant, sativa hybrid→sativa_dominant |
|
||||
| - | indica_percentage | Calculated from type |
|
||||
| - | sativa_percentage | Calculated from type |
|
||||
|
||||
### Result
|
||||
- Imported: 219
|
||||
- Skipped: 0
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Product Categories
|
||||
|
||||
**Status:** ✅ Complete
|
||||
|
||||
### MySQL Schema
|
||||
- `id`, `parent_id`, `name`, `description`, `type` (product/component), `public`, `deleted_at`
|
||||
- Total: 166 (25 soft-deleted, 141 active)
|
||||
|
||||
### Mapping
|
||||
| MySQL | PostgreSQL | Notes |
|
||||
|-------|------------|-------|
|
||||
| id | id | Preserved |
|
||||
| parent_id | parent_id | Direct |
|
||||
| - | business_id | Cannabrands |
|
||||
| name | name | Direct |
|
||||
| - | slug | Generated: name-id |
|
||||
| description | description | Direct |
|
||||
| public | is_active | Boolean |
|
||||
|
||||
### Decisions
|
||||
- Skip 25 soft-deleted categories (packaging/component items deleted by user)
|
||||
|
||||
### Result
|
||||
- Found: 141 active
|
||||
- Imported: 93
|
||||
- Skipped: 48 existing
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Companies → Businesses
|
||||
|
||||
**Status:** ✅ Complete
|
||||
|
||||
### MySQL Schema (key fields)
|
||||
- `id`, `name`, `phone`, `email`, `description`
|
||||
- `license_no`, `license_type`
|
||||
- `cop_address/city/state/zip` (physical)
|
||||
- `billing_address/city/state/zip`
|
||||
- `ship_address/city/state/zip`
|
||||
- `delivery_*` fields
|
||||
- `buyer_name/phone/email`
|
||||
- `payment_method`, `tin`, `dba_name`
|
||||
|
||||
### Mapping
|
||||
| MySQL | PostgreSQL | Notes |
|
||||
|-------|------------|-------|
|
||||
| name | name | Direct |
|
||||
| - | slug | Generated |
|
||||
| - | uuid | UUIDv7 (18 char) |
|
||||
| - | type | 'buyer' |
|
||||
| phone | business_phone | |
|
||||
| email | business_email | |
|
||||
| cop_* | physical_* | Address |
|
||||
| billing_* | billing_* | Address |
|
||||
| ship_* | shipping_* | Address |
|
||||
| delivery_contact_name | delivery_contact_first/last_name | Split |
|
||||
|
||||
### Result
|
||||
- Found: 78 (after soft-delete filter)
|
||||
- Imported: 77
|
||||
- Skipped: 1 existing
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Company Locations → Locations
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Contacts
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Products
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Product Images
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
Images can come from:
|
||||
1. MySQL `products.image` blob column
|
||||
2. Old cannabrands hub git folder
|
||||
|
||||
---
|
||||
|
||||
## Step 9: Invoices
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
---
|
||||
|
||||
## Step 10: Invoice Lines
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
---
|
||||
|
||||
## Decisions Log
|
||||
|
||||
| Date | Decision |
|
||||
|------|----------|
|
||||
| 2024-12-04 | Brand_id 10 (Cannabrands) products moved to brand_id 13 (White Label Canna) in MySQL |
|
||||
| 2024-12-04 | PostgreSQL brand 8 slug changed from "outlaw" to "outlaw-cannabis" |
|
||||
| 2024-12-04 | Deleted PostgreSQL brands: twisites (typo), bulk, cannabrands-brand |
|
||||
| 2024-12-04 | Added PostgreSQL brands: Proper Cock, Blitz'd, White Label Canna |
|
||||
| 2024-12-04 | Changed dairy-to-dank slug to dairy2dank |
|
||||
| 2024-12-04 | Changed brand 8 name from "Outlaw" to "Outlaw Cannabis" |
|
||||
|
||||
---
|
||||
|
||||
## Seeders Created
|
||||
|
||||
1. `database/seeders/MysqlImport/StrainsImportSeeder.php`
|
||||
2. `database/seeders/MysqlImport/ProductCategoriesImportSeeder.php`
|
||||
@@ -457,6 +457,42 @@
|
||||
<p class="text-sm text-base-content/40 mt-1">Try adjusting your filters</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
@if($productsPaginator->hasPages())
|
||||
<div class="mt-6 flex flex-col sm:flex-row items-center justify-between gap-4 border-t border-base-200 pt-4">
|
||||
<div class="text-sm text-base-content/60">
|
||||
Showing {{ $productsPaginator->firstItem() }} to {{ $productsPaginator->lastItem() }} of {{ $productsPaginator->total() }} products
|
||||
</div>
|
||||
<div class="join">
|
||||
@if($productsPaginator->onFirstPage())
|
||||
<button class="join-item btn btn-sm btn-disabled">
|
||||
<span class="icon-[heroicons--chevron-left] size-4"></span>
|
||||
</button>
|
||||
@else
|
||||
<a href="{{ $productsPaginator->previousPageUrl() }}&tab=products" class="join-item btn btn-sm">
|
||||
<span class="icon-[heroicons--chevron-left] size-4"></span>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@foreach($productsPaginator->getUrlRange(max(1, $productsPaginator->currentPage() - 2), min($productsPaginator->lastPage(), $productsPaginator->currentPage() + 2)) as $page => $url)
|
||||
<a href="{{ $url }}&tab=products" class="join-item btn btn-sm {{ $page == $productsPaginator->currentPage() ? 'btn-active' : '' }}">
|
||||
{{ $page }}
|
||||
</a>
|
||||
@endforeach
|
||||
|
||||
@if($productsPaginator->hasMorePages())
|
||||
<a href="{{ $productsPaginator->nextPageUrl() }}&tab=products" class="join-item btn btn-sm">
|
||||
<span class="icon-[heroicons--chevron-right] size-4"></span>
|
||||
</a>
|
||||
@else
|
||||
<button class="join-item btn btn-sm btn-disabled">
|
||||
<span class="icon-[heroicons--chevron-right] size-4"></span>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2348,6 +2384,7 @@
|
||||
stockFilter: 'all',
|
||||
duplicating: null,
|
||||
products: @json($products),
|
||||
pagination: @json($productsPagination),
|
||||
get filteredProducts() {
|
||||
return this.products.filter(product => {
|
||||
const matchesSearch = !this.search ||
|
||||
|
||||
@@ -671,6 +671,44 @@
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
{{-- Pagination --}}
|
||||
@if($productsPaginator->hasPages())
|
||||
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||
<div class="card-body flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="text-sm text-base-content/60">
|
||||
Showing {{ $productsPaginator->firstItem() }} to {{ $productsPaginator->lastItem() }} of {{ $productsPaginator->total() }} products
|
||||
</div>
|
||||
<div class="join">
|
||||
@if($productsPaginator->onFirstPage())
|
||||
<button class="join-item btn btn-sm btn-disabled">
|
||||
<span class="icon-[heroicons--chevron-left] size-4"></span>
|
||||
</button>
|
||||
@else
|
||||
<a href="{{ $productsPaginator->previousPageUrl() }}" class="join-item btn btn-sm">
|
||||
<span class="icon-[heroicons--chevron-left] size-4"></span>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@foreach($productsPaginator->getUrlRange(max(1, $productsPaginator->currentPage() - 2), min($productsPaginator->lastPage(), $productsPaginator->currentPage() + 2)) as $page => $url)
|
||||
<a href="{{ $url }}" class="join-item btn btn-sm {{ $page == $productsPaginator->currentPage() ? 'btn-active' : '' }}">
|
||||
{{ $page }}
|
||||
</a>
|
||||
@endforeach
|
||||
|
||||
@if($productsPaginator->hasMorePages())
|
||||
<a href="{{ $productsPaginator->nextPageUrl() }}" class="join-item btn btn-sm">
|
||||
<span class="icon-[heroicons--chevron-right] size-4"></span>
|
||||
</a>
|
||||
@else
|
||||
<button class="join-item btn btn-sm btn-disabled">
|
||||
<span class="icon-[heroicons--chevron-right] size-4"></span>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||
<div class="card-body text-center py-16">
|
||||
|
||||
@@ -720,7 +720,9 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
|
||||
});
|
||||
|
||||
// Accounts (Buyer Businesses) - requires account management permission
|
||||
Route::prefix('accounts')->name('accounts.')->middleware('suite.permission:sales,manage_accounts|view_buyer_intelligence')->group(function () {
|
||||
// Note: scopeBindings(false) disables implicit model scoping because {account}
|
||||
// is a Business model but not related to the parent {business} via FK
|
||||
Route::prefix('accounts')->name('accounts.')->middleware('suite.permission:sales,manage_accounts|view_buyer_intelligence')->scopeBindings(false)->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Seller\Crm\AccountController::class, 'index'])->name('index');
|
||||
Route::get('{account:slug}', [\App\Http\Controllers\Seller\Crm\AccountController::class, 'show'])->name('show');
|
||||
Route::get('{account:slug}/contacts', [\App\Http\Controllers\Seller\Crm\AccountController::class, 'contacts'])->name('contacts');
|
||||
@@ -728,6 +730,7 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
|
||||
Route::get('{account:slug}/orders', [\App\Http\Controllers\Seller\Crm\AccountController::class, 'orders'])->name('orders');
|
||||
Route::get('{account:slug}/activity', [\App\Http\Controllers\Seller\Crm\AccountController::class, 'activity'])->name('activity');
|
||||
Route::get('{account:slug}/tasks', [\App\Http\Controllers\Seller\Crm\AccountController::class, 'tasks'])->name('tasks');
|
||||
Route::post('{account:slug}/notes', [\App\Http\Controllers\Seller\Crm\AccountController::class, 'storeNote'])->name('notes.store');
|
||||
});
|
||||
|
||||
// Tasks - general access (personal task management)
|
||||
|
||||
Reference in New Issue
Block a user