Compare commits

..

2 Commits

Author SHA1 Message Date
Kelly
905580ca1d fix: resolve duplicate migration timestamp for vehicles table
- Renamed 2025_10_10_034707_create_vehicles_table.php to 2025_10_10_034708_create_vehicles_table.php
- Migration now runs sequentially after drivers table creation
- Resolves PostgreSQL duplicate table creation errors in CI tests
2025-11-11 02:35:16 -07:00
Kelly
39ab10759b chore: trigger CI pipeline
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 00:14:49 -07:00
2523 changed files with 14979 additions and 474610 deletions

View File

@@ -0,0 +1,35 @@
# Number Input Spinners Removed
## Summary
All number input spinner arrows (up/down buttons) have been globally removed from the application.
## Implementation
CSS has been added to both main layout files to hide spinners:
1. **app.blade.php** (lines 17-31)
2. **app-with-sidebar.blade.php** (lines 17-31)
## CSS Used
```css
/* Chrome, Safari, Edge, Opera */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
```
## User Preference
User specifically requested:
- Remove up/down arrows on number input boxes
- Apply this globally across all pages
- Remember this preference for future pages
## Date
2025-11-05

View File

@@ -0,0 +1,27 @@
{
"permissions": {
"allow": [
"Bash(test:*)",
"Bash(docker exec:*)",
"Bash(docker stats:*)",
"Bash(docker logs:*)",
"Bash(docker-compose down:*)",
"Bash(docker-compose up:*)",
"Bash(php --version:*)",
"Bash(docker-compose build:*)",
"Bash(docker-compose restart:*)",
"Bash(find:*)",
"Bash(docker ps:*)",
"Bash(php -l:*)",
"Bash(curl:*)",
"Bash(cat:*)",
"Bash(docker update:*)",
"Bash(grep:*)",
"Bash(sed:*)",
"Bash(php artisan:*)",
"Bash(php check_blade.php:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -8,8 +8,8 @@ node_modules
npm-debug.log
yarn-error.log
# Composer (NOT excluded - Dockerfile.fast needs pre-built vendor)
# /vendor
# Composer
/vendor
# Environment
.env
@@ -58,7 +58,7 @@ docker-compose.*.yml
# Build artifacts
/public/hot
/public/storage
# /public/build - NOT excluded, Dockerfile.fast needs pre-built assets
/public/build
# Misc
.env.backup

View File

@@ -8,10 +8,6 @@ APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
# Stock Notification Settings
# Number of days before stock notification requests expire (default: 30)
STOCK_NOTIFICATION_EXPIRATION_DAYS=30
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
@@ -24,13 +20,12 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# PostgreSQL: 10.100.6.50:5432
DB_CONNECTION=pgsql
DB_HOST=10.100.6.50
DB_HOST=pgsql
DB_PORT=5432
DB_DATABASE=cannabrands_dev
DB_USERNAME=cannabrands
DB_PASSWORD=SpDyCannaBrands2024
DB_DATABASE=cannabrands_app
DB_USERNAME=sail
DB_PASSWORD=password
SESSION_DRIVER=redis
SESSION_LIFETIME=120
@@ -39,7 +34,7 @@ SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=minio
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
# Laravel Reverb (WebSocket Server for Real-Time Broadcasting)
@@ -67,10 +62,9 @@ CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
# Redis: 10.100.9.50:6379
REDIS_CLIENT=phpredis
REDIS_HOST=10.100.9.50
REDIS_PASSWORD=SpDyR3d1s2024!
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
@@ -83,36 +77,25 @@ MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# MinIO/S3 Storage Configuration
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Versioning is enabled in all environments for asset recovery
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# ┌─────────────────────────────────────────────────────────────────────┐
# │ MinIO (S3-Compatible Storage) │
# └─────────────────────────────────────────────────────────────────────┘
# Server: 10.100.9.80:9000 | Console: 10.100.9.80:9001
FILESYSTEM_DISK=minio
AWS_ACCESS_KEY_ID=cannabrands-app
AWS_SECRET_ACCESS_KEY=cdbdcd0c7b6f3994d4ab09f68eaff98665df234f
# AWS/MinIO S3 Storage Configuration
# Local development: Use FILESYSTEM_DISK=public (default)
# Production: Use FILESYSTEM_DISK=s3 with MinIO credentials below
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=cannabrands
AWS_ENDPOINT=http://10.100.9.80:9000
AWS_URL=http://10.100.9.80:9000/cannabrands
AWS_USE_PATH_STYLE_ENDPOINT=true
AWS_BUCKET=
AWS_ENDPOINT=
AWS_URL=
AWS_USE_PATH_STYLE_ENDPOINT=false
# Production MinIO Configuration (example):
# FILESYSTEM_DISK=s3
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
# AWS_DEFAULT_REGION=us-east-1
# AWS_BUCKET=media
# AWS_ENDPOINT=https://cdn.cannabrands.app
# AWS_URL=https://cdn.cannabrands.app/media
# AWS_USE_PATH_STYLE_ENDPOINT=true
VITE_APP_NAME="${APP_NAME}"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# AI Orchestrator Configuration
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
AI_ENABLED=true
AI_LLM_PROVIDER=mock
# AI_LLM_PROVIDER=openai
# AI_LLM_PROVIDER=anthropic
AI_OPENAI_API_KEY=
AI_OPENAI_MODEL=gpt-4o
AI_ANTHROPIC_API_KEY=
AI_ANTHROPIC_MODEL=claude-sonnet-4-20250514
AI_LOG_CHANNEL=ai

View File

@@ -23,11 +23,10 @@ chmod +x .githooks/*
### `pre-commit` - Laravel Pint Auto-formatter ✅ ENFORCED
**What it does:**
- Runs Laravel Pint on staged PHP files only (not unstaged files)
- Runs Laravel Pint on staged files only (`--dirty`)
- Auto-formats code to match team standards
- Automatically re-stages the formatted files
- Automatically stages formatted files
- Fast feedback (runs in seconds)
- Safe: Won't format or stage files you haven't explicitly added
**When it runs:**
- Every time you run `git commit`

View File

@@ -1,37 +1,22 @@
#!/bin/sh
# Laravel Pint Pre-commit Hook
# Automatically format staged PHP files before committing
# Automatically format code before committing
echo "🎨 Running Laravel Pint..."
# Get only staged PHP files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')
# Run Pint on staged files only
./vendor/bin/pint --dirty
# Exit early if no PHP files are staged
if [ -z "$STAGED_FILES" ]; then
echo "✅ No PHP files staged"
exit 0
fi
# Run Pint only on staged files
echo "$STAGED_FILES" | xargs ./vendor/bin/pint
# Check if Pint made changes to any of the staged files
CHANGED=false
for file in $STAGED_FILES; do
if ! git diff --quiet "$file" 2>/dev/null; then
CHANGED=true
break
fi
done
# Re-stage the formatted files (only the ones that were already staged)
if [ "$CHANGED" = true ]; then
# Check if Pint made changes
if ! git diff --quiet; then
echo "✅ Code formatted! Files have been updated."
echo " Changes have been staged automatically."
echo "$STAGED_FILES" | xargs git add
# Stage the formatted files
git add -u
exit 0
else
echo "✅ Code style looks good!"
exit 0
fi
exit 0

View File

@@ -1,22 +1,21 @@
#!/bin/sh
#
# Pre-push hook - Optionally run tests before pushing
# Pre-push hook - Runs tests before pushing (supports both Sail and K8s)
# Can be skipped with: git push --no-verify
#
# This is OPTIONAL - CI/CD will run comprehensive tests automatically.
# Running tests locally can catch issues faster, but it's not required.
#
echo "🚀 Preparing to push..."
echo "🧪 Running tests before push..."
echo " (Use 'git push --no-verify' to skip)"
echo ""
# Detect which environment is running
SAIL_RUNNING=false
K8S_RUNNING=false
# Check if Sail is running (use vendor/bin/sail ps which works for all project names)
if [ -f ./vendor/bin/sail ] && ./vendor/bin/sail ps 2>/dev/null | grep -q "Up"; then
# Check if Sail is running
if docker ps --format '{{.Names}}' | grep -q "sail" 2>/dev/null; then
SAIL_RUNNING=true
echo "📦 Detected Sail environment"
fi
# Check if k8s namespace exists for this worktree
@@ -25,46 +24,41 @@ K8S_NS=$(echo "$BRANCH" | sed 's/feature\//feat-/' | sed 's/bugfix\//fix-/' | se
if kubectl get namespace "$K8S_NS" >/dev/null 2>&1; then
K8S_RUNNING=true
echo "☸️ Detected K8s environment (namespace: $K8S_NS)"
fi
# Offer to run tests if environment is available
if [ "$SAIL_RUNNING" = true ] || [ "$K8S_RUNNING" = true ]; then
echo "💡 Tests will run automatically in CI/CD"
# Run tests in appropriate environment
if [ "$SAIL_RUNNING" = true ]; then
./vendor/bin/sail artisan test --parallel
TEST_EXIT_CODE=$?
elif [ "$K8S_RUNNING" = true ]; then
echo " Running tests in k8s pod..."
kubectl -n "$K8S_NS" exec deploy/web -- php artisan test
TEST_EXIT_CODE=$?
else
echo "⚠️ No environment running (Sail or K8s)"
echo " Skipping tests - please run tests manually"
echo ""
read -p "Run tests locally before push? (y/N) " -n 1 -r
read -p "Continue push anyway? (y/n) " -n 1 -r
echo ""
echo ""
if [ "$REPLY" = "y" ] || [ "$REPLY" = "Y" ]; then
echo "🧪 Running tests..."
echo ""
if [ "$SAIL_RUNNING" = true ]; then
./vendor/bin/sail artisan test --parallel
TEST_EXIT_CODE=$?
elif [ "$K8S_RUNNING" = true ]; then
kubectl -n "$K8S_NS" exec deploy/web -- php artisan test
TEST_EXIT_CODE=$?
fi
if [ $TEST_EXIT_CODE -ne 0 ]; then
echo ""
echo "❌ Tests failed!"
echo ""
echo "Options:"
echo " 1. Fix the failing tests (recommended)"
echo " 2. Push anyway - CI will catch failures: git push --no-verify"
echo ""
exit 1
fi
echo ""
echo "✅ All tests passed!"
echo ""
if [ ! "$REPLY" = "y" ] && [ ! "$REPLY" = "Y" ]; then
echo "Push aborted"
exit 1
fi
exit 0
fi
echo "⚡ Pushing to remote (CI will run full test suite)..."
echo ""
# Check test results
if [ $TEST_EXIT_CODE -ne 0 ]; then
echo ""
echo "❌ Tests failed!"
echo ""
echo "Options:"
echo " 1. Fix the failing tests (recommended)"
echo " 2. Push anyway with: git push --no-verify"
echo ""
exit 1
fi
exit 0
echo ""
echo "✅ All tests passed! Pushing..."

26
.gitignore vendored
View File

@@ -30,14 +30,10 @@ yarn-error.log
# Node symlink (for ARM-based machines)
/node
# Git worktrees directory
/.worktrees/
# Database backups
*.gz
*.sql.gz
*.sql
!database/dumps/*.sql
# Version files (generated at build time or locally)
version.txt
@@ -47,9 +43,6 @@ version.env
.cannabrands-secrets/
reverb-keys*
# Local Claude context (DO NOT COMMIT)
CLAUDE.local.md
# Core dumps and debug files
core
core.*
@@ -65,21 +58,4 @@ core.*
!resources/**/*.png
!resources/**/*.jpg
!resources/**/*.jpeg
# Claude Code settings (personal AI preferences)
.claude/
storage/tmp/*
!storage/tmp/.gitignore
SESSION_ACTIVE
# Developer personal notes (keep local, don't commit)
/docs/dev-notes/
*.dev.md
NOTES.md
TODO.personal.md
SESSION_*
# AI workflow personal context files
CLAUDE.local.md
claude.*.md
cannabrands_dev_backup.dump
.claude/settings.local.json

View File

@@ -1,301 +1,343 @@
# Woodpecker CI/CD Pipeline for Cannabrands Hub
# Optimized for fast deploys (~8-10 min)
# Documentation: https://woodpecker-ci.org/docs/intro
#
# Optimizations:
# - Parallel composer + frontend builds
# - Split tests (unit + feature run in parallel)
# - Dependency caching (npm + composer)
# - Single-stage Dockerfile.fast
# - Kaniko layer caching
#
# External Services:
# - PostgreSQL: 10.100.6.50:5432 (cannabrands_dev)
# - Redis: 10.100.9.50:6379
# - MinIO: 10.100.9.80:9000
# - Docker Registry: git.spdy.io (for k8s pulls)
# 3-Environment Workflow:
# - develop branch → dev.cannabrands.app (unstable, daily integration)
# - master branch → staging.cannabrands.app (stable, pre-production)
# - tags (2025.X) → cannabrands.app (production releases)
when:
- branch: [develop, master]
event: push
- event: [pull_request, tag]
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 50
lfs: false
partial: false
# Install dependencies first (needed for php-lint to resolve traits/classes)
steps:
# ============================================
# PARALLEL: Composer + Frontend (with caching)
# ============================================
# Restore Composer cache
restore-composer-cache:
image: meltwater/drone-cache:dev
settings:
backend: "filesystem"
restore: true
cache_key: "composer-{{ checksum \"composer.lock\" }}"
archive_format: "gzip"
mount:
- "vendor"
volumes:
- /tmp/woodpecker-cache:/tmp/cache
# Install dependencies
composer-install:
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
image: php:8.3-cli
commands:
- echo "Installing system dependencies..."
- apt-get update -qq
- apt-get install -y -qq git zip unzip libicu-dev libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libpq-dev
- echo "Installing PHP extensions..."
- docker-php-ext-configure gd --with-freetype --with-jpeg
- docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip gd
- echo "Installing Composer..."
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
- echo "Creating minimal .env for package discovery..."
- |
cat > .env << 'EOF'
APP_NAME="Cannabrands Hub"
APP_ENV=development
APP_ENV=testing
APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
APP_DEBUG=true
CACHE_STORE=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=testing
DB_USERNAME=testing
DB_PASSWORD=testing
EOF
# Restore composer cache if available
- mkdir -p /root/.composer/cache
- if [ -d .composer-cache ]; then cp -r .composer-cache/* /root/.composer/cache/ 2>/dev/null || true; fi
# Clean vendor and bootstrap cache to force fresh install
- rm -rf vendor bootstrap/cache/*.php
- composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
# Verify test command is available
- php artisan list test | head -5
# Save cache for next build
- mkdir -p .composer-cache && cp -r /root/.composer/cache/* .composer-cache/ 2>/dev/null || true
- echo "Composer done"
- echo "Checking for cached dependencies..."
- |
if [ -d "vendor" ] && [ -f "vendor/autoload.php" ]; then
echo "✅ Restored vendor from cache"
echo "Verifying cached dependencies are up to date..."
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
else
echo "📦 Installing fresh dependencies (cache miss)"
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
fi
- echo "Composer dependencies ready!"
build-frontend:
image: 10.100.9.70:5000/library/node:22-alpine
environment:
VITE_REVERB_APP_KEY: 6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU=
VITE_REVERB_HOST: dev.cannabrands.app
VITE_REVERB_PORT: "443"
VITE_REVERB_SCHEME: https
npm_config_cache: .npm-cache
commands:
# Use cached node_modules if available
- npm ci --prefer-offline
- npm run build
- echo "✅ Frontend built"
# ============================================
# PR CHECKS (Parallel: lint, style, tests)
# ============================================
# Rebuild Composer cache
rebuild-composer-cache:
image: meltwater/drone-cache:dev
settings:
backend: "filesystem"
rebuild: true
cache_key: "composer-{{ checksum \"composer.lock\" }}"
archive_format: "gzip"
mount:
- "vendor"
volumes:
- /tmp/woodpecker-cache:/tmp/cache
# PHP Syntax Check (runs after composer install so traits/classes are available)
php-lint:
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
image: php:8.3-cli
commands:
- ./vendor/bin/parallel-lint app routes database config --colors --blame
when:
event: pull_request
- echo "Checking PHP syntax..."
- find app -name "*.php" -exec php -l {} \;
- find routes -name "*.php" -exec php -l {} \;
- find database -name "*.php" -exec php -l {} \;
- echo "PHP syntax check complete!"
# Run Laravel Pint (Code Style)
code-style:
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
image: php:8.3-cli
commands:
- echo "Checking code style with Laravel Pint..."
- ./vendor/bin/pint --test
when:
event: pull_request
- echo "Code style check complete!"
# Split tests: Unit tests (with DB - some unit tests use factories)
tests-unit:
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
when:
event: pull_request
# Run PHPUnit Tests
# Note: Uses array cache/session for speed and isolation (Laravel convention)
# Redis + Reverb services used for real-time broadcasting tests
tests:
image: kirschbaumdevelopment/laravel-test-runner:8.3
environment:
APP_ENV: testing
BROADCAST_CONNECTION: reverb
CACHE_STORE: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: pgsql
DB_HOST: 10.100.6.50
DB_HOST: postgres
DB_PORT: 5432
DB_DATABASE: cannabrands_test
DB_USERNAME: cannabrands
DB_PASSWORD: SpDyCannaBrands2024
DB_DATABASE: testing
DB_USERNAME: testing
DB_PASSWORD: testing
REDIS_HOST: redis
REVERB_APP_ID: test-app-id
REVERB_APP_KEY: test-key
REVERB_APP_SECRET: test-secret
REVERB_HOST: localhost
REVERB_PORT: 8080
REVERB_SCHEME: http
commands:
- echo "Setting up Laravel environment..."
- cp .env.example .env
- php artisan key:generate
- php artisan test --testsuite=Unit
- echo "✅ Unit tests passed"
# Split tests: Feature tests (with DB)
tests-feature:
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
when:
event: pull_request
environment:
APP_ENV: testing
CACHE_STORE: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: pgsql
DB_HOST: 10.100.7.50
DB_PORT: 5432
DB_DATABASE: cannabrands_test
DB_USERNAME: cannabrands
DB_PASSWORD: SpDyCannaBrands2024
REDIS_HOST: 10.100.9.50
REDIS_PORT: 6379
REDIS_PASSWORD: SpDyR3d1s2024!
commands:
- cp .env.example .env
- php artisan key:generate
- php artisan test --testsuite=Feature
- echo "✅ Feature tests passed"
# ============================================
# BUILD & DEPLOY
# ============================================
# Create Docker config for registry auth (runs before Kaniko)
setup-registry-auth:
image: alpine
depends_on:
- composer-install
- build-frontend
environment:
REGISTRY_USER:
from_secret: registry_user
REGISTRY_PASSWORD:
from_secret: registry_password
commands:
- mkdir -p /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker
- |
cat > /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker/config.json << EOF
{"auths":{"registry.spdy.io":{"username":"$REGISTRY_USER","password":"$REGISTRY_PASSWORD"}}}
EOF
- echo "Auth config created"
when:
branch: [develop, master]
event: push
- echo "Starting Reverb server in background..."
- php artisan reverb:start --host=0.0.0.0 --port=8080 > /dev/null 2>&1 &
- sleep 2
- echo "Running tests..."
- php artisan test --parallel
- echo "Tests complete!"
# Build and push Docker image for DEV environment (develop branch)
build-image-dev:
image: 10.100.9.70:5000/kaniko-project/executor:debug
depends_on:
- setup-registry-auth
commands:
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
- |
/kaniko/executor \
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
--destination=registry.spdy.io/cannabrands/hub:dev \
--destination=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
--build-arg=APP_VERSION=dev \
--registry-mirror=10.100.9.70:5000 \
--insecure-registry=10.100.9.70:5000 \
--cache=true \
--cache-ttl=168h \
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
from_secret: gitea_token
tags:
- dev # Latest dev build → dev.cannabrands.app
- dev-${CI_COMMIT_SHA:0:7} # Unique dev tag with SHA
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
- ${CI_COMMIT_BRANCH} # Branch name (develop)
build_args:
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "dev"
VITE_REVERB_APP_KEY: "6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU="
VITE_REVERB_HOST: "dev.cannabrands.app"
VITE_REVERB_PORT: "443"
VITE_REVERB_SCHEME: "https"
cache_images:
- code.cannabrands.app/cannabrands/hub:buildcache-dev
platforms: linux/amd64
when:
branch: develop
event: push
status: success
# Auto-deploy to dev.cannabrands.app (develop branch only)
deploy-dev:
image: 10.100.9.70:5000/bitnami/kubectl:latest
depends_on:
- build-image-dev
image: bitnami/kubectl:latest
environment:
KUBECONFIG_CONTENT:
from_secret: kubeconfig_dev
commands:
- echo "🚀 Auto-deploying to dev.cannabrands.app..."
- echo "Commit SHA${CI_COMMIT_SHA:0:7}"
- echo ""
# Setup kubeconfig
- mkdir -p ~/.kube
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
- chmod 600 ~/.kube/config
# Update deployment to use new SHA-tagged image (both app and init containers)
- |
kubectl set image deployment/cannabrands-hub \
app=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
migrate=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
app=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
migrate=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
-n cannabrands-dev
# Wait for rollout to complete (timeout 5 minutes)
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-dev --timeout=300s
- echo "✅ Deployed to dev.cannabrands.app"
# Verify deployment health
- |
echo ""
echo "✅ Deployment successful!"
echo "Pod status:"
kubectl get pods -n cannabrands-dev -l app=cannabrands-hub
echo ""
echo "Image deployed:"
kubectl get deployment cannabrands-hub -n cannabrands-dev -o jsonpath='{.spec.template.spec.containers[0].image}'
echo ""
when:
branch: develop
event: push
status: success
build-image-production:
image: 10.100.9.70:5000/kaniko-project/executor:debug
depends_on:
- setup-registry-auth
commands:
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
- |
/kaniko/executor \
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
--destination=git.spdy.io/cannabrands/hub:latest \
--destination=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
--build-arg=APP_VERSION=production \
--cache=true \
--cache-ttl=168h \
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache \
--insecure \
--insecure-pull \
--skip-tls-verify
# Build and push Docker image for STAGING environment (master branch)
build-image-staging:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
from_secret: gitea_token
tags:
- staging # Latest staging build → staging.cannabrands.app
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
- ${CI_COMMIT_BRANCH} # Branch name (master)
build_args:
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "staging"
cache_images:
- code.cannabrands.app/cannabrands/hub:buildcache-staging
platforms: linux/amd64
when:
branch: master
event: push
status: success
deploy-production:
image: 10.100.9.70:5000/bitnami/kubectl:latest
depends_on:
- build-image-production
environment:
KUBECONFIG_CONTENT:
from_secret: kubeconfig_prod
commands:
- mkdir -p ~/.kube
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
- chmod 600 ~/.kube/config
- |
kubectl set image deployment/cannabrands-hub \
app=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
migrate=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
-n cannabrands-prod
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-prod --timeout=300s
- echo "✅ Deployed to cannabrands.app"
when:
branch: master
event: push
# For tags, setup auth first
setup-registry-auth-release:
image: alpine
depends_on:
- composer-install
- build-frontend
environment:
REGISTRY_USER:
from_secret: registry_user
REGISTRY_PASSWORD:
from_secret: registry_password
commands:
- mkdir -p /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker
- |
cat > /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker/config.json << EOF
{"auths":{"git.spdy.io":{"username":"$REGISTRY_USER","password":"$REGISTRY_PASSWORD"}}}
EOF
when:
event: tag
# Build and push Docker image for PRODUCTION (tagged releases)
build-image-release:
image: 10.100.9.70:5000/kaniko-project/executor:debug
depends_on:
- setup-registry-auth-release
commands:
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
- |
/kaniko/executor \
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
--destination=git.spdy.io/cannabrands/hub:${CI_COMMIT_TAG} \
--destination=git.spdy.io/cannabrands/hub:latest \
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
--build-arg=APP_VERSION=${CI_COMMIT_TAG} \
--cache=true \
--cache-ttl=168h \
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache \
--insecure \
--insecure-pull \
--skip-tls-verify
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
from_secret: gitea_token
tags:
- ${CI_COMMIT_TAG} # CalVer tag (e.g., 2025.10.1)
- latest # Latest stable release
build_args:
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "${CI_COMMIT_TAG}"
cache_images:
- code.cannabrands.app/cannabrands/hub:buildcache-prod
platforms: linux/amd64
when:
event: tag
status: success
# Success notification
success:
image: alpine:latest
when:
- evaluate: 'CI_PIPELINE_STATUS == "success"'
commands:
- echo "✅ Pipeline completed successfully!"
- echo "All checks passed for commit ${CI_COMMIT_SHA:0:7}"
- |
if [ "${CI_PIPELINE_EVENT}" = "tag" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🎉 PRODUCTION RELEASE BUILD COMPLETE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Version: ${CI_COMMIT_TAG}"
echo "Registry: code.cannabrands.app/cannabrands/hub"
echo ""
echo "Available as:"
echo " - code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
echo " - code.cannabrands.app/cannabrands/hub:latest"
echo ""
echo "🚀 Deploy to PRODUCTION (cannabrands.app):"
echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
echo " docker-compose -f docker-compose.production.yml up -d"
echo ""
echo "⚠️ This is a CUSTOMER-FACING release!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "master" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🧪 STAGING BUILD COMPLETE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Branch: master"
echo "Registry: code.cannabrands.app/cannabrands/hub"
echo "Tags:"
echo " - staging"
echo " - sha-${CI_COMMIT_SHA:0:7}"
echo " - ${CI_COMMIT_BRANCH}"
echo ""
echo "📦 Deploy to STAGING (staging.cannabrands.app):"
echo " docker pull code.cannabrands.app/cannabrands/hub:staging"
echo " docker-compose -f docker-compose.staging.yml up -d"
echo ""
echo "👥 Next steps:"
echo " 1. Super-admin tests on staging.cannabrands.app"
echo " 2. Validate all features work"
echo " 3. When ready, create production tag:"
echo " git tag -a 2025.10.1 -m 'Release 2025.10.1'"
echo " git push origin 2025.10.1"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "develop" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🚀 DEV BUILD + AUTO-DEPLOY COMPLETE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Branch: develop"
echo "Commit: ${CI_COMMIT_SHA:0:7}"
echo ""
echo "✅ Built & Tagged:"
echo " - code.cannabrands.app/cannabrands/hub:dev"
echo " - code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7}"
echo " - code.cannabrands.app/cannabrands/hub:sha-${CI_COMMIT_SHA:0:7}"
echo ""
echo "✅ Auto-Deployed to Kubernetes:"
echo " - Environment: dev.cannabrands.app"
echo " - Namespace: cannabrands-dev"
echo " - Image: dev-${CI_COMMIT_SHA:0:7}"
echo ""
echo "🧪 Test your changes:"
echo " - Visit: https://dev.cannabrands.app"
echo " - Login: admin@example.com / password"
echo " - Check: https://dev.cannabrands.app/telescope"
echo ""
echo "👥 Next steps:"
echo " 1. Verify feature works on dev.cannabrands.app"
echo " 2. When stable, merge to master for staging:"
echo " git checkout master && git merge develop && git push"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
# Services for tests
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: testing
POSTGRES_PASSWORD: testing
POSTGRES_DB: testing
redis:
image: redis:7-alpine
commands:
- redis-server --bind 0.0.0.0

View File

@@ -69,14 +69,14 @@ git push origin develop
**Before (Mutable Tags - Problematic):**
```
git.spdy.io/cannabrands/hub:dev # Overwritten each build
code.cannabrands.app/cannabrands/hub:dev # Overwritten each build
```
**After (Immutable Tags - Best Practice):**
```
git.spdy.io/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
git.spdy.io/cannabrands/hub:dev # Latest dev (convenience)
git.spdy.io/cannabrands/hub:sha-a28d5b5 # Generic SHA
code.cannabrands.app/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
code.cannabrands.app/cannabrands/hub:dev # Latest dev (convenience)
code.cannabrands.app/cannabrands/hub:sha-a28d5b5 # Generic SHA
```
### Auto-Deploy Flow
@@ -109,14 +109,14 @@ If a deployment breaks dev, roll back to the previous version:
kubectl get deployment cannabrands-hub -n cannabrands-dev \
-o jsonpath='{.spec.template.spec.containers[0].image}'
# Output: git.spdy.io/cannabrands/hub:dev-a28d5b5
# Output: code.cannabrands.app/cannabrands/hub:dev-a28d5b5
# 2. Check git log for previous commit
git log --oneline develop | head -5
# 3. Rollback to previous SHA
kubectl set image deployment/cannabrands-hub \
app=git.spdy.io/cannabrands/hub:dev-PREVIOUS_SHA \
app=code.cannabrands.app/cannabrands/hub:dev-PREVIOUS_SHA \
-n cannabrands-dev
# 4. Verify rollback
@@ -156,7 +156,7 @@ deploy-staging:
- chmod 600 ~/.kube/config
- |
kubectl set image deployment/cannabrands-hub \
app=git.spdy.io/cannabrands/hub:staging-${CI_COMMIT_SHA:0:7} \
app=code.cannabrands.app/cannabrands/hub:staging-${CI_COMMIT_SHA:0:7} \
-n cannabrands-staging
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-staging --timeout=300s
when:
@@ -207,7 +207,7 @@ kubectl logs -n cannabrands-dev deployment/cannabrands-hub --tail=100
cannabrands-hub-7d85986845-gnkbv 1/1 Running 0 45s
Image deployed:
git.spdy.io/cannabrands/hub:dev-a28d5b5
code.cannabrands.app/cannabrands/hub:dev-a28d5b5
```
---

View File

@@ -47,8 +47,8 @@ steps:
build-image:
image: plugins/docker
settings:
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
tags: [latest, ${CI_COMMIT_SHA:0:8}]
when:
branch: master
@@ -68,7 +68,7 @@ steps:
```bash
# On production server
ssh cannabrands-prod
docker pull git.spdy.io/cannabrands/hub:bef77df8
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
docker-compose up -d
# Or use deployment tool like Ansible, Deployer, etc.
```
@@ -108,7 +108,7 @@ steps:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
- docker exec cannabrands php artisan migrate --force
- docker exec cannabrands php artisan config:cache
@@ -160,7 +160,7 @@ steps:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
when:
branch: develop
@@ -176,7 +176,7 @@ steps:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
when:
branch: master
@@ -367,7 +367,7 @@ Production:
```bash
# Quick rollback (under 2 minutes)
ssh cannabrands-prod
docker pull git.spdy.io/cannabrands/hub:PREVIOUS_COMMIT_SHA
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_COMMIT_SHA
docker-compose up -d
# Database rollback (if migrations ran)
@@ -536,8 +536,8 @@ steps:
build-image:
image: plugins/docker
settings:
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
tags:
- ${CI_COMMIT_BRANCH}
- ${CI_COMMIT_SHA:0:8}
@@ -559,7 +559,7 @@ steps:
from_secret: staging_ssh_key
script:
- cd /var/www/cannabrands
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
- docker exec cannabrands php artisan migrate --force
- docker exec cannabrands php artisan config:cache
@@ -582,7 +582,7 @@ steps:
- echo "To deploy to production:"
- echo " ssh cannabrands-prod"
- echo " cd /var/www/cannabrands"
- echo " docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
- echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
- echo " docker-compose up -d"
- echo ""
- echo "⚠️ Remember: Check deployment checklist first!"

View File

@@ -102,7 +102,7 @@ Push to master → Woodpecker runs:
→ Build Docker image
→ Tag: cannabrands-hub:c165bf9 (commit SHA)
→ Tag: cannabrands-hub:latest
→ Push to git.spdy.io/cannabrands/hub
→ Push to code.cannabrands.app/cannabrands/hub
→ Image ready, no deployment yet
```
@@ -177,7 +177,7 @@ CMD ["php-fpm"]
### Staging Deployment:
```bash
# Pull the same image
docker pull git.spdy.io/cannabrands/hub:c165bf9
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
# Run with staging environment
docker run \
@@ -186,13 +186,13 @@ docker run \
-e DB_DATABASE=cannabrands_staging \
-e APP_DEBUG=true \
-e MAIL_MAILER=log \
git.spdy.io/cannabrands/hub:c165bf9
code.cannabrands.app/cannabrands/hub:c165bf9
```
### Production Deployment:
```bash
# Pull THE EXACT SAME IMAGE
docker pull git.spdy.io/cannabrands/hub:c165bf9
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
# Run with production environment
docker run \
@@ -201,7 +201,7 @@ docker run \
-e DB_DATABASE=cannabrands_production \
-e APP_DEBUG=false \
-e MAIL_MAILER=smtp \
git.spdy.io/cannabrands/hub:c165bf9
code.cannabrands.app/cannabrands/hub:c165bf9
```
**Key point**: Notice it's the **exact same image** (`c165bf9`), only environment variables differ.
@@ -218,7 +218,7 @@ docker run \
version: '3.8'
services:
app:
image: git.spdy.io/cannabrands/hub:latest
image: code.cannabrands.app/cannabrands/hub:latest
env_file:
- .env.staging # Staging-specific vars
ports:
@@ -253,7 +253,7 @@ secrets:
version: '3.8'
services:
app:
image: git.spdy.io/cannabrands/hub:c165bf9 # Specific SHA
image: code.cannabrands.app/cannabrands/hub:c165bf9 # Specific SHA
env_file:
- .env.production # Production-specific vars
ports:
@@ -301,7 +301,7 @@ spec:
spec:
containers:
- name: app
image: git.spdy.io/cannabrands/hub:c165bf9
image: code.cannabrands.app/cannabrands/hub:c165bf9
envFrom:
- configMapRef:
name: app-config-staging # Different per namespace
@@ -350,8 +350,8 @@ steps:
build-and-publish:
image: plugins/docker
settings:
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
tags:
- latest # Always overwrite
- ${CI_COMMIT_SHA:0:8} # Immutable SHA
@@ -384,7 +384,7 @@ Date: 2025-01-15 14:30:00 PST
Image: cannabrands-hub:c165bf9
Deployed by: jon@cannabrands.com
Approved by: compliance@cannabrands.com
Git commit: https://git.spdy.io/.../c165bf9
Git commit: https://code.cannabrands.app/.../c165bf9
Changes: Invoice picking workflow update
Tests passed: ✅ 28/28
Staging tested: ✅ 2 hours
@@ -424,7 +424,7 @@ Rollback image: cannabrands-hub:a1b2c3d
```bash
# On production server
ssh cannabrands-prod
docker pull git.spdy.io/cannabrands/hub:c165bf9
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
docker-compose -f docker-compose.production.yml up -d
```
@@ -487,14 +487,14 @@ steps:
security-scan:
image: aquasec/trivy
commands:
- trivy image git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- trivy image code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
```
### 4. Sign Images (Advanced)
Use Cosign to cryptographically sign images:
```bash
cosign sign git.spdy.io/cannabrands/hub:c165bf9
cosign sign code.cannabrands.app/cannabrands/hub:c165bf9
```
Compliance benefit: Prove image hasn't been tampered with.
@@ -507,10 +507,10 @@ Compliance benefit: Prove image hasn't been tampered with.
```bash
# List recent deployments
docker images git.spdy.io/cannabrands/hub
docker images code.cannabrands.app/cannabrands/hub
# Rollback to previous version
docker pull git.spdy.io/cannabrands/hub:a1b2c3d
docker pull code.cannabrands.app/cannabrands/hub:a1b2c3d
docker-compose -f docker-compose.production.yml up -d
```
@@ -531,7 +531,7 @@ deploy:
# Before risky deployment
git tag -a v1.5.2-stable -m "Last known good version"
docker tag cannabrands-hub:current cannabrands-hub:v1.5.2-stable
docker push git.spdy.io/cannabrands/hub:v1.5.2-stable
docker push code.cannabrands.app/cannabrands/hub:v1.5.2-stable
```
---

View File

@@ -254,25 +254,25 @@ WORKDIR /woodpecker/src
**Build and push to Gitea:**
```bash
docker build -f docker/ci-php.Dockerfile -t git.spdy.io/cannabrands/ci-php:8.3 .
docker push git.spdy.io/cannabrands/ci-php:8.3
docker build -f docker/ci-php.Dockerfile -t code.cannabrands.app/cannabrands/ci-php:8.3 .
docker push code.cannabrands.app/cannabrands/ci-php:8.3
```
**Update `.woodpecker/.ci.yml`:**
```yaml
steps:
php-lint:
image: git.spdy.io/cannabrands/ci-php:8.3
image: code.cannabrands.app/cannabrands/ci-php:8.3
commands:
- find app routes database -name "*.php" -exec php -l {} \;
composer-install:
image: git.spdy.io/cannabrands/ci-php:8.3
image: code.cannabrands.app/cannabrands/ci-php:8.3
commands:
- composer install --no-interaction --prefer-dist --optimize-autoloader
code-style:
image: git.spdy.io/cannabrands/ci-php:8.3
image: code.cannabrands.app/cannabrands/ci-php:8.3
commands:
- ./vendor/bin/pint --test
```

View File

@@ -107,7 +107,7 @@ version: '3.8'
services:
app:
image: git.spdy.io/cannabrands/hub:latest
image: code.cannabrands.app/cannabrands/hub:latest
container_name: cannabrands_app
restart: unless-stopped
ports:
@@ -204,8 +204,8 @@ steps:
build-image:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
@@ -564,7 +564,7 @@ docker images | grep cannabrands
```bash
# Pull previous commit's image
docker pull git.spdy.io/cannabrands/hub:PREVIOUS_SHA
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_SHA
# Update docker-compose.yml to use specific tag
docker compose up -d app

View File

@@ -11,10 +11,10 @@ Once you implement production deployments, Woodpecker will:
Your images will be available at:
```
git.spdy.io/cannabrands/hub
code.cannabrands.app/cannabrands/hub
```
**View packages**: https://git.spdy.io/Cannabrands/hub/-/packages
**View packages**: https://code.cannabrands.app/Cannabrands/hub/-/packages
## Step 1: Enable Gitea Package Registry
@@ -22,7 +22,7 @@ First, verify the registry is enabled on your Gitea instance:
1. **Check as admin**: Admin → Site Administration → Configuration
2. **Look for**: `[packages]` section with `ENABLED = true`
3. **Test**: Visit https://git.spdy.io/-/packages
3. **Test**: Visit https://code.cannabrands.app/-/packages
If not enabled, ask your Gitea admin to enable it in `app.ini`:
```ini
@@ -61,8 +61,8 @@ steps:
build-and-publish:
image: plugins/docker
settings:
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
@@ -136,15 +136,15 @@ Once images are published, you can pull them on your production servers:
```bash
# Login to Gitea registry
docker login git.spdy.io
docker login code.cannabrands.app
# Username: your-gitea-username
# Password: your-personal-access-token
# Pull latest image
docker pull git.spdy.io/cannabrands/hub:latest
docker pull code.cannabrands.app/cannabrands/hub:latest
# Or pull specific commit
docker pull git.spdy.io/cannabrands/hub:bef77df8
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
```
## Image Tagging Strategy
@@ -218,8 +218,8 @@ steps:
build-and-publish:
image: plugins/docker
settings:
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
@@ -236,7 +236,7 @@ steps:
notify-deploy:
image: alpine:latest
commands:
- echo "✅ New image published: git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
- echo "✅ New image published: code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
- echo "Ready for deployment to production!"
when:
- branch: master
@@ -271,8 +271,8 @@ services:
- Subsequent builds will work fine
**Images not appearing in Gitea packages**
- Check Gitea packages are enabled: https://git.spdy.io/-/packages
- Verify registry URL is `git.spdy.io` (not `ci.cannabrands.app`)
- Check Gitea packages are enabled: https://code.cannabrands.app/-/packages
- Verify registry URL is `code.cannabrands.app` (not `ci.cannabrands.app`)
## Next Steps

View File

@@ -85,7 +85,7 @@ git push origin 2025.11.3
### Step 3: Wait for CI Build (2-4 minutes)
Watch at: `git.spdy.io/cannabrands/hub/pipelines`
Watch at: `code.cannabrands.app/cannabrands/hub/pipelines`
CI will automatically:
- Run tests
@@ -113,7 +113,7 @@ git push origin master
```bash
# Deploy specific version
kubectl set image deployment/cannabrands \
app=git.spdy.io/cannabrands/hub:2025.11.3
app=code.cannabrands.app/cannabrands/hub:2025.11.3
# Watch deployment
kubectl rollout status deployment/cannabrands
@@ -131,7 +131,7 @@ kubectl get pods
```bash
# Option 1: Rollback to previous version
kubectl set image deployment/cannabrands \
app=git.spdy.io/cannabrands/hub:2025.11.2
app=code.cannabrands.app/cannabrands/hub:2025.11.2
# Option 2: Kubernetes automatic rollback
kubectl rollout undo deployment/cannabrands
@@ -154,7 +154,7 @@ git push origin 2025.11.4
# 4. Deploy when confident
kubectl set image deployment/cannabrands \
app=git.spdy.io/cannabrands/hub:2025.11.4
app=code.cannabrands.app/cannabrands/hub:2025.11.4
```
---
@@ -170,7 +170,7 @@ master → Branch tracking
**Use in K3s dev/staging:**
```yaml
image: git.spdy.io/cannabrands/hub:latest-dev
image: code.cannabrands.app/cannabrands/hub:latest-dev
imagePullPolicy: Always
```
@@ -182,7 +182,7 @@ stable → Latest production release
**Use in K3s production:**
```yaml
image: git.spdy.io/cannabrands/hub:2025.11.3
image: code.cannabrands.app/cannabrands/hub:2025.11.3
imagePullPolicy: IfNotPresent
```
@@ -214,7 +214,7 @@ docker build -t cannabrands:test .
### View CI Status
```bash
# Visit Woodpecker
open https://git.spdy.io/cannabrands/hub/pipelines
open https://code.cannabrands.app/cannabrands/hub/pipelines
# Or check latest build
# (Visit Gitea → Repository → Pipelines)
@@ -227,7 +227,7 @@ open https://git.spdy.io/cannabrands/hub/pipelines
### CI Build Failing
```bash
# Check Woodpecker logs
# Visit: git.spdy.io/cannabrands/hub/pipelines
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
# Run tests locally first
./vendor/bin/sail artisan test
@@ -291,42 +291,6 @@ npm run changelog
---
## CI/CD Pipeline Stages
The Woodpecker CI pipeline runs the following stages for every push to `develop` or `master`:
1. **PHP Lint** - Syntax validation
2. **Code Style (Pint)** - Formatting check
3. **Tests** - PHPUnit/Pest tests with `APP_ENV=testing`
4. **Seeder Validation** - Validates seeders with `APP_ENV=development`
5. **Docker Build** - Creates container image
6. **Auto-Deploy** - Deploys to dev.cannabrands.app (develop branch only)
### Why Seeder Validation?
The dev environment (`dev.cannabrands.app`) runs `migrate:fresh --seed` on every K8s deployment via init container. If seeders have bugs (e.g., undefined functions, missing relationships), the deployment fails and pods crash.
**The Problem:**
- Tests run with `APP_ENV=testing` which **skips DevSeeder**
- K8s runs with `APP_ENV=development` which **runs DevSeeder**
- Seeder bugs passed CI but crashed in K8s
**The Solution:**
- Add dedicated seeder validation step with `APP_ENV=development`
- Runs the exact same command as K8s init container
- Catches seeder errors before deployment
**Time Cost:** ~20-30 seconds added to CI pipeline
**What It Catches:**
- Runtime errors (e.g., `fake()` outside factory context)
- Database constraint violations
- Missing relationships (foreign key errors)
- Invalid enum values
- Seeder syntax errors
---
## Pre-Commit Checklist
Before committing:
@@ -336,7 +300,6 @@ Before committing:
Before releasing:
- [ ] All tests green in CI
- [ ] **Seeder validation passed in CI**
- [ ] Tested in dev/staging environment
- [ ] Release notes written
- [ ] CHANGELOG updated (auto-generated)
@@ -362,8 +325,8 @@ Before deploying:
- Pair with senior dev for first release
### CI/CD
- Woodpecker: `git.spdy.io/cannabrands/hub`
- Gitea: `git.spdy.io/cannabrands/hub`
- Woodpecker: `code.cannabrands.app/cannabrands/hub`
- Gitea: `code.cannabrands.app/cannabrands/hub`
- K3s Dashboard: (ask devops for link)
---
@@ -371,13 +334,13 @@ Before deploying:
## Important URLs
**Code Repository:**
https://git.spdy.io/cannabrands/hub
https://code.cannabrands.app/cannabrands/hub
**CI/CD Pipeline:**
https://git.spdy.io/cannabrands/hub/pipelines
https://code.cannabrands.app/cannabrands/hub/pipelines
**Container Registry:**
https://git.spdy.io/-/packages/container/cannabrands%2Fhub
https://code.cannabrands.app/-/packages/container/cannabrands%2Fhub
**Documentation:**
`.woodpecker/` directory in repository
@@ -430,7 +393,7 @@ Closes #42"
| Deploy | `kubectl set image deployment/cannabrands app=...:2025.11.1` |
| Rollback | `kubectl set image deployment/cannabrands app=...:2025.11.0` |
| Check version | `kubectl get deployment cannabrands -o jsonpath='{.spec.template.spec.containers[0].image}'` |
| View builds | Visit `git.spdy.io/cannabrands/hub/pipelines` |
| View builds | Visit `code.cannabrands.app/cannabrands/hub/pipelines` |
---

View File

@@ -33,7 +33,7 @@ git push origin master
2. Tests run (PHP lint, Pint, PHPUnit)
3. Docker image builds (if tests pass)
4. Tagged as: latest-dev, dev-c658193, master
5. Pushed to git.spdy.io/cannabrands/hub
5. Pushed to code.cannabrands.app/cannabrands/hub
6. Available in K3s dev namespace (manual or auto-pull)
```
@@ -47,7 +47,7 @@ git push origin master
**Use in K3s:**
```yaml
# dev/staging namespace
image: git.spdy.io/cannabrands/hub:latest-dev
image: code.cannabrands.app/cannabrands/hub:latest-dev
imagePullPolicy: Always # Always pull newest
```
@@ -81,7 +81,7 @@ git push origin 2025.11.1
**Use in K3s:**
```yaml
# production namespace
image: git.spdy.io/cannabrands/hub:2025.11.1
image: code.cannabrands.app/cannabrands/hub:2025.11.1
imagePullPolicy: IfNotPresent # Pin to specific version
```
@@ -212,7 +212,7 @@ git push origin master
./vendor/bin/sail artisan test
# Check CI is green
# Visit: git.spdy.io/cannabrands/hub/pipelines
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
# Test in staging/dev environment
# Verify key workflows work
@@ -264,12 +264,12 @@ git push origin 2025.11.3
```bash
# Watch Woodpecker build
# Visit: git.spdy.io/cannabrands/hub/pipelines
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
# Wait for success (2-4 minutes)
# CI will build and push:
# - git.spdy.io/cannabrands/hub:2025.11.3
# - git.spdy.io/cannabrands/hub:stable
# - code.cannabrands.app/cannabrands/hub:2025.11.3
# - code.cannabrands.app/cannabrands/hub:stable
```
#### 5. Deploy to Production (When Ready)
@@ -277,7 +277,7 @@ git push origin 2025.11.3
```bash
# Deploy new version
kubectl set image deployment/cannabrands \
app=git.spdy.io/cannabrands/hub:2025.11.3
app=code.cannabrands.app/cannabrands/hub:2025.11.3
# Watch rollout
kubectl rollout status deployment/cannabrands
@@ -328,11 +328,11 @@ git push origin master
```bash
# Option 1: Rollback to specific version
kubectl set image deployment/cannabrands \
app=git.spdy.io/cannabrands/hub:2025.11.2
app=code.cannabrands.app/cannabrands/hub:2025.11.2
# Option 2: Use previous stable
kubectl set image deployment/cannabrands \
app=git.spdy.io/cannabrands/hub:stable
app=code.cannabrands.app/cannabrands/hub:stable
# Note: 'stable' is updated on every release
# So if you just deployed 2025.11.3, 'stable' points to 2025.11.3
@@ -367,7 +367,7 @@ git push origin 2025.11.4
# Deploy
kubectl set image deployment/cannabrands \
app=git.spdy.io/cannabrands/hub:2025.11.4
app=code.cannabrands.app/cannabrands/hub:2025.11.4
```
---

View File

@@ -4,9 +4,9 @@
**Current tagging strategy:**
```
git.spdy.io/cannabrands/hub:latest # Always changes
git.spdy.io/cannabrands/hub:c658193 # Commit SHA (meaningless)
git.spdy.io/cannabrands/hub:master # Branch name (changes)
code.cannabrands.app/cannabrands/hub:latest # Always changes
code.cannabrands.app/cannabrands/hub:c658193 # Commit SHA (meaningless)
code.cannabrands.app/cannabrands/hub:master # Branch name (changes)
```
**Issues:**
@@ -143,8 +143,8 @@ The CI pipeline now builds images with version metadata for both development and
build-image-dev:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
tags:
- dev # Latest dev build
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA
@@ -170,13 +170,13 @@ build-image-release:
**Result:**
```
# Development push to master
git.spdy.io/cannabrands/hub:dev
git.spdy.io/cannabrands/hub:sha-c658193
git.spdy.io/cannabrands/hub:master
code.cannabrands.app/cannabrands/hub:dev
code.cannabrands.app/cannabrands/hub:sha-c658193
code.cannabrands.app/cannabrands/hub:master
# Release (git tag 2025.10.1)
git.spdy.io/cannabrands/hub:2025.10.1 # Specific version
git.spdy.io/cannabrands/hub:latest # Latest stable
code.cannabrands.app/cannabrands/hub:2025.10.1 # Specific version
code.cannabrands.app/cannabrands/hub:latest # Latest stable
```
---
@@ -243,11 +243,11 @@ git checkout c658193
```bash
# Option 1: Rollback to specific version (recommended)
kubectl set image deployment/cannabrands \
app=git.spdy.io/cannabrands/hub:v1.2.2
app=code.cannabrands.app/cannabrands/hub:v1.2.2
# Option 2: Rollback to last stable
kubectl set image deployment/cannabrands \
app=git.spdy.io/cannabrands/hub:stable
app=code.cannabrands.app/cannabrands/hub:stable
# Option 3: Kubernetes rollback (uses previous deployment)
kubectl rollout undo deployment/cannabrands
@@ -281,7 +281,7 @@ cat CHANGELOG.md
# 5. Deploy specific version
kubectl set image deployment/cannabrands \
app=git.spdy.io/cannabrands/hub:v1.2.1
app=code.cannabrands.app/cannabrands/hub:v1.2.1
```
---
@@ -357,7 +357,7 @@ audit-deployment:
```
Developer → Commit to master → CI tests → Build dev image
git.spdy.io/cannabrands/hub:dev-COMMIT
code.cannabrands.app/cannabrands/hub:dev-COMMIT
Deploy to dev/staging (optional)
```
@@ -486,7 +486,7 @@ spec:
spec:
containers:
- name: app
image: git.spdy.io/cannabrands/hub:v1.2.3
image: code.cannabrands.app/cannabrands/hub:v1.2.3
imagePullPolicy: IfNotPresent # Don't pull if tag exists
ports:
- containerPort: 80
@@ -535,7 +535,7 @@ git push origin master
# 5. Deploy to production (manual)
kubectl set image deployment/cannabrands \
app=git.spdy.io/cannabrands/hub:v1.3.0
app=code.cannabrands.app/cannabrands/hub:v1.3.0
```
### Emergency Rollback
@@ -546,7 +546,7 @@ kubectl rollout undo deployment/cannabrands
# Or specific version
kubectl set image deployment/cannabrands \
app=git.spdy.io/cannabrands/hub:v1.2.3
app=code.cannabrands.app/cannabrands/hub:v1.2.3
# Verify
kubectl rollout status deployment/cannabrands

View File

@@ -157,7 +157,7 @@
* implement Brand model with 14 actual brands ([49d4f11](https://code.cannabrands.app/Cannabrands/hub/commit/49d4f11102f83ce5d2fdf1add9c95f17d28d2005))
* implement business management features (contacts, locations, users, profiles) ([38ac09e](https://code.cannabrands.app/Cannabrands/hub/commit/38ac09e9e7def2d4d4533b880eee0dfbdea1df4b))
* implement buyer order and invoice management portal (Day 13) ([aaad277](https://code.cannabrands.app/Cannabrands/hub/commit/aaad277a49d80640e7b397d3df22adeda2a8ff7d))
* implement buyer-specific Nexus dashboard with Marketplace Platform-style navigation ([9b72a8f](https://code.cannabrands.app/Cannabrands/hub/commit/9b72a8f3ba97924e94eed806cc014af538110ec2))
* implement buyer-specific Nexus dashboard with LeafLink-style navigation ([9b72a8f](https://code.cannabrands.app/Cannabrands/hub/commit/9b72a8f3ba97924e94eed806cc014af538110ec2))
* implement buyer/seller routing structure with dual registration options ([5393969](https://code.cannabrands.app/Cannabrands/hub/commit/53939692c01669724372028f8056d9bfdf0bb92a))
* implement buyer/seller user type separation in database layer ([4e15b3d](https://code.cannabrands.app/Cannabrands/hub/commit/4e15b3d15cc8fd046dd40d0a6de512ec8b878b84))
* implement CalVer versioning system with sidebar display ([197d102](https://code.cannabrands.app/Cannabrands/hub/commit/197d10269004f758692cacb444a0e9698ec7e7f1))

404
CLAUDE.md
View File

@@ -1,24 +1,5 @@
# Claude Code Context
## 📌 IMPORTANT: Check Personal Context Files
**ALWAYS read `claude.kelly.md` first** - Contains personal preferences and session tracking workflow
## 📘 Platform Conventions
**For ALL naming, routing, and architectural conventions, see:**
`/docs/platform_naming_and_style_guide.md`
This guide is the **source of truth** for:
- Terminology (no vendor references)
- Routing patterns
- Model naming
- UI copy standards
- Commit message rules
- Database conventions
---
## 🚨 Critical Mistakes You Make
### 1. Business Isolation (MOST COMMON!)
@@ -54,95 +35,7 @@ ALL routes need auth + user type middleware except public pages
❌ No IF/ELSE logic in migrations (not supported)
✅ Use Laravel Schema builder or conditional PHP code
### 7. Git Workflow - ALWAYS Use PRs
**NEVER** push directly to `develop` or `master`
**NEVER** bypass pull requests
**NEVER** use GitHub CLI (`gh`) - we use Gitea
**ALWAYS** create a feature branch and PR for review
**ALWAYS** use Gitea API for PR creation (see below)
**Why:** PRs are required for code review, CI checks, and audit trail
**Creating PRs via Gitea API:**
```bash
# Requires GITEA_TOKEN environment variable
curl -X POST "https://git.spdy.io/api/v1/repos/Cannabrands/hub/pulls" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "PR title", "body": "Description", "head": "feature-branch", "base": "develop"}'
```
**Infrastructure Services:**
| Service | Host | Notes |
|---------|------|-------|
| **Gitea** | `https://git.spdy.io` | Git repository |
| **Woodpecker CI** | `https://ci.spdy.io` | CI/CD pipelines |
| **Docker Registry** | `registry.spdy.io` | HTTPS registry with Let's Encrypt |
**PostgreSQL (Dev) - EXTERNAL DATABASE**
⚠️ **DO NOT create PostgreSQL databases on spdy.io infrastructure for cannabrands.**
Cannabrands uses an external managed PostgreSQL database.
```
Host: 10.100.6.50 (read replica)
Port: 5432
Database: cannabrands_dev
Username: cannabrands
Password: SpDyCannaBrands2024
URL: postgresql://cannabrands:SpDyCannaBrands2024@10.100.6.50:5432/cannabrands_dev
```
**PostgreSQL (CI)** - Ephemeral container for isolated tests
```
Host: postgres (service name)
Port: 5432
Database: testing
Username: testing
Password: testing
```
**Redis**
```
Host: 10.100.9.50
Port: 6379
Password: SpDyR3d1s2024!
URL: redis://:SpDyR3d1s2024!@10.100.9.50:6379
```
**MinIO (S3-Compatible Storage)**
```
Endpoint: 10.100.9.80:9000
Console: 10.100.9.80:9001
Region: us-east-1
Path Style: true
Bucket: cannabrands
Access Key: cannabrands-app
Secret Key: cdbdcd0c7b6f3994d4ab09f68eaff98665df234f
```
**Gitea Container Registry** (for CI image pushes)
```
Registry: git.spdy.io
User: kelly@spdy.io
Token: c89fa0eeb417343b171f11de6b8e4292b2f50e2b
Scope: write:package
```
Woodpecker secrets: `registry_user`, `registry_password`
**CI/CD Notes:**
- Uses **Kaniko** for Docker builds (no Docker daemon, avoids DNS issues)
- Images pushed to `registry.spdy.io/cannabrands/hub`
- Base images pulled from `registry.spdy.io` (HTTPS with Let's Encrypt)
- Deploy: `develop` → dev.cannabrands.app, `master` → cannabrands.app
### 8. User-Business Relationship (Pivot Table)
Users connect to businesses via `business_user` pivot table (many-to-many).
**Wrong:** `User::where('business_id', $id)` — users table has NO business_id column
**Right:** `User::whereHas('businesses', fn($q) => $q->where('businesses.id', $id))`
**Pivot table columns:** `business_id`, `user_id`, `role`, `role_template`, `is_primary`, `permissions`
**Why:** Allows users to belong to multiple businesses with different roles per business
### 9. Styling - DaisyUI/Tailwind Only
### 7. Styling - DaisyUI/Tailwind Only
**NEVER use inline `style=""` attributes** in Blade templates
✅ **ALWAYS use DaisyUI/Tailwind utility classes**
**Why:** Consistency, maintainability, theme switching, and better performance
@@ -155,196 +48,6 @@ Users connect to businesses via `business_user` pivot table (many-to-many).
**Exception:** Only use inline styles for truly dynamic values from database (e.g., user-uploaded brand colors)
### 10. Suites Architecture - NOT Modules (CRITICAL!)
**NEVER use** `has_crm`, `has_marketing`, or other legacy module flags
**NEVER create** routes like `seller.crm.*` (without `.business.`)
**NEVER extend** `seller.crm.layouts.crm` layout (outdated CRM module layout)
**ALWAYS use** Suites system (Sales Suite, Processing Suite, etc.)
**ALWAYS use** route pattern `seller.business.crm.*` (includes `{business}` segment)
**ALWAYS extend** `layouts.seller` for seller views
**Why:** We migrated from individual modules to a Suites architecture. CRM features are now part of the **Sales Suite**.
**See:** `docs/SUITES_AND_PRICING_MODEL.md` for full architecture
**The 7 Suites:**
1. **Sales Suite** - Products, Orders, Buyers, CRM, Marketing, Analytics, Orchestrator
2. **Processing Suite** - Extraction, Wash Reports, Yields (internal)
3. **Manufacturing Suite** - Work Orders, BOM, Packaging (internal)
4. **Delivery Suite** - Pick/Pack, Drivers, Manifests (internal)
5. **Management Suite** - Finance, AP/AR, Budgets (Canopy only)
6. **Brand Manager Suite** - Read-only brand portal (external partners)
7. **Dispensary Suite** - Buyer marketplace (dispensaries)
**Legacy module flags still exist** in database but are deprecated. Suite permissions control access now.
### 11. Media Storage - MinIO Architecture (CRITICAL!)
**NEVER use** `Storage::disk('public')` for brand/product media
**ALWAYS use** `Storage` (respects .env FILESYSTEM_DISK=minio)
**Why:** All media lives on MinIO (S3-compatible storage), not local disk. Using wrong disk breaks production images.
**⚠️ BLADE TEMPLATE RULES (CRITICAL):**
**NEVER use** `/storage/` prefix in image src attributes
**NEVER use** `asset('storage/...')` for media
**ALWAYS use** dynamic image routes with model methods
**Correct Image Display Patterns:**
```blade
{{-- Product images - use getImageUrl() method --}}
<img src="{{ $product->getImageUrl('medium') }}" alt="{{ $product->name }}">
<img src="{{ $product->getImageUrl('thumb') }}" alt="{{ $product->name }}">
{{-- Brand logos - use getLogoUrl() method --}}
<img src="{{ $brand->getLogoUrl('medium') }}" alt="{{ $brand->name }}">
{{-- In Alpine.js - use route() helper --}}
<img :src="`{{ url('/images/product/') }}/${product.hashid}/400`">
```
**URL Patterns (for accessing images):**
- **Product image:** `/images/product/{product_hashid}/{width?}`
- Example: `/images/product/78xd4/400` (400px width)
- **Brand logo:** `/images/brand-logo/{brand_hashid}/{width?}`
- Example: `/images/brand-logo/75pg7/600` (600px thumbnail)
- **Brand banner:** `/images/brand-banner/{brand_hashid}/{width?}`
- Example: `/images/brand-banner/75pg7/1344` (1344px banner)
**Product Image Storage (TWO METHODS):**
Products can store images in TWO ways - **always check both**:
1. **Direct `image_path` column** - Single image stored directly on product
- Access via `$product->getImageUrl()` method
- Path stored like: `businesses/cannabrands/brands/thunder-bud/products/TB-AM-AZ1G/images/alien-marker.png`
2. **`images()` relation** - Multiple images in `product_images` table
- Access via `$product->images` collection
- Used for galleries with multiple images
**When loading product images for display:**
```php
// Check BOTH methods - direct image_path first, then relation
if ($product->image_path) {
$imageUrl = $product->getImageUrl('medium');
} elseif ($product->images->count() > 0) {
$imageUrl = $product->images->first()->url;
}
```
**Storage Path Requirements (on MinIO):**
- **Brand logos/banners:** `businesses/{business_slug}/brands/{brand_slug}/branding/{filename}`
- Example: `businesses/cannabrands/brands/thunder-bud/branding/logo.png`
- **Product images:** `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`
**DO NOT:**
- Use `/storage/` prefix in Blade templates for ANY media
- Use `asset('storage/...')` for ANY media
- Use numeric IDs in paths (e.g., `products/14/`)
- Use hashids in storage paths
- Skip business or brand directories
- Use `Storage::disk('public')` anywhere in media code
- Assume images are ONLY in `images()` relation - check `image_path` too!
**See Comments In:**
- `app/Models/Brand.php` (line 47) - Brand asset paths
- `app/Models/Product.php` (line 108) - Product image paths
- `app/Http/Controllers/ImageController.php` (line 10) - Critical storage rules
- `docs/architecture/MEDIA_STORAGE.md` - Complete documentation
**This has caused multiple production outages - review docs before ANY storage changes!**
### 12. Dashboard & Metrics Performance (CRITICAL!)
**Production outages have occurred from violating these rules.**
#### The Golden Rule
**NEVER compute aggregations in HTTP controllers. Dashboard data comes from Redis, period.**
#### What Goes Where
| Location | Allowed | Not Allowed |
|----------|---------|-------------|
| Controller | `Redis::get()`, simple lookups by ID | `->sum()`, `->count()`, `->avg()`, loops with queries |
| Background Job | All aggregations, joins, complex queries | N/A |
#### ❌ BANNED Patterns in Controllers:
```php
// BANNED: Aggregation in controller
$revenue = Order::sum('total');
// BANNED: N+1 in loop
$items->map(fn($i) => Order::where('product_id', $i->id)->sum('qty'));
// BANNED: Query per day/iteration
for ($i = 0; $i < 30; $i++) {
$data[] = Order::whereDate('created_at', $date)->sum('total');
}
// BANNED: Selecting columns that don't exist
->select('id', 'stage_1_metadata') // Column doesn't exist!
```
#### ✅ REQUIRED Pattern:
```php
// Controller: Just read Redis
public function analytics(Business $business)
{
$data = Redis::get("dashboard:{$business->id}:analytics");
if (!$data) {
CalculateDashboardMetrics::dispatch($business->id);
return view('dashboard.analytics', ['data' => $this->emptyState()]);
}
return view('dashboard.analytics', ['data' => json_decode($data, true)]);
}
// Background Job: Do all the heavy lifting
public function handle()
{
// Batch query - ONE query for all products
$salesByProduct = OrderItem::whereIn('product_id', $productIds)
->groupBy('product_id')
->selectRaw('product_id, SUM(quantity) as total')
->pluck('total', 'product_id');
Redis::setex("dashboard:{$businessId}:analytics", 900, json_encode($data));
}
```
#### Before Merging Dashboard PRs:
1. Search for `->sum(`, `->count(`, `->avg(` in the controller
2. Search for `->map(function` with queries inside
3. If found → Move to background job
4. Query count must be < 20 for any dashboard page
#### The Architecture
```
BACKGROUND (every 10 min) HTTP REQUEST
======================== =============
┌─────────────────────┐ ┌─────────────────────┐
│ CalculateMetricsJob │ │ DashboardController │
│ │ │ │
│ - Heavy queries │ │ - Redis::get() only │
│ - Joins │──► Redis ──►│ - No aggregations │
│ - Aggregations │ │ - No loops+queries │
│ - Loops are OK here │ │ │
└─────────────────────┘ └─────────────────────┘
Takes 5-30 sec Takes 10ms
Runs in background User waits for this
```
#### Prevention Checklist for Future Dashboard Work
- [ ] All `->sum()`, `->count()`, `->avg()` are in background jobs, not controllers
- [ ] No `->map(function` with queries inside in controllers
- [ ] Redis keys exist after job runs (`redis-cli KEYS "dashboard:*"`)
- [ ] Job completes without errors (check `storage/logs/worker.log`)
- [ ] Controller only does `Redis::get()` for metrics
- [ ] Column names in `->select()` match actual database schema
---
## Tech Stack by Area
@@ -367,35 +70,6 @@ Users have `user_type` matching their business type.
---
## Local Development Setup
**First-time setup or fresh database:**
```bash
./vendor/bin/sail artisan dev:setup --fresh
```
This command:
- Runs migrations (use `--fresh` to drop all tables first)
- Prompts to seed dev fixtures (users, businesses, brands)
- Seeds brand profiles and orchestrator profiles
- Displays test credentials when complete
**Options:**
- `--fresh` — Drop all tables and re-run migrations
- `--skip-seed` — Skip the seeding prompt
**Test Credentials (seeded by dev:setup):**
| Role | Email | Password |
|------|-------|----------|
| Super Admin | admin@cannabrands.com | password |
| Admin | admin@example.com | password |
| Seller | seller@example.com | password |
| Buyer | buyer@example.com | password |
| Cannabrands Owner | cannabrands-owner@example.com | password |
| Brand Manager | brand-manager@example.com | password |
---
## Testing & Git
**Before commit:**
@@ -404,12 +78,7 @@ php artisan test --parallel # REQUIRED
./vendor/bin/pint # REQUIRED
```
**Commit Messages:**
- ❌ **DO NOT** include Claude Code signature/attribution in commit messages
- ❌ **DO NOT** add "🤖 Generated with Claude Code" or "Co-Authored-By: Claude"
- ✅ Write clean, professional commit messages without AI attribution
**Credentials:** See "Local Development Setup" section above
**Credentials:** `{buyer,seller,admin}@example.com` / `password`
**Branches:** Never commit to `master`/`develop` directly - use feature branches
@@ -435,74 +104,15 @@ Product::where('is_active', true)->get(); // No business_id filter!
---
## Architecture Docs (Read When Needed)
## External Docs (Read When Needed)
**🎯 START HERE:**
- **`SYSTEM_ARCHITECTURE.md`** - Complete system guide covering ALL architectural patterns, security rules, modules, departments, performance, and development workflow
**Deep Dives (when needed):**
- `docs/supplements/departments.md` - Department system, permissions, access control
- `docs/supplements/processing.md` - Processing operations (Solventless vs BHO, conversions, wash batches)
- `docs/supplements/permissions.md` - RBAC, impersonation, audit logging
- `docs/supplements/precognition.md` - Real-time form validation migration
- `docs/supplements/analytics.md` - Product tracking, email campaigns
- `docs/supplements/batch-system.md` - Batch management and COAs
- `docs/supplements/performance.md` - Caching, indexing, N+1 prevention
- `docs/supplements/horizon.md` - Queue monitoring and deployment
**Architecture Details:**
- `docs/architecture/URL_STRUCTURE.md` - **READ BEFORE** routing changes
- `docs/architecture/DATABASE.md` - **READ BEFORE** migrations
- `docs/architecture/API.md` - API endpoints and contracts
**Other:**
- `VERSIONING_AND_AUDITING.md` - Quicksave and Laravel Auditing
- `docs/URL_STRUCTURE.md` - **READ BEFORE** routing changes
- `docs/DATABASE.md` - **READ BEFORE** migrations
- `docs/DEVELOPMENT.md` - Local setup
- `CONTRIBUTING.md` - Detailed git workflow
---
## Performance Requirements
**Database Queries:**
- NEVER write N+1 queries - always use eager loading (`with()`) for relationships
- NEVER run queries inside loops - batch them before the loop
- Avoid multiple queries when one JOIN or subquery works
- Dashboard/index pages should use MAX 5-10 queries total, not 50+
- Use `DB::enableQueryLog()` mentally - if a page would log 20+ queries, refactor
- Cache expensive aggregations (Redis, 5-min TTL) instead of recalculating every request
- Test with `DB::listen()` or Laravel Debugbar before committing controller code
**Before submitting controller code, verify:**
1. No queries inside foreach/map loops
2. All relationships eager loaded
3. Aggregations done in SQL, not PHP collections
4. Would this cause a 503 under load? If unsure, simplify.
**Examples:**
```php
// ❌ N+1 query - DON'T DO THIS
$orders = Order::all();
foreach ($orders as $order) {
echo $order->customer->name; // Query per iteration!
}
// ✅ Eager loaded - DO THIS
$orders = Order::with('customer')->get();
// ❌ Query in loop - DON'T DO THIS
foreach ($products as $product) {
$stock = Inventory::where('product_id', $product->id)->sum('quantity');
}
// ✅ Batch query - DO THIS
$stocks = Inventory::whereIn('product_id', $products->pluck('id'))
->groupBy('product_id')
->selectRaw('product_id, SUM(quantity) as total')
->pluck('total', 'product_id');
```
---
## What You Often Forget
✅ Scope by business_id BEFORE finding by ID
@@ -511,5 +121,3 @@ $stocks = Inventory::whereIn('product_id', $products->pluck('id'))
✅ DaisyUI for buyer/seller, Filament only for admin
✅ NO inline styles - use Tailwind/DaisyUI classes only
✅ Run tests before committing
✅ Eager load relationships to prevent N+1 queries
✅ No queries inside loops - batch before the loop

View File

@@ -44,7 +44,7 @@ Our workflow provides audit trails regulators love:
1. **Clone the repository**
```bash
git clone https://git.spdy.io/Cannabrands/hub.git
git clone https://code.cannabrands.app/Cannabrands/hub.git
cd hub
```
@@ -86,7 +86,7 @@ git commit -m "feat: add new feature"
git push origin feature/my-feature-name
# 4. Create Pull Request on Gitea
# - Navigate to https://git.spdy.io
# - Navigate to https://code.cannabrands.app
# - Create PR to merge your branch into develop
# - CI will run automatically
# - Request review from team
@@ -630,7 +630,7 @@ git push origin chore/changelog-2025.11.1
### Services
- **Woodpecker CI:** `https://ci.cannabrands.app`
- **Gitea:** `https://git.spdy.io`
- **Gitea:** `https://code.cannabrands.app`
- **Production:** `https://app.cannabrands.com` (future)
---

View File

@@ -3,7 +3,7 @@
# ============================================
# ==================== Stage 1: Node Builder ====================
FROM 10.100.9.70:5000/library/node:22-alpine AS node-builder
FROM node:22-alpine AS node-builder
WORKDIR /app
@@ -34,18 +34,13 @@ COPY public ./public
RUN npm run build
# ==================== Stage 2: Composer Builder ====================
# Pin to PHP 8.4 - composer:2 uses latest PHP which may not be supported by dependencies yet
FROM 10.100.9.70:5000/library/php:8.4-cli-alpine AS composer-builder
# Install Composer
COPY --from=10.100.9.70:5000/library/composer:2.8 /usr/bin/composer /usr/bin/composer
FROM composer:2 AS composer-builder
WORKDIR /app
# Install required PHP extensions for Filament and Horizon
RUN apk add --no-cache icu-dev libpng-dev libjpeg-turbo-dev freetype-dev libzip-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install intl gd pcntl zip
# Install required PHP extensions for Filament
RUN apk add --no-cache icu-dev \
&& docker-php-ext-install intl
# Copy composer files
COPY composer.json composer.lock ./
@@ -60,7 +55,7 @@ RUN composer install \
--optimize-autoloader
# ==================== Stage 3: Production Runtime ====================
FROM 10.100.9.70:5000/library/php:8.3-fpm-alpine
FROM php:8.3-fpm-alpine
LABEL maintainer="CannaBrands Team"

View File

@@ -1,93 +0,0 @@
# ============================================
# Fast Production Dockerfile
# Single-stage build using CI pre-built assets
# Saves time by skipping multi-stage node/composer builders
# ============================================
#
# This Dockerfile expects:
# - vendor/ already populated (from CI composer-install step)
# - public/build/ already populated (from CI build-frontend step)
#
# Build time: ~5-7 min (vs 15-20 min with multi-stage Dockerfile)
# ============================================
FROM 10.100.9.70:5000/library/php:8.3-fpm-alpine
LABEL maintainer="CannaBrands Team"
# Install system dependencies
RUN apk add --no-cache \
nginx \
supervisor \
postgresql-dev \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
libzip-dev \
icu-dev \
icu-data-full \
zip \
unzip \
git \
curl \
bash
# Install build dependencies for PHP extensions
RUN apk add --no-cache --virtual .build-deps \
autoconf \
g++ \
make
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
pdo_pgsql \
pgsql \
gd \
zip \
intl \
pcntl \
bcmath \
opcache
# Install Redis extension
RUN pecl install redis \
&& docker-php-ext-enable redis \
&& apk del .build-deps
WORKDIR /var/www/html
ARG GIT_COMMIT_SHA=unknown
ARG APP_VERSION=dev
# Copy application code
COPY --chown=www-data:www-data . .
# Copy pre-built frontend assets (built in CI step)
# These are already in public/build from the build-frontend step
# Copy pre-installed vendor (from CI composer-install step)
# Already included in COPY . .
# Create version metadata file
RUN echo "VERSION=${APP_VERSION}" > /var/www/html/version.env && \
echo "COMMIT=${GIT_COMMIT_SHA}" >> /var/www/html/version.env && \
chown www-data:www-data /var/www/html/version.env
# Copy production configurations
COPY docker/production/nginx/default.conf /etc/nginx/http.d/default.conf
COPY docker/production/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
COPY docker/production/php/php.ini /usr/local/etc/php/conf.d/99-custom.ini
# Remove default PHP-FPM pool config and use our custom one
RUN rm -f /usr/local/etc/php-fpm.d/www.conf /usr/local/etc/php-fpm.d/www.conf.default
COPY docker/production/php/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf
# Create supervisor log directory and fix permissions
RUN mkdir -p /var/log/supervisor \
&& chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache \
&& chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]

View File

@@ -1,26 +1,31 @@
.PHONY: help dev dev-down dev-build dev-shell dev-logs dev-vite k-setup k-dev k-down k-logs k-shell k-artisan k-composer k-vite k-status prod-build prod-up prod-down prod-logs prod-shell prod-vite prod-test prod-test-build prod-test-up prod-test-down prod-test-logs prod-test-shell prod-test-status prod-test-clean migrate test clean install
.PHONY: help dev dev-down dev-build dev-shell dev-logs dev-vite k-dev k-down k-logs k-shell k-artisan k-composer k-vite k-status prod-build prod-up prod-down prod-logs prod-shell prod-vite prod-test prod-test-build prod-test-up prod-test-down prod-test-logs prod-test-shell prod-test-status prod-test-clean migrate test clean install
# Default target
.DEFAULT_GOAL := help
# ==================== K8s Variables ====================
# K3d cluster must be created with dual volume mounts:
# k3d cluster create dev \
# --api-port 6443 \
# --port "80:80@loadbalancer" \
# --port "443:443@loadbalancer" \
# --volume /Users/jon/projects/cannabrands/cannabrands_new/.worktrees:/worktrees \
# --volume /Users/jon/projects/cannabrands/cannabrands_new:/project-root \
# --volume k3d-dev-images:/k3d/images
# Detect if we're in a worktree or project root
GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null)
IS_WORKTREE := $(shell echo "$(GIT_DIR)" | grep -q ".worktrees" && echo "true" || echo "false")
# Find project root (handles both worktree and main repo)
# Set paths based on location
ifeq ($(IS_WORKTREE),true)
# In a worktree - project root is two levels up
PROJECT_ROOT := $(shell cd ../.. && pwd)
# In a worktree - use worktree-specific path
WORKTREE_NAME := $(shell basename $(CURDIR))
K8S_VOLUME_PATH := /worktrees/$(WORKTREE_NAME)
HOST_WORKTREE_PATH := $(PROJECT_ROOT)/.worktrees
else
# In project root
PROJECT_ROOT := $(shell pwd)
# In project root - use root path
WORKTREE_NAME := root
K8S_VOLUME_PATH := /project-root
HOST_WORKTREE_PATH := $(PROJECT_ROOT)/.worktrees
endif
# Generate namespace from branch name (feat-branch-name)
@@ -64,28 +69,6 @@ dev-vite: ## Start Vite dev server (run after 'make dev')
./vendor/bin/sail npm run dev
# ==================== K8s Local Development ====================
k-setup: ## One-time setup: Create K3d cluster with auto-detected volume mounts
@echo "🔧 Setting up K3d cluster 'dev' with auto-detected paths"
@echo " Project Root: $(PROJECT_ROOT)"
@echo " Worktrees Path: $(HOST_WORKTREE_PATH)"
@echo ""
@# Check if cluster already exists
@if k3d cluster list | grep -q "^dev "; then \
echo "⚠️ Cluster 'dev' already exists!"; \
echo " To recreate, run: k3d cluster delete dev && make k-setup"; \
exit 1; \
fi
@# Create cluster with dynamic volume mounts
k3d cluster create dev \
--api-port 6443 \
--port "80:80@loadbalancer" \
--port "443:443@loadbalancer" \
--volume $(HOST_WORKTREE_PATH):/worktrees \
--volume $(PROJECT_ROOT):/project-root
@echo ""
@echo "✅ K3d cluster created successfully!"
@echo " Next step: Run 'make k-dev' to start your environment"
k-dev: ## Start k8s local environment (like Sail, but with namespace isolation)
@echo "🚀 Starting k8s environment"
@echo " Location: $(if $(filter true,$(IS_WORKTREE)),Worktree ($(WORKTREE_NAME)),Project Root)"
@@ -271,13 +254,6 @@ install: ## Initial project setup
@echo " 2. Run 'make dev' to start development environment"
@echo " 3. Run 'make migrate' to set up database"
setup-hooks: ## Configure git hooks for code quality
@git config core.hooksPath .githooks
@chmod +x .githooks/*
@echo "✅ Git hooks configured!"
@echo " - pre-commit: Auto-formats code with Laravel Pint"
@echo " - pre-push: Optionally runs tests before pushing"
mailpit: ## Open Mailpit web UI
@open http://localhost:8025 || xdg-open http://localhost:8025 || echo "Open http://localhost:8025 in your browser"

258
PRODUCT2_INSTRUCTIONS.md Normal file
View File

@@ -0,0 +1,258 @@
# PRODUCT2 MIGRATION INSTRUCTIONS
## Context
We are migrating the OLD seller product page from `../cannabrands-hub-old` to create a new "Product2" page in the current project at `/hub`. This page will be a comprehensive, modernized version of the old seller product edit page.
## Critical Rules
1. **SELLER SIDE ONLY** - Work only with `/s/` routes (seller area)
2. **STAY IN BRANCH** - `feature/product-page-migrate` (verify before making changes)
3. **ROLLBACK READY** - All database migrations must be fully reversible
4. **DO NOT TOUCH BOM** - Leave existing BOM functionality completely as-is (we'll discuss later)
5. **SINGLE PAGE LAYOUT** - No tabs, use card-based layout with Nexus components
6. **FOLLOW OLD LAYOUT** - Modernize the old product page structure, don't reinvent
## Old Project Analysis Complete
- Old project location: `../cannabrands-hub-old`
- Old used Laravel CRM for product management
- Comprehensive field analysis done (see below)
- Old layout analyzed from vendor views
## Complete Missing Fields (from migrations analysis)
### From `products` table:
```sql
-- Metadata
product_line (text, nullable)
product_link (text, nullable) -- External URL
creatives (text, nullable) -- Marketing assets
barcode (string, nullable)
brand_display_order (integer, nullable)
-- Configuration
has_varieties (boolean, default: false)
license_id (unsignedBigInteger, nullable)
sell_multiples (boolean, default: false)
fractional_quantities (boolean, default: false)
allow_sample (boolean, default: false)
isFPR (boolean, default: false)
isSellable (boolean, default: false)
-- Case/Box Packaging
isCase (boolean, default: false)
cased_qty (integer, default: 0)
isBox (boolean, default: false)
boxed_qty (integer, default: 0)
-- Dates
launch_date (date, nullable)
-- Inventory Management
inventory_manage_pct (integer, nullable) -- 0-100%
min_order_qty (integer, nullable)
max_order_qty (integer, nullable)
low_stock_threshold (integer, nullable)
low_stock_alert_enabled (boolean, default: false)
-- Strain
strain_value (decimal 8,2, nullable)
-- Arizona Compliance
arz_total_weight (decimal 10,3, nullable)
arz_usable_mmj (decimal 10,3, nullable)
-- Descriptions
long_description (text, nullable)
ingredients (text, nullable)
effects (text, nullable)
dosage_guidelines (text, nullable)
-- Visibility
show_inventory_to_buyers (boolean, default: false)
-- Threshold Automation
decreasing_qty_threshold (integer, nullable)
decreasing_qty_action (string, nullable)
increasing_qty_threshold (integer, nullable)
increasing_qty_action (string, nullable)
-- Packaging Reference
packaging_id (foreignId, nullable)
-- Enhanced Status
status (enum: available, archived, sample, backorder, internal, unavailable)
```
### Need to create:
- `product_packaging` table (id, name, description, is_active, timestamps)
## Product2 Page Layout (Single Page, No Tabs)
### Structure:
```
HEADER (Product name, SKU, status badges, action buttons)
LEFT SIDEBAR (1/3 width):
- Product Images (main + gallery + upload)
- Quick Stats Card (cost, wholesale, MSRP, margin)
- Audit Info Card (created, modified, by user)
MAIN CONTENT (2/3 width):
Card 1: Basic Information
Card 2: Pricing & Units
Card 3: Inventory Management
Card 4: Cannabis Information
Card 5: Product Details & Content
Card 6: Advanced Settings
Card 7: Compliance & Tracking
FULL WIDTH (bottom):
Card 8: Product Varieties (if has_varieties = true)
Card 9: Lab Test Results (link to separate management)
Collapsible: Audit History
```
### Cards Detail:
**Card 1: Basic Information**
- Brand (dropdown) *
- Product Line (text)
- SKU (text) *
- Barcode (text)
- Product Name (text) *
- Type (dropdown) *
- Category (text)
- Description (textarea)
- Active toggle
- Featured toggle
**Card 2: Pricing & Units**
- Cost Price, Wholesale, MSRP, Margin (auto-calc)
- Price Unit dropdown
- Net Weight + Weight Unit
- Units Per Case
- Checkboxes: Sell in Multiples, Fractional Quantities, Sell as Case, Sell as Box
**Card 3: Inventory Management**
- On Hand, Allocated, Available, Reorder Point (display)
- Min/Max Order Qty
- Low Stock Threshold + Alert checkbox
- Show Inventory to Buyers checkbox
- Inventory Management slider (0-100%)
- Threshold Automation (decrease/increase triggers)
**Card 4: Cannabis Information**
- THC%, CBD%, THC mg, CBD mg
- Strain dropdown (with classification)
- Strain Value
- Product Packaging dropdown
- Ingredients, Effects, Dosing Guidelines (text areas)
- Arizona Compliance (Total Weight, Usable MMJ)
**Card 5: Product Details & Content**
- Short Description
- Long Description (rich text editor)
- Product Link (external URL)
- Creatives/Assets
**Card 6: Advanced Settings**
- Enable Sample Requests checkbox
- Sellable Product checkbox
- Finished Product Ready checkbox
- Status dropdown
- Display Order (within brand)
**Card 7: Compliance & Tracking**
- Metrc ID
- License dropdown
- Launch Date, Harvest Date, Package Date, Test Date
**Card 8: Product Varieties** (conditional)
- Table showing child products with name, SKU, prices, stock
- Add Variety button
**Card 9: Lab Test Results**
- Summary of latest lab test
- Link to full lab management (don't build lab CRUD yet)
## Tasks to Complete
### 1. Database Migration (with rollback)
- Create migration: `add_product2_fields_to_products_table.php`
- Add ALL missing fields listed above
- Proper indexes
- Full `down()` method for rollback
- Create `product_packaging` table migration
### 2. Routes
- File: `routes/seller.php`
- Add under existing products routes:
- `/{product}/edit2` → Product2 edit page
- Keep existing routes intact
### 3. Controller
- Create: `app/Http/Controllers/Seller/Product2Controller.php`
- Methods: edit(), update()
- Full validation for all new fields
- Business isolation checks (CRITICAL - see CLAUDE.md)
- Image upload handling
### 4. Model Updates
- Update `app/Models/Product.php` fillable array
- Add new relationships if needed (packaging)
- Add accessors/mutators as needed
### 5. Views
- Create: `resources/views/seller/products/edit2.blade.php`
- Use Nexus card components
- Single page layout (no tabs)
- Alpine.js for interactivity
- Follow structure outlined above
- Use existing DaisyUI + Nexus patterns
### 6. Nexus Components Available
From `nexus-html@3.1.0/resources/views/`:
- Cards: `card`, `card-body`, `card-title`
- Forms: `input`, `select`, `textarea`, `checkbox`, `toggle`, `label`, `fieldset`
- Layouts: Grid system with responsive columns
- File upload: FilePond integration
- Date picker: Flatpickr
- Icons: Iconify (lucide set)
## Key Files from Old Project
- Controller: `vendor/venturedrake/laravel-crm/src/Http/Controllers/ProductController.php`
- Edit View: `vendor/venturedrake/laravel-crm/resources/views/products/edit.blade.php`
- Fields Form: `vendor/venturedrake/laravel-crm/resources/views/products/partials/fields.blade.php` (1400+ lines!)
## Current Project Files
- Routes: `routes/seller.php`
- Controller: `app/Http/Controllers/Seller/ProductController.php`
- Model: `app/Models/Product.php`
- Current Edit: `resources/views/seller/products/edit.blade.php`
- Migration: `database/migrations/2025_10_07_172951_create_products_table.php`
## Important Notes from CLAUDE.md
1. **Business Isolation**: ALWAYS scope by business_id BEFORE finding by ID
- `Product::whereHas('brand', fn($q) => $q->where('business_id', $business->id))->findOrFail($id)`
2. **Route Protection**: Use middleware `['auth', 'verified', 'seller', 'approved']`
3. **No Filament**: Use DaisyUI + Blade for seller area
4. **Run tests before commit**: `php artisan test --parallel && ./vendor/bin/pint`
## Git Branch
- Current: `feature/product-page-migrate`
- DO NOT commit to develop directly
## Next Steps
1. Verify branch: `git branch` (should show feature/product-page-migrate)
2. Create migrations with full rollback capability
3. Update Product model
4. Create Product2Controller
5. Create edit2.blade.php view
6. Test thoroughly
7. Run Pint + tests
8. Commit with clear message
## Questions to Clarify Before Building
- Collapsible cards to reduce clutter? (yes/no)
- Should quantity_on_hand be editable in UI? (currently hidden)
- Which fields are absolutely required vs nice-to-have?
- SQL dump ready for real data analysis?

View File

@@ -1,6 +1,6 @@
# Cannabrands B2B Platform
A comprehensive B2B cannabis marketplace platform built with Laravel, featuring business onboarding, compliance tracking, and multi-tenant architecture.
A LeafLink-style cannabis marketplace platform built with Laravel, featuring business onboarding, compliance tracking, and multi-tenant architecture foundation.
---
@@ -579,7 +579,7 @@ See `.env.production.example` for complete configuration template.
- Follow PSR-12 coding standards
- Use Pest for testing new features
- Reference `/docs/APP_OVERVIEW.md` for development approach
- All features should maintain strong compliance and regulatory focus
- All features should maintain LeafLink-style compliance focus
---

View File

@@ -1,193 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Ai;
use App\Models\Ai\AiGeneratorState;
use App\Models\Ai\AiPromptLog;
use App\Models\Ai\AiSuggestion;
use App\Models\Business;
use Illuminate\Console\Command;
/**
* AI Stats Command
*
* Display AI orchestrator statistics.
*/
class AiStatsCommand extends Command
{
protected $signature = 'ai:stats
{--business= : Filter by business ID}
{--days=30 : Number of days to analyze}';
protected $description = 'Display AI orchestrator statistics';
public function handle(): int
{
$businessId = $this->option('business');
$days = (int) $this->option('days');
$this->info("AI Orchestrator Statistics (Last {$days} days)");
$this->newLine();
if ($businessId) {
$business = Business::find($businessId);
if (! $business) {
$this->error("Business not found: {$businessId}");
return self::FAILURE;
}
$this->info("Business: {$business->name}");
$this->newLine();
}
// Suggestion stats
$this->displaySuggestionStats($businessId, $days);
// LLM usage stats
$this->displayLlmStats($businessId, $days);
// Generator performance
if ($businessId) {
$this->displayGeneratorStats((int) $businessId);
}
return self::SUCCESS;
}
protected function displaySuggestionStats(?int $businessId, int $days): void
{
$this->info('📊 Suggestion Statistics');
$query = AiSuggestion::where('created_at', '>=', now()->subDays($days));
if ($businessId) {
$query->where('business_id', $businessId);
}
$suggestions = $query->get();
$total = $suggestions->count();
$pending = $suggestions->where('status', 'pending')->count();
$actioned = $suggestions->where('status', 'actioned')->count();
$dismissed = $suggestions->where('status', 'dismissed')->count();
$expired = $suggestions->where('status', 'expired')->count();
$actionRate = $total > 0 ? round(($actioned / $total) * 100, 1) : 0;
$this->table(
['Metric', 'Value'],
[
['Total Suggestions', number_format($total)],
['Pending', number_format($pending)],
['Actioned', number_format($actioned)." ({$actionRate}%)"],
['Dismissed', number_format($dismissed)],
['Expired', number_format($expired)],
]
);
// By category
$byCategory = $suggestions->groupBy('category')->map->count()->sortDesc();
if ($byCategory->isNotEmpty()) {
$this->newLine();
$this->line('By Category:');
$this->table(
['Category', 'Count'],
$byCategory->map(fn ($count, $cat) => [$cat, $count])->values()
);
}
// By priority
$byPriority = $suggestions->groupBy('priority')->map->count();
if ($byPriority->isNotEmpty()) {
$this->newLine();
$this->line('By Priority:');
$this->table(
['Priority', 'Count'],
collect(['urgent', 'high', 'normal', 'low'])
->map(fn ($p) => [$p, $byPriority[$p] ?? 0])
);
}
$this->newLine();
}
protected function displayLlmStats(?int $businessId, int $days): void
{
$this->info('🤖 LLM Usage Statistics');
$stats = AiPromptLog::getUsageStats($businessId ?? 0, $days);
$this->table(
['Metric', 'Value'],
[
['Total Requests', number_format($stats['total_requests'])],
['Total Tokens', number_format($stats['total_tokens'])],
['Total Cost', '$'.number_format($stats['total_cost'], 4)],
['Avg Latency', round($stats['average_latency_ms'] ?? 0).'ms'],
['Error Rate', number_format($stats['error_rate'], 1).'%'],
['Cache Hit Rate', number_format($stats['cache_hit_rate'], 1).'%'],
]
);
// By provider
if (! empty($stats['by_provider'])) {
$this->newLine();
$this->line('By Provider:');
$this->table(
['Provider', 'Requests', 'Tokens', 'Cost'],
collect($stats['by_provider'])->map(fn ($data, $provider) => [
$provider,
number_format($data['requests']),
number_format($data['tokens']),
'$'.number_format($data['cost'], 4),
])->values()
);
}
$this->newLine();
}
protected function displayGeneratorStats(int $businessId): void
{
$this->info('⚙️ Generator Performance');
$report = AiGeneratorState::getPerformanceReport($businessId);
if (empty($report['generators'])) {
$this->line('No generator data available.');
return;
}
$this->table(
['Generator', 'Context', 'Runs', 'Suggestions', 'Action Rate', 'Grade'],
collect($report['generators'])->map(fn ($g) => [
$g['type'],
$g['context'],
number_format($g['total_runs']),
number_format($g['total_suggestions']),
number_format($g['action_rate'], 1).'%',
$g['performance_grade'],
])
);
$this->newLine();
$this->line('Summary:');
$this->table(
['Metric', 'Value'],
[
['Total Generators', $report['summary']['total_generators']],
['Enabled', $report['summary']['enabled_generators']],
['Total Suggestions', number_format($report['summary']['total_suggestions'])],
['Overall Action Rate', number_format($report['summary']['overall_action_rate'], 1).'%'],
['Avg Accuracy', $report['summary']['average_accuracy']
? number_format($report['summary']['average_accuracy'] * 100, 1).'%'
: 'N/A'],
]
);
}
}

View File

@@ -1,198 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Ai;
use App\Jobs\Ai\GenerateBriefingJob;
use App\Models\Business;
use App\Models\User;
use Illuminate\Console\Command;
/**
* Generate Briefings Command
*
* Artisan command to generate AI briefings for users.
*/
class GenerateBriefingsCommand extends Command
{
protected $signature = 'ai:generate-briefings
{type=daily : Type of briefing (daily, weekly, monthly)}
{--user= : Generate for specific user ID}
{--business= : Generate for specific business ID}
{--sync : Run synchronously instead of queuing}';
protected $description = 'Generate AI briefings for users';
public function handle(): int
{
$type = $this->argument('type');
if (! in_array($type, ['daily', 'weekly', 'monthly'])) {
$this->error("Invalid briefing type: {$type}");
return self::FAILURE;
}
// Check if feature is enabled
if (! config('ai_orchestrator.briefings.enabled', true)) {
$this->warn('Briefings are disabled in configuration.');
return self::SUCCESS;
}
if (! config("ai_orchestrator.briefings.{$type}.enabled", true)) {
$this->warn("{$type} briefings are disabled in configuration.");
return self::SUCCESS;
}
$userId = $this->option('user');
$businessId = $this->option('business');
$sync = $this->option('sync');
if ($userId) {
return $this->generateForUser((int) $userId, $type, $sync);
}
if ($businessId) {
return $this->generateForBusiness((int) $businessId, $type, $sync);
}
return $this->generateForAll($type, $sync);
}
protected function generateForUser(int $userId, string $type, bool $sync): int
{
$user = User::with('business')->find($userId);
if (! $user) {
$this->error("User not found: {$userId}");
return self::FAILURE;
}
if (! $user->business_id) {
$this->error('User has no associated business');
return self::FAILURE;
}
$this->info("Generating {$type} briefing for user: {$user->name}");
if ($sync) {
$this->dispatchSync($userId, $user->business_id, $type);
} else {
GenerateBriefingJob::dispatch($userId, $user->business_id, $type);
}
$this->info('Done.');
return self::SUCCESS;
}
protected function generateForBusiness(int $businessId, string $type, bool $sync): int
{
$business = Business::find($businessId);
if (! $business) {
$this->error("Business not found: {$businessId}");
return self::FAILURE;
}
// Get users who should receive briefings (sellers/admins)
$users = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $businessId))
->whereIn('user_type', ['seller', 'both'])
->where('is_active', true)
->get();
if ($users->isEmpty()) {
$this->warn("No eligible users found for business: {$business->name}");
return self::SUCCESS;
}
$this->info("Generating {$type} briefings for {$users->count()} users in {$business->name}");
$bar = $this->output->createProgressBar($users->count());
$bar->start();
foreach ($users as $user) {
if ($sync) {
$this->dispatchSync($user->id, $businessId, $type);
} else {
GenerateBriefingJob::dispatch($user->id, $businessId, $type);
}
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info('Done.');
return self::SUCCESS;
}
protected function generateForAll(string $type, bool $sync): int
{
// Get all active seller businesses
$businesses = Business::where('type', 'seller')
->orWhere('type', 'both')
->get();
if ($businesses->isEmpty()) {
$this->warn('No eligible businesses found.');
return self::SUCCESS;
}
$totalUsers = 0;
foreach ($businesses as $business) {
$userCount = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->whereIn('user_type', ['seller', 'both'])
->where('is_active', true)
->count();
$totalUsers += $userCount;
}
$this->info("Generating {$type} briefings for {$totalUsers} users across {$businesses->count()} businesses");
$bar = $this->output->createProgressBar($totalUsers);
$bar->start();
foreach ($businesses as $business) {
$users = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->whereIn('user_type', ['seller', 'both'])
->where('is_active', true)
->get();
foreach ($users as $user) {
if ($sync) {
$this->dispatchSync($user->id, $business->id, $type);
} else {
GenerateBriefingJob::dispatch($user->id, $business->id, $type);
}
$bar->advance();
}
}
$bar->finish();
$this->newLine();
$this->info('Done.');
return self::SUCCESS;
}
protected function dispatchSync(int $userId, int $businessId, string $type): void
{
try {
$orchestrator = app(\App\Services\Ai\Contracts\AiOrchestratorContract::class);
$orchestrator->generateBriefing($userId, $businessId, $type);
} catch (\Throwable $e) {
$this->error("Failed for user {$userId}: {$e->getMessage()}");
}
}
}

View File

@@ -1,128 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Ai;
use App\Models\Business;
use App\Services\Ai\Contracts\AiOrchestratorContract;
use Illuminate\Console\Command;
/**
* Process AI Suggestions Command
*
* Artisan command to manually trigger AI suggestion generation.
*/
class ProcessAiSuggestionsCommand extends Command
{
protected $signature = 'ai:process-suggestions
{context : Context type (deal, order, thread, buyer)}
{entity : Entity ID to process}
{--business= : Business ID (required)}
{--no-llm : Disable LLM generation, use rules only}';
protected $description = 'Manually process AI suggestions for an entity';
public function handle(AiOrchestratorContract $orchestrator): int
{
$contextType = $this->argument('context');
$entityId = (int) $this->argument('entity');
$businessId = (int) $this->option('business');
$useLlm = ! $this->option('no-llm');
if (! $businessId) {
$this->error('--business option is required');
return self::FAILURE;
}
$business = Business::find($businessId);
if (! $business) {
$this->error("Business not found: {$businessId}");
return self::FAILURE;
}
$validContexts = ['deal', 'order', 'thread', 'buyer'];
if (! in_array($contextType, $validContexts)) {
$this->error('Invalid context type. Must be one of: '.implode(', ', $validContexts));
return self::FAILURE;
}
$this->info("Processing {$contextType} #{$entityId} for business: {$business->name}");
$this->info('LLM: '.($useLlm ? 'enabled' : 'disabled'));
try {
// Build context
$context = $orchestrator->buildContext($contextType, $entityId, $businessId);
$this->info('Context built successfully');
$this->line(" Type: {$context->type}");
$this->line(" Entity ID: {$context->entityId}");
// Generate suggestions
$suggestions = $orchestrator->generateSuggestions($context, [
'use_llm' => $useLlm,
]);
$this->info("Generated {$suggestions->count()} suggestions");
if ($suggestions->isNotEmpty()) {
$this->newLine();
$this->table(
['Title', 'Type', 'Priority', 'Confidence'],
$suggestions->map(fn ($s) => [
substr($s->title, 0, 40),
$s->type->value,
$s->priority->value,
number_format($s->confidence * 100, 0).'%',
])
);
// Persist suggestions
if ($this->confirm('Persist these suggestions?', true)) {
foreach ($suggestions as $suggestion) {
$orchestrator->persistSuggestion($suggestion);
}
$this->info('Suggestions persisted.');
}
}
// Assess risks
$risks = $orchestrator->assessRisks($context);
if ($risks->isNotEmpty()) {
$this->newLine();
$this->info("Identified {$risks->count()} risks");
$this->table(
['Title', 'Level', 'Score', 'Impact'],
$risks->map(fn ($r) => [
substr($r->title, 0, 40),
$r->level,
number_format($r->score * 100, 0).'%',
$r->impactValue ? '$'.number_format($r->impactValue) : '-',
])
);
if ($this->confirm('Persist these risks?', true)) {
foreach ($risks as $risk) {
$orchestrator->persistRisk($risk);
}
$this->info('Risks persisted.');
}
}
return self::SUCCESS;
} catch (\Throwable $e) {
$this->error("Error: {$e->getMessage()}");
if ($this->output->isVerbose()) {
$this->line($e->getTraceAsString());
}
return self::FAILURE;
}
}
}

View File

@@ -1,98 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use App\Services\AI\BrandAiProfileGenerator;
use Illuminate\Console\Command;
class AutoTuneMissingBrandProfiles extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'brand-ai-profiles:auto-tune-missing
{--limit=0 : Maximum number of brands to process (0 = all)}
{--dry-run : Show what would be processed without actually generating}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Auto-generate AI profiles for brands that don\'t have one';
/**
* Execute the console command.
*/
public function handle(BrandAiProfileGenerator $generator): int
{
$this->info('Finding brands without AI profiles...');
// Get brands that don't have an AI profile
$query = Brand::whereDoesntHave('aiProfile')
->whereHas('business', function ($q) {
$q->where('status', 'approved');
})
->orderBy('name');
$limit = (int) $this->option('limit');
if ($limit > 0) {
$query->limit($limit);
}
$brands = $query->get();
if ($brands->isEmpty()) {
$this->info('All brands already have AI profiles. Nothing to do.');
return self::SUCCESS;
}
$this->info("Found {$brands->count()} brand(s) without AI profiles.");
if ($this->option('dry-run')) {
$this->warn('DRY RUN - No profiles will be generated.');
$this->table(
['ID', 'Brand Name', 'Business', 'Voice', 'Audience'],
$brands->map(fn ($b) => [
$b->id,
$b->name,
$b->business?->name ?? 'N/A',
$b->brand_voice ?? 'N/A',
$b->brand_audience ?? 'N/A',
])->toArray()
);
return self::SUCCESS;
}
$bar = $this->output->createProgressBar($brands->count());
$bar->start();
$success = 0;
$failed = 0;
foreach ($brands as $brand) {
try {
$generator->generateForBrand($brand);
$success++;
$this->line(" <info>✓</info> {$brand->name}");
} catch (\Exception $e) {
$failed++;
$this->line(" <error>✗</error> {$brand->name}: {$e->getMessage()}");
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("Completed: {$success} profiles generated, {$failed} failed.");
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -1,61 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\CalculateDashboardMetrics;
use App\Models\Business;
use Illuminate\Console\Command;
class CalculateDashboardMetricsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'dashboard:calculate-metrics
{--business= : Specific business ID to calculate (optional)}
{--sync : Run synchronously instead of queuing}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Pre-calculate dashboard metrics and store in Redis';
/**
* Execute the console command.
*/
public function handle(): int
{
$businessId = $this->option('business');
$sync = $this->option('sync');
if ($businessId) {
$business = Business::find($businessId);
if (! $business) {
$this->error("Business {$businessId} not found");
return 1;
}
$this->info("Calculating metrics for business: {$business->name}");
} else {
$count = Business::where('type', 'seller')->where('status', 'approved')->count();
$this->info("Calculating metrics for {$count} businesses");
}
$job = new CalculateDashboardMetrics($businessId ? (int) $businessId : null);
if ($sync) {
$this->info('Running synchronously...');
$job->handle();
$this->info('Done!');
} else {
CalculateDashboardMetrics::dispatch($businessId ? (int) $businessId : null);
$this->info('Job dispatched to queue');
}
return 0;
}
}

View File

@@ -1,148 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use App\Models\Product;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class CheckMediaFiles extends Command
{
protected $signature = 'media:check {--brands : Check brand images} {--products : Check product images} {--all : Check all media}';
protected $description = 'Check which brand and product images exist on MinIO storage';
public function handle()
{
$checkBrands = $this->option('brands') || $this->option('all');
$checkProducts = $this->option('products') || $this->option('all');
if (! $checkBrands && ! $checkProducts) {
$checkBrands = $checkProducts = true; // Default to checking everything
}
if ($checkBrands) {
$this->checkBrandImages();
}
if ($checkProducts) {
$this->checkProductImages();
}
return 0;
}
private function checkBrandImages()
{
$this->info('🔍 Checking brand images...');
$this->newLine();
$brands = Brand::whereNotNull('logo_path')
->orWhereNotNull('banner_path')
->get();
$broken = [];
$working = [];
foreach ($brands as $brand) {
$logoOk = $brand->logo_path ? Storage::exists($brand->logo_path) : true;
$bannerOk = $brand->banner_path ? Storage::exists($brand->banner_path) : true;
if (! $logoOk || ! $bannerOk) {
$status = [];
if (! $logoOk) {
$status[] = '❌ LOGO: '.$brand->logo_path;
}
if (! $bannerOk) {
$status[] = '❌ BANNER: '.$brand->banner_path;
}
$broken[] = [
'brand' => $brand->name.' (slug: '.$brand->slug.')',
'status' => implode(' | ', $status),
];
} else {
$working[] = $brand->name;
}
}
if (empty($broken)) {
$this->info('✅ All '.count($working).' brand images exist on MinIO!');
} else {
$this->error('Found '.count($broken).' brands with missing images:');
$this->newLine();
foreach ($broken as $b) {
$this->line(' '.$b['brand']);
$this->line(' '.$b['status']);
}
$this->newLine();
$this->info('Working: '.count($working).' brands');
}
$this->newLine();
}
private function checkProductImages()
{
$this->info('🔍 Checking product images...');
$this->newLine();
$products = Product::whereNotNull('image_path')->get();
$broken = [];
$working = [];
$wrongPath = [];
foreach ($products as $product) {
$exists = Storage::exists($product->image_path);
if (! $exists) {
$broken[] = [
'product' => $product->name.' (SKU: '.$product->sku.')',
'path' => $product->image_path,
];
} else {
$working[] = $product->name;
// Check if path follows correct pattern
$expectedPattern = 'businesses/*/brands/*/products/*/images/*';
if (! preg_match('#^businesses/[^/]+/brands/[^/]+/products/[^/]+/images/#', $product->image_path)) {
$wrongPath[] = [
'product' => $product->name.' (SKU: '.$product->sku.')',
'path' => $product->image_path,
];
}
}
}
if (empty($broken)) {
$this->info('✅ All '.count($working).' product images exist on MinIO!');
} else {
$this->error('Found '.count($broken).' products with missing images:');
$this->newLine();
foreach (array_slice($broken, 0, 10) as $p) {
$this->line(' ❌ '.$p['product']);
$this->line(' Path: '.$p['path']);
}
if (count($broken) > 10) {
$this->line(' ... and '.(count($broken) - 10).' more');
}
}
if (! empty($wrongPath)) {
$this->newLine();
$this->warn('⚠️ Found '.count($wrongPath).' products with WRONG path pattern:');
$this->newLine();
foreach (array_slice($wrongPath, 0, 5) as $p) {
$this->line(' '.$p['product']);
$this->line(' Current: '.$p['path']);
$this->line(' Should be: businesses/{business_slug}/brands/{brand_slug}/products/{sku}/images/');
}
if (count($wrongPath) > 5) {
$this->line(' ... and '.(count($wrongPath) - 5).' more');
}
}
$this->newLine();
}
}

View File

@@ -1,155 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\PermissionAuditLog;
use Illuminate\Console\Command;
class CleanupPermissionAuditLogs extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'permissions:cleanup-audit
{--dry-run : Show what would be deleted without actually deleting}
{--force : Skip confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete expired permission audit logs (non-critical logs past their expiration date)';
/**
* Execute the console command.
*/
public function handle(): int
{
$isDryRun = $this->option('dry-run');
$isForced = $this->option('force');
$this->info('🔍 Scanning for expired permission audit logs...');
$this->newLine();
// Find expired logs
$expiredLogs = PermissionAuditLog::expired()->get();
if ($expiredLogs->isEmpty()) {
$this->info('✅ No expired audit logs found. Everything is up to date!');
return self::SUCCESS;
}
// Statistics
$totalCount = $expiredLogs->count();
$oldestLog = $expiredLogs->sortBy('created_at')->first();
$newestLog = $expiredLogs->sortByDesc('created_at')->first();
// Display summary
$this->table(
['Metric', 'Value'],
[
['Expired logs found', $totalCount],
['Oldest expired log', $oldestLog->created_at->format('Y-m-d H:i:s')],
['Newest expired log', $newestLog->created_at->format('Y-m-d H:i:s')],
['Date range', $oldestLog->created_at->diffForHumans($newestLog->created_at, true)],
]
);
$this->newLine();
// Show sample of logs to be deleted
$this->info('📋 Sample of logs to be deleted:');
$sampleLogs = $expiredLogs->take(5);
foreach ($sampleLogs as $log) {
$this->line(sprintf(
' • [%s] %s - %s (expired %s)',
$log->created_at->format('Y-m-d'),
$log->action_name,
$log->targetUser?->name ?? 'Unknown User',
$log->expires_at->diffForHumans()
));
}
if ($totalCount > 5) {
$this->line(" ... and {$totalCount} more");
}
$this->newLine();
// Dry run mode
if ($isDryRun) {
$this->warn('🧪 DRY RUN MODE - No logs will be deleted');
$this->info("Would delete {$totalCount} expired audit logs");
return self::SUCCESS;
}
// Confirmation prompt (unless forced)
if (! $isForced) {
$confirmed = $this->confirm(
"Are you sure you want to delete {$totalCount} expired audit logs?",
false
);
if (! $confirmed) {
$this->info('❌ Cleanup cancelled');
return self::SUCCESS;
}
}
// Perform deletion
$this->info('🗑️ Deleting expired audit logs...');
$progressBar = $this->output->createProgressBar($totalCount);
$progressBar->start();
$deletedCount = 0;
$errorCount = 0;
foreach ($expiredLogs as $log) {
try {
$log->delete();
$deletedCount++;
} catch (\Exception $e) {
$errorCount++;
$this->error("Failed to delete log ID {$log->id}: {$e->getMessage()}");
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
// Final summary
if ($errorCount === 0) {
$this->info("✅ Successfully deleted {$deletedCount} expired audit logs");
} else {
$this->warn("⚠️ Deleted {$deletedCount} logs with {$errorCount} errors");
}
// Show remaining stats
$remainingTotal = PermissionAuditLog::count();
$remainingCritical = PermissionAuditLog::critical()->count();
$remainingNonExpired = $remainingTotal - $remainingCritical;
$this->newLine();
$this->info('📊 Database statistics after cleanup:');
$this->table(
['Category', 'Count'],
[
['Critical logs (kept forever)', $remainingCritical],
['Non-critical logs (not yet expired)', $remainingNonExpired],
['Total remaining logs', $remainingTotal],
]
);
return self::SUCCESS;
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\MediaStorageService;
use Illuminate\Console\Command;
class CleanupTempFiles extends Command
{
protected $signature = 'media:cleanup-temp';
protected $description = 'Clean up temporary files older than 24 hours from MinIO storage';
public function handle(): int
{
$this->info('🧹 Cleaning up temporary files...');
$deleted = MediaStorageService::cleanupTempFiles();
if ($deleted > 0) {
$this->info("✅ Deleted {$deleted} temporary file(s)");
} else {
$this->info('✅ No temporary files to clean up');
}
return 0;
}
}

View File

@@ -1,68 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Product;
use Illuminate\Console\Command;
class ClearVarieties extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'products:clear-varieties
{--brand-id= : Limit to a specific brand ID}
{--force : Skip confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clear all parent_product_id links (undo variety relationships)';
/**
* Execute the console command.
*/
public function handle(): int
{
$brandId = $this->option('brand-id');
$force = $this->option('force');
$query = Product::query()->whereNotNull('parent_product_id');
if ($brandId) {
$query->where('brand_id', $brandId);
$this->info("Filtering to brand_id: {$brandId}");
}
$count = $query->count();
if ($count === 0) {
$this->info('No products have parent_product_id set. Nothing to clear.');
return Command::SUCCESS;
}
$scope = $brandId ? "brand #{$brandId}" : 'all brands';
$this->warn("This will clear parent_product_id for {$count} products in {$scope}.");
if (! $force && ! $this->confirm('Are you sure you want to continue?')) {
$this->info('Operation cancelled.');
return Command::SUCCESS;
}
// Perform the update
$updated = Product::query()
->whereNotNull('parent_product_id')
->when($brandId, fn ($q) => $q->where('brand_id', $brandId))
->update(['parent_product_id' => null]);
$this->info("✓ Cleared parent_product_id for {$updated} products.");
return Command::SUCCESS;
}
}

View File

@@ -1,59 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use App\Models\Menu;
use Illuminate\Console\Command;
class CreateSystemMenusCommand extends Command
{
protected $signature = 'menus:create-system {--brand= : Specific brand ID to create menus for}';
protected $description = 'Create system menus (Available Now, Promotions, Daily Deals, Best Sellers) for all brands';
public function handle(): int
{
$brandId = $this->option('brand');
if ($brandId) {
$brands = Brand::where('id', $brandId)->get();
if ($brands->isEmpty()) {
$this->error("Brand with ID {$brandId} not found.");
return self::FAILURE;
}
} else {
$brands = Brand::all();
}
$this->info('Creating system menus for '.count($brands).' brand(s)...');
$bar = $this->output->createProgressBar(count($brands));
$bar->start();
$created = 0;
$skipped = 0;
foreach ($brands as $brand) {
$menus = Menu::createSystemMenusForBrand($brand);
foreach ($menus as $menu) {
if ($menu->wasRecentlyCreated) {
$created++;
} else {
$skipped++;
}
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("Done! Created {$created} new system menus, {$skipped} already existed.");
return self::SUCCESS;
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Models\Business;
use App\Models\Company;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
@@ -40,21 +40,19 @@ class CreateTestInvoiceForApproval extends Command
$this->info("✓ Using buyer: {$buyer->name} ({$buyer->email})");
// Get any business
$business = Business::first();
// Get any company
$company = Company::first();
if (! $business) {
$this->error('No business found. Please seed database first.');
if (! $company) {
$this->error('No company found. Please seed database first.');
return 1;
}
$this->info("Business: {$business->name}");
$this->info("Company: {$company->name}");
// Get some products that have inventory
$products = Product::whereHas('inventoryItems', function ($q) {
$q->where('quantity_on_hand', '>', 10);
})->where('is_active', true)->take(5)->get();
// Get some products
$products = Product::where('quantity_on_hand', '>', 10)->where('is_active', true)->take(5)->get();
if ($products->isEmpty()) {
$this->error('No products found. Please seed products first.');
@@ -64,7 +62,7 @@ class CreateTestInvoiceForApproval extends Command
$this->info("✓ Found {$products->count()} products for order");
// Create order
$order = $this->createOrder($buyer, $business);
$order = $this->createOrder($buyer, $company);
$this->info("✓ Created order: {$order->order_number}");
// Add items to order
@@ -127,11 +125,11 @@ class CreateTestInvoiceForApproval extends Command
/**
* Create a test order.
*/
protected function createOrder(User $buyer, Business $business): Order
protected function createOrder(User $buyer, Company $company): Order
{
return Order::create([
'order_number' => 'ORD-TEST-'.strtoupper(substr(md5(time()), 0, 10)),
'business_id' => $business->id,
'company_id' => $company->id,
'user_id' => $buyer->id,
'subtotal' => 0, // Will be calculated
'tax' => 0,

View File

@@ -1,85 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class DevSetup extends Command
{
protected $signature = 'dev:setup
{--fresh : Drop all tables and re-run migrations (DESTRUCTIVE - requires confirmation)}
{--skip-seed : Skip seeding dev fixtures}';
protected $description = 'Set up local development environment with migrations and dev fixtures';
public function handle(): int
{
if (app()->environment('production')) {
$this->error('This command cannot be run in production!');
return self::FAILURE;
}
$this->info('Setting up development environment...');
$this->newLine();
// Run migrations
if ($this->option('fresh')) {
$this->newLine();
$this->error('WARNING: --fresh will DELETE ALL DATA in the database!');
$this->warn('This includes development data being preserved for production release.');
$this->newLine();
if (! $this->confirm('Are you SURE you want to drop all tables and lose all data?', false)) {
$this->info('Aborted. Running normal migrations instead...');
$this->call('migrate');
} else {
$this->warn('Dropping all tables and re-running migrations...');
$this->call('migrate:fresh');
}
} else {
$this->info('Running migrations...');
$this->call('migrate');
}
$this->newLine();
// Seed dev fixtures
if (! $this->option('skip-seed')) {
if ($this->confirm('Seed development fixtures (users, businesses, brands)?', true)) {
$this->info('Seeding development fixtures...');
$this->call('db:seed', ['--class' => 'ProductionSyncSeeder']);
$this->newLine();
$this->info('Seeding dev suites and plans...');
$this->call('db:seed', ['--class' => 'DevSuitesSeeder']);
$this->newLine();
$this->info('Seeding brand profiles...');
$this->call('db:seed', ['--class' => 'BrandProfilesSeeder']);
$this->newLine();
$this->info('Seeding orchestrator profiles...');
$this->call('orchestrator:seed-brand-profiles', ['--force' => true]);
}
}
$this->newLine();
$this->info('Development setup complete!');
$this->newLine();
$this->table(
['Credential', 'Email', 'Password'],
[
['Super Admin', 'admin@cannabrands.com', 'password'],
['Admin', 'admin@example.com', 'password'],
['Seller', 'seller@example.com', 'password'],
['Buyer', 'buyer@example.com', 'password'],
['Cannabrands Owner', 'cannabrands-owner@example.com', 'password'],
['Brand Manager', 'brand-manager@example.com', 'password'],
]
);
return self::SUCCESS;
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\SendMarketingCampaignJob;
use App\Models\Marketing\MarketingCampaign;
use Illuminate\Console\Command;
/**
* DispatchScheduledCampaigns - Dispatch scheduled marketing campaigns.
*
* Run via scheduler: Schedule::command('marketing:dispatch-scheduled-campaigns')->everyMinute();
*/
class DispatchScheduledCampaigns extends Command
{
protected $signature = 'marketing:dispatch-scheduled-campaigns';
protected $description = 'Dispatch scheduled marketing campaigns that are ready to send';
public function handle(): int
{
$campaigns = MarketingCampaign::readyToSend()->get();
if ($campaigns->isEmpty()) {
$this->info('No scheduled campaigns ready to send.');
return self::SUCCESS;
}
$this->info("Found {$campaigns->count()} campaign(s) ready to send.");
foreach ($campaigns as $campaign) {
$this->info("Dispatching campaign: {$campaign->name} (ID: {$campaign->id})");
SendMarketingCampaignJob::dispatch($campaign->id);
}
$this->info('Done.');
return self::SUCCESS;
}
}

View File

@@ -1,91 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ExploreRemoteDatabase extends Command
{
protected $signature = 'explore:remote-db {query?}';
protected $description = 'Explore the remote MySQL database';
public function handle()
{
// Configure remote MySQL connection
config(['database.connections.remote_mysql' => [
'driver' => 'mysql',
'host' => 'sql1.creationshop.net',
'port' => '3306',
'database' => 'hub_cannabrands',
'username' => 'claude',
'password' => 'claude',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
]]);
$this->info('✓ Connected to remote MySQL database');
$this->newLine();
// Show brands table structure
$this->info('=== BRANDS TABLE STRUCTURE ===');
$columns = DB::connection('remote_mysql')->select('DESCRIBE brands');
foreach ($columns as $column) {
$this->line(" {$column->Field} ({$column->Type})");
}
$this->newLine();
// Show first 5 brands
$this->info('=== BRANDS ===');
$brands = DB::connection('remote_mysql')->table('brands')->limit(5)->get();
foreach ($brands as $brand) {
$this->line(json_encode($brand, JSON_PRETTY_PRINT));
$this->line('---');
}
$this->newLine();
// Show products table structure
$this->info('=== PRODUCTS TABLE ===');
$this->line('Sample products with SKU codes:');
$products = DB::connection('remote_mysql')
->table('products')
->select('id', 'brand_id', 'name', 'code', 'barcode', 'wholesale_price', 'cost', 'quantity')
->where('active', 1)
->whereNotNull('code')
->limit(10)
->get();
foreach ($products as $product) {
$this->line(json_encode($product, JSON_PRETTY_PRINT));
}
$this->newLine();
// Show orders table structure
$this->info('=== ORDERS & ORDER_PRODUCTS ===');
$orderSample = DB::connection('remote_mysql')
->table('order_products')
->join('orders', 'orders.id', '=', 'order_products.order_id')
->join('products', 'products.id', '=', 'order_products.product_id')
->select(
'orders.id as order_id',
'orders.created_at',
'products.code as sku',
'products.name',
'order_products.quantity',
'order_products.price',
'order_products.subtotal'
)
->limit(5)
->get();
foreach ($orderSample as $order) {
$this->line(json_encode($order, JSON_PRETTY_PRINT));
}
return 0;
}
}

View File

@@ -1,278 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Business;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
/**
* Exports current business configurations to a seeder file.
*
* This captures the current admin settings for all businesses:
* - Suite assignments (business_suite pivot)
* - Enterprise plan status
* - Module flags
* - Usage limits
*
* The generated seeder is IDEMPOTENT - it updates existing records
* without deleting data.
*/
class ExportBusinessConfigSeeder extends Command
{
protected $signature = 'export:business-config-seeder
{--output= : Output file path (default: database/seeders/BusinessConfigSeeder.php)}
{--business= : Export only specific business by slug}';
protected $description = 'Export current business configurations to a seeder file';
/**
* Configuration fields to export.
*/
private array $configFields = [
// Enterprise plan
'is_enterprise_plan',
// Legacy module flags
'has_marketing',
'has_analytics',
'has_inventory',
'has_manufacturing',
'has_processing',
'has_compliance',
'has_crm',
'has_buyer_intelligence',
'copilot_enabled',
'has_conversations',
// Legacy suite flags (kept for compatibility)
'has_sales_suite',
'has_processing_suite',
'has_manufacturing_suite',
'has_delivery_suite',
'has_management_suite',
'has_enterprise_suite',
// Navigation
'use_suite_navigation',
// Usage limits
'sales_suite_brand_limit',
'sales_suite_sku_limit_per_brand',
'sales_suite_menu_limit_per_brand',
'sales_suite_message_limit_per_brand',
'sales_suite_ai_credits_per_brand',
'sales_suite_contact_limit_per_brand',
];
public function handle(): int
{
$outputPath = $this->option('output') ?? database_path('seeders/BusinessConfigSeeder.php');
$specificBusiness = $this->option('business');
$this->info('Exporting business configurations...');
// Get businesses to export
$query = Business::with('suites')->orderBy('name');
if ($specificBusiness) {
$query->where('slug', $specificBusiness);
}
$businesses = $query->get();
if ($businesses->isEmpty()) {
$this->error('No businesses found to export.');
return self::FAILURE;
}
$this->info("Found {$businesses->count()} businesses to export.");
// Build the seeder content
$seederContent = $this->generateSeederContent($businesses);
// Write to file
File::put($outputPath, $seederContent);
$this->info("Seeder exported to: {$outputPath}");
$this->newLine();
// Show summary
$this->table(
['Business', 'Type', 'Enterprise', 'Suites'],
$businesses->map(fn ($b) => [
$b->name,
$b->type,
$b->is_enterprise_plan ? 'Yes' : 'No',
$b->suites->pluck('key')->implode(', ') ?: '-',
])
);
return self::SUCCESS;
}
private function generateSeederContent($businesses): string
{
$timestamp = now()->format('Y-m-d H:i:s');
$configs = [];
foreach ($businesses as $business) {
$config = [
'slug' => $business->slug,
'name' => $business->name,
'suites' => $business->suites->pluck('key')->toArray(),
];
// Add only non-null/non-default config values
foreach ($this->configFields as $field) {
$value = $business->{$field};
if ($value !== null && $value !== false && $value !== 0) {
$config[$field] = $value;
}
}
$configs[] = $config;
}
$configsPhp = $this->arrayToPhp($configs, 2);
return <<<PHP
<?php
namespace Database\Seeders;
use App\Models\Business;
use App\Models\Suite;
use Illuminate\Database\Seeder;
/**
* BusinessConfigSeeder - Sets business configurations and suite assignments.
*
* Auto-generated on: {$timestamp}
* Generated by: php artisan export:business-config-seeder
*
* This seeder is IDEMPOTENT - it updates existing records without deleting data.
* Run with: php artisan db:seed --class=BusinessConfigSeeder
*/
class BusinessConfigSeeder extends Seeder
{
/**
* Business configurations exported from admin settings.
*/
private array \$configs = {$configsPhp};
/**
* Run the database seeds.
*/
public function run(): void
{
\$this->command->info('Applying business configurations...');
// Cache suite IDs by key for efficiency
\$suiteIds = Suite::pluck('id', 'key')->toArray();
\$updated = 0;
\$skipped = 0;
foreach (\$this->configs as \$config) {
\$business = Business::where('slug', \$config['slug'])->first();
if (! \$business) {
\$this->command->warn(" Skipping: {\$config['name']} (slug: {\$config['slug']}) - not found");
\$skipped++;
continue;
}
// Extract suites from config
\$suites = \$config['suites'] ?? [];
unset(\$config['suites'], \$config['slug'], \$config['name']);
// Update business config fields
\$business->update(\$config);
// Sync suite assignments (without detaching extras)
\$suiteIdsToSync = collect(\$suites)
->map(fn (\$key) => \$suiteIds[\$key] ?? null)
->filter()
->toArray();
if (! empty(\$suiteIdsToSync)) {
\$business->suites()->syncWithoutDetaching(\$suiteIdsToSync);
}
\$updated++;
}
\$this->command->info(" Updated: {\$updated} businesses");
if (\$skipped > 0) {
\$this->command->warn(" Skipped: {\$skipped} businesses (not found)");
}
}
}
PHP;
}
/**
* Convert PHP array to formatted PHP code string.
*/
private function arrayToPhp(array $array, int $indent = 1): string
{
$spaces = str_repeat(' ', $indent);
$closingSpaces = str_repeat(' ', $indent - 1);
$items = [];
foreach ($array as $key => $value) {
$keyStr = is_int($key) ? '' : "'{$key}' => ";
if (is_array($value)) {
if (empty($value)) {
// Empty array
$valueStr = '[]';
} elseif ($this->isSequentialArray($value) && $this->isSimpleArray($value)) {
// Simple sequential array - inline
$valueStr = '['.implode(', ', array_map(fn ($v) => $this->valueToPhp($v), $value)).']';
} else {
// Complex array - multiline
$valueStr = $this->arrayToPhp($value, $indent + 1);
}
} else {
$valueStr = $this->valueToPhp($value);
}
$items[] = $spaces.$keyStr.$valueStr;
}
return "[\n".implode(",\n", $items).",\n{$closingSpaces}]";
}
private function valueToPhp($value): string
{
if (is_null($value)) {
return 'null';
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_int($value) || is_float($value)) {
return (string) $value;
}
return "'".addslashes($value)."'";
}
private function isSequentialArray(array $array): bool
{
return array_keys($array) === range(0, count($array) - 1);
}
private function isSimpleArray(array $array): bool
{
foreach ($array as $value) {
if (is_array($value)) {
return false;
}
}
return true;
}
}

View File

@@ -1,176 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Process;
/**
* Export Cannabrands data to PostgreSQL SQL dumps.
*
* This command exports current database data to SQL files in database/dumps/
* for later restoration without requiring a MySQL connection.
*
* Usage:
* - Configure your local database with the desired settings
* - Run: php artisan db:export-cannabrands
* - Commit the updated dump files (if they should be in git)
*/
class ExportCannabrandsData extends Command
{
protected $signature = 'db:export-cannabrands
{--tables= : Comma-separated list of specific tables to export}';
protected $description = 'Export Cannabrands data to PostgreSQL SQL dumps';
// Tables to export (same as restore command)
protected array $tables = [
'strains',
'product_categories',
'businesses',
'users',
'brands',
'locations',
'contacts',
'products',
'orders',
'order_items',
'invoices',
'business_user',
'brand_user',
'model_has_roles',
'ai_settings',
'orchestrator_sales_configs',
'orchestrator_marketing_configs',
];
protected string $dumpsPath;
public function __construct()
{
parent::__construct();
$this->dumpsPath = database_path('dumps');
}
public function handle(): int
{
$this->info('Exporting Cannabrands data to SQL dumps...');
// Create dumps directory if it doesn't exist
if (! is_dir($this->dumpsPath)) {
mkdir($this->dumpsPath, 0755, true);
$this->info("Created dumps directory: {$this->dumpsPath}");
}
// Determine which tables to export
$tablesToExport = $this->tables;
if ($this->option('tables')) {
$requestedTables = array_map('trim', explode(',', $this->option('tables')));
$tablesToExport = array_intersect($this->tables, $requestedTables);
if (empty($tablesToExport)) {
$this->error('No valid tables specified. Available tables: '.implode(', ', $this->tables));
return Command::FAILURE;
}
}
// Get database connection info
$database = config('database.connections.pgsql.database');
$username = config('database.connections.pgsql.username');
$host = config('database.connections.pgsql.host');
$port = config('database.connections.pgsql.port');
$exported = 0;
$errors = 0;
foreach ($tablesToExport as $table) {
$dumpFile = "{$this->dumpsPath}/{$table}.sql";
$this->line("Exporting {$table}...");
// Build pg_dump command
// Using --column-inserts for portable SQL
// Using --on-conflict-do-nothing for idempotent inserts
$pgDumpArgs = sprintf(
'--data-only --column-inserts --on-conflict-do-nothing --table=%s %s',
escapeshellarg($table),
escapeshellarg($database)
);
// pg_dump with connection info
// Works both inside Sail container (pgsql hostname) and natively
$command = sprintf(
'PGPASSWORD=%s pg_dump -h %s -p %s -U %s %s',
escapeshellarg(config('database.connections.pgsql.password')),
escapeshellarg($host),
escapeshellarg($port),
escapeshellarg($username),
$pgDumpArgs
);
$result = Process::run($command);
if ($result->successful()) {
// Extract only INSERT statements (remove pg_dump headers and SET commands)
// Handle multi-line INSERTs by looking for the ending pattern
$output = $result->output();
$lines = explode("\n", $output);
$inserts = [];
$currentInsert = '';
$inInsert = false;
foreach ($lines as $line) {
if (str_starts_with(trim($line), 'INSERT INTO')) {
// Start of new INSERT
$inInsert = true;
$currentInsert = $line;
// Check if this INSERT ends on same line
if (str_ends_with(trim($line), 'ON CONFLICT DO NOTHING;')) {
$inserts[] = $currentInsert;
$currentInsert = '';
$inInsert = false;
}
} elseif ($inInsert) {
// Continuation of current INSERT (multi-line due to embedded newlines in data)
// We need to escape the actual newline in the SQL string value
// Since we're inside a string value, replace with \n escape sequence
$currentInsert .= "\n".$line;
// Check if this line ends the INSERT
if (str_ends_with(trim($line), 'ON CONFLICT DO NOTHING;')) {
$inserts[] = $currentInsert;
$currentInsert = '';
$inInsert = false;
}
}
}
// Don't forget the last one if it didn't end properly
if (! empty($currentInsert)) {
$inserts[] = $currentInsert;
}
$cleanOutput = implode("\n", $inserts);
file_put_contents($dumpFile, $cleanOutput);
$this->info(' -> Exported '.count($inserts)." rows to {$table}.sql");
$exported++;
} else {
$this->error("Failed to export {$table}: ".$result->errorOutput());
$errors++;
}
}
$this->newLine();
$this->info("Exported {$exported} tables. Errors: {$errors}");
if ($exported > 0) {
$this->newLine();
$this->info('To restore this data on another machine:');
$this->line(' php artisan db:restore-cannabrands');
}
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
}
}

View File

@@ -1,54 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\InventoryItem;
use Illuminate\Console\Command;
class GenerateInventoryItemHashids extends Command
{
protected $signature = 'inventory:generate-hashids';
protected $description = 'Generate hashids for inventory items, movements, and alerts that don\'t have them';
public function handle(): int
{
// Process InventoryItems
$this->processModel(InventoryItem::class, 'inventory items');
// Process InventoryMovements
$this->processModel(\App\Models\InventoryMovement::class, 'inventory movements');
// Process InventoryAlerts
$this->processModel(\App\Models\InventoryAlert::class, 'inventory alerts');
return self::SUCCESS;
}
protected function processModel(string $modelClass, string $label): void
{
$records = $modelClass::whereNull('hashid')->get();
if ($records->isEmpty()) {
$this->info("✓ All {$label} already have hashids!");
return;
}
$this->info("Found {$records->count()} {$label} without hashids. Generating...");
$bar = $this->output->createProgressBar($records->count());
$bar->start();
foreach ($records as $record) {
$record->hashid = $record->generateHashid();
$record->saveQuietly(); // Don't trigger observers/events
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("✅ Generated hashids for {$records->count()} {$label}!");
$this->newLine();
}
}

View File

@@ -1,635 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\AutomationRunLog;
use App\Models\Brand;
use App\Models\Business;
use App\Models\MenuViewEvent;
use App\Models\OrchestratorMarketingConfig;
use App\Models\OrchestratorTask;
use App\Models\Order;
use App\Models\Product;
use App\Models\SendMenuLog;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Marketing Orchestrator - "Head of Marketing" automated playbooks.
*
* Generates actionable tasks for marketing teams based on engagement signals:
* - Campaign blast candidates (high-engagement customers)
* - Segment refinement suggestions
* - Launch announcements for new brands/SKUs
* - Holiday campaign opportunities
* - New SKU feature suggestions
* - Nurture sequence recommendations
*/
class GenerateMarketingOrchestratorTasks extends Command
{
protected $signature = 'orchestrator:generate-marketing-tasks
{--business= : Limit to specific business ID}
{--playbook= : Run only specific playbook}
{--dry-run : Show what would be created without creating}';
protected $description = 'Generate Marketing Orchestrator tasks from automated playbooks (Head of Marketing)';
private int $tasksCreated = 0;
private bool $dryRun = false;
private OrchestratorMarketingConfig $config;
/**
* Per-brand task counter for throttling.
*/
private array $brandTaskCount = [];
public function handle(): int
{
AutomationRunLog::recordStart(AutomationRunLog::CMD_GENERATE_MARKETING_TASKS);
$this->dryRun = $this->option('dry-run');
$specificBusinessId = $this->option('business');
$specificPlaybook = $this->option('playbook');
$this->info('📣 Marketing Orchestrator - Generating tasks...');
if ($this->dryRun) {
$this->warn(' (DRY RUN - no tasks will be created)');
}
$this->newLine();
// Load global config
try {
$this->config = OrchestratorMarketingConfig::getGlobal();
} catch (\Exception $e) {
$this->config = new OrchestratorMarketingConfig;
}
$this->line(" Throttle: max {$this->config->getMaxTasksPerBrandPerRun()} tasks/brand/run");
$this->line(" Cooldown: {$this->config->getCooldownDays()} days between touches");
$this->newLine();
// Get seller businesses
$businessQuery = Business::query()->where('type', '!=', 'buyer');
if ($specificBusinessId) {
$businessQuery->where('id', $specificBusinessId);
}
$businesses = $businessQuery->get();
foreach ($businesses as $business) {
$this->brandTaskCount = [];
$this->line("📊 Processing: {$business->name}");
// Run playbooks based on filter or all
if (! $specificPlaybook || $specificPlaybook === 'campaign-blast') {
$count = $this->playbook1CampaignBlastCandidates($business);
$this->line(" ├─ Playbook 1 (Campaign Blast): {$count} tasks");
}
if (! $specificPlaybook || $specificPlaybook === 'segment-refinement') {
$count = $this->playbook2SegmentRefinement($business);
$this->line(" ├─ Playbook 2 (Segment Refinement): {$count} tasks");
}
if (! $specificPlaybook || $specificPlaybook === 'launch-announcement') {
$count = $this->playbook3LaunchAnnouncement($business);
$this->line(" ├─ Playbook 3 (Launch Announcement): {$count} tasks");
}
if (! $specificPlaybook || $specificPlaybook === 'holiday-campaign') {
$count = $this->playbook4HolidayCampaign($business);
$this->line(" ├─ Playbook 4 (Holiday Campaign): {$count} tasks");
}
if (! $specificPlaybook || $specificPlaybook === 'new-sku-feature') {
$count = $this->playbook5NewSkuFeature($business);
$this->line(" ├─ Playbook 5 (New SKU Feature): {$count} tasks");
}
if (! $specificPlaybook || $specificPlaybook === 'nurture-sequence') {
$count = $this->playbook6NurtureSequence($business);
$this->line(" └─ Playbook 6 (Nurture Sequence): {$count} tasks");
}
$this->newLine();
}
$this->info("✅ Complete! Total marketing tasks created: {$this->tasksCreated}");
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_GENERATE_MARKETING_TASKS, [
'tasks_created' => $this->tasksCreated,
'businesses_processed' => $businesses->count(),
]);
return self::SUCCESS;
}
// ─────────────────────────────────────────────────────────────
// Playbook 1: Campaign Blast Candidates
// ─────────────────────────────────────────────────────────────
/**
* Find high-engagement customers who haven't received marketing in a while.
*/
private function playbook1CampaignBlastCandidates(Business $business): int
{
if (! $this->config->isCampaignBlastEnabled()) {
return 0;
}
$count = 0;
$settings = $this->config->getCampaignBlastSettings();
$brands = $business->brands;
foreach ($brands as $brand) {
if (! $this->canCreateTaskForBrand($brand->id)) {
continue;
}
// Find customers with high engagement (menu views + orders)
$engagedCustomers = $this->getEngagedCustomers($brand, $settings['min_engagement_score']);
foreach ($engagedCustomers as $customer) {
// Check if already has pending marketing task
if (OrchestratorTask::existsPending(
$business->id,
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE,
$customer->id
)) {
continue;
}
// Check cooldown - no marketing sends in X days
$lastMarketingSend = SendMenuLog::where('business_id', $business->id)
->where('customer_id', $customer->id)
->whereNotNull('orchestrator_task_id')
->where('sent_at', '>=', now()->subDays($settings['days_since_last_send']))
->exists();
if ($lastMarketingSend) {
continue;
}
$this->createTask([
'business_id' => $business->id,
'brand_id' => $brand->id,
'customer_id' => $customer->id,
'type' => OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE,
'owner_role' => OrchestratorTask::ROLE_MARKETING,
'status' => OrchestratorTask::STATUS_PENDING,
'priority' => OrchestratorTask::PRIORITY_NORMAL,
'due_at' => now()->addDays(3),
'payload' => [
'customer_name' => $customer->name,
'brand_name' => $brand->name,
'engagement_score' => $customer->engagement_score ?? 0,
'reason' => "High-engagement customer ({$customer->name}) ready for campaign blast for {$brand->name}",
'suggested_action' => 'Include in next email campaign or menu blast',
],
]);
$count++;
$this->recordTaskForBrand($brand->id);
if ($count >= $settings['max_tasks_per_run']) {
break 2;
}
}
}
return $count;
}
// ─────────────────────────────────────────────────────────────
// Playbook 2: Segment Refinement
// ─────────────────────────────────────────────────────────────
/**
* Find brands with many customers but no defined segments.
*/
private function playbook2SegmentRefinement(Business $business): int
{
if (! $this->config->isSegmentRefinementEnabled()) {
return 0;
}
$count = 0;
$settings = $this->config->getSegmentRefinementSettings();
$brands = $business->brands;
foreach ($brands as $brand) {
// Check if already has pending segment task
if (OrchestratorTask::where('business_id', $business->id)
->where('brand_id', $brand->id)
->where('type', OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT)
->where('status', OrchestratorTask::STATUS_PENDING)
->exists()) {
continue;
}
// Count unique customers who have engaged with this brand
$customerCount = MenuViewEvent::where('brand_id', $brand->id)
->whereNotNull('customer_id')
->distinct('customer_id')
->count('customer_id');
if ($customerCount < $settings['min_customers']) {
continue;
}
$this->createTask([
'business_id' => $business->id,
'brand_id' => $brand->id,
'type' => OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT,
'owner_role' => OrchestratorTask::ROLE_MARKETING,
'status' => OrchestratorTask::STATUS_PENDING,
'priority' => OrchestratorTask::PRIORITY_LOW,
'due_at' => now()->addWeek(),
'payload' => [
'brand_name' => $brand->name,
'customer_count' => $customerCount,
'reason' => "{$brand->name} has {$customerCount} engaged customers - consider creating marketing segments",
'suggested_action' => 'Define customer segments (VIP, Regular, New) for targeted campaigns',
],
]);
$count++;
}
return $count;
}
// ─────────────────────────────────────────────────────────────
// Playbook 3: Launch Announcement
// ─────────────────────────────────────────────────────────────
/**
* Suggest launch campaigns for new brands.
*/
private function playbook3LaunchAnnouncement(Business $business): int
{
if (! $this->config->isLaunchAnnouncementEnabled()) {
return 0;
}
$count = 0;
$settings = $this->config->getLaunchAnnouncementSettings();
// Find brands created recently
$newBrands = $business->brands()
->where('created_at', '>=', now()->subDays($settings['days_new']))
->get();
foreach ($newBrands as $brand) {
// Check if already has pending launch task
if (OrchestratorTask::where('business_id', $business->id)
->where('brand_id', $brand->id)
->where('type', OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT)
->where('status', OrchestratorTask::STATUS_PENDING)
->exists()) {
continue;
}
$this->createTask([
'business_id' => $business->id,
'brand_id' => $brand->id,
'type' => OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT,
'owner_role' => OrchestratorTask::ROLE_MARKETING,
'status' => OrchestratorTask::STATUS_PENDING,
'priority' => OrchestratorTask::PRIORITY_HIGH,
'due_at' => now()->addDays(2),
'payload' => [
'brand_name' => $brand->name,
'brand_created_at' => $brand->created_at->toDateString(),
'days_since_launch' => now()->diffInDays($brand->created_at),
'reason' => "New brand '{$brand->name}' launched - create announcement campaign",
'suggested_action' => 'Send launch email to customer list, update website banners',
],
]);
$count++;
}
return $count;
}
// ─────────────────────────────────────────────────────────────
// Playbook 4: Holiday Campaign
// ─────────────────────────────────────────────────────────────
/**
* Suggest holiday campaigns based on upcoming holidays.
*/
private function playbook4HolidayCampaign(Business $business): int
{
if (! $this->config->isHolidayCampaignEnabled()) {
return 0;
}
$count = 0;
$settings = $this->config->getHolidayCampaignSettings();
// Define holidays relevant to the cannabis industry
$holidays = $this->getUpcomingHolidays($settings['days_before']);
if (empty($holidays)) {
return 0;
}
foreach ($holidays as $holiday) {
// Check if already has pending holiday task for this business
$existingTask = OrchestratorTask::where('business_id', $business->id)
->where('type', OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN)
->where('status', OrchestratorTask::STATUS_PENDING)
->whereJsonContains('payload->holiday_name', $holiday['name'])
->exists();
if ($existingTask) {
continue;
}
$this->createTask([
'business_id' => $business->id,
'type' => OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN,
'owner_role' => OrchestratorTask::ROLE_MARKETING,
'status' => OrchestratorTask::STATUS_PENDING,
'priority' => OrchestratorTask::PRIORITY_HIGH,
'due_at' => $holiday['date']->copy()->subDays(7),
'payload' => [
'holiday_name' => $holiday['name'],
'holiday_date' => $holiday['date']->toDateString(),
'days_until' => now()->diffInDays($holiday['date']),
'reason' => "{$holiday['name']} is in {$holiday['days_until']} days - prepare campaign",
'suggested_action' => $holiday['suggested_action'] ?? 'Create themed email campaign and promotions',
],
]);
$count++;
}
return $count;
}
// ─────────────────────────────────────────────────────────────
// Playbook 5: New SKU Feature
// ─────────────────────────────────────────────────────────────
/**
* Suggest featuring new SKUs in marketing materials.
*/
private function playbook5NewSkuFeature(Business $business): int
{
if (! $this->config->isNewSkuFeatureEnabled()) {
return 0;
}
$count = 0;
$settings = $this->config->getNewSkuFeatureSettings();
$brands = $business->brands;
foreach ($brands as $brand) {
// Find new products for this brand
$newProducts = Product::where('brand_id', $brand->id)
->where('created_at', '>=', now()->subDays($settings['days_new']))
->where('is_active', true)
->get();
if ($newProducts->count() < $settings['min_products']) {
continue;
}
// Check if already has pending new SKU task
if (OrchestratorTask::where('business_id', $business->id)
->where('brand_id', $brand->id)
->where('type', OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE)
->where('status', OrchestratorTask::STATUS_PENDING)
->exists()) {
continue;
}
$productNames = $newProducts->pluck('name')->take(5)->toArray();
$this->createTask([
'business_id' => $business->id,
'brand_id' => $brand->id,
'type' => OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE,
'owner_role' => OrchestratorTask::ROLE_MARKETING,
'status' => OrchestratorTask::STATUS_PENDING,
'priority' => OrchestratorTask::PRIORITY_NORMAL,
'due_at' => now()->addDays(5),
'payload' => [
'brand_name' => $brand->name,
'new_product_count' => $newProducts->count(),
'product_names' => $productNames,
'reason' => "{$brand->name} has {$newProducts->count()} new products to feature",
'suggested_action' => 'Create "New Arrivals" email or update product showcase',
],
]);
$count++;
}
return $count;
}
// ─────────────────────────────────────────────────────────────
// Playbook 6: Nurture Sequence
// ─────────────────────────────────────────────────────────────
/**
* Suggest nurture sequences for new customers.
*/
private function playbook6NurtureSequence(Business $business): int
{
if (! $this->config->isNurtureSequenceEnabled()) {
return 0;
}
$count = 0;
$settings = $this->config->getNurtureSequenceSettings();
if (! Schema::hasTable('orders')) {
return 0;
}
$brandIds = $business->brands()->pluck('id');
if ($brandIds->isEmpty()) {
return 0;
}
// Find customers with first order in date range and limited orders
$nurtureCandiates = DB::table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->whereIn('products.brand_id', $brandIds)
->select('orders.business_id as customer_id')
->groupBy('orders.business_id')
->havingRaw('COUNT(DISTINCT orders.id) <= ?', [$settings['max_orders']])
->havingRaw('MIN(orders.created_at) <= ?', [now()->subDays($settings['days_since_first_order'])])
->limit(20)
->get();
foreach ($nurtureCandiates as $candidate) {
// Check if already has pending nurture task
if (OrchestratorTask::existsPending(
$business->id,
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE,
$candidate->customer_id
)) {
continue;
}
$customer = Business::find($candidate->customer_id);
if (! $customer) {
continue;
}
$this->createTask([
'business_id' => $business->id,
'customer_id' => $customer->id,
'type' => OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE,
'owner_role' => OrchestratorTask::ROLE_MARKETING,
'status' => OrchestratorTask::STATUS_PENDING,
'priority' => OrchestratorTask::PRIORITY_LOW,
'due_at' => now()->addWeek(),
'payload' => [
'customer_name' => $customer->name,
'reason' => "New customer '{$customer->name}' ready for nurture sequence",
'suggested_action' => 'Add to welcome email series or educational content drip',
],
]);
$count++;
}
return $count;
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
/**
* Get engaged customers for a brand.
*/
private function getEngagedCustomers(Brand $brand, int $minScore): \Illuminate\Support\Collection
{
// Get customers who have viewed menus multiple times
return MenuViewEvent::where('brand_id', $brand->id)
->whereNotNull('customer_id')
->select('customer_id', DB::raw('COUNT(*) as view_count'))
->groupBy('customer_id')
->havingRaw('COUNT(*) >= ?', [3])
->orderByDesc('view_count')
->limit(50)
->get()
->map(function ($item) {
$customer = Business::find($item->customer_id);
if ($customer) {
$customer->engagement_score = min(100, $item->view_count * 10);
return $customer;
}
return null;
})
->filter()
->filter(fn ($c) => ($c->engagement_score ?? 0) >= $minScore);
}
/**
* Get upcoming holidays.
*/
private function getUpcomingHolidays(int $daysAhead): array
{
$holidays = [
['name' => '4/20', 'date' => now()->setMonth(4)->setDay(20), 'suggested_action' => 'Major cannabis holiday - plan big promotional campaign'],
['name' => 'Green Wednesday', 'date' => $this->getGreenWednesday(), 'suggested_action' => 'Day before Thanksgiving - high sales day'],
['name' => 'Black Friday', 'date' => $this->getBlackFriday(), 'suggested_action' => 'Major sales event - prepare deals and promotions'],
['name' => '7/10 (Dab Day)', 'date' => now()->setMonth(7)->setDay(10), 'suggested_action' => 'Concentrate holiday - feature extracts and dabs'],
];
$upcoming = [];
foreach ($holidays as $holiday) {
$date = $holiday['date'];
// If date is in past this year, check next year
if ($date->isPast()) {
$date = $date->addYear();
}
$daysUntil = now()->diffInDays($date, false);
if ($daysUntil > 0 && $daysUntil <= $daysAhead) {
$holiday['date'] = $date;
$holiday['days_until'] = $daysUntil;
$upcoming[] = $holiday;
}
}
return $upcoming;
}
private function getGreenWednesday(): \Carbon\Carbon
{
// Wednesday before Thanksgiving (4th Thursday of November)
$november = now()->setMonth(11)->startOfMonth();
$thanksgiving = $november->copy()->nthOfMonth(4, \Carbon\Carbon::THURSDAY);
return $thanksgiving->copy()->subDay();
}
private function getBlackFriday(): \Carbon\Carbon
{
$november = now()->setMonth(11)->startOfMonth();
$thanksgiving = $november->copy()->nthOfMonth(4, \Carbon\Carbon::THURSDAY);
return $thanksgiving->copy()->addDay();
}
/**
* Check if we can create a task for this brand (throttling).
*/
private function canCreateTaskForBrand(int $brandId): bool
{
$currentCount = $this->brandTaskCount[$brandId] ?? 0;
return $currentCount < $this->config->getMaxTasksPerBrandPerRun();
}
/**
* Record that we created a task for this brand.
*/
private function recordTaskForBrand(int $brandId): void
{
if (! isset($this->brandTaskCount[$brandId])) {
$this->brandTaskCount[$brandId] = 0;
}
$this->brandTaskCount[$brandId]++;
}
/**
* Create a marketing task.
*/
private function createTask(array $taskData): ?OrchestratorTask
{
// Ensure marketing role
$taskData['owner_role'] = OrchestratorTask::ROLE_MARKETING;
// Marketing tasks are admin-only (not visible to seller reps)
$taskData['visible_to_reps'] = false;
$taskData['approval_state'] = OrchestratorTask::APPROVAL_AUTO;
if ($this->dryRun) {
return null;
}
$this->tasksCreated++;
return OrchestratorTask::create($taskData);
}
}

View File

@@ -1,228 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Business;
use App\Models\MenuViewEvent;
use App\Models\OrchestratorTask;
use App\Models\SendMenuLog;
use Illuminate\Console\Command;
class GenerateMenuFollowups extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'orchestrator:generate-menu-followups
{--days-no-view=3 : Days after send with no view to trigger followup}
{--days-viewed-no-order=3 : Days after view with no order to trigger followup}
{--business= : Limit to specific business ID}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate Orchestrator followup tasks for menu sends without views or orders';
/**
* Execute the console command.
*/
public function handle(): int
{
$daysNoView = (int) $this->option('days-no-view');
$daysViewedNoOrder = (int) $this->option('days-viewed-no-order');
$specificBusinessId = $this->option('business');
$this->info('Generating menu followup tasks...');
$this->info(" - No view threshold: {$daysNoView} days");
$this->info(" - Viewed no order threshold: {$daysViewedNoOrder} days");
$businessQuery = Business::query()
->where('type', '!=', 'buyer'); // Only process seller businesses
if ($specificBusinessId) {
$businessQuery->where('id', $specificBusinessId);
}
$businesses = $businessQuery->get();
$totalNoView = 0;
$totalViewedNoOrder = 0;
foreach ($businesses as $business) {
$this->line("Processing business: {$business->name} (ID: {$business->id})");
// Case A: No view after send
$noViewCount = $this->generateNoViewFollowups($business, $daysNoView);
$totalNoView += $noViewCount;
// Case B: Viewed but no order
$viewedNoOrderCount = $this->generateViewedNoOrderFollowups($business, $daysViewedNoOrder);
$totalViewedNoOrder += $viewedNoOrderCount;
$this->line(" - Created {$noViewCount} no-view followups");
$this->line(" - Created {$viewedNoOrderCount} viewed-no-order followups");
}
$this->newLine();
$this->info('Complete! Total tasks created:');
$this->info(" - No view followups: {$totalNoView}");
$this->info(" - Viewed no order followups: {$totalViewedNoOrder}");
return self::SUCCESS;
}
/**
* Generate followup tasks for menus sent but never viewed.
*/
protected function generateNoViewFollowups(Business $business, int $daysThreshold): int
{
$count = 0;
// Find SendMenuLog rows where:
// - sent_at is {daysThreshold} to {daysThreshold + 2} days ago (window)
// - There is no MenuViewEvent for the same business_id, menu_id, customer_id after sent_at
// - There is no existing OrchestratorTask of type menu_followup_no_view in pending status
$cutoffStart = now()->subDays($daysThreshold + 2);
$cutoffEnd = now()->subDays($daysThreshold);
$sendLogs = SendMenuLog::query()
->where('business_id', $business->id)
->whereBetween('sent_at', [$cutoffStart, $cutoffEnd])
->whereNotNull('customer_id')
->whereNotNull('menu_id')
->get();
foreach ($sendLogs as $log) {
// Check if there's been a view after the send
$hasView = MenuViewEvent::hasViewAfter(
$business->id,
$log->menu_id,
$log->customer_id,
$log->sent_at
);
if ($hasView) {
continue; // Menu was viewed, skip
}
// Check if task already exists
$existingTask = OrchestratorTask::query()
->where('business_id', $business->id)
->where('customer_id', $log->customer_id)
->where('menu_id', $log->menu_id)
->where('type', OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW)
->where('status', OrchestratorTask::STATUS_PENDING)
->exists();
if ($existingTask) {
continue; // Task already exists
}
// Create the followup task
OrchestratorTask::create([
'business_id' => $business->id,
'brand_id' => $log->brand_id,
'menu_id' => $log->menu_id,
'customer_id' => $log->customer_id,
'type' => OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW,
'status' => OrchestratorTask::STATUS_PENDING,
'due_at' => now(),
'payload' => [
'send_menu_log_id' => $log->id,
'recipient_name' => $log->meta['recipient_name'] ?? 'Unknown',
'recipient_type' => $log->recipient_type,
'recipient_id' => $log->recipient_id,
'channel' => $log->channel,
'original_sent_at' => $log->sent_at->toIso8601String(),
'suggested_message' => 'Hi! Just checking if you had a chance to look at the menu I sent over. Let me know if you have any questions!',
],
]);
$count++;
}
return $count;
}
/**
* Generate followup tasks for menus viewed but no order placed.
*/
protected function generateViewedNoOrderFollowups(Business $business, int $daysThreshold): int
{
$count = 0;
// Find MenuViewEvent rows where:
// - viewed_at is {daysThreshold} to {daysThreshold + 2} days ago
// - There was a SendMenuLog for that menu/customer earlier
// - There is no order yet (simplified: we just check if task exists)
// - There is no existing OrchestratorTask of type menu_followup_viewed_no_order in pending status
$cutoffStart = now()->subDays($daysThreshold + 2);
$cutoffEnd = now()->subDays($daysThreshold);
$viewEvents = MenuViewEvent::query()
->where('business_id', $business->id)
->whereBetween('viewed_at', [$cutoffStart, $cutoffEnd])
->whereNotNull('customer_id')
->whereNotNull('menu_id')
->get();
foreach ($viewEvents as $viewEvent) {
// Check if there was a SendMenuLog for this menu/customer
$sendLog = SendMenuLog::query()
->where('business_id', $business->id)
->where('menu_id', $viewEvent->menu_id)
->where('customer_id', $viewEvent->customer_id)
->where('sent_at', '<', $viewEvent->viewed_at)
->orderByDesc('sent_at')
->first();
if (! $sendLog) {
continue; // No prior send, this was a direct view
}
// Check if task already exists
$existingTask = OrchestratorTask::query()
->where('business_id', $business->id)
->where('customer_id', $viewEvent->customer_id)
->where('menu_id', $viewEvent->menu_id)
->where('type', OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER)
->where('status', OrchestratorTask::STATUS_PENDING)
->exists();
if ($existingTask) {
continue;
}
// For V1.3, we skip order checking (would need to hook into orders table)
// In future: check Order::where('business_id', $customer_id)->where('created_at', '>', $viewEvent->viewed_at)->exists()
// Create the followup task
OrchestratorTask::create([
'business_id' => $business->id,
'brand_id' => $viewEvent->brand_id,
'menu_id' => $viewEvent->menu_id,
'customer_id' => $viewEvent->customer_id,
'type' => OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER,
'status' => OrchestratorTask::STATUS_PENDING,
'due_at' => now(),
'payload' => [
'send_menu_log_id' => $sendLog->id,
'recipient_name' => $sendLog->meta['recipient_name'] ?? 'Unknown',
'recipient_type' => $sendLog->recipient_type,
'recipient_id' => $sendLog->recipient_id,
'channel' => $sendLog->channel,
'original_sent_at' => $sendLog->sent_at->toIso8601String(),
'viewed_at' => $viewEvent->viewed_at->toIso8601String(),
'suggested_message' => "I saw you checked out the menu I sent over. Is there anything specific you're looking for? Happy to help!",
],
]);
$count++;
}
return $count;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,464 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class ImportAlohaSales extends Command
{
protected $signature = 'import:aloha-sales {--dry-run : Show what would be imported without actually importing} {--force : Overwrite existing orders} {--skip-existing : Skip orders that already exist} {--limit= : Limit number of invoices to import}';
protected $description = 'Import Aloha TymeMachine sales history (invoices and customers) from remote MySQL';
private $mysqli;
private $stats = [
'total_invoices' => 0,
'imported_invoices' => 0,
'skipped_invoices' => 0,
'failed_invoices' => 0,
'customers_created' => 0,
'total_items' => 0,
];
private $customerCache = [];
public function handle()
{
$dryRun = $this->option('dry-run');
$force = $this->option('force');
$skipExisting = $this->option('skip-existing');
$limit = $this->option('limit');
if ($dryRun) {
$this->warn('🔍 DRY RUN MODE - No data will be imported');
}
$this->info('🚀 Starting Aloha TymeMachine Sales Import');
$this->newLine();
// Connect to remote MySQL
$this->info('📡 Connecting to remote MySQL database...');
$this->mysqli = new \mysqli('sql1.creationshop.net', 'claude', 'claude', 'hub_cannabrands');
if ($this->mysqli->connect_error) {
$this->error('Failed to connect: '.$this->mysqli->connect_error);
return 1;
}
$this->info('✓ Connected to remote MySQL');
$this->newLine();
// Get all invoices with Aloha TymeMachine products (brand_id = 11)
$this->info('📦 Fetching invoices with Aloha TymeMachine products...');
$query = '
SELECT DISTINCT i.id
FROM invoices i
INNER JOIN invoice_lines il ON i.id = il.invoice_id
INNER JOIN products p ON il.product_id = p.id
WHERE p.brand_id = 11
AND i.deleted_at IS NULL
ORDER BY i.id
';
if ($limit) {
$query .= ' LIMIT '.(int) $limit;
}
$result = $this->mysqli->query($query);
$invoiceIds = [];
while ($row = $result->fetch_assoc()) {
$invoiceIds[] = $row['id'];
}
$this->stats['total_invoices'] = count($invoiceIds);
$this->info("Found {$this->stats['total_invoices']} invoices with Aloha TymeMachine products");
$this->newLine();
if (! $dryRun && ! $force && ! $skipExisting) {
if (! $this->confirm('This will import all invoices and customers. Continue?', true)) {
$this->warn('Import cancelled');
return 0;
}
$this->newLine();
}
// Import each invoice
$progressBar = $this->output->createProgressBar($this->stats['total_invoices']);
$progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
$progressBar->setMessage('Starting...');
foreach ($invoiceIds as $invoiceId) {
$progressBar->setMessage("Invoice #{$invoiceId}");
try {
$result = $this->importInvoice($invoiceId, $dryRun, $force, $skipExisting);
if ($result === 'imported') {
$this->stats['imported_invoices']++;
} elseif ($result === 'skipped') {
$this->stats['skipped_invoices']++;
}
} catch (\Exception $e) {
$this->stats['failed_invoices']++;
$progressBar->clear();
$this->error("Failed to import invoice #{$invoiceId}: {$e->getMessage()}");
$progressBar->display();
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
// Show summary
$this->info('📊 Import Summary:');
$this->table(
['Metric', 'Count'],
[
['Total Invoices', $this->stats['total_invoices']],
['✓ Imported', $this->stats['imported_invoices']],
['⊘ Skipped', $this->stats['skipped_invoices']],
['✗ Failed', $this->stats['failed_invoices']],
['Customers Created', $this->stats['customers_created']],
['Order Items Created', $this->stats['total_items']],
]
);
$this->mysqli->close();
return $this->stats['failed_invoices'] > 0 ? 1 : 0;
}
private function importInvoice(int $invoiceId, bool $dryRun, bool $force, bool $skipExisting): string
{
// Fetch invoice from remote
$result = $this->mysqli->query("SELECT * FROM invoices WHERE id = {$invoiceId}");
$remote = $result->fetch_assoc();
if (! $remote) {
throw new \Exception("Invoice #{$invoiceId} not found in remote database");
}
// Check if already exists
if (Order::where('id', $invoiceId)->exists()) {
if ($skipExisting) {
return 'skipped';
}
if (! $force && ! $dryRun) {
return 'skipped';
}
if (! $dryRun && $force) {
// Force delete existing order and items (hard delete, not soft delete)
DB::table('order_items')->where('order_id', $invoiceId)->delete();
Order::where('id', $invoiceId)->forceDelete();
}
}
if ($dryRun) {
return 'imported';
}
// Get or create customer business
$customer = $this->findOrCreateCustomer($remote['organisation_id'], $dryRun);
if (! $customer) {
throw new \Exception("Failed to create customer for organisation #{$remote['organisation_id']}");
}
// Get Cannabrands business (seller)
$seller = Business::where('slug', 'cannabrands')->first();
if (! $seller) {
throw new \Exception('Cannabrands business not found');
}
// Get first user for this business to assign as order creator
$user = $customer->users()->first();
if (! $user) {
throw new \Exception("No user found for customer business #{$customer->id}");
}
// Get invoice lines
$linesResult = $this->mysqli->query("
SELECT il.*, p.brand_id
FROM invoice_lines il
INNER JOIN products p ON il.product_id = p.id
WHERE il.invoice_id = {$invoiceId}
AND il.deleted_at IS NULL
");
$invoiceLines = [];
while ($line = $linesResult->fetch_assoc()) {
$invoiceLines[] = $line;
}
// Create order
$order = new Order;
$order->id = $invoiceId;
$order->business_id = $customer->id; // Buyer business
$order->user_id = $user->id; // User who placed the order
$order->order_number = $remote['invoice_id'] ?? "ALOHA-{$invoiceId}";
$order->status = $this->mapStatus($remote['status']);
$order->subtotal = ($remote['subtotal'] ?? 0) / 100; // Convert cents to dollars
$order->tax = ($remote['tax'] ?? 0) / 100;
$order->total = ($remote['total'] ?? 0) / 100;
$order->notes = $this->sanitizeUtf8($remote['comments']);
$order->payment_terms = $this->sanitizeUtf8($remote['terms']);
$order->delivery_method = 'pickup'; // Default
$order->timestamps = false;
$order->created_at = $remote['created_at'];
$order->updated_at = $remote['updated_at'];
$order->save();
// Create order items
$itemCount = 0;
foreach ($invoiceLines as $line) {
// Find the product locally - map by remote product_id
// Note: The remote product_id may not match the local product_id
// We need to find the local product by SKU (code from remote)
$remoteProduct = $this->mysqli->query("SELECT code, name FROM products WHERE id = {$line['product_id']}")->fetch_assoc();
if (! $remoteProduct) {
continue;
}
// Find local product by SKU and ensure it's Aloha brand
$localBrand = Brand::where('name', 'Aloha TymeMachine')->first();
if (! $localBrand) {
continue;
}
$product = Product::where('sku', $remoteProduct['code'])
->where('brand_id', $localBrand->id)
->first();
if (! $product) {
continue; // Skip products not imported
}
// Calculate line_total (amount + tax)
$amount = (($line['amount'] ?? 0) / 100);
$tax = (($line['tax_amount'] ?? 0) / 100);
$lineTotal = $amount + $tax;
$orderItem = new OrderItem;
$orderItem->order_id = $order->id;
$orderItem->product_id = $product->id; // Use local product ID
$orderItem->quantity = (int) ($line['quantity'] ?? 1); // Cast to integer
$orderItem->unit_price = $line['price'] ?? 0;
$orderItem->line_total = $lineTotal;
// Product snapshot fields
$orderItem->product_name = $product->name;
$orderItem->product_sku = $product->sku;
$orderItem->brand_name = $product->brand->name ?? 'Aloha TymeMachine';
$orderItem->timestamps = false;
$orderItem->created_at = $line['created_at'];
$orderItem->updated_at = $line['updated_at'];
$orderItem->save();
$itemCount++;
}
$this->stats['total_items'] += $itemCount;
return 'imported';
}
private function findOrCreateCustomer(int $organisationId, bool $dryRun): ?Business
{
// Check cache first
if (isset($this->customerCache[$organisationId])) {
return $this->customerCache[$organisationId];
}
// Check if already imported
$mapping = DB::table('remote_customer_mappings')
->where('remote_organisation_id', $organisationId)
->first();
if ($mapping) {
$business = Business::find($mapping->business_id);
if ($business) {
// Ensure business has at least one user
if ($business->users()->count() == 0) {
$this->createUserForBusiness($business);
}
$this->customerCache[$organisationId] = $business;
return $business;
}
}
// Fetch from remote
$result = $this->mysqli->query("SELECT * FROM companies WHERE id = {$organisationId}");
$remote = $result->fetch_assoc();
if (! $remote) {
return null;
}
if ($dryRun) {
return new Business(['name' => $remote['name']]);
}
// Check if business already exists by slug
$slug = Str::slug($remote['name']);
$business = Business::where('slug', $slug)->first();
if ($business) {
// Business already exists, create mapping and return it
// Ensure it has a user
if ($business->users()->count() == 0) {
$this->createUserForBusiness($business);
}
// Create mapping if it doesn't exist
$existingMapping = DB::table('remote_customer_mappings')
->where('business_id', $business->id)
->where('remote_organisation_id', $organisationId)
->exists();
if (! $existingMapping) {
DB::table('remote_customer_mappings')->insert([
'business_id' => $business->id,
'remote_organisation_id' => $organisationId,
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->customerCache[$organisationId] = $business;
return $business;
}
// Create new business
$business = new Business;
$business->name = $this->sanitizeUtf8($remote['name']);
$business->slug = Str::slug($remote['name']);
$business->type = 'buyer';
$business->status = 'approved';
$business->is_active = true;
$business->onboarding_completed = true;
$business->tax_rate = 0;
$business->tax_exempt = false;
$business->has_analytics = false;
$business->has_marketing = false;
$business->has_manufacturing = false;
$business->has_processing = false;
// Map address if available
if (! empty($remote['address'])) {
$business->physical_address = $this->sanitizeUtf8($remote['address']);
}
if (! empty($remote['city'])) {
$business->physical_city = $this->sanitizeUtf8($remote['city']);
}
if (! empty($remote['state'])) {
$business->physical_state = $this->sanitizeUtf8($remote['state']);
}
if (! empty($remote['zipcode'])) {
$business->physical_zipcode = $this->sanitizeUtf8($remote['zipcode']);
}
$business->save();
// Create a default user for this business
$this->createUserForBusiness($business);
// Create mapping
DB::table('remote_customer_mappings')->insert([
'business_id' => $business->id,
'remote_organisation_id' => $organisationId,
'created_at' => now(),
'updated_at' => now(),
]);
$this->stats['customers_created']++;
$this->customerCache[$organisationId] = $business;
return $business;
}
private function mapStatus(?string $remoteStatus): string
{
// Map remote invoice status to local order status
// Valid local statuses: new, buyer_modified, seller_modified, accepted, in_progress,
// ready_for_invoice, awaiting_invoice_approval, ready_for_manifest, ready_for_delivery,
// delivered, cancelled, rejected
$statusMap = [
'draft' => 'new', // Order just created
'sent' => 'accepted', // Order sent to customer, accepted
'paid' => 'delivered', // Payment received, order completed
'partial' => 'in_progress', // Partially paid/fulfilled
'overdue' => 'accepted', // Still active but overdue
];
return $statusMap[$remoteStatus] ?? 'new';
}
/**
* Create a default user for a business
*/
private function createUserForBusiness(Business $business): User
{
$user = new User;
$user->first_name = 'System';
$user->last_name = 'User';
$user->email = 'system+'.$business->slug.'@imported.local';
$user->password = Hash::make(Str::random(32)); // Random password
$user->user_type = 'buyer';
$user->email_verified_at = now();
$user->save();
// Attach user to business
$user->businesses()->attach($business->id);
return $user;
}
/**
* Sanitize text from MySQL (Windows-1252 encoding) to proper UTF-8
*/
private function sanitizeUtf8(?string $text): ?string
{
if (! $text) {
return $text;
}
// First, try to detect the encoding
$encoding = mb_detect_encoding($text, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true);
// If already UTF-8 and valid, return as-is
if ($encoding === 'UTF-8' && mb_check_encoding($text, 'UTF-8')) {
return $text;
}
// Try to convert from Windows-1252 to UTF-8
$converted = @iconv('Windows-1252', 'UTF-8//TRANSLIT//IGNORE', $text);
// If iconv fails, fall back to mb_convert_encoding
if ($converted === false) {
$converted = mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
}
// Final cleanup: remove any remaining invalid UTF-8 sequences
$converted = mb_convert_encoding($converted, 'UTF-8', 'UTF-8');
return $converted;
}
}

View File

@@ -1,289 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
class ImportBrandFromMySQL extends Command
{
protected $signature = 'brand:import-from-mysql {remoteName? : Remote brand name} {localName? : Local brand name (if different)}';
protected $description = 'Import brand data and images from remote MySQL database';
public function handle()
{
$remoteBrandName = $this->argument('remoteName') ?? 'Canna';
$localBrandName = $this->argument('localName') ?? $remoteBrandName;
$this->info('Connecting to remote MySQL database...');
try {
// Connect to remote MySQL with latin1 charset (Windows-1252)
$pdo = new \PDO(
'mysql:host=sql1.creationshop.net;dbname=hub_cannabrands;charset=latin1',
'claude',
'claude'
);
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$this->info('Connected successfully!');
// Fetch brand data from MySQL
$stmt = $pdo->prepare('
SELECT brand_id, name, tagline, short_desc, `desc`, url,
image, banner, address, unit_number, city, state, zip, phone,
public, fb, insta, twitter, youtube
FROM brands
WHERE name = :name
');
$stmt->execute(['name' => $remoteBrandName]);
$remoteBrand = $stmt->fetch(\PDO::FETCH_ASSOC);
if (! $remoteBrand) {
$this->error("Brand '{$remoteBrandName}' not found in remote database");
return 1;
}
$this->info("Found remote brand: {$remoteBrand['name']}");
// Find local brand by name
$localBrand = Brand::where('name', $localBrandName)->first();
if (! $localBrand) {
$this->error("Brand '{$localBrandName}' not found in local database");
$this->info('Available brands: '.Brand::pluck('name')->implode(', '));
return 1;
}
$this->info("Found local brand: {$localBrand->name} (ID: {$localBrand->id})");
// Create brands directory if it doesn't exist
if (! Storage::disk('public')->exists('brands')) {
Storage::disk('public')->makeDirectory('brands');
$this->info('Created brands directory');
}
// Initialize Intervention Image
$manager = new ImageManager(new Driver);
// Process logo image with thumbnails (save as PNG for transparency support)
if ($remoteBrand['image']) {
$logoPath = "brands/{$localBrand->slug}-logo.png";
// Read and process the original image
$originalImage = $manager->read($remoteBrand['image']);
// Try to remove white background by making white pixels transparent
// Sample corners to detect if background is white
$width = $originalImage->width();
$height = $originalImage->height();
// Use GD to manipulate pixels
$gdImage = imagecreatefromstring($remoteBrand['image']);
if ($gdImage !== false) {
// Enable alpha blending
imagealphablending($gdImage, false);
imagesavealpha($gdImage, true);
// Make white and near-white pixels transparent
for ($x = 0; $x < imagesx($gdImage); $x++) {
for ($y = 0; $y < imagesy($gdImage); $y++) {
$rgb = imagecolorat($gdImage, $x, $y);
$colors = imagecolorsforindex($gdImage, $rgb);
// If pixel is white or very close to white (RGB > 245)
if ($colors['red'] > 245 && $colors['green'] > 245 && $colors['blue'] > 245) {
$transparent = imagecolorallocatealpha($gdImage, 255, 255, 255, 127);
imagesetpixel($gdImage, $x, $y, $transparent);
}
}
}
// Save as PNG
ob_start();
imagepng($gdImage);
$processedData = ob_get_clean();
imagedestroy($gdImage);
Storage::disk('public')->put($logoPath, $processedData);
$originalImage = $manager->read($processedData);
} else {
// Fallback: save original as PNG
Storage::disk('public')->put($logoPath, $originalImage->toPng());
}
// Generate thumbnails optimized for retina displays (PNG for transparency)
// Thumbnail (160x160) for list views (2x retina at 80px)
$thumbRetina = clone $originalImage;
$thumbRetina->scale(width: 160);
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-thumb.png", $thumbRetina->toPng());
// Medium (600x600) for product cards (2x retina at 300px)
$mediumRetina = clone $originalImage;
$mediumRetina->scale(width: 600);
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-medium.png", $mediumRetina->toPng());
// Large (1600x1600) for detail views
$largeRetina = clone $originalImage;
$largeRetina->scale(width: 1600);
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-large.png", $largeRetina->toPng());
$localBrand->logo_path = $logoPath;
$this->info("✓ Saved logo + thumbnails: {$logoPath} (".strlen($remoteBrand['image']).' bytes)');
}
// Process banner image with thumbnails
if ($remoteBrand['banner']) {
$bannerPath = "brands/{$localBrand->slug}-banner.jpg";
// Save original
Storage::disk('public')->put($bannerPath, $remoteBrand['banner']);
// Generate banner thumbnails if banner is large enough
if (strlen($remoteBrand['banner']) > 1000) {
$image = $manager->read($remoteBrand['banner']);
// Medium banner (1344px wide) for retina displays at 672px
$mediumBanner = clone $image;
$mediumBanner->scale(width: 1344);
Storage::disk('public')->put("brands/{$localBrand->slug}-banner-medium.jpg", $mediumBanner->toJpeg(quality: 92));
// Large banner (2560px wide) for full-width hero sections
$largeBanner = clone $image;
$largeBanner->scale(width: 2560);
Storage::disk('public')->put("brands/{$localBrand->slug}-banner-large.jpg", $largeBanner->toJpeg(quality: 92));
}
$localBrand->banner_path = $bannerPath;
$this->info("✓ Saved banner + thumbnails: {$bannerPath} (".strlen($remoteBrand['banner']).' bytes)');
}
// Helper function to sanitize text (convert Windows-1252 to UTF-8)
$sanitize = function ($text) {
if (! $text) {
return $text;
}
// First, convert from Windows-1252/ISO-8859-1 to UTF-8
$text = mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
// Replace common Windows-1252 special characters with standard equivalents
$replacements = [
"\xE2\x80\x98" => "'", // Left single quote
"\xE2\x80\x99" => "'", // Right single quote (apostrophe)
"\xE2\x80\x9C" => '"', // Left double quote
"\xE2\x80\x9D" => '"', // Right double quote
"\xE2\x80\x93" => '-', // En dash
"\xE2\x80\x94" => '-', // Em dash
"\xE2\x80\x26" => '...', // Ellipsis
];
$text = str_replace(array_keys($replacements), array_values($replacements), $text);
return trim($text);
};
// Update other brand fields
$updates = [];
if ($remoteBrand['tagline']) {
$localBrand->tagline = $sanitize($remoteBrand['tagline']);
$updates[] = 'tagline';
}
if ($remoteBrand['short_desc']) {
$localBrand->description = $sanitize($remoteBrand['short_desc']);
$updates[] = 'description';
}
if ($remoteBrand['desc']) {
$localBrand->long_description = $sanitize($remoteBrand['desc']);
$updates[] = 'long_description';
}
if ($remoteBrand['url']) {
$localBrand->website_url = $remoteBrand['url'];
$updates[] = 'website_url';
}
// Address fields
if ($remoteBrand['address']) {
$localBrand->address = $remoteBrand['address'];
$updates[] = 'address';
}
if ($remoteBrand['unit_number']) {
$localBrand->unit_number = $remoteBrand['unit_number'];
$updates[] = 'unit_number';
}
if ($remoteBrand['city']) {
$localBrand->city = $remoteBrand['city'];
$updates[] = 'city';
}
if ($remoteBrand['state']) {
$localBrand->state = $remoteBrand['state'];
$updates[] = 'state';
}
if ($remoteBrand['zip']) {
$localBrand->zip_code = $remoteBrand['zip'];
$updates[] = 'zip_code';
}
if ($remoteBrand['phone']) {
$localBrand->phone = $remoteBrand['phone'];
$updates[] = 'phone';
}
// Social media
if ($remoteBrand['fb']) {
$localBrand->facebook_url = 'https://facebook.com/'.$remoteBrand['fb'];
$updates[] = 'facebook_url';
}
if ($remoteBrand['insta']) {
$localBrand->instagram_handle = $remoteBrand['insta'];
$updates[] = 'instagram_handle';
}
if ($remoteBrand['twitter']) {
$localBrand->twitter_handle = $remoteBrand['twitter'];
$updates[] = 'twitter_handle';
}
if ($remoteBrand['youtube']) {
$localBrand->youtube_url = $remoteBrand['youtube'];
$updates[] = 'youtube_url';
}
// Visibility
$localBrand->is_public = (bool) $remoteBrand['public'];
$updates[] = 'is_public';
// Save the brand
$localBrand->save();
$this->info("\n✓ Successfully imported brand data!");
$this->info('Updated fields: '.implode(', ', $updates));
$this->newLine();
$this->info('View the brand at:');
$this->line("http://localhost/s/cannabrands/brands/{$localBrand->hashid}/edit");
} catch (\Exception $e) {
$this->error('Error: '.$e->getMessage());
return 1;
}
return 0;
}
}

View File

@@ -1,144 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Product;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class ImportProductsFromRemote extends Command
{
protected $signature = 'import:products-from-remote {--business=cannabrands}';
protected $description = 'Import products and SKUs from remote MySQL database';
public function handle()
{
// Configure remote MySQL connection
config(['database.connections.remote_mysql' => [
'driver' => 'mysql',
'host' => 'sql1.creationshop.net',
'port' => '3306',
'database' => 'hub_cannabrands',
'username' => 'claude',
'password' => 'claude',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
]]);
$this->info('🔗 Connected to remote MySQL database');
$this->newLine();
// Get or create the local business
$businessSlug = $this->option('business');
$localBusiness = Business::where('slug', $businessSlug)->first();
if (! $localBusiness) {
$this->error("Business with slug '{$businessSlug}' not found in local database.");
$this->info('Available businesses:');
Business::all()->each(fn ($b) => $this->line(" - {$b->slug}"));
return 1;
}
$this->info("📦 Importing products for: {$localBusiness->name}");
$this->newLine();
// Get all brands from remote database
$remoteBrands = DB::connection('remote_mysql')
->table('brands')
->whereNotNull('name')
->get();
$this->info("Found {$remoteBrands->count()} brands in remote database");
$this->newLine();
$brandMap = [];
$importedBrands = 0;
$importedProducts = 0;
foreach ($remoteBrands as $remoteBrand) {
// Create or update brand in local database
$localBrand = Brand::updateOrCreate(
[
'business_id' => $localBusiness->id,
'name' => $remoteBrand->name,
],
[
'slug' => Str::slug($remoteBrand->name),
'tagline' => $remoteBrand->tagline,
'description' => $remoteBrand->desc ?? $remoteBrand->short_desc,
'website_url' => $remoteBrand->url ? 'https://'.ltrim($remoteBrand->url, 'https://') : null,
'is_public' => (bool) $remoteBrand->public,
'is_active' => true,
]
);
$brandMap[$remoteBrand->brand_id] = $localBrand->id;
$importedBrands++;
$this->line(" ✓ Brand: {$localBrand->name}");
// Get products for this brand
$remoteProducts = DB::connection('remote_mysql')
->table('products')
->where('brand_id', $remoteBrand->brand_id)
->where('active', 1)
->whereNotNull('code')
->get();
foreach ($remoteProducts as $remoteProduct) {
try {
// Create or update product (skip strain_id foreign key for now)
Product::updateOrCreate(
[
'brand_id' => $localBrand->id,
'sku' => $remoteProduct->code,
],
[
'name' => $remoteProduct->name,
'description' => $remoteProduct->description,
'price' => $remoteProduct->wholesale_price ?? 0,
'cost' => $remoteProduct->cost ?? 0,
'is_active' => (bool) $remoteProduct->active,
'unit_id' => null, // Units will need to be mapped separately
'strain_id' => null, // Strains will need to be imported separately
]
);
$importedProducts++;
} catch (\Illuminate\Database\UniqueConstraintViolationException $e) {
// Skip products with slug conflicts (already exist for different brand)
$this->warn(" ⚠ Skipped '{$remoteProduct->name}' (slug conflict)");
continue;
}
}
if ($remoteProducts->count() > 0) {
$this->line(" → Imported {$remoteProducts->count()} products");
}
}
$this->newLine();
$this->info('✅ Import Complete!');
$this->table(
['Metric', 'Count'],
[
['Brands Imported', $importedBrands],
['Products Imported', $importedProducts],
]
);
$this->newLine();
$this->info('📊 You can now view real SKU data in the brand stats dashboard!');
return 0;
}
}

View File

@@ -1,474 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use App\Models\Product;
use App\Models\ProductImage;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ImportThunderBudBulk extends Command
{
protected $signature = 'import:thunderbud-bulk {--dry-run : Show what would be imported without actually importing} {--force : Overwrite existing products} {--skip-existing : Skip products that already exist} {--limit= : Limit number of products to import}';
protected $description = 'Import all Thunder Bud products from remote MySQL database';
private $mysqli;
private $stats = [
'total' => 0,
'imported' => 0,
'skipped' => 0,
'failed' => 0,
];
private $productLineCache = [];
public function handle()
{
$dryRun = $this->option('dry-run');
$force = $this->option('force');
$skipExisting = $this->option('skip-existing');
$limit = $this->option('limit');
if ($dryRun) {
$this->warn('🔍 DRY RUN MODE - No data will be imported');
}
$this->info('🚀 Starting Thunder Bud Bulk Product Import');
$this->newLine();
// Connect to remote MySQL
$this->info('📡 Connecting to remote MySQL database...');
$this->mysqli = new \mysqli('sql1.creationshop.net', 'claude', 'claude', 'hub_cannabrands');
if ($this->mysqli->connect_error) {
$this->error('Failed to connect: '.$this->mysqli->connect_error);
return 1;
}
$this->info('✓ Connected to remote MySQL');
$this->newLine();
// Get all Thunder Bud products
$this->info('📦 Fetching Thunder Bud products (brand_id = 6)...');
// Order by parent_product_id so parent products (NULL) are imported first
$query = 'SELECT id FROM products WHERE brand_id = 6 ORDER BY parent_product_id IS NULL DESC, parent_product_id, id';
if ($limit) {
$query .= ' LIMIT '.(int) $limit;
}
$result = $this->mysqli->query($query);
$productIds = [];
while ($row = $result->fetch_assoc()) {
$productIds[] = $row['id'];
}
$this->stats['total'] = count($productIds);
$this->info("Found {$this->stats['total']} products to import");
$this->newLine();
if (! $dryRun && ! $force && ! $skipExisting) {
if (! $this->confirm('This will import all products. Continue?', true)) {
$this->warn('Import cancelled');
return 0;
}
$this->newLine();
}
// Import each product
$progressBar = $this->output->createProgressBar($this->stats['total']);
$progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
$progressBar->setMessage('Starting...');
foreach ($productIds as $productId) {
$progressBar->setMessage("Product #{$productId}");
try {
$result = $this->importProduct($productId, $dryRun, $force, $skipExisting);
if ($result === 'imported') {
$this->stats['imported']++;
} elseif ($result === 'skipped') {
$this->stats['skipped']++;
}
} catch (\Exception $e) {
$this->stats['failed']++;
$progressBar->clear();
$this->error("Failed to import product #{$productId}: {$e->getMessage()}");
$progressBar->display();
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
// Show summary
$this->info('📊 Import Summary:');
$this->table(
['Status', 'Count'],
[
['Total Products', $this->stats['total']],
['✓ Imported', $this->stats['imported']],
['⊘ Skipped', $this->stats['skipped']],
['✗ Failed', $this->stats['failed']],
]
);
$this->mysqli->close();
return $this->stats['failed'] > 0 ? 1 : 0;
}
private function importProduct(int $productId, bool $dryRun, bool $force, bool $skipExisting): string
{
// Fetch product from remote
$result = $this->mysqli->query("SELECT * FROM products WHERE id = {$productId}");
$remote = $result->fetch_assoc();
if (! $remote) {
throw new \Exception("Product #{$productId} not found in remote database");
}
// Check if this product is a variety (has a parent)
$isVariety = ! empty($remote['parent_product_id']);
$parentProductId = $remote['parent_product_id'];
if ($isVariety) {
// Check if parent product exists locally
$parentProduct = Product::find($parentProductId);
if (! $parentProduct) {
// Parent not imported yet - skip this variety for now
// It will be imported in a second pass or when parent is imported
return 'skipped';
}
}
// Check if already exists
if (Product::where('id', $productId)->exists()) {
if ($skipExisting) {
return 'skipped';
}
if (! $force && ! $dryRun) {
return 'skipped';
}
if (! $dryRun && $force) {
// Store existing hashid to preserve it
$existingHashid = Product::where('id', $productId)->value('hashid');
// Force delete product and related records (hard delete)
DB::table('product_images')->where('product_id', $productId)->delete();
Product::where('id', $productId)->forceDelete();
}
} else {
$existingHashid = null;
}
if ($dryRun) {
return 'imported';
}
// Get category mapping
$categoryMapping = $this->getCategoryMapping($remote['product_category_id']);
// Get descriptions from both tables with UTF-8 sanitization
$description = $this->sanitizeUtf8(ltrim($remote['description'] ?? '', '? '));
// Parse out "Thunder Bud {Name}: {tagline}" format to extract just the tagline
// Example: "Thunder Bud Violet Meadows: Floral calm, sweet vibes" → "Floral calm, sweet vibes"
if ($description && preg_match('/^Thunder Bud .+?:\s*(.+)$/s', $description, $matches)) {
$description = trim($matches[1]);
}
// Get long description from product_extras
$longDescription = null;
$extrasResult = $this->mysqli->query("SELECT long_description FROM product_extras WHERE product_id = {$productId}");
if ($extrasResult && $extra = $extrasResult->fetch_assoc()) {
$longDescription = $this->sanitizeUtf8($extra['long_description']);
}
// Get unit from remote units table
$remoteUnit = null;
if ($remote['unit']) {
$unitResult = $this->mysqli->query("SELECT unit FROM units WHERE id = {$remote['unit']}");
if ($unitResult && $unitRow = $unitResult->fetch_assoc()) {
$remoteUnit = $unitRow['unit'];
}
}
// Map remote unit abbreviation to local
$unitAbbr = null;
if ($remoteUnit) {
$remoteToLocalUnit = [
'GM' => 'g',
'EA' => 'ea',
'OZ' => 'oz',
'LB' => 'lb',
];
$unitAbbr = $remoteToLocalUnit[$remoteUnit] ?? strtolower($remoteUnit);
}
// Extract and save image BLOB
$imagePath = null;
if ($remote['product_image']) {
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->buffer($remote['product_image']);
$extension = match ($mimeType) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
default => 'jpg'
};
$slug = Str::slug($remote['name']);
$imagePath = "businesses/cannabrands/products/{$productId}/{$slug}.{$extension}";
Storage::put($imagePath, $remote['product_image']);
}
// Get brand
$brand = Brand::find(6); // Thunder Bud
// Map type to unit if not set
if (! $unitAbbr) {
$unitMapping = [
'pre_roll' => 'ea',
'flower' => 'g',
'concentrate' => 'g',
];
$type = $categoryMapping['type'];
$unitAbbr = $unitMapping[$type] ?? 'ea';
}
// Find or create product line from child category name
$productLineName = $categoryMapping['child_category_name'];
$productLine = $this->findOrCreateProductLine($brand->business_id, $productLineName);
// Find unit
$unit = DB::table('units')->where('abbreviation', $unitAbbr)->first();
// Check for varieties
$varietiesResult = $this->mysqli->query("SELECT COUNT(*) as count FROM products WHERE parent_product_id = {$productId} AND deleted_at IS NULL");
$varietiesCount = $varietiesResult->fetch_assoc()['count'];
$hasVarieties = $varietiesCount > 0;
// Create product
$product = new Product;
$product->id = $productId;
$product->brand_id = 6; // Thunder Bud local brand
$product->name = $this->sanitizeUtf8($remote['name']);
// Handle slug - varieties need unique slugs
$baseSlug = Str::slug($remote['name']);
if ($isVariety) {
// Append product ID to make variety slug unique
$product->slug = $baseSlug.'-'.$productId;
} else {
$product->slug = $baseSlug;
}
// Handle SKU - varieties need unique SKUs
$baseSku = $this->sanitizeUtf8($remote['code']) ?? 'TB-'.Str::upper(Str::random(6));
if ($isVariety) {
// Append product ID to make variety SKU unique
$product->sku = $baseSku.'-'.$productId;
$product->parent_product_id = $parentProductId;
} else {
$product->sku = $baseSku;
}
$product->description = $description;
$product->long_description = $longDescription;
$product->type = $categoryMapping['type'];
$product->subcategory = $categoryMapping['parent_category_name'];
$product->status = $remote['active'] ? 'available' : 'unavailable';
$product->is_active = (bool) $remote['active'];
$product->wholesale_price = $remote['wholesale_price'] ?? 0;
$product->image_path = $imagePath;
$product->product_link = $this->sanitizeUtf8($remote['product_link']);
$product->creatives = $this->sanitizeUtf8($remote['creatives']);
$product->brand_display_order = (int) $remote['brand_display_order'];
$product->product_line_id = $productLine->id ?? null;
$product->unit_id = $unit->id ?? null;
$product->has_varieties = $hasVarieties;
// Set defaults for required fields
$product->is_featured = false;
$product->is_assembly = false;
$product->is_raw_material = false;
$product->price_unit = 'unit';
$product->weight_unit = 'g';
$product->sort_order = 0;
$product->sell_multiples = false;
$product->fractional_quantities = false;
$product->allow_sample = false;
$product->is_fpr = false;
$product->is_sellable = true;
$product->is_case = false;
$product->cased_qty = 0;
$product->is_box = false;
$product->boxed_qty = 0;
$product->show_inventory_to_buyers = true;
$product->sync_bamboo = false;
$product->timestamps = false;
$product->created_at = $remote['created_at'];
$product->updated_at = $remote['updated_at'];
$product->save();
// Restore existing hashid to preserve URLs
if ($existingHashid) {
$product->hashid = $existingHashid;
$product->save();
}
// Update parent product if this is a variety
if ($isVariety && isset($parentProduct)) {
if (! $parentProduct->has_varieties) {
$parentProduct->has_varieties = true;
$parentProduct->save();
}
}
// Create ProductImage record
if ($imagePath) {
$productImage = new ProductImage;
$productImage->product_id = $product->id;
$productImage->path = $imagePath;
$productImage->type = 'image';
$productImage->is_primary = true;
$productImage->sort_order = 0;
$productImage->order = 0;
$productImage->save();
}
return 'imported';
}
private function findOrCreateProductLine(int $businessId, string $name): ?\stdClass
{
// Check cache first
$cacheKey = "{$businessId}:{$name}";
if (isset($this->productLineCache[$cacheKey])) {
return $this->productLineCache[$cacheKey];
}
// Find or create
$productLine = DB::table('product_lines')
->where('business_id', $businessId)
->where('name', $name)
->first();
if (! $productLine) {
$productLineId = DB::table('product_lines')->insertGetId([
'business_id' => $businessId,
'name' => $name,
'created_at' => now(),
'updated_at' => now(),
]);
$productLine = (object) ['id' => $productLineId];
}
// Cache it
$this->productLineCache[$cacheKey] = $productLine;
return $productLine;
}
private function getCategoryMapping(?int $categoryId): array
{
if (! $categoryId) {
return [
'type' => 'pre_roll',
'category_name' => 'Unknown',
'parent_category_name' => 'Unknown',
'child_category_name' => 'Unknown',
];
}
// Fetch category
$result = $this->mysqli->query("SELECT * FROM product_categories WHERE id = {$categoryId}");
$category = $result->fetch_assoc();
if (! $category) {
return [
'type' => 'pre_roll',
'category_name' => 'Unknown',
'parent_category_name' => 'Unknown',
'child_category_name' => 'Unknown',
];
}
$childCategoryName = $category['name'];
$parentCategoryName = $category['name']; // Default to same if no parent
if ($category['parent_id']) {
$parentResult = $this->mysqli->query("SELECT name FROM product_categories WHERE id = {$category['parent_id']}");
$parent = $parentResult->fetch_assoc();
if ($parent) {
$parentCategoryName = $parent['name'];
}
}
// Map parent category to type
$categoryToType = [
'Pre-Rolls' => 'pre_roll',
'Flower' => 'flower',
'Concentrates' => 'concentrate',
'Edibles' => 'edible',
];
$type = $categoryToType[$parentCategoryName] ?? 'pre_roll';
return [
'type' => $type,
'category_name' => $category['parent_id'] ? "{$parentCategoryName} / {$childCategoryName}" : $childCategoryName,
'parent_category_name' => $parentCategoryName,
'child_category_name' => $childCategoryName,
];
}
/**
* Sanitize text from MySQL (Windows-1252 encoding) to proper UTF-8
* Uses iconv for automatic conversion of all Windows-1252 characters
*/
private function sanitizeUtf8(?string $text): ?string
{
if (! $text) {
return $text;
}
// First, try to detect the encoding
$encoding = mb_detect_encoding($text, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true);
// If already UTF-8 and valid, just clean up corrupted emoji placeholders
if ($encoding === 'UTF-8' && mb_check_encoding($text, 'UTF-8')) {
return str_replace('??', '', $text);
}
// Try to convert from Windows-1252 to UTF-8
// Use //TRANSLIT to transliterate unsupported characters
// Use //IGNORE to skip characters that can't be converted
$converted = @iconv('Windows-1252', 'UTF-8//TRANSLIT//IGNORE', $text);
// If iconv fails, fall back to mb_convert_encoding
if ($converted === false) {
$converted = mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
}
// Final cleanup: remove any remaining invalid UTF-8 sequences
$converted = mb_convert_encoding($converted, 'UTF-8', 'UTF-8');
// Remove corrupted emoji placeholders (literal "??" characters from source data)
$converted = str_replace('??', '', $converted);
return $converted;
}
}

View File

@@ -1,564 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\ProductImage;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ImportThunderBudProduct extends Command
{
protected $signature = 'import:thunderbud-product {--dry-run : Show what would be imported without actually importing} {--regenerate-hashid : Generate new hashid instead of preserving existing one}';
protected $description = 'Import Thunder Bud Product #44 (Cap Junky) from remote MySQL with full sales history (Option B)';
private $mysqli;
public function handle()
{
$dryRun = $this->option('dry-run');
if ($dryRun) {
$this->warn('🔍 DRY RUN MODE - No data will be imported');
}
$this->info('🚀 Starting Thunder Bud Product Import (Option B: Full Chain)');
$this->newLine();
// Connect to remote MySQL
$this->info('📡 Connecting to remote MySQL database...');
$this->mysqli = new \mysqli('sql1.creationshop.net', 'claude', 'claude', 'hub_cannabrands');
if ($this->mysqli->connect_error) {
$this->error('Failed to connect: '.$this->mysqli->connect_error);
return 1;
}
$this->info('✓ Connected to remote MySQL');
$this->newLine();
// Step 1: Import Product
$this->info('📦 Step 1: Importing Product #44 (Cap Junky)...');
$product = $this->importProduct($dryRun);
if (! $product) {
$this->error('Failed to import product');
$this->mysqli->close();
return 1;
}
$this->newLine();
// Step 2: Import Customer
$this->info('👥 Step 2: Importing Customer #61 (Story)...');
$customer = $this->importCustomer($dryRun);
if (! $customer) {
$this->error('Failed to import customer');
$this->mysqli->close();
return 1;
}
$this->newLine();
// Step 3: Import Order
$this->info('📋 Step 3: Importing Invoice #293 as Order...');
$order = $this->importOrder($customer, $product, $dryRun);
if (! $order) {
$this->error('Failed to import order');
$this->mysqli->close();
return 1;
}
$this->newLine();
$this->mysqli->close();
// Summary
$this->info('✅ Import completed successfully!');
$this->newLine();
$this->table(
['Item', 'Status', 'Details'],
[
['Product', '✓', $product ? "ID: {$product->id} - {$product->name}" : 'N/A'],
['Image', '✓', $product && $product->image_path ? $product->image_path : 'N/A'],
['Customer', '✓', $customer ? "ID: {$customer->id} - {$customer->name}" : 'N/A'],
['Order', '✓', $order ? "ID: {$order->id} - {$order->order_number}" : 'N/A'],
['Order Items', '✓', $order ? $order->items()->count().' line items' : 'N/A'],
]
);
if (! $dryRun) {
$this->newLine();
$this->info('🔗 Verification URLs:');
$business = $product->brand->business;
$this->line('Product: '.route('seller.business.products.edit', [$business->slug, $product]));
$this->line('Order: '.route('seller.business.orders.show', [$business->slug, $order]));
}
return 0;
}
private function importProduct($dryRun): ?Product
{
// Fetch product from remote
$result = $this->mysqli->query('SELECT * FROM products WHERE id = 44');
$remote = $result->fetch_assoc();
if (! $remote) {
$this->error('Product #44 not found in remote database');
return null;
}
// Get category mapping
$categoryMapping = $this->getCategoryMapping($remote['product_category_id']);
// Check for varieties (child products)
$varietiesResult = $this->mysqli->query('SELECT COUNT(*) as count FROM products WHERE parent_product_id = 44 AND deleted_at IS NULL');
$varietiesCount = $varietiesResult->fetch_assoc()['count'];
$hasVarieties = $varietiesCount > 0;
// Get descriptions from both tables
$description = ltrim($remote['description'], '? '); // Short description
// Get long description from product_extras
$longDescription = null;
$extrasResult = $this->mysqli->query('SELECT long_description FROM product_extras WHERE product_id = 44');
if ($extrasResult && $extra = $extrasResult->fetch_assoc()) {
$longDescription = $extra['long_description'];
}
// Get unit from remote units table
$remoteUnit = null;
if ($remote['unit']) {
$unitResult = $this->mysqli->query("SELECT unit FROM units WHERE id = {$remote['unit']}");
if ($unitResult && $unitRow = $unitResult->fetch_assoc()) {
$remoteUnit = $unitRow['unit'];
}
}
// Map remote unit abbreviation to local
$unitAbbr = null;
if ($remoteUnit) {
// Map common remote abbreviations to local
$remoteToLocalUnit = [
'GM' => 'g',
'EA' => 'ea',
'OZ' => 'oz',
'LB' => 'lb',
];
$unitAbbr = $remoteToLocalUnit[$remoteUnit] ?? strtolower($remoteUnit);
}
// Preview the data
$this->newLine();
$this->info('📦 Product Preview:');
$this->table(
['Field', 'Value'],
[
['ID', $remote['id']],
['Name', $remote['name']],
['SKU', $remote['code']],
['Remote Category', $categoryMapping['category_name']],
[' → Type (mapped)', $categoryMapping['type']],
[' → Subcategory', $categoryMapping['parent_category_name']],
[' → Product Line', $categoryMapping['child_category_name']],
['Remote Unit', $remoteUnit ?? 'NULL'],
[' → Unit (mapped)', $unitAbbr ?? 'NULL'],
['Description (short)', substr($description ?? '', 0, 60).'...'],
['Long Description', $longDescription ? substr($longDescription, 0, 60).'...' : 'NULL'],
['Price', '$'.$remote['wholesale_price']],
['Has Image', $remote['product_image'] ? 'Yes ('.strlen($remote['product_image']).' bytes)' : 'No'],
['Has Varieties', $hasVarieties ? "Yes ($varietiesCount)" : 'No'],
['Active', $remote['active'] ? 'Yes' : 'No'],
['Brand Display Order', $remote['brand_display_order'] ?? 'NULL'],
]
);
// Check if already exists
$existingHashid = null;
if (Product::where('id', 44)->exists()) {
if (! $this->confirm('Product #44 already exists locally. Delete and re-import?', false)) {
$this->warn('Skipping product import');
return Product::find(44);
}
if (! $dryRun) {
// Store existing hashid to preserve it
$existingHashid = Product::where('id', 44)->value('hashid');
// Delete product and related records
DB::table('product_images')->where('product_id', 44)->delete();
Product::where('id', 44)->delete();
$this->info('✓ Deleted existing product');
}
}
if ($dryRun) {
$this->warn('[DRY RUN] Would import this product');
return new Product(['id' => 44, 'name' => $remote['name']]);
}
// Confirm import
if (! $this->confirm('Import this product?', true)) {
$this->warn('Import cancelled');
return null;
}
// Extract and save image BLOB
$imagePath = null;
if ($remote['product_image']) {
$this->line(' Extracting image BLOB ('.strlen($remote['product_image']).' bytes)...');
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->buffer($remote['product_image']);
$extension = match ($mimeType) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
default => 'jpg'
};
$imagePath = 'businesses/cannabrands/products/44/cap-junky.'.$extension;
Storage::put($imagePath, $remote['product_image']);
$this->info(" ✓ Saved image to: {$imagePath}");
}
// Get brand to find business for product line lookup
$brand = Brand::find(6); // Thunder Bud
// Map type to unit
$unitMapping = [
'pre_roll' => 'ea',
'flower' => 'g',
'concentrate' => 'g',
];
$type = $categoryMapping['type'];
$unitAbbr = $unitMapping[$type] ?? 'ea';
// Find or create product line from child category name
// Child category (e.g., "Non-Infused") becomes the product line
$productLineName = $categoryMapping['child_category_name'];
$productLine = DB::table('product_lines')
->where('business_id', $brand->business_id)
->where('name', $productLineName)
->first();
if (! $productLine) {
// Create new product line
$productLineId = DB::table('product_lines')->insertGetId([
'business_id' => $brand->business_id,
'name' => $productLineName,
'created_at' => now(),
'updated_at' => now(),
]);
$this->info(" ✓ Created new product line: {$productLineName} (ID: {$productLineId})");
$productLine = (object) ['id' => $productLineId];
}
// Find unit
$unit = DB::table('units')->where('abbreviation', $unitAbbr)->first();
// Create product
$product = new Product;
$product->id = 44; // Preserve remote ID
$product->brand_id = 6; // Thunder Bud local brand
$product->name = $remote['name'];
$product->slug = Str::slug($remote['name']);
$product->sku = $remote['code'] ?? 'TB-CJ-AZ1G';
$product->description = $description; // Short description
$product->long_description = $longDescription; // Long description from product_extras
$product->type = $type; // Mapped from category
$product->status = $remote['active'] ? 'available' : 'unavailable';
$product->is_active = (bool) $remote['active'];
$product->wholesale_price = $remote['wholesale_price'];
$product->image_path = $imagePath;
$product->product_link = $remote['product_link'];
$product->creatives = $remote['creatives'];
$product->brand_display_order = (int) $remote['brand_display_order'];
$product->product_line_id = $productLine->id ?? null;
$product->unit_id = $unit->id ?? null;
$product->subcategory = $categoryMapping['parent_category_name']; // e.g., "Pre-Rolls"
// Set defaults for required fields
$product->is_featured = false;
$product->is_assembly = false;
$product->is_raw_material = false;
$product->price_unit = 'unit';
$product->weight_unit = 'g';
$product->sort_order = 0;
$product->has_varieties = $hasVarieties; // From variety check
$product->sell_multiples = false;
$product->fractional_quantities = false;
$product->allow_sample = false;
$product->is_fpr = false;
$product->is_sellable = true;
$product->is_case = false;
$product->cased_qty = 0;
$product->is_box = false;
$product->boxed_qty = 0;
$product->show_inventory_to_buyers = true;
$product->sync_bamboo = false;
$product->timestamps = false;
$product->created_at = $remote['created_at'];
$product->updated_at = $remote['updated_at'];
$product->save();
// Restore existing hashid to preserve URLs (unless --regenerate-hashid flag is set)
if ($existingHashid && ! $this->option('regenerate-hashid')) {
$product->hashid = $existingHashid;
$product->save();
$this->info(" ✓ Preserved existing hashid: {$existingHashid}");
} elseif ($existingHashid && $this->option('regenerate-hashid')) {
$this->info(" ✓ Generated new hashid: {$product->hashid}");
}
// Create ProductImage record for the listing page
if ($imagePath) {
$productImage = new ProductImage;
$productImage->product_id = $product->id;
$productImage->path = $imagePath;
$productImage->type = 'image';
$productImage->is_primary = true;
$productImage->sort_order = 0;
$productImage->order = 0;
$productImage->save();
$this->info(' ✓ Created ProductImage record');
}
$this->info("✓ Created product: {$product->name} (ID: {$product->id})");
return $product;
}
private function importCustomer($dryRun): ?Business
{
// Fetch company from remote
$result = $this->mysqli->query('SELECT * FROM companies WHERE id = 61');
$remote = $result->fetch_assoc();
if (! $remote) {
$this->error('Company #61 not found in remote database');
return null;
}
$this->line("Found: {$remote['name']}");
// Check if mapping already exists
$mapping = DB::table('remote_customer_mappings')->where('remote_company_id', 61)->first();
if ($mapping) {
$existing = Business::find($mapping->business_id);
if ($existing) {
$this->warn(" Customer already imported as Business #{$existing->id}");
return $existing;
}
}
if ($dryRun) {
$this->info('[DRY RUN] Would import customer: '.$remote['name']);
return new Business(['name' => $remote['name']]);
}
// Create business
$business = new Business;
$business->name = $remote['name'];
$business->slug = Str::slug($remote['name']);
$business->type = 'buyer';
$business->status = 'approved';
$business->is_active = true;
$business->onboarding_completed = true;
$business->tax_rate = 0;
$business->tax_exempt = false;
$business->has_analytics = false;
$business->has_marketing = false;
$business->has_manufacturing = false;
$business->has_processing = false;
// Map address fields if available
if (isset($remote['address'])) {
$business->physical_address = $remote['address'];
}
if (isset($remote['city'])) {
$business->physical_city = $remote['city'];
}
if (isset($remote['state'])) {
$business->physical_state = $remote['state'];
}
if (isset($remote['zipcode'])) {
$business->physical_zipcode = $remote['zipcode'];
}
$business->save();
// Create remote customer mapping
DB::table('remote_customer_mappings')->insert([
'business_id' => $business->id,
'remote_company_id' => 61,
'remote_organisation_id' => 5, // From invoice data
'remote_person_id' => 13, // From invoice data
'created_at' => now(),
'updated_at' => now(),
]);
$this->info("✓ Created business: {$business->name} (ID: {$business->id})");
$this->info(' ✓ Created remote_customer_mappings record');
return $business;
}
private function importOrder($customer, $product, $dryRun): ?Order
{
// Fetch invoice from remote
$result = $this->mysqli->query('SELECT * FROM invoices WHERE id = 293');
$remote = $result->fetch_assoc();
if (! $remote) {
$this->error('Invoice #293 not found in remote database');
return null;
}
$this->line("Found: Invoice #{$remote['invoice_id']} - Issue Date: {$remote['issue_date']}");
// Check if already exists
if (Order::where('id', 293)->exists()) {
if ($this->confirm('Order #293 already exists locally. Delete and re-import?', false)) {
if (! $dryRun) {
Order::where('id', 293)->delete();
$this->info('✓ Deleted existing order');
}
} else {
$this->warn('Skipping order import');
return Order::find(293);
}
}
if ($dryRun) {
$this->info('[DRY RUN] Would import invoice #'.$remote['invoice_id'].' as order');
return new Order(['id' => 293, 'order_number' => 'IMPORT-293']);
}
// Create order
$order = new Order;
$order->id = 293; // Preserve remote ID
$order->order_number = 'IMPORT-'.$remote['invoice_id'];
$order->business_id = $customer->id;
$order->remote_organisation_id = $remote['organisation_id'];
$order->subtotal = $remote['subtotal'] / 100; // Convert cents to decimal
$order->tax = $remote['tax'] / 100;
$order->total = $remote['total'] / 100;
$order->status = 'invoiced'; // Imported from invoices table
$order->workorder_status = 0;
$order->created_by = 'seller'; // Orders were created by sellers (invoices)
$order->surcharge = 0.00;
$order->timestamps = false;
$order->created_at = $remote['issue_date'];
$order->updated_at = $remote['updated_at'];
$order->save();
$this->info("✓ Created order: {$order->order_number} (ID: {$order->id})");
// Import invoice lines
$linesResult = $this->mysqli->query('SELECT * FROM invoice_lines WHERE invoice_id = 293 AND product_id = 44');
$lineCount = 0;
while ($line = $linesResult->fetch_assoc()) {
$orderItem = new OrderItem;
$orderItem->order_id = $order->id;
$orderItem->product_id = $product->id;
$orderItem->quantity = (int) $line['quantity'];
$orderItem->unit_price = $line['price']; // Already in decimal format
$orderItem->line_total = $line['amount'] / 100; // Convert cents to decimal
// Denormalized product fields (required)
$orderItem->product_name = $product->name;
$orderItem->product_sku = $product->sku;
$orderItem->brand_name = $product->brand->name;
// Note: tax is stored at order level, not line level
$orderItem->timestamps = false;
$orderItem->created_at = $remote['created_at'];
$orderItem->updated_at = $remote['updated_at'];
$orderItem->save();
$lineCount++;
}
$this->info(" ✓ Created {$lineCount} order line item(s)");
return $order;
}
private function getCategoryMapping(?int $categoryId): array
{
if (! $categoryId) {
return [
'type' => 'flower', // default
'category_name' => 'Uncategorized',
'parent_category_name' => 'Uncategorized',
'child_category_name' => 'Uncategorized',
];
}
// Get category from remote
$result = $this->mysqli->query("SELECT id, name, parent_id FROM product_categories WHERE id = {$categoryId}");
$category = $result->fetch_assoc();
if (! $category) {
return [
'type' => 'flower',
'category_name' => 'Unknown Category',
'parent_category_name' => 'Unknown Category',
'child_category_name' => 'Unknown Category',
];
}
$childCategoryName = $category['name'];
$parentCategoryName = $category['name']; // Default to same if no parent
// If has parent, get parent category name
if ($category['parent_id']) {
$parentResult = $this->mysqli->query("SELECT name FROM product_categories WHERE id = {$category['parent_id']}");
$parent = $parentResult->fetch_assoc();
if ($parent) {
$parentCategoryName = $parent['name'];
}
}
// Map parent category name to local type
$typeMap = [
'Pre-Rolls' => 'pre_roll',
'Flower' => 'flower',
'Concentrates' => 'concentrate',
'Edibles' => 'edible',
'Vapes' => 'vape',
'Topicals' => 'topical',
'Tinctures' => 'tincture',
'Accessories' => 'accessory',
];
$type = $typeMap[$parentCategoryName] ?? 'flower';
return [
'type' => $type,
'category_name' => $category['parent_id'] ? "{$parentCategoryName} / {$childCategoryName}" : $childCategoryName,
'parent_category_name' => $parentCategoryName,
'child_category_name' => $childCategoryName,
];
}
}

View File

@@ -1,446 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Business;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class ImportThunderBudSales extends Command
{
protected $signature = 'import:thunderbud-sales {--dry-run : Show what would be imported without actually importing} {--force : Overwrite existing orders} {--skip-existing : Skip orders that already exist} {--limit= : Limit number of invoices to import}';
protected $description = 'Import Thunder Bud sales history (invoices and customers) from remote MySQL';
private $mysqli;
private $stats = [
'total_invoices' => 0,
'imported_invoices' => 0,
'skipped_invoices' => 0,
'failed_invoices' => 0,
'customers_created' => 0,
'total_items' => 0,
];
private $customerCache = [];
public function handle()
{
$dryRun = $this->option('dry-run');
$force = $this->option('force');
$skipExisting = $this->option('skip-existing');
$limit = $this->option('limit');
if ($dryRun) {
$this->warn('🔍 DRY RUN MODE - No data will be imported');
}
$this->info('🚀 Starting Thunder Bud Sales Import');
$this->newLine();
// Connect to remote MySQL
$this->info('📡 Connecting to remote MySQL database...');
$this->mysqli = new \mysqli('sql1.creationshop.net', 'claude', 'claude', 'hub_cannabrands');
if ($this->mysqli->connect_error) {
$this->error('Failed to connect: '.$this->mysqli->connect_error);
return 1;
}
$this->info('✓ Connected to remote MySQL');
$this->newLine();
// Get all invoices with Thunder Bud products
$this->info('📦 Fetching invoices with Thunder Bud products...');
$query = '
SELECT DISTINCT i.id
FROM invoices i
INNER JOIN invoice_lines il ON i.id = il.invoice_id
INNER JOIN products p ON il.product_id = p.id
WHERE p.brand_id = 6
AND i.deleted_at IS NULL
ORDER BY i.id
';
if ($limit) {
$query .= ' LIMIT '.(int) $limit;
}
$result = $this->mysqli->query($query);
$invoiceIds = [];
while ($row = $result->fetch_assoc()) {
$invoiceIds[] = $row['id'];
}
$this->stats['total_invoices'] = count($invoiceIds);
$this->info("Found {$this->stats['total_invoices']} invoices with Thunder Bud products");
$this->newLine();
if (! $dryRun && ! $force && ! $skipExisting) {
if (! $this->confirm('This will import all invoices and customers. Continue?', true)) {
$this->warn('Import cancelled');
return 0;
}
$this->newLine();
}
// Import each invoice
$progressBar = $this->output->createProgressBar($this->stats['total_invoices']);
$progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
$progressBar->setMessage('Starting...');
foreach ($invoiceIds as $invoiceId) {
$progressBar->setMessage("Invoice #{$invoiceId}");
try {
$result = $this->importInvoice($invoiceId, $dryRun, $force, $skipExisting);
if ($result === 'imported') {
$this->stats['imported_invoices']++;
} elseif ($result === 'skipped') {
$this->stats['skipped_invoices']++;
}
} catch (\Exception $e) {
$this->stats['failed_invoices']++;
$progressBar->clear();
$this->error("Failed to import invoice #{$invoiceId}: {$e->getMessage()}");
$progressBar->display();
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
// Show summary
$this->info('📊 Import Summary:');
$this->table(
['Metric', 'Count'],
[
['Total Invoices', $this->stats['total_invoices']],
['✓ Imported', $this->stats['imported_invoices']],
['⊘ Skipped', $this->stats['skipped_invoices']],
['✗ Failed', $this->stats['failed_invoices']],
['Customers Created', $this->stats['customers_created']],
['Order Items Created', $this->stats['total_items']],
]
);
$this->mysqli->close();
return $this->stats['failed_invoices'] > 0 ? 1 : 0;
}
private function importInvoice(int $invoiceId, bool $dryRun, bool $force, bool $skipExisting): string
{
// Fetch invoice from remote
$result = $this->mysqli->query("SELECT * FROM invoices WHERE id = {$invoiceId}");
$remote = $result->fetch_assoc();
if (! $remote) {
throw new \Exception("Invoice #{$invoiceId} not found in remote database");
}
// Check if already exists
if (Order::where('id', $invoiceId)->exists()) {
if ($skipExisting) {
return 'skipped';
}
if (! $force && ! $dryRun) {
return 'skipped';
}
if (! $dryRun && $force) {
// Force delete existing order and items (hard delete, not soft delete)
DB::table('order_items')->where('order_id', $invoiceId)->delete();
Order::where('id', $invoiceId)->forceDelete();
}
}
if ($dryRun) {
return 'imported';
}
// Get or create customer business
$customer = $this->findOrCreateCustomer($remote['organisation_id'], $dryRun);
if (! $customer) {
throw new \Exception("Failed to create customer for organisation #{$remote['organisation_id']}");
}
// Get Thunder Bud business (seller)
$seller = Business::where('slug', 'cannabrands')->first();
if (! $seller) {
throw new \Exception('Thunder Bud/Cannabrands business not found');
}
// Get first user for this business to assign as order creator
$user = $customer->users()->first();
if (! $user) {
throw new \Exception("No user found for customer business #{$customer->id}");
}
// Get invoice lines
$linesResult = $this->mysqli->query("
SELECT il.*, p.brand_id
FROM invoice_lines il
INNER JOIN products p ON il.product_id = p.id
WHERE il.invoice_id = {$invoiceId}
AND il.deleted_at IS NULL
");
$invoiceLines = [];
while ($line = $linesResult->fetch_assoc()) {
$invoiceLines[] = $line;
}
// Create order
$order = new Order;
$order->id = $invoiceId;
$order->business_id = $customer->id; // Buyer business
$order->user_id = $user->id; // User who placed the order
$order->order_number = $remote['invoice_id'] ?? "TB-{$invoiceId}";
$order->status = $this->mapStatus($remote['status']);
$order->subtotal = ($remote['subtotal'] ?? 0) / 100; // Convert cents to dollars
$order->tax = ($remote['tax'] ?? 0) / 100;
$order->total = ($remote['total'] ?? 0) / 100;
$order->notes = $this->sanitizeUtf8($remote['comments']);
$order->payment_terms = $this->sanitizeUtf8($remote['terms']);
$order->delivery_method = 'pickup'; // Default
$order->timestamps = false;
$order->created_at = $remote['created_at'];
$order->updated_at = $remote['updated_at'];
$order->save();
// Create order items
$itemCount = 0;
foreach ($invoiceLines as $line) {
// Only import items for Thunder Bud products that exist locally
$product = Product::find($line['product_id']);
if (! $product || $product->brand_id != 6) {
continue; // Skip non-Thunder Bud products or products not imported
}
// Calculate line_total (amount + tax)
$amount = (($line['amount'] ?? 0) / 100);
$tax = (($line['tax_amount'] ?? 0) / 100);
$lineTotal = $amount + $tax;
$orderItem = new OrderItem;
$orderItem->order_id = $order->id;
$orderItem->product_id = $line['product_id'];
$orderItem->quantity = (int) ($line['quantity'] ?? 1); // Cast to integer
$orderItem->unit_price = $line['price'] ?? 0;
$orderItem->line_total = $lineTotal;
// Product snapshot fields
$orderItem->product_name = $product->name;
$orderItem->product_sku = $product->sku;
$orderItem->brand_name = $product->brand->name ?? 'Thunder Bud';
$orderItem->timestamps = false;
$orderItem->created_at = $line['created_at'];
$orderItem->updated_at = $line['updated_at'];
$orderItem->save();
$itemCount++;
}
$this->stats['total_items'] += $itemCount;
return 'imported';
}
private function findOrCreateCustomer(int $organisationId, bool $dryRun): ?Business
{
// Check cache first
if (isset($this->customerCache[$organisationId])) {
return $this->customerCache[$organisationId];
}
// Check if already imported
$mapping = DB::table('remote_customer_mappings')
->where('remote_organisation_id', $organisationId)
->first();
if ($mapping) {
$business = Business::find($mapping->business_id);
if ($business) {
// Ensure business has at least one user
if ($business->users()->count() == 0) {
$this->createUserForBusiness($business);
}
$this->customerCache[$organisationId] = $business;
return $business;
}
}
// Fetch from remote
$result = $this->mysqli->query("SELECT * FROM companies WHERE id = {$organisationId}");
$remote = $result->fetch_assoc();
if (! $remote) {
return null;
}
if ($dryRun) {
return new Business(['name' => $remote['name']]);
}
// Check if business already exists by slug
$slug = Str::slug($remote['name']);
$business = Business::where('slug', $slug)->first();
if ($business) {
// Business already exists, create mapping and return it
// Ensure it has a user
if ($business->users()->count() == 0) {
$this->createUserForBusiness($business);
}
// Create mapping if it doesn't exist
$existingMapping = DB::table('remote_customer_mappings')
->where('business_id', $business->id)
->where('remote_organisation_id', $organisationId)
->exists();
if (! $existingMapping) {
DB::table('remote_customer_mappings')->insert([
'business_id' => $business->id,
'remote_organisation_id' => $organisationId,
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->customerCache[$organisationId] = $business;
return $business;
}
// Create new business
$business = new Business;
$business->name = $this->sanitizeUtf8($remote['name']);
$business->slug = Str::slug($remote['name']);
$business->type = 'buyer';
$business->status = 'approved';
$business->is_active = true;
$business->onboarding_completed = true;
$business->tax_rate = 0;
$business->tax_exempt = false;
$business->has_analytics = false;
$business->has_marketing = false;
$business->has_manufacturing = false;
$business->has_processing = false;
// Map address if available
if (! empty($remote['address'])) {
$business->physical_address = $this->sanitizeUtf8($remote['address']);
}
if (! empty($remote['city'])) {
$business->physical_city = $this->sanitizeUtf8($remote['city']);
}
if (! empty($remote['state'])) {
$business->physical_state = $this->sanitizeUtf8($remote['state']);
}
if (! empty($remote['zipcode'])) {
$business->physical_zipcode = $this->sanitizeUtf8($remote['zipcode']);
}
$business->save();
// Create a default user for this business
$this->createUserForBusiness($business);
// Create mapping
DB::table('remote_customer_mappings')->insert([
'business_id' => $business->id,
'remote_organisation_id' => $organisationId,
'created_at' => now(),
'updated_at' => now(),
]);
$this->stats['customers_created']++;
$this->customerCache[$organisationId] = $business;
return $business;
}
private function mapStatus(?string $remoteStatus): string
{
// Map remote invoice status to local order status
// Valid local statuses: new, buyer_modified, seller_modified, accepted, in_progress,
// ready_for_invoice, awaiting_invoice_approval, ready_for_manifest, ready_for_delivery,
// delivered, cancelled, rejected
$statusMap = [
'draft' => 'new', // Order just created
'sent' => 'accepted', // Order sent to customer, accepted
'paid' => 'delivered', // Payment received, order completed
'partial' => 'in_progress', // Partially paid/fulfilled
'overdue' => 'accepted', // Still active but overdue
];
return $statusMap[$remoteStatus] ?? 'new';
}
/**
* Create a default user for a business
*/
private function createUserForBusiness(Business $business): User
{
$user = new User;
$user->first_name = 'System';
$user->last_name = 'User';
$user->email = 'system+'.$business->slug.'@imported.local';
$user->password = Hash::make(Str::random(32)); // Random password
$user->user_type = 'buyer';
$user->email_verified_at = now();
$user->save();
// Attach user to business
$user->businesses()->attach($business->id);
return $user;
}
/**
* Sanitize text from MySQL (Windows-1252 encoding) to proper UTF-8
*/
private function sanitizeUtf8(?string $text): ?string
{
if (! $text) {
return $text;
}
// First, try to detect the encoding
$encoding = mb_detect_encoding($text, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true);
// If already UTF-8 and valid, return as-is
if ($encoding === 'UTF-8' && mb_check_encoding($text, 'UTF-8')) {
return $text;
}
// Try to convert from Windows-1252 to UTF-8
$converted = @iconv('Windows-1252', 'UTF-8//TRANSLIT//IGNORE', $text);
// If iconv fails, fall back to mb_convert_encoding
if ($converted === false) {
$converted = mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
}
// Final cleanup: remove any remaining invalid UTF-8 sequences
$converted = mb_convert_encoding($converted, 'UTF-8', 'UTF-8');
return $converted;
}
}

View File

@@ -1,105 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Intelligence;
use App\Models\Business;
use App\Services\Intelligence\BrandPlacementSignalsService;
use Illuminate\Console\Command;
/**
* Compute Brand Placement Signals
*
* Nightly job to compute brand coverage and sales opportunities
* for internal sales intelligence.
*
* Usage:
* php artisan intelligence:compute-brand-placement # All sellers
* php artisan intelligence:compute-brand-placement --seller=4 # Specific seller
*/
class ComputeBrandPlacementSignals extends Command
{
protected $signature = 'intelligence:compute-brand-placement
{--seller= : Specific seller business ID to compute}
{--dry-run : Show what would be computed without saving}';
protected $description = 'Compute brand placement signals for sales intelligence';
public function handle(BrandPlacementSignalsService $service): int
{
$this->info('🧠 Computing Brand Placement Signals...');
$this->newLine();
$sellerId = $this->option('seller');
$dryRun = $this->option('dry-run');
if ($dryRun) {
$this->warn('DRY RUN MODE - No data will be saved');
$this->newLine();
}
// Get seller(s) to process
if ($sellerId) {
$sellers = Business::where('id', $sellerId)
->whereIn('type', ['seller', 'both'])
->get();
if ($sellers->isEmpty()) {
$this->error("Seller business #{$sellerId} not found or not a seller type.");
return Command::FAILURE;
}
} else {
// Get all seller businesses with brands
$sellers = Business::whereIn('type', ['seller', 'both'])
->whereHas('brands')
->get();
}
if ($sellers->isEmpty()) {
$this->warn('No seller businesses found with brands.');
return Command::SUCCESS;
}
$this->info("Processing {$sellers->count()} seller(s)...");
$this->newLine();
$totalCoverage = 0;
$totalOpportunities = 0;
$progressBar = $this->output->createProgressBar($sellers->count());
$progressBar->start();
foreach ($sellers as $seller) {
if ($dryRun) {
$brandCount = $seller->brands()->where('is_active', true)->count();
$this->line(" Would process: {$seller->name} ({$brandCount} brands)");
} else {
$result = $service->computeForSeller($seller->id);
$totalCoverage += $result['coverage_updated'];
$totalOpportunities += $result['opportunities_created'];
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
if (! $dryRun) {
$this->info('✅ Computation complete!');
$this->table(
['Metric', 'Count'],
[
['Sellers Processed', $sellers->count()],
['Store Coverage Records', $totalCoverage],
['Opportunities Identified', $totalOpportunities],
]
);
}
return Command::SUCCESS;
}
}

View File

@@ -1,143 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Product;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class LinkVarietiesFromSku extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'products:link-varieties-from-sku
{--dry-run : Preview changes without writing to the database}
{--brand-id= : Limit to a specific brand ID}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Link variety products to their parent based on SKU pattern (e.g., AZ3G → AZ1G)';
/**
* Execute the console command.
*/
public function handle(): int
{
$dryRun = $this->option('dry-run');
$brandId = $this->option('brand-id');
if ($dryRun) {
$this->info('🔍 DRY RUN MODE - No changes will be made');
$this->newLine();
}
$query = Product::query()
->whereNotNull('sku')
->whereNull('parent_product_id'); // Only process unlinked products
if ($brandId) {
$query->where('brand_id', $brandId);
$this->info("Filtering to brand_id: {$brandId}");
}
$linked = 0;
$skipped = 0;
$noParent = 0;
$this->info('Scanning products for variety patterns...');
$this->newLine();
// Process in chunks for memory efficiency
$query->chunk(100, function ($products) use ($dryRun, &$linked, &$skipped, &$noParent) {
foreach ($products as $product) {
$result = $this->processProduct($product, $dryRun);
match ($result) {
'linked' => $linked++,
'skipped' => $skipped++,
'no_parent' => $noParent++,
default => null,
};
}
});
$this->newLine();
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info('Summary:');
$this->info(" ✓ Linked: {$linked}");
$this->info(" ○ Skipped (not variety pattern): {$skipped}");
$this->info(" ✗ No parent found: {$noParent}");
if ($dryRun && $linked > 0) {
$this->newLine();
$this->warn('Run without --dry-run to apply these changes.');
}
return Command::SUCCESS;
}
/**
* Process a single product to determine if it's a variety and link it.
*/
private function processProduct(Product $product, bool $dryRun): string
{
$sku = $product->sku;
$parts = explode('-', $sku);
// Guard: need at least 3 parts (e.g., TB-BM-AZ3G)
if (count($parts) < 3) {
return 'skipped';
}
// Check if third segment matches pattern like AZ1G, AZ3G, AZ5G
if (! preg_match('/^([A-Z]+)(\d+)G$/', $parts[2], $matches)) {
return 'skipped';
}
$state = $matches[1]; // e.g., 'AZ'
$qty = (int) $matches[2]; // e.g., 1, 3, 5
// If qty === 1, this is a parent, not a variety
if ($qty === 1) {
return 'skipped';
}
// This is a variety candidate (qty > 1)
// Build the parent's third segment and base SKU
$parentThird = $state.'1G'; // e.g., 'AZ1G'
$parentPrefix = "{$parts[0]}-{$parts[1]}-{$parentThird}"; // e.g., 'TB-BM-AZ1G'
// Look up parent within the same brand
$parent = Product::where('brand_id', $product->brand_id)
->where(function ($q) use ($parentPrefix) {
$q->where('sku', $parentPrefix)
->orWhere('sku', 'like', $parentPrefix.'-%');
})
->first();
if (! $parent) {
$this->warn(" ✗ No parent found for #{$product->id} ({$sku})");
return 'no_parent';
}
// Link the variety to its parent
if ($dryRun) {
$this->line(" → Would link #{$product->id} (<fg=cyan>{$sku}</>) → parent #{$parent->id} (<fg=green>{$parent->sku}</>)");
} else {
DB::transaction(function () use ($product, $parent) {
$product->parent_product_id = $parent->id;
$product->save();
});
$this->line(" ✓ Linked #{$product->id} (<fg=cyan>{$sku}</>) → parent #{$parent->id} (<fg=green>{$parent->sku}</>)");
}
return 'linked';
}
}

View File

@@ -1,356 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
/**
* Debug command to list seller-side feature routes and check legacy menu coverage.
*
* This helps developers identify implemented features that may not appear
* in the current navigation menu, ensuring nothing is forgotten when
* building suite-based menus.
*
* @see docs/architecture/SUITES_AND_PRICING_MODEL.md
*/
class ListFeatureRoutes extends Command
{
protected $signature = 'debug:list-feature-routes
{--show-routes : Show all routes for each feature}
{--json : Output as JSON}';
protected $description = 'List seller-side feature domains and check if they appear in the legacy menu';
/**
* Legacy menu feature references.
* These are patterns/keywords that appear in the legacy seller-sidebar.blade.php
* Used to determine if a feature domain is represented in the current menu.
*/
protected array $legacyMenuFeatures = [
'dashboard' => ['seller.business.dashboard', 'seller.dashboard'],
'brands' => ['seller.business.brands'],
'analytics' => ['seller.business.dashboard.analytics', 'analytics'],
'orchestrator' => ['seller.business.orchestrator'],
'conversations' => ['seller.business.messaging', 'conversations'],
'contacts' => ['seller.business.contacts'],
'sales' => ['seller.business.dashboard.sales'],
'customers' => ['seller.business.customers'],
'orders' => ['seller.business.orders'],
'invoices' => ['seller.business.invoices'],
'backorders' => ['seller.business.backorders'],
'promotions' => ['seller.business.promotions'],
'products' => ['seller.business.products'],
'components' => ['seller.business.components'],
'inventory' => ['seller.business.inventory'],
'processing' => ['seller.business.processing'],
'manufacturing' => ['seller.business.manufacturing'],
'fleet' => ['seller.business.fleet'],
'marketing' => ['seller.business.marketing'],
'crm' => ['seller.business.crm'],
'settings' => ['seller.business.settings'],
'reports' => ['seller.business.processing.wash-reports'],
];
/**
* Feature grouping rules based on route name patterns.
* Maps route name segments to feature keys.
*/
protected array $featurePatterns = [
'dashboard' => ['dashboard'],
'brands' => ['brands'],
'products' => ['products'],
'components' => ['components'],
'inventory' => ['inventory.items', 'inventory.movements', 'inventory.alerts', 'inventory.dashboard'],
'orders' => ['orders'],
'invoices' => ['invoices'],
'backorders' => ['backorders'],
'customers' => ['customers'],
'contacts' => ['contacts'],
'conversations' => ['messaging'],
'promotions' => ['promotions'],
'menus' => ['menus'],
'orchestrator' => ['orchestrator'],
'copilot' => ['copilot'],
'processing' => ['processing', 'batches'],
'manufacturing' => ['manufacturing'],
'fleet' => ['fleet'],
'marketing' => ['marketing'],
'crm' => ['crm'],
'settings' => ['settings'],
'compliance' => ['compliance'],
'bulk-actions' => ['bulk-actions', 'bulk'],
'api' => ['api'],
'webhooks' => ['webhooks'],
'integrations' => ['integrations'],
'onboarding' => ['onboarding'],
'impersonate' => ['impersonate'],
];
public function handle(): int
{
$this->info('Scanning seller-side routes...');
$this->newLine();
// Collect all seller routes
$sellerRoutes = $this->collectSellerRoutes();
// Group by feature
$features = $this->groupByFeature($sellerRoutes);
// Check legacy menu coverage
$results = $this->analyzeFeatures($features);
// Output results
if ($this->option('json')) {
$this->line(json_encode($results, JSON_PRETTY_PRINT));
} else {
$this->outputTable($results);
if ($this->option('show-routes')) {
$this->outputDetailedRoutes($features);
}
$this->outputSummary($results);
}
return Command::SUCCESS;
}
/**
* Collect all routes that belong to the seller area.
*/
protected function collectSellerRoutes(): array
{
$routes = [];
foreach (Route::getRoutes() as $route) {
$name = $route->getName();
$uri = $route->uri();
// Filter to seller routes by URI prefix or route name
$isSellerRoute = Str::startsWith($uri, 's/')
|| Str::startsWith($uri, 's/{')
|| Str::startsWith($name ?? '', 'seller.');
if (! $isSellerRoute) {
continue;
}
// Skip Livewire and internal routes
if (Str::contains($uri, ['livewire', '__clockwork'])) {
continue;
}
$routes[] = [
'name' => $name,
'uri' => $uri,
'methods' => implode('|', $route->methods()),
'controller' => $route->getActionName(),
];
}
return $routes;
}
/**
* Group routes by inferred feature key.
*/
protected function groupByFeature(array $routes): array
{
$features = [];
foreach ($routes as $route) {
$featureKey = $this->inferFeatureKey($route);
if (! isset($features[$featureKey])) {
$features[$featureKey] = [];
}
$features[$featureKey][] = $route;
}
// Sort by feature name
ksort($features);
return $features;
}
/**
* Infer the feature key from a route.
*/
protected function inferFeatureKey(array $route): string
{
$name = $route['name'] ?? '';
$uri = $route['uri'];
// Try to match against known feature patterns
foreach ($this->featurePatterns as $feature => $patterns) {
foreach ($patterns as $pattern) {
if (Str::contains($name, $pattern) || Str::contains($uri, $pattern)) {
return $feature;
}
}
}
// Fall back to extracting from route name
// seller.business.{feature}.action -> extract {feature}
if (preg_match('/^seller\.business\.([a-z-]+)/', $name, $matches)) {
return $matches[1];
}
// Extract from URI: s/{business}/{feature}/...
if (preg_match('/^s\/\{[^}]+\}\/([a-z-]+)/', $uri, $matches)) {
return $matches[1];
}
// Extract from URI without business param: s/{feature}/...
if (preg_match('/^s\/([a-z-]+)/', $uri, $matches)) {
return $matches[1];
}
return 'other';
}
/**
* Analyze features and check legacy menu coverage.
*/
protected function analyzeFeatures(array $features): array
{
$results = [];
foreach ($features as $featureKey => $routes) {
$inLegacyMenu = $this->isInLegacyMenu($featureKey, $routes);
// Get example routes (up to 3)
$examples = array_slice(
array_map(fn ($r) => $r['name'] ?: $r['uri'], $routes),
0,
3
);
$results[] = [
'feature' => $featureKey,
'route_count' => count($routes),
'in_legacy_menu' => $inLegacyMenu,
'examples' => $examples,
];
}
// Sort: features NOT in legacy menu first (to highlight gaps)
usort($results, function ($a, $b) {
if ($a['in_legacy_menu'] !== $b['in_legacy_menu']) {
return $a['in_legacy_menu'] ? 1 : -1;
}
return strcmp($a['feature'], $b['feature']);
});
return $results;
}
/**
* Check if a feature appears in the legacy menu.
*/
protected function isInLegacyMenu(string $featureKey, array $routes): bool
{
// Check direct feature mapping
if (isset($this->legacyMenuFeatures[$featureKey])) {
return true;
}
// Check if any route matches known legacy menu patterns
foreach ($routes as $route) {
$routeName = $route['name'] ?? '';
foreach ($this->legacyMenuFeatures as $patterns) {
foreach ($patterns as $pattern) {
if (Str::startsWith($routeName, $pattern)) {
return true;
}
}
}
}
return false;
}
/**
* Output results as a table.
*/
protected function outputTable(array $results): void
{
$tableData = [];
foreach ($results as $result) {
$tableData[] = [
$result['feature'],
$result['route_count'],
$result['in_legacy_menu'] ? '<fg=green>YES</>' : '<fg=yellow>NO</>',
Str::limit(implode(', ', $result['examples']), 60),
];
}
$this->table(
['Feature', 'Routes', 'In Menu?', 'Example Routes'],
$tableData
);
}
/**
* Output detailed routes for each feature.
*/
protected function outputDetailedRoutes(array $features): void
{
$this->newLine();
$this->info('Detailed Routes by Feature:');
$this->newLine();
foreach ($features as $feature => $routes) {
$this->line("<fg=cyan>[$feature]</> (".count($routes).' routes)');
foreach ($routes as $route) {
$methods = $route['methods'];
$name = $route['name'] ?: '(unnamed)';
$uri = $route['uri'];
$this->line(" <fg=gray>{$methods}</> {$uri}");
$this->line(" <fg=gray>→ {$name}</>");
}
$this->newLine();
}
}
/**
* Output summary statistics.
*/
protected function outputSummary(array $results): void
{
$totalFeatures = count($results);
$inMenu = count(array_filter($results, fn ($r) => $r['in_legacy_menu']));
$notInMenu = $totalFeatures - $inMenu;
$totalRoutes = array_sum(array_column($results, 'route_count'));
$this->newLine();
$this->info('Summary:');
$this->line(" Total seller routes: <fg=white>{$totalRoutes}</>");
$this->line(" Feature domains: <fg=white>{$totalFeatures}</>");
$this->line(" In legacy menu: <fg=green>{$inMenu}</>");
$this->line(" Not in legacy menu: <fg=yellow>{$notInMenu}</>");
if ($notInMenu > 0) {
$this->newLine();
$this->warn('Features not in legacy menu may need to be added to suite menus:');
foreach ($results as $result) {
if (! $result['in_legacy_menu']) {
$this->line(" - <fg=yellow>{$result['feature']}</> ({$result['route_count']} routes)");
}
}
}
$this->newLine();
$this->line('<fg=gray>Tip: Use --show-routes to see all routes per feature</>');
$this->line('<fg=gray>Tip: Use --json for machine-readable output</>');
}
}

View File

@@ -1,144 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Business;
use App\Models\BusinessDba;
use Illuminate\Console\Command;
/**
* Migrate existing business DBA data to the new business_dbas table.
*
* This command creates DBA records from existing business fields:
* - dba_name
* - invoice_payable_company_name, invoice_payable_address, etc.
* - ap_contact_* fields
* - primary_contact_* fields
*/
class MigrateDbaData extends Command
{
protected $signature = 'dba:migrate
{--dry-run : Show what would be created without actually creating records}
{--business= : Migrate only a specific business by ID or slug}
{--force : Skip confirmation prompt}';
protected $description = 'Migrate existing dba_name and invoice_payable_* fields to the business_dbas table';
public function handle(): int
{
$this->info('DBA Data Migration');
$this->line('==================');
$dryRun = $this->option('dry-run');
$specificBusiness = $this->option('business');
if ($dryRun) {
$this->warn('DRY RUN MODE - No records will be created');
}
// Build query
$query = Business::query()
->whereNotNull('dba_name')
->where('dba_name', '!=', '');
if ($specificBusiness) {
$query->where(function ($q) use ($specificBusiness) {
$q->where('id', $specificBusiness)
->orWhere('slug', $specificBusiness);
});
}
$businesses = $query->get();
$this->info("Found {$businesses->count()} businesses with dba_name set.");
if ($businesses->isEmpty()) {
$this->info('No businesses to migrate.');
return self::SUCCESS;
}
// Show preview
$this->newLine();
$this->table(
['ID', 'Business Name', 'DBA Name', 'Has Invoice Address', 'Already Has DBAs'],
$businesses->map(fn ($b) => [
$b->id,
\Illuminate\Support\Str::limit($b->name, 30),
\Illuminate\Support\Str::limit($b->dba_name, 30),
$b->invoice_payable_address ? 'Yes' : 'No',
$b->dbas()->exists() ? 'Yes' : 'No',
])
);
if (! $dryRun && ! $this->option('force')) {
if (! $this->confirm('Do you want to proceed with creating DBA records?')) {
$this->info('Aborted.');
return self::SUCCESS;
}
}
$created = 0;
$skipped = 0;
foreach ($businesses as $business) {
// Skip if business already has DBAs
if ($business->dbas()->exists()) {
$this->line(" Skipping {$business->name} - already has DBAs");
$skipped++;
continue;
}
if ($dryRun) {
$this->line(" Would create DBA for: {$business->name} -> {$business->dba_name}");
$created++;
continue;
}
// Create DBA from existing business fields
$dba = BusinessDba::create([
'business_id' => $business->id,
'trade_name' => $business->dba_name,
// Address - prefer invoice_payable fields, fall back to physical
'address' => $business->invoice_payable_address ?: $business->physical_address,
'city' => $business->invoice_payable_city ?: $business->physical_city,
'state' => $business->invoice_payable_state ?: $business->physical_state,
'zip' => $business->invoice_payable_zipcode ?: $business->physical_zipcode,
// License
'license_number' => $business->license_number,
'license_type' => $business->license_type,
// Contacts
'primary_contact_name' => trim(($business->primary_contact_first_name ?? '').' '.($business->primary_contact_last_name ?? '')) ?: null,
'primary_contact_email' => $business->primary_contact_email,
'primary_contact_phone' => $business->primary_contact_phone,
'ap_contact_name' => trim(($business->ap_contact_first_name ?? '').' '.($business->ap_contact_last_name ?? '')) ?: null,
'ap_contact_email' => $business->ap_contact_email,
'ap_contact_phone' => $business->ap_contact_phone,
// Invoice Settings
'invoice_footer' => $business->order_invoice_footer,
// Status
'is_default' => true,
'is_active' => true,
]);
$this->info(" Created DBA #{$dba->id} for {$business->name}: {$dba->trade_name}");
$created++;
}
$this->newLine();
$this->info("Summary: {$created} created, {$skipped} skipped");
if ($dryRun) {
$this->warn('Run without --dry-run to actually create records.');
}
return self::SUCCESS;
}
}

View File

@@ -1,216 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Business;
use App\Models\Suite;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Migrates the legacy has_* flags on businesses to the new suites pivot table.
*
* This command maps the old feature flags (has_analytics, has_manufacturing, etc.)
* to suite assignments in the business_suite pivot table.
*/
class MigrateFlagsToSuites extends Command
{
protected $signature = 'migrate:flags-to-suites
{--dry-run : Run without making changes}
{--force : Force migration without confirmation}';
protected $description = 'Migrate legacy has_* flags to business_suite pivot table';
/**
* Map feature flags to suite keys.
*
* Some feature flags map to specific suites, others are deprecated or
* will be handled by the new granular permissions system.
*/
private const FLAG_TO_SUITE_MAP = [
// Feature flags that map directly to suites
'has_manufacturing' => 'manufacturing',
'has_processing' => 'processing',
'has_marketing' => 'marketing',
'has_compliance' => 'compliance',
'has_inventory' => 'inventory',
'has_accounting' => 'finance', // accounting maps to finance suite
// Feature flags that are part of the Sales suite
'has_analytics' => 'sales',
'has_crm' => 'sales',
'has_assemblies' => 'inventory', // assemblies is part of inventory
'has_conversations' => 'inbox', // conversations maps to inbox suite
'has_buyer_intelligence' => 'sales',
// Legacy suite flags (already named as suites)
'has_sales_suite' => 'sales',
'has_processing_suite' => 'processing',
'has_manufacturing_suite' => 'manufacturing',
'has_delivery_suite' => 'distribution',
'has_management_suite' => 'management',
'has_enterprise_suite' => 'enterprise',
];
public function handle(): int
{
$isDryRun = $this->option('dry-run');
$force = $this->option('force');
$this->info('Starting has_* flags to suites migration...');
if ($isDryRun) {
$this->warn('DRY RUN MODE - No changes will be made');
}
if (! $force && ! $isDryRun) {
if (! $this->confirm('This will migrate has_* flags to the business_suite pivot table. Continue?')) {
$this->info('Migration cancelled.');
return self::SUCCESS;
}
}
// Ensure suites exist
$suites = Suite::all()->keyBy('key');
if ($suites->isEmpty()) {
$this->error('No suites found. Please run: php artisan db:seed --class=SuitesSeeder');
return self::FAILURE;
}
$this->info('Found '.$suites->count().' suites in database');
// Get all businesses with any has_* flags enabled
$businesses = Business::query()
->where(function ($query) {
foreach (array_keys(self::FLAG_TO_SUITE_MAP) as $flag) {
$query->orWhere($flag, true);
}
})
->get();
$this->info("Found {$businesses->count()} businesses with enabled flags");
$this->newLine();
$stats = [
'total_businesses' => 0,
'total_suite_assignments' => 0,
'skipped_existing' => 0,
'errors' => 0,
];
$progressBar = $this->output->createProgressBar($businesses->count());
$progressBar->start();
foreach ($businesses as $business) {
$stats['total_businesses']++;
try {
$suitesToAssign = $this->determineSuitesForBusiness($business, $suites);
if (empty($suitesToAssign)) {
$progressBar->advance();
continue;
}
foreach ($suitesToAssign as $suiteKey) {
$suite = $suites->get($suiteKey);
if (! $suite) {
$this->newLine();
$this->warn(" Suite '{$suiteKey}' not found for business {$business->name}");
continue;
}
// Check if already assigned
$existingAssignment = DB::table('business_suite')
->where('business_id', $business->id)
->where('suite_id', $suite->id)
->exists();
if ($existingAssignment) {
$stats['skipped_existing']++;
continue;
}
if (! $isDryRun) {
DB::table('business_suite')->insert([
'business_id' => $business->id,
'suite_id' => $suite->id,
'created_at' => now(),
'updated_at' => now(),
]);
}
$stats['total_suite_assignments']++;
}
} catch (\Exception $e) {
$stats['errors']++;
$this->newLine();
$this->error(" Error processing {$business->name}: {$e->getMessage()}");
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
// Display stats
$this->table(
['Metric', 'Count'],
[
['Total Businesses Processed', $stats['total_businesses']],
['Suite Assignments Created', $isDryRun ? "{$stats['total_suite_assignments']} (would create)" : $stats['total_suite_assignments']],
['Skipped (Already Assigned)', $stats['skipped_existing']],
['Errors', $stats['errors']],
]
);
$this->info('Migration completed!');
if (! $isDryRun) {
$this->newLine();
$this->info('Note: The has_* flags remain on the businesses for backwards compatibility.');
$this->info('They can be deprecated once all code uses the suites system.');
}
return self::SUCCESS;
}
/**
* Determine which suites a business should have based on its flags.
*/
private function determineSuitesForBusiness(Business $business, $suites): array
{
$assignedSuites = [];
foreach (self::FLAG_TO_SUITE_MAP as $flag => $suiteKey) {
// Check if the business has this flag enabled
if ($business->getAttribute($flag)) {
// Don't duplicate suite assignments
if (! in_array($suiteKey, $assignedSuites)) {
$assignedSuites[] = $suiteKey;
}
}
}
// Enterprise suite gets all suites
if (in_array('enterprise', $assignedSuites)) {
// Add all non-internal suites
foreach ($suites as $suite) {
if (! $suite->is_internal && ! in_array($suite->key, $assignedSuites)) {
$assignedSuites[] = $suite->key;
}
}
}
return $assignedSuites;
}
}

View File

@@ -1,162 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use App\Models\Business;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class MigrateImagesToMinIO extends Command
{
protected $signature = 'media:migrate-to-minio {--dry-run : Show what would be migrated without actually doing it}';
protected $description = 'Migrate existing brand images from storage/app/public to MinIO with proper hierarchy';
protected int $migratedLogos = 0;
protected int $migratedBanners = 0;
protected int $errors = 0;
public function handle(): int
{
$dryRun = $this->option('dry-run');
if ($dryRun) {
$this->warn('🔍 DRY RUN MODE - No files will be moved or database records updated');
$this->newLine();
} else {
$this->info('🚀 Starting image migration to MinIO...');
$this->newLine();
}
// Get all brands with images
$brands = Brand::with('business')
->where(function ($query) {
$query->whereNotNull('logo_path')
->orWhereNotNull('banner_path');
})
->get();
if ($brands->isEmpty()) {
$this->info('✅ No brands with images found. Nothing to migrate.');
return 0;
}
$this->info("Found {$brands->count()} brands with images");
$this->newLine();
$progressBar = $this->output->createProgressBar($brands->count());
$progressBar->start();
foreach ($brands as $brand) {
$this->migrateBrandImages($brand, $dryRun);
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
// Summary
$this->info('✅ Migration Complete!');
$this->table(
['Metric', 'Count'],
[
['Logos Migrated', $this->migratedLogos],
['Banners Migrated', $this->migratedBanners],
['Errors', $this->errors],
]
);
if (! $dryRun && $this->errors === 0) {
$this->newLine();
$this->info('🎉 All images successfully migrated to MinIO!');
$this->info('📂 Check MinIO console: http://localhost:9001');
$this->info('🗑️ You can now safely delete storage/app/public/brands/');
}
return 0;
}
protected function migrateBrandImages(Brand $brand, bool $dryRun): void
{
$business = $brand->business;
// Migrate logo
if ($brand->logo_path) {
$this->migrateImage(
$brand,
$business,
$brand->logo_path,
'logo',
$dryRun
);
}
// Migrate banner
if ($brand->banner_path) {
$this->migrateImage(
$brand,
$business,
$brand->banner_path,
'banner',
$dryRun
);
}
}
protected function migrateImage(
Brand $brand,
Business $business,
string $oldPath,
string $type,
bool $dryRun
): void {
try {
// Check if file exists in old location
$oldDisk = Storage::disk('public');
if (! $oldDisk->exists($oldPath)) {
$this->newLine();
$this->warn(" ⚠️ File not found: {$oldPath} (skipping)");
$this->errors++;
return;
}
// Determine file extension
$extension = pathinfo($oldPath, PATHINFO_EXTENSION);
// Build new path using our hierarchy
$newPath = "businesses/{$business->slug}/brands/{$brand->slug}/branding/{$type}.{$extension}";
if ($dryRun) {
$this->newLine();
$this->line(' 📋 Would migrate:');
$this->line(" From: {$oldPath}");
$this->line(" To: {$newPath}");
} else {
// Get file contents
$fileContents = $oldDisk->get($oldPath);
// Upload to MinIO using our new hierarchy
$minioDisk = Storage::disk('minio');
$minioDisk->put($newPath, $fileContents);
// Update database
if ($type === 'logo') {
$brand->update(['logo_path' => $newPath]);
$this->migratedLogos++;
} else {
$brand->update(['banner_path' => $newPath]);
$this->migratedBanners++;
}
}
} catch (\Exception $e) {
$this->newLine();
$this->error(" ❌ Error migrating {$type} for {$brand->name}: ".$e->getMessage());
$this->errors++;
}
}
}

View File

@@ -1,126 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Product;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class MigrateProductImagePaths extends Command
{
protected $signature = 'media:migrate-product-images {--dry-run : Show what would be migrated without making changes}';
protected $description = 'Migrate product images from old path (products/{id}/) to correct path (brands/{brand}/products/{sku}/images/)';
public function handle()
{
$dryRun = $this->option('dry-run');
if ($dryRun) {
$this->warn('🔍 DRY RUN MODE - No changes will be made');
$this->newLine();
}
$this->info('🚀 Starting product image migration...');
$this->newLine();
// Get all products with image_path
$products = Product::whereNotNull('image_path')
->with('brand.business')
->get();
$this->info("Found {$products->count()} products with images");
$this->newLine();
$stats = [
'total' => $products->count(),
'migrated' => 0,
'skipped_correct_path' => 0,
'skipped_missing' => 0,
'failed' => 0,
];
$progressBar = $this->output->createProgressBar($products->count());
$progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
$progressBar->setMessage('Starting...');
foreach ($products as $product) {
$progressBar->setMessage("Product #{$product->id}: {$product->name}");
try {
// Check if already using correct path pattern
if (preg_match('#^businesses/[^/]+/brands/[^/]+/products/[^/]+/images/#', $product->image_path)) {
$stats['skipped_correct_path']++;
$progressBar->advance();
continue;
}
// Check if old file exists
if (! Storage::exists($product->image_path)) {
$stats['skipped_missing']++;
$progressBar->clear();
$this->warn(" ⚠️ Product #{$product->id} - Image missing at: {$product->image_path}");
$progressBar->display();
$progressBar->advance();
continue;
}
// Build new path
$filename = basename($product->image_path);
$businessSlug = $product->brand->business->slug ?? 'unknown';
$brandSlug = $product->brand->slug ?? 'unknown';
$productSku = $product->sku;
$newPath = "businesses/{$businessSlug}/brands/{$brandSlug}/products/{$productSku}/images/{$filename}";
$oldPath = $product->image_path;
if (! $dryRun) {
// Copy file to new location on MinIO
$contents = Storage::get($oldPath);
Storage::put($newPath, $contents);
// Update database
$product->image_path = $newPath;
$product->save();
// Delete old file
Storage::delete($oldPath);
}
$stats['migrated']++;
} catch (\Exception $e) {
$stats['failed']++;
$progressBar->clear();
$this->error(" ✗ Failed to migrate product #{$product->id}: {$e->getMessage()}");
$progressBar->display();
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
// Show summary
$this->info('📊 Migration Summary:');
$this->table(
['Metric', 'Count'],
[
['Total Products', $stats['total']],
['✓ Migrated', $stats['migrated']],
['→ Already Correct Path', $stats['skipped_correct_path']],
['⊘ Missing Files', $stats['skipped_missing']],
['✗ Failed', $stats['failed']],
]
);
if ($dryRun) {
$this->newLine();
$this->warn('This was a dry run. Run without --dry-run to actually migrate the images.');
}
return $stats['failed'] > 0 ? 1 : 0;
}
}

View File

@@ -1,304 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\AutomationRunLog;
use App\Models\MenuViewEvent;
use App\Models\OrchestratorTask;
use App\Models\OrchestratorTimingInsight;
use App\Models\Order;
use App\Models\SendMenuLog;
use Illuminate\Console\Command;
/**
* OrchestratorAnalyzeTiming - Analyzes timing patterns for optimal send windows.
*
* Computes engagement metrics by hour-of-day and playbook type,
* storing results in orchestrator_timing_insights for visualization.
*/
class OrchestratorAnalyzeTiming extends Command
{
protected $signature = 'orchestrator:analyze-timing
{--days=90 : Number of days back to analyze}
{--business= : Analyze for specific business ID}
{--dry-run : Show what would be computed without saving}';
protected $description = 'Analyze menu send timing patterns to identify optimal engagement windows';
public function handle(): int
{
AutomationRunLog::recordStart(AutomationRunLog::CMD_ANALYZE_TIMING);
$days = (int) $this->option('days');
$businessId = $this->option('business');
$dryRun = $this->option('dry-run');
$this->info("Analyzing timing patterns from last {$days} days...");
if ($dryRun) {
$this->warn('DRY RUN - No data will be saved.');
}
$startDate = now()->subDays($days);
// Get all send menu logs with outcomes
$query = SendMenuLog::query()
->whereNotNull('sent_at')
->where('sent_at', '>=', $startDate)
->whereNotNull('outcome_checked_at');
if ($businessId) {
$query->where('business_id', $businessId);
}
$logs = $query->get();
$this->info("Found {$logs->count()} send logs with outcomes to analyze.");
if ($logs->isEmpty()) {
$this->warn('No data to analyze. Run orchestrator:evaluate-outcomes first.');
return Command::SUCCESS;
}
// Group by hour-of-day
$byHour = $logs->groupBy(fn ($log) => $log->sent_at->hour);
// Also compute by playbook type if orchestrator_task_id is present
$byPlaybook = $this->groupByPlaybook($logs);
// Compute global insights (all playbooks)
$this->computeInsights(
$byHour,
null, // business_id = null for global
'all',
$dryRun
);
// Compute per-playbook insights
foreach ($byPlaybook as $playbookType => $playbookLogs) {
$byHourForPlaybook = $playbookLogs->groupBy(fn ($log) => $log->sent_at->hour);
$this->computeInsights(
$byHourForPlaybook,
null,
$playbookType,
$dryRun
);
}
// If specific business, also compute business-specific insights
if ($businessId) {
$this->info("Computing insights for business ID {$businessId}...");
$this->computeInsights(
$byHour,
(int) $businessId,
'all',
$dryRun
);
foreach ($byPlaybook as $playbookType => $playbookLogs) {
$byHourForPlaybook = $playbookLogs->groupBy(fn ($log) => $log->sent_at->hour);
$this->computeInsights(
$byHourForPlaybook,
(int) $businessId,
$playbookType,
$dryRun
);
}
}
$this->info('Timing analysis complete!');
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_ANALYZE_TIMING, [
'logs_analyzed' => $logs->count(),
'days_analyzed' => $days,
]);
return Command::SUCCESS;
}
/**
* Group logs by playbook type (via orchestrator_task_id).
*/
private function groupByPlaybook($logs): \Illuminate\Support\Collection
{
$logsWithTask = $logs->whereNotNull('orchestrator_task_id');
if ($logsWithTask->isEmpty()) {
return collect();
}
$taskIds = $logsWithTask->pluck('orchestrator_task_id')->unique();
$tasks = OrchestratorTask::whereIn('id', $taskIds)->pluck('type', 'id');
return $logsWithTask->groupBy(function ($log) use ($tasks) {
return $tasks[$log->orchestrator_task_id] ?? 'unknown';
});
}
/**
* Compute and store insights for a grouping.
*/
private function computeInsights(
\Illuminate\Support\Collection $byHour,
?int $businessId,
string $playbookType,
bool $dryRun
): void {
$insights = [];
for ($hour = 0; $hour < 24; $hour++) {
$hourLogs = $byHour->get($hour, collect());
if ($hourLogs->isEmpty()) {
continue;
}
$total = $hourLogs->count();
$views = $hourLogs->where('resulted_in_view', true)->count();
$orders = $hourLogs->where('resulted_in_order', true)->count();
// Calculate average time to view (for logs that resulted in view)
$viewedLogs = $hourLogs->where('resulted_in_view', true);
$avgHoursToView = null;
if ($viewedLogs->isNotEmpty()) {
// Get matching menu view events
$avgHoursToView = $this->calculateAvgHoursToView($viewedLogs);
}
// Calculate average time to order
$orderedLogs = $hourLogs->where('resulted_in_order', true);
$avgHoursToOrder = null;
if ($orderedLogs->isNotEmpty()) {
$avgHoursToOrder = $this->calculateAvgHoursToOrder($orderedLogs);
}
$viewRate = $total > 0 ? round(($views / $total) * 100, 2) : 0;
$orderRate = $total > 0 ? round(($orders / $total) * 100, 2) : 0;
$insights[$hour] = [
'business_id' => $businessId,
'playbook_type' => $playbookType,
'hour_of_day' => $hour,
'avg_view_rate' => $viewRate,
'avg_order_rate' => $orderRate,
'avg_hours_to_view' => $avgHoursToView,
'avg_hours_to_order' => $avgHoursToOrder,
'sample_size' => $total,
'computed_at' => now(),
];
if (! $dryRun) {
OrchestratorTimingInsight::updateOrCreate(
[
'business_id' => $businessId,
'playbook_type' => $playbookType,
'hour_of_day' => $hour,
'day_of_week' => null, // Future: add day-of-week breakdowns
],
$insights[$hour]
);
}
}
// Display summary
$this->displaySummary($insights, $businessId, $playbookType);
}
/**
* Calculate average hours between send and first view.
*/
private function calculateAvgHoursToView($logs): ?float
{
$totalHours = 0;
$count = 0;
foreach ($logs as $log) {
// Find first view after send
$firstView = MenuViewEvent::where('menu_id', $log->menu_id)
->where('customer_id', $log->customer_id)
->where('viewed_at', '>', $log->sent_at)
->orderBy('viewed_at')
->first();
if ($firstView) {
$hours = $log->sent_at->diffInHours($firstView->viewed_at, true);
$totalHours += $hours;
$count++;
}
}
return $count > 0 ? round($totalHours / $count, 2) : null;
}
/**
* Calculate average hours between send and order.
*/
private function calculateAvgHoursToOrder($logs): ?float
{
$totalHours = 0;
$count = 0;
foreach ($logs as $log) {
// Find first order after send (within 7 days)
$order = Order::where('business_id', $log->customer_id)
->where('created_at', '>', $log->sent_at)
->where('created_at', '<=', $log->sent_at->copy()->addDays(7))
->orderBy('created_at')
->first();
if ($order) {
$hours = $log->sent_at->diffInHours($order->created_at, true);
$totalHours += $hours;
$count++;
}
}
return $count > 0 ? round($totalHours / $count, 2) : null;
}
/**
* Display timing summary.
*/
private function displaySummary(array $insights, ?int $businessId, string $playbookType): void
{
if (empty($insights)) {
return;
}
$scope = $businessId ? "Business #{$businessId}" : 'Global';
$this->newLine();
$this->info("=== {$scope} / {$playbookType} ===");
// Find top 3 hours by order rate
$sorted = collect($insights)->sortByDesc('avg_order_rate')->take(3);
$this->table(
['Hour', 'View Rate', 'Order Rate', 'Avg Hrs to View', 'Sample Size'],
$sorted->map(fn ($row) => [
$this->formatHour($row['hour_of_day']),
$row['avg_view_rate'].'%',
$row['avg_order_rate'].'%',
$row['avg_hours_to_view'] ?? '-',
$row['sample_size'],
])
);
}
private function formatHour(int $hour): string
{
if ($hour === 0) {
return '12 AM';
}
if ($hour < 12) {
return $hour.' AM';
}
if ($hour === 12) {
return '12 PM';
}
return ($hour - 12).' PM';
}
}

View File

@@ -1,235 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\AutomationRunLog;
use App\Models\SystemAlert;
use App\Notifications\OrchestratorCriticalAlert;
use App\Services\OrchestratorGovernanceService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Notification;
class OrchestratorCheckHorizon extends Command
{
protected $signature = 'orchestrator:check-horizon
{--attempt-restart : Attempt to restart Horizon if down}
{--notify : Send notifications for critical issues}';
protected $description = 'Check Horizon and queue health status';
private OrchestratorGovernanceService $governance;
private int $consecutiveFailures = 0;
public function __construct(OrchestratorGovernanceService $governance)
{
parent::__construct();
$this->governance = $governance;
}
public function handle(): int
{
AutomationRunLog::recordStart(AutomationRunLog::CMD_CHECK_HORIZON);
$this->info('Checking Horizon and queue health...');
try {
$health = $this->governance->checkHorizonHealth();
$this->displayHealth($health);
// Process alerts
$alertsCreated = 0;
foreach ($health['alerts'] as $alertData) {
$alert = SystemAlert::createAlert(
$alertData['type'],
$alertData['severity'],
SystemAlert::SOURCE_HORIZON,
$alertData['title'],
$alertData['message'],
$alertData['context'] ?? [],
30 // Dedupe window: 30 minutes
);
if ($alert) {
$alertsCreated++;
// Send notification for critical alerts
if ($this->option('notify') && $alert->isCritical()) {
$this->sendCriticalNotification($alert);
}
}
}
// Handle restart if needed
if ($health['status'] === 'critical' && $this->option('attempt-restart')) {
$this->attemptRestart();
}
// Auto-resolve alerts if health is now good
if ($health['status'] === 'healthy') {
$resolved = SystemAlert::autoResolve(
SystemAlert::TYPE_HORIZON_DOWN,
SystemAlert::SOURCE_HORIZON,
'Auto-resolved: Horizon is healthy'
);
$resolved += SystemAlert::autoResolve(
SystemAlert::TYPE_HORIZON_DEGRADED,
SystemAlert::SOURCE_HORIZON,
'Auto-resolved: Horizon is healthy'
);
if ($resolved > 0) {
$this->info("Auto-resolved {$resolved} previous alerts.");
}
}
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_CHECK_HORIZON, [
'status' => $health['status'],
'alerts_created' => $alertsCreated,
'checks' => array_keys($health['checks']),
]);
return $health['status'] === 'critical' ? self::FAILURE : self::SUCCESS;
} catch (\Exception $e) {
AutomationRunLog::recordFailure(AutomationRunLog::CMD_CHECK_HORIZON, $e->getMessage());
$this->error('Health check failed: '.$e->getMessage());
// Create alert for check failure
SystemAlert::critical(
SystemAlert::TYPE_HORIZON_DOWN,
SystemAlert::SOURCE_HORIZON,
'Horizon health check failed',
'Unable to check Horizon health: '.$e->getMessage()
);
return self::FAILURE;
}
}
private function displayHealth(array $health): void
{
$statusColor = match ($health['status']) {
'healthy' => 'green',
'warning' => 'yellow',
'critical' => 'red',
default => 'white',
};
$this->newLine();
$this->line("Overall Status: <fg={$statusColor}>{$health['status']}</>");
$this->newLine();
$this->table(
['Check', 'Status', 'Details'],
collect($health['checks'])->map(function ($check, $name) {
$status = $check['status'] ?? 'unknown';
$statusColor = match ($status) {
'healthy' => 'green',
'warning' => 'yellow',
'critical' => 'red',
default => 'white',
};
$details = collect($check)
->except('status', 'error')
->map(fn ($v, $k) => "{$k}: {$v}")
->implode(', ');
if (isset($check['error'])) {
$details = "Error: {$check['error']}";
}
return [
$name,
"<fg={$statusColor}>{$status}</>",
$details ?: '-',
];
})->toArray()
);
if (! empty($health['alerts'])) {
$this->newLine();
$this->warn('Alerts:');
foreach ($health['alerts'] as $alert) {
$icon = $alert['severity'] === 'critical' ? '!!!' : '!';
$this->line(" [{$icon}] {$alert['title']}");
}
}
}
private function attemptRestart(): void
{
$this->warn('Attempting to restart Horizon...');
// Check consecutive failure count
$failureKey = 'horizon_restart_failures';
$this->consecutiveFailures = (int) Cache::get($failureKey, 0);
if ($this->consecutiveFailures >= 2) {
$this->error('Restart failed twice. Escalating critical alert.');
SystemAlert::critical(
SystemAlert::TYPE_HORIZON_DOWN,
SystemAlert::SOURCE_HORIZON,
'Horizon restart failed multiple times',
'Horizon has failed to restart after 2 attempts. Manual intervention required.',
['consecutive_failures' => $this->consecutiveFailures]
);
// Reset counter
Cache::forget($failureKey);
return;
}
try {
// Try to terminate and restart via Artisan
Artisan::call('horizon:terminate');
$this->info('Horizon terminate signal sent.');
// The actual restart would be handled by supervisor/docker
// We just send the terminate signal here
// Wait a moment and check again
sleep(5);
$health = $this->governance->checkHorizonHealth();
if ($health['status'] === 'healthy') {
$this->info('Horizon restarted successfully.');
Cache::forget($failureKey);
} else {
Cache::put($failureKey, $this->consecutiveFailures + 1, now()->addHours(1));
$this->warn('Horizon still unhealthy after restart attempt.');
}
} catch (\Exception $e) {
Cache::put($failureKey, $this->consecutiveFailures + 1, now()->addHours(1));
$this->error('Restart attempt failed: '.$e->getMessage());
}
}
private function sendCriticalNotification(SystemAlert $alert): void
{
try {
// Get admin users to notify
$admins = \App\Models\User::where('user_type', 'admin')
->orWhere('user_type', 'superadmin')
->get();
if ($admins->isNotEmpty()) {
Notification::send($admins, new OrchestratorCriticalAlert($alert));
$alert->markNotificationSent();
$this->info('Critical alert notification sent.');
}
} catch (\Exception $e) {
$this->warn('Failed to send notification: '.$e->getMessage());
}
}
}

View File

@@ -1,255 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\AutomationRunLog;
use App\Models\MenuViewEvent;
use App\Models\OrchestratorMessageVariantStat;
use App\Models\OrchestratorTask;
use App\Models\Order;
use App\Models\SendMenuLog;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Schema;
/**
* OrchestratorEvaluateOutcomes - Evaluates outcomes of menu sends.
*
* This command runs periodically to determine if menu sends resulted in:
* - Menu views (buyer viewed the menu after send)
* - Orders (buyer placed an order after send)
*
* The outcomes are stored on both SendMenuLog and OrchestratorTask for analytics.
*/
class OrchestratorEvaluateOutcomes extends Command
{
protected $signature = 'orchestrator:evaluate-outcomes
{--days=30 : Number of days back to evaluate}
{--dry-run : Show what would be updated without making changes}';
protected $description = 'Evaluate outcomes (views/orders) for menu sends and orchestrator tasks';
protected int $viewsFound = 0;
protected int $ordersFound = 0;
protected int $logsUpdated = 0;
protected int $tasksUpdated = 0;
protected int $variantStatsRecorded = 0;
public function handle(): int
{
AutomationRunLog::recordStart(AutomationRunLog::CMD_EVALUATE_OUTCOMES);
$days = (int) $this->option('days');
$dryRun = $this->option('dry-run');
$this->info("Evaluating outcomes for send_menu_logs from the last {$days} days...");
if ($dryRun) {
$this->warn('DRY RUN - No changes will be made.');
}
// Get send menu logs that haven't been evaluated yet
$logs = SendMenuLog::query()
->whereNull('outcome_checked_at')
->where('sent_at', '>=', now()->subDays($days))
->with(['brand', 'menu', 'customer'])
->orderBy('sent_at', 'asc')
->get();
$this->info("Found {$logs->count()} logs to evaluate.");
$progressBar = $this->output->createProgressBar($logs->count());
$progressBar->start();
foreach ($logs as $log) {
$this->evaluateSendMenuLog($log, $dryRun);
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
// Summary
$this->info('Evaluation complete:');
$this->table(
['Metric', 'Count'],
[
['Logs evaluated', $logs->count()],
['Views found', $this->viewsFound],
['Orders found', $this->ordersFound],
['Send logs updated', $this->logsUpdated],
['Orchestrator tasks updated', $this->tasksUpdated],
['Variant stats recorded', $this->variantStatsRecorded],
]
);
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_EVALUATE_OUTCOMES, [
'logs_evaluated' => $logs->count(),
'views_found' => $this->viewsFound,
'orders_found' => $this->ordersFound,
'tasks_updated' => $this->tasksUpdated,
]);
return Command::SUCCESS;
}
/**
* Evaluate a single SendMenuLog for outcomes.
*/
protected function evaluateSendMenuLog(SendMenuLog $log, bool $dryRun): void
{
$resultedInView = false;
$resultedInOrder = false;
// Look for menu view events after the send
$viewExists = MenuViewEvent::query()
->where('business_id', $log->brand?->business_id)
->where('customer_id', $log->customer_id)
->where('menu_id', $log->menu_id)
->where('viewed_at', '>', $log->sent_at)
->exists();
if ($viewExists) {
$resultedInView = true;
$this->viewsFound++;
}
// Look for orders after the send (if Order table has the columns we need)
if (Schema::hasColumn('orders', 'business_id')) {
$orderExists = Order::query()
->where('business_id', $log->customer_id) // buyer's business
->whereHas('items.product.brand', function ($q) use ($log) {
$q->where('business_id', $log->brand?->business_id);
})
->where('created_at', '>', $log->sent_at)
->where('created_at', '<=', $log->sent_at->copy()->addDays(7)) // Within 7 days
->exists();
if ($orderExists) {
$resultedInOrder = true;
$this->ordersFound++;
}
}
// Update SendMenuLog
if (! $dryRun) {
$log->update([
'resulted_in_view' => $resultedInView,
'resulted_in_order' => $resultedInOrder,
'outcome_checked_at' => now(),
]);
$this->logsUpdated++;
}
// Update related OrchestratorTask if one exists
$this->updateRelatedTask($log, $resultedInView, $resultedInOrder, $dryRun);
}
/**
* Update the orchestrator task associated with this send, if any.
*/
protected function updateRelatedTask(SendMenuLog $log, bool $resultedInView, bool $resultedInOrder, bool $dryRun): void
{
// Check if outcome columns exist on orchestrator_tasks
if (! Schema::hasColumn('orchestrator_tasks', 'resulted_in_view')) {
return;
}
// Find tasks that reference this send_menu_log_id in their payload
$tasks = OrchestratorTask::query()
->where('business_id', $log->brand?->business_id)
->where('customer_id', $log->customer_id)
->whereJsonContains('payload->send_menu_log_id', $log->id)
->whereNull('outcome_checked_at')
->get();
if ($tasks->isEmpty()) {
// Also try finding by menu_id and rough time match
$tasks = OrchestratorTask::query()
->where('business_id', $log->brand?->business_id)
->where('customer_id', $log->customer_id)
->whereJsonContains('payload->menu_id', $log->menu_id)
->where('status', OrchestratorTask::STATUS_COMPLETED)
->where('completed_at', '>=', $log->sent_at->copy()->subMinutes(30))
->where('completed_at', '<=', $log->sent_at->copy()->addMinutes(30))
->whereNull('outcome_checked_at')
->get();
}
foreach ($tasks as $task) {
if (! $dryRun) {
$task->update([
'resulted_in_view' => $resultedInView,
'resulted_in_order' => $resultedInOrder,
'outcome_checked_at' => now(),
]);
$this->tasksUpdated++;
// Track A/B variant stats if this task used a variant
$this->recordVariantStats($task, $resultedInView, $resultedInOrder);
}
}
}
/**
* Record A/B variant statistics for a task.
*
* Increments view and/or order counts for the message variant used by this task.
* Send count is recorded separately when tasks are completed (via SendMenuLog tracking).
*/
protected function recordVariantStats(OrchestratorTask $task, bool $resultedInView, bool $resultedInOrder): void
{
// Check if variant stats table exists
if (! Schema::hasTable('orchestrator_message_variant_stats')) {
return;
}
$payload = $task->payload ?? [];
$variantKey = $payload['message_variant_key'] ?? null;
if (! $variantKey) {
return; // Task didn't use a variant
}
$businessId = $task->business_id;
$brandId = $task->brand_id;
$playbookType = $task->type;
// Record view stat
if ($resultedInView) {
OrchestratorMessageVariantStat::incrementView(
$businessId,
$brandId,
$playbookType,
$variantKey
);
$this->variantStatsRecorded++;
}
// Record order stat
if ($resultedInOrder) {
OrchestratorMessageVariantStat::incrementOrder(
$businessId,
$brandId,
$playbookType,
$variantKey
);
$this->variantStatsRecorded++;
}
// Record send stat (task was completed = message was sent)
// Only count once per task, check if this task was just now having its outcome evaluated
if ($task->status === OrchestratorTask::STATUS_COMPLETED) {
OrchestratorMessageVariantStat::incrementSend(
$businessId,
$brandId,
$playbookType,
$variantKey
);
$this->variantStatsRecorded++;
}
}
}

View File

@@ -1,149 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\AutomationRunLog;
use App\Models\OrchestratorPlaybookStatus;
use App\Models\OrchestratorTask;
use Illuminate\Console\Command;
/**
* OrchestratorEvaluatePlaybooks - Evaluates playbook performance and auto-quarantines.
*
* Analyzes 30-day rolling metrics for each playbook and quarantines those
* that fall below performance thresholds. This prevents misbehaving playbooks
* from generating poor suggestions.
*/
class OrchestratorEvaluatePlaybooks extends Command
{
protected $signature = 'orchestrator:evaluate-playbooks
{--dry-run : Show what would happen without making changes}
{--business= : Evaluate for specific business ID}
{--auto-quarantine : Actually quarantine underperformers (default: report only)}';
protected $description = 'Evaluate playbook performance and auto-quarantine underperformers';
public function handle(): int
{
AutomationRunLog::recordStart(AutomationRunLog::CMD_EVALUATE_PLAYBOOKS);
$dryRun = $this->option('dry-run');
$businessId = $this->option('business');
$autoQuarantine = $this->option('auto-quarantine');
$this->info('Evaluating playbook performance...');
if ($dryRun) {
$this->warn('DRY RUN - No changes will be made.');
}
$playbooks = OrchestratorPlaybookStatus::getAllPlaybookTypes();
$thirtyDaysAgo = now()->subDays(30);
$results = [];
foreach ($playbooks as $playbookType) {
$this->line("Analyzing: {$playbookType}");
// Get task metrics
$query = OrchestratorTask::forType($playbookType)
->where('created_at', '>=', $thirtyDaysAgo);
if ($businessId) {
$query->forBusiness((int) $businessId);
}
$created = $query->count();
$completed = (clone $query)->completed()->count();
$dismissed = (clone $query)->dismissed()->count();
$resultedInView = (clone $query)->where('resulted_in_view', true)->count();
$resultedInOrder = (clone $query)->where('resulted_in_order', true)->count();
// Calculate rates
$resolved = $completed + $dismissed;
$viewRate = $resolved > 0 ? round(($resultedInView / $resolved) * 100, 2) : 0;
$orderRate = $resolved > 0 ? round(($resultedInOrder / $resolved) * 100, 2) : 0;
$dismissalRate = $resolved > 0 ? round(($dismissed / $resolved) * 100, 2) : 0;
// Get or create status record
$status = OrchestratorPlaybookStatus::getOrCreate($playbookType, $businessId ? (int) $businessId : null);
// Update metrics
if (! $dryRun) {
$status->updateMetrics([
'tasks_created_30d' => $created,
'tasks_completed_30d' => $completed,
'tasks_dismissed_30d' => $dismissed,
'resulted_in_view_30d' => $resultedInView,
'resulted_in_order_30d' => $resultedInOrder,
'view_rate_30d' => $viewRate,
'order_rate_30d' => $orderRate,
'dismissal_rate_30d' => $dismissalRate,
]);
}
// Check quarantine conditions
$shouldQuarantine = $status->shouldQuarantine();
$healthStatus = $status->getHealthStatus();
$results[] = [
'playbook' => $this->formatPlaybookName($playbookType),
'status' => $status->status,
'created' => $created,
'completed' => $completed,
'dismissed' => $dismissed,
'view_rate' => $viewRate.'%',
'order_rate' => $orderRate.'%',
'dismissal' => $dismissalRate.'%',
'health' => $healthStatus,
'action' => $shouldQuarantine ? 'QUARANTINE' : '-',
];
// Auto-quarantine if enabled
if ($shouldQuarantine && $autoQuarantine && ! $dryRun && $status->isActive()) {
$status->quarantine($shouldQuarantine);
$this->error(" QUARANTINED: {$shouldQuarantine}");
} elseif ($shouldQuarantine) {
$this->warn(" Would quarantine: {$shouldQuarantine}");
}
}
// Display results table
$this->newLine();
$this->table(
['Playbook', 'Status', 'Created', 'Done', 'Dismissed', 'View%', 'Order%', 'Dismiss%', 'Health', 'Action'],
$results
);
// Summary
$quarantined = collect($results)->where('action', 'QUARANTINE')->count();
$this->newLine();
$this->info("Evaluation complete. {$quarantined} playbook(s) flagged for quarantine.");
if ($quarantined > 0 && ! $autoQuarantine) {
$this->warn('Run with --auto-quarantine to actually quarantine underperformers.');
}
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_EVALUATE_PLAYBOOKS, [
'playbooks_evaluated' => count($playbooks),
'quarantined' => $quarantined,
]);
return Command::SUCCESS;
}
private function formatPlaybookName(string $type): string
{
return match ($type) {
OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW => 'No View',
OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER => 'Viewed No Order',
OrchestratorTask::TYPE_REACTIVATION_NO_ORDER_30D => 'Reactivation',
OrchestratorTask::TYPE_PROMOTION_BROADCAST_SUGGESTION => 'New Menu',
OrchestratorTask::TYPE_HIGH_INTENT_BUYER => 'High Intent',
OrchestratorTask::TYPE_VIP_BUYER => 'VIP Buyer',
OrchestratorTask::TYPE_GHOSTED_BUYER_RESCUE => 'Ghosted',
OrchestratorTask::TYPE_AT_RISK_ACCOUNT => 'At-Risk',
default => $type,
};
}
}

View File

@@ -1,413 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\AutomationRunLog;
use App\Models\BrandOrchestratorProfile;
use App\Models\OrchestratorMessageVariant;
use App\Models\OrchestratorTask;
use App\Models\SystemAlert;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class OrchestratorSelfAudit extends Command
{
protected $signature = 'orchestrator:self-audit
{--fix : Attempt to fix issues where possible}
{--days=30 : Days to look back for stale tasks}';
protected $description = 'Audit orchestrator data for integrity issues';
private array $issues = [];
private array $fixes = [];
public function handle(): int
{
AutomationRunLog::recordStart(AutomationRunLog::CMD_SELF_AUDIT);
$this->info('Running orchestrator self-audit...');
$this->newLine();
$days = (int) $this->option('days');
$shouldFix = $this->option('fix');
try {
// Run all audit checks
$this->auditStaleTasks($days, $shouldFix);
$this->auditMissingPayloadFields($shouldFix);
$this->auditImpossibleStates($shouldFix);
$this->auditBrandProfiles();
$this->auditMessageVariants();
$this->auditMissingOutcomes($days);
$this->auditOrphanedRecords();
// Display results
$this->displayResults();
// Create system alerts for significant issues
$this->createAlertsForIssues();
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_SELF_AUDIT, [
'issues_found' => count($this->issues),
'fixes_applied' => count($this->fixes),
'days_audited' => $days,
]);
return count($this->issues) > 0 ? self::FAILURE : self::SUCCESS;
} catch (\Exception $e) {
AutomationRunLog::recordFailure(AutomationRunLog::CMD_SELF_AUDIT, $e->getMessage());
$this->error('Self-audit failed: '.$e->getMessage());
return self::FAILURE;
}
}
private function auditStaleTasks(int $days, bool $fix): void
{
$this->info('Checking for stale uncompleted tasks...');
$staleCount = OrchestratorTask::where('status', OrchestratorTask::STATUS_PENDING)
->where('created_at', '<', now()->subDays($days))
->count();
if ($staleCount > 0) {
$this->issues[] = [
'type' => 'stale_tasks',
'severity' => $staleCount > 100 ? 'warning' : 'info',
'message' => "{$staleCount} tasks pending for more than {$days} days",
'count' => $staleCount,
];
if ($fix && $staleCount > 0) {
// Auto-dismiss very old tasks (older than 60 days)
$veryOld = OrchestratorTask::where('status', OrchestratorTask::STATUS_PENDING)
->where('created_at', '<', now()->subDays(60))
->update([
'status' => OrchestratorTask::STATUS_DISMISSED,
'dismissed_reason' => 'Auto-dismissed by self-audit (>60 days old)',
]);
if ($veryOld > 0) {
$this->fixes[] = "Auto-dismissed {$veryOld} tasks older than 60 days";
}
}
}
$this->line(" Found {$staleCount} stale tasks");
}
private function auditMissingPayloadFields(bool $fix): void
{
$this->info('Checking for tasks with missing payload fields...');
// Tasks should have suggested_message in payload
$missingMessage = OrchestratorTask::whereNull('payload')
->orWhereRaw("payload::text = '{}'")
->orWhereRaw("payload::text = 'null'")
->count();
if ($missingMessage > 0) {
$this->issues[] = [
'type' => 'missing_payload',
'severity' => 'info',
'message' => "{$missingMessage} tasks have empty or null payload",
'count' => $missingMessage,
];
}
$this->line(" Found {$missingMessage} tasks with missing payload");
}
private function auditImpossibleStates(bool $fix): void
{
$this->info('Checking for impossible task states...');
$issues = 0;
// Approved but not visible to reps
if (Schema::hasColumn('orchestrator_tasks', 'approval_state')) {
$approvedInvisible = OrchestratorTask::where('approval_state', 'approved')
->where('visible_to_reps', false)
->where('status', OrchestratorTask::STATUS_PENDING)
->count();
if ($approvedInvisible > 0) {
$issues += $approvedInvisible;
$this->issues[] = [
'type' => 'impossible_state',
'severity' => 'warning',
'message' => "{$approvedInvisible} tasks are approved but not visible to reps",
'count' => $approvedInvisible,
];
if ($fix) {
$fixed = OrchestratorTask::where('approval_state', 'approved')
->where('visible_to_reps', false)
->where('status', OrchestratorTask::STATUS_PENDING)
->update(['visible_to_reps' => true]);
if ($fixed > 0) {
$this->fixes[] = "Fixed {$fixed} approved tasks to be visible";
}
}
}
// Blocked but still visible
$blockedVisible = OrchestratorTask::where('approval_state', 'blocked')
->where('visible_to_reps', true)
->count();
if ($blockedVisible > 0) {
$issues += $blockedVisible;
$this->issues[] = [
'type' => 'impossible_state',
'severity' => 'warning',
'message' => "{$blockedVisible} tasks are blocked but still visible to reps",
'count' => $blockedVisible,
];
if ($fix) {
$fixed = OrchestratorTask::where('approval_state', 'blocked')
->where('visible_to_reps', true)
->update(['visible_to_reps' => false]);
if ($fixed > 0) {
$this->fixes[] = "Fixed {$fixed} blocked tasks to be invisible";
}
}
}
}
// Completed but no completed_at
$completedNoDate = OrchestratorTask::where('status', OrchestratorTask::STATUS_COMPLETED)
->whereNull('completed_at')
->count();
if ($completedNoDate > 0) {
$issues += $completedNoDate;
$this->issues[] = [
'type' => 'impossible_state',
'severity' => 'info',
'message' => "{$completedNoDate} completed tasks have no completed_at timestamp",
'count' => $completedNoDate,
];
if ($fix) {
$fixed = OrchestratorTask::where('status', OrchestratorTask::STATUS_COMPLETED)
->whereNull('completed_at')
->update(['completed_at' => DB::raw('updated_at')]);
if ($fixed > 0) {
$this->fixes[] = "Set completed_at for {$fixed} completed tasks";
}
}
}
$this->line(" Found {$issues} impossible state issues");
}
private function auditBrandProfiles(): void
{
$this->info('Checking brand orchestrator profiles...');
if (! Schema::hasTable('brand_orchestrator_profiles')) {
$this->line(' Brand profiles table not found (skipping)');
return;
}
// Check for profiles with invalid brand_id
$orphanedProfiles = BrandOrchestratorProfile::whereDoesntHave('brand')->count();
if ($orphanedProfiles > 0) {
$this->issues[] = [
'type' => 'orphaned_profiles',
'severity' => 'warning',
'message' => "{$orphanedProfiles} brand profiles reference non-existent brands",
'count' => $orphanedProfiles,
];
}
// Check for duplicate brand profiles
$duplicates = DB::table('brand_orchestrator_profiles')
->select('brand_id', DB::raw('COUNT(*) as count'))
->groupBy('brand_id')
->having('count', '>', 1)
->count();
if ($duplicates > 0) {
$this->issues[] = [
'type' => 'duplicate_profiles',
'severity' => 'warning',
'message' => "{$duplicates} brands have duplicate orchestrator profiles",
'count' => $duplicates,
];
}
$this->line(" Found {$orphanedProfiles} orphaned, {$duplicates} duplicate profiles");
}
private function auditMessageVariants(): void
{
$this->info('Checking message variants...');
if (! Schema::hasTable('orchestrator_message_variants')) {
$this->line(' Message variants table not found (skipping)');
return;
}
// Check for variants with invalid business_id
$orphanedVariants = OrchestratorMessageVariant::whereDoesntHave('business')->count();
if ($orphanedVariants > 0) {
$this->issues[] = [
'type' => 'orphaned_variants',
'severity' => 'warning',
'message' => "{$orphanedVariants} message variants reference non-existent businesses",
'count' => $orphanedVariants,
];
}
// Check for variants with empty body
$emptyBody = OrchestratorMessageVariant::where(function ($q) {
$q->whereNull('body')
->orWhere('body', '');
})->count();
if ($emptyBody > 0) {
$this->issues[] = [
'type' => 'empty_variants',
'severity' => 'warning',
'message' => "{$emptyBody} message variants have empty body text",
'count' => $emptyBody,
];
}
$this->line(" Found {$orphanedVariants} orphaned, {$emptyBody} empty variants");
}
private function auditMissingOutcomes(int $days): void
{
$this->info('Checking for tasks missing outcome evaluation...');
if (! Schema::hasColumn('orchestrator_tasks', 'outcome_checked_at')) {
$this->line(' Outcome columns not found (skipping)');
return;
}
// Tasks completed more than 7 days ago but never checked for outcomes
$missingOutcomes = OrchestratorTask::where('status', OrchestratorTask::STATUS_COMPLETED)
->whereNull('outcome_checked_at')
->where('completed_at', '<', now()->subDays(7))
->where('completed_at', '>=', now()->subDays($days))
->count();
if ($missingOutcomes > 0) {
$this->issues[] = [
'type' => 'missing_outcomes',
'severity' => 'info',
'message' => "{$missingOutcomes} completed tasks never had outcomes evaluated",
'count' => $missingOutcomes,
];
}
$this->line(" Found {$missingOutcomes} tasks missing outcome evaluation");
}
private function auditOrphanedRecords(): void
{
$this->info('Checking for orphaned records...');
// Tasks referencing non-existent businesses
$orphanedByBusiness = OrchestratorTask::whereDoesntHave('business')->count();
if ($orphanedByBusiness > 0) {
$this->issues[] = [
'type' => 'orphaned_tasks',
'severity' => 'warning',
'message' => "{$orphanedByBusiness} tasks reference non-existent businesses",
'count' => $orphanedByBusiness,
];
}
// Tasks referencing non-existent customers
$orphanedByCustomer = OrchestratorTask::whereNotNull('customer_id')
->whereDoesntHave('customer')
->count();
if ($orphanedByCustomer > 0) {
$this->issues[] = [
'type' => 'orphaned_tasks',
'severity' => 'info',
'message' => "{$orphanedByCustomer} tasks reference non-existent customers",
'count' => $orphanedByCustomer,
];
}
$this->line(" Found {$orphanedByBusiness} orphaned by business, {$orphanedByCustomer} by customer");
}
private function displayResults(): void
{
$this->newLine();
if (empty($this->issues)) {
$this->info('No issues found. Data integrity looks good!');
} else {
$this->warn('Issues Found:');
$this->newLine();
$this->table(
['Type', 'Severity', 'Count', 'Message'],
collect($this->issues)->map(fn ($issue) => [
$issue['type'],
$issue['severity'],
$issue['count'] ?? '-',
$issue['message'],
])->toArray()
);
}
if (! empty($this->fixes)) {
$this->newLine();
$this->info('Fixes Applied:');
foreach ($this->fixes as $fix) {
$this->line(" - {$fix}");
}
}
$this->newLine();
$this->line('Summary: '.count($this->issues).' issues found, '.count($this->fixes).' fixes applied');
}
private function createAlertsForIssues(): void
{
// Only create alerts for significant issues
$significantIssues = collect($this->issues)
->filter(fn ($issue) => $issue['severity'] === 'warning' || ($issue['count'] ?? 0) > 50);
if ($significantIssues->isEmpty()) {
return;
}
$summary = $significantIssues
->map(fn ($issue) => $issue['message'])
->implode('; ');
SystemAlert::createAlert(
SystemAlert::TYPE_AUDIT_ISSUE,
SystemAlert::SEVERITY_WARNING,
SystemAlert::SOURCE_SELF_AUDIT,
'Self-audit found '.count($this->issues).' issues',
$summary,
['issues' => $this->issues, 'fixes' => $this->fixes],
1440 // Dedupe: 24 hours
);
}
}

View File

@@ -1,120 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Mail\DailySalesOpsReport;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class OrchestratorSendDailyReport extends Command
{
protected $signature = 'orchestrator:send-daily-report
{--to= : Email address to send to (overrides default)}
{--preview : Display report in console instead of sending}';
protected $description = 'Send the daily sales ops report email';
public function handle(): int
{
$this->info('Preparing daily sales ops report...');
$report = new DailySalesOpsReport;
if ($this->option('preview')) {
$this->displayPreview($report);
return self::SUCCESS;
}
$recipients = $this->getRecipients();
if (empty($recipients)) {
$this->warn('No recipients configured. Use --to option or configure admin emails.');
return self::FAILURE;
}
$this->info('Sending report to: '.implode(', ', $recipients));
try {
Mail::to($recipients)->send($report);
$this->info('Daily report sent successfully.');
return self::SUCCESS;
} catch (\Exception $e) {
$this->error('Failed to send report: '.$e->getMessage());
return self::FAILURE;
}
}
private function getRecipients(): array
{
// Check for override
if ($to = $this->option('to')) {
return [$to];
}
// Get admin/superadmin users
$admins = User::where('user_type', 'admin')
->orWhere('user_type', 'superadmin')
->whereNotNull('email')
->pluck('email')
->toArray();
// Could also check BusinessMailSettings for specific ops email
// $opsEmail = config('orchestrator.daily_report_email');
// if ($opsEmail) {
// $admins[] = $opsEmail;
// }
return array_unique($admins);
}
private function displayPreview(DailySalesOpsReport $report): void
{
$this->newLine();
$this->info('=== Daily Sales Ops Report Preview ===');
$this->newLine();
$summary = $report->summary;
$this->table(
['Metric', 'Value'],
[
['Tasks Created (24h)', $summary['tasks_24h']['created']],
['Completed', $summary['tasks_24h']['completed']],
['Dismissed', $summary['tasks_24h']['dismissed']],
['Pending', $summary['tasks_24h']['pending']],
['High-Priority Pending', $summary['tasks_24h']['high_priority_pending']],
['Awaiting Approval', $summary['approvals']['pending_count']],
]
);
$this->newLine();
$this->info('Alerts:');
$this->table(
['Severity', 'Count'],
[
['Critical', $summary['alerts']['critical']],
['Warning', $summary['alerts']['warning']],
['Info', $summary['alerts']['info']],
]
);
$this->newLine();
$this->info('Queue Health: '.$summary['queue_health']['status']);
$this->info('Automation Health: '.$summary['automation_health']['overall_status']);
if (! empty($summary['alerts']['items'])) {
$this->newLine();
$this->warn('Alert Details:');
foreach (array_slice($summary['alerts']['items'], 0, 5) as $alert) {
$this->line(" [{$alert['severity']}] {$alert['title']}");
}
}
}
}

View File

@@ -1,204 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\AutomationRunLog;
use App\Models\SystemAlert;
use App\Notifications\OrchestratorCriticalAlert;
use App\Services\OrchestratorGovernanceService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Notification;
class OrchestratorWatchdog extends Command
{
protected $signature = 'orchestrator:watchdog
{--notify : Send notifications for critical issues}';
protected $description = 'Watch automation schedules and alert on stale processes';
private OrchestratorGovernanceService $governance;
public function __construct(OrchestratorGovernanceService $governance)
{
parent::__construct();
$this->governance = $governance;
}
public function handle(): int
{
AutomationRunLog::recordStart(AutomationRunLog::CMD_WATCHDOG);
$this->info('Running orchestrator watchdog checks...');
$this->newLine();
$issues = [];
$alertsCreated = 0;
try {
// Check all automation schedules
$statuses = AutomationRunLog::getAllStatuses();
$this->table(
['Automation', 'Last Run', 'Status', 'Health'],
collect($statuses)->map(function ($status) {
$lastRun = $status['last_run_at']
? $status['last_run_at']->diffForHumans()
: 'Never';
$statusColor = match ($status['last_status']) {
AutomationRunLog::STATUS_SUCCESS => 'green',
AutomationRunLog::STATUS_FAILED => 'red',
AutomationRunLog::STATUS_RUNNING => 'yellow',
default => 'gray',
};
$healthColor = $status['health'] === 'healthy' ? 'green' : 'red';
return [
$status['command'],
$lastRun,
"<fg={$statusColor}>{$status['last_status']}</>",
"<fg={$healthColor}>{$status['health']}</>",
];
})->toArray()
);
// Create alerts for unhealthy automations
foreach ($statuses as $command => $status) {
if ($status['health'] === 'unhealthy') {
$issues[] = $command;
$alert = $this->createStaleAlert($command, $status);
if ($alert) {
$alertsCreated++;
if ($this->option('notify') && $status['is_failing']) {
$this->sendNotification($alert);
}
}
} else {
// Auto-resolve previous alerts for this command
SystemAlert::autoResolve(
SystemAlert::TYPE_AUTOMATION_STALE,
SystemAlert::SOURCE_WATCHDOG,
"Auto-resolved: {$command} is running"
);
}
}
// Run governance checks
$this->newLine();
$this->info('Running governance checks...');
$governanceAlerts = $this->governance->runAllChecks();
foreach ($governanceAlerts as $alertData) {
$alert = SystemAlert::createAlert(
$alertData['type'],
$alertData['severity'],
SystemAlert::SOURCE_GOVERNANCE,
$alertData['title'],
$alertData['message'],
$alertData['context'] ?? [],
60 // Dedupe: 1 hour
);
if ($alert) {
$alertsCreated++;
$this->line(" [!] {$alertData['title']}");
if ($this->option('notify') && $alert->isCritical()) {
$this->sendNotification($alert);
}
}
}
// Summary
$this->newLine();
$unhealthyCount = count($issues);
$governanceIssues = count($governanceAlerts);
if ($unhealthyCount === 0 && $governanceIssues === 0) {
$this->info('All systems healthy.');
} else {
$this->warn("Issues found: {$unhealthyCount} stale automations, {$governanceIssues} governance alerts");
}
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_WATCHDOG, [
'unhealthy_automations' => $unhealthyCount,
'governance_alerts' => $governanceIssues,
'alerts_created' => $alertsCreated,
]);
return $unhealthyCount > 0 ? self::FAILURE : self::SUCCESS;
} catch (\Exception $e) {
AutomationRunLog::recordFailure(AutomationRunLog::CMD_WATCHDOG, $e->getMessage());
$this->error('Watchdog failed: '.$e->getMessage());
return self::FAILURE;
}
}
private function createStaleAlert(string $command, array $status): ?SystemAlert
{
$label = $this->getCommandLabel($command);
$severity = $status['is_failing']
? SystemAlert::SEVERITY_CRITICAL
: SystemAlert::SEVERITY_WARNING;
$message = $status['is_stale']
? "{$label} has not run in over {$status['expected_interval_minutes']} minutes."
: "{$label} is failing. Last error: ".($status['last_error'] ?? 'Unknown');
return SystemAlert::createAlert(
SystemAlert::TYPE_AUTOMATION_STALE,
$severity,
SystemAlert::SOURCE_WATCHDOG,
"{$label} is unhealthy",
$message,
[
'command' => $command,
'last_run_at' => $status['last_run_at']?->toIso8601String(),
'last_status' => $status['last_status'],
'consecutive_failures' => $status['consecutive_failures'],
'expected_interval' => $status['expected_interval_minutes'],
],
30 // Dedupe: 30 minutes
);
}
private function getCommandLabel(string $command): string
{
return match ($command) {
AutomationRunLog::CMD_GENERATE_SALES_TASKS => 'Sales Task Generation',
AutomationRunLog::CMD_GENERATE_MARKETING_TASKS => 'Marketing Task Generation',
AutomationRunLog::CMD_EVALUATE_OUTCOMES => 'Outcome Evaluation',
AutomationRunLog::CMD_ANALYZE_TIMING => 'Timing Analysis',
AutomationRunLog::CMD_EVALUATE_PLAYBOOKS => 'Playbook Evaluation',
AutomationRunLog::CMD_CHECK_HORIZON => 'Horizon Health Check',
AutomationRunLog::CMD_WATCHDOG => 'Watchdog',
AutomationRunLog::CMD_SELF_AUDIT => 'Self Audit',
AutomationRunLog::CMD_BUYER_SCORING => 'Buyer Scoring',
default => $command,
};
}
private function sendNotification(SystemAlert $alert): void
{
try {
$admins = \App\Models\User::where('user_type', 'admin')
->orWhere('user_type', 'superadmin')
->get();
if ($admins->isNotEmpty()) {
Notification::send($admins, new OrchestratorCriticalAlert($alert));
$alert->markNotificationSent();
$this->info('Notification sent for: '.$alert->title);
}
} catch (\Exception $e) {
$this->warn('Failed to send notification: '.$e->getMessage());
}
}
}

View File

@@ -1,207 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\AuditPruningSettings;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class PruneAudits extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'audits:prune {--business= : Prune audits for specific business ID} {--dry-run : Show what would be deleted without actually deleting}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Prune old audit logs based on configured retention policies';
/**
* Execute the console command.
*/
public function handle()
{
$dryRun = $this->option('dry-run');
$businessId = $this->option('business');
if ($dryRun) {
$this->warn('🔍 DRY RUN MODE - No audits will be deleted');
}
// Get businesses with pruning enabled
$query = AuditPruningSettings::where('enabled', true);
if ($businessId) {
$query->where('business_id', $businessId);
}
$settings = $query->get();
if ($settings->isEmpty()) {
$this->info('No businesses have audit pruning enabled.');
return 0;
}
$totalDeleted = 0;
foreach ($settings as $setting) {
$business = $setting->business;
$businessName = $business ? $business->name : 'Global';
$this->info("Processing: {$businessName}");
$this->line(" Strategy: {$setting->strategy}");
$deleted = 0;
try {
switch ($setting->strategy) {
case 'revisions':
$deleted = $this->pruneByRevisions($setting, $dryRun);
break;
case 'time':
$deleted = $this->pruneByTime($setting, $dryRun);
break;
case 'hybrid':
$deleted = $this->pruneHybrid($setting, $dryRun);
break;
}
if (! $dryRun) {
$setting->update([
'last_pruned_at' => now(),
'last_pruned_count' => $deleted,
]);
}
$this->line(" Deleted: {$deleted} audits");
$totalDeleted += $deleted;
} catch (\Exception $e) {
$this->error(" Error: {$e->getMessage()}");
}
}
$this->newLine();
$this->info("✓ Pruning complete! Total deleted: {$totalDeleted}");
return 0;
}
/**
* Prune audits keeping only last N revisions per record
*/
protected function pruneByRevisions(AuditPruningSettings $setting, bool $dryRun = false): int
{
$keepRevisions = $setting->keep_revisions;
$businessId = $setting->business_id;
// Get all unique auditable records
$auditableRecords = DB::table('audits')
->select('auditable_type', 'auditable_id')
->distinct()
->get();
$totalDeleted = 0;
foreach ($auditableRecords as $record) {
// Get IDs of audits to keep (last N revisions)
$keepIds = DB::table('audits')
->where('auditable_type', $record->auditable_type)
->where('auditable_id', $record->auditable_id)
->orderBy('created_at', 'desc')
->limit($keepRevisions)
->pluck('id');
// Delete older audits
$query = DB::table('audits')
->where('auditable_type', $record->auditable_type)
->where('auditable_id', $record->auditable_id)
->whereNotIn('id', $keepIds);
if ($dryRun) {
$totalDeleted += $query->count();
} else {
$totalDeleted += $query->delete();
}
}
return $totalDeleted;
}
/**
* Prune audits older than N days
*/
protected function pruneByTime(AuditPruningSettings $setting, bool $dryRun = false): int
{
$keepDays = $setting->keep_days;
$cutoffDate = now()->subDays($keepDays);
$query = DB::table('audits')
->where('created_at', '<', $cutoffDate);
if ($dryRun) {
return $query->count();
}
return $query->delete();
}
/**
* Hybrid: Keep last N revisions OR audits from last M days (union, whichever keeps more)
*/
protected function pruneHybrid(AuditPruningSettings $setting, bool $dryRun = false): int
{
$keepRevisions = $setting->keep_revisions;
$keepDays = $setting->keep_days;
$cutoffDate = now()->subDays($keepDays);
// Get all unique auditable records
$auditableRecords = DB::table('audits')
->select('auditable_type', 'auditable_id')
->distinct()
->get();
$totalDeleted = 0;
foreach ($auditableRecords as $record) {
// Get IDs to keep from both strategies
$keepByRevision = DB::table('audits')
->where('auditable_type', $record->auditable_type)
->where('auditable_id', $record->auditable_id)
->orderBy('created_at', 'desc')
->limit($keepRevisions)
->pluck('id');
$keepByTime = DB::table('audits')
->where('auditable_type', $record->auditable_type)
->where('auditable_id', $record->auditable_id)
->where('created_at', '>=', $cutoffDate)
->pluck('id');
// Union of both (keep if matches either rule)
$keepIds = $keepByRevision->merge($keepByTime)->unique();
// Delete everything NOT in keep list
$query = DB::table('audits')
->where('auditable_type', $record->auditable_type)
->where('auditable_id', $record->auditable_id)
->whereNotIn('id', $keepIds);
if ($dryRun) {
$totalDeleted += $query->count();
} else {
$totalDeleted += $query->delete();
}
}
return $totalDeleted;
}
}

View File

@@ -1,43 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Product;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class ResetProductImagePaths extends Command
{
protected $signature = 'media:reset-product-paths';
protected $description = 'Reset product image paths back to old format for re-migration';
public function handle()
{
$products = Product::whereNotNull('image_path')->get();
$this->info("Resetting {$products->count()} product image paths...");
$progressBar = $this->output->createProgressBar($products->count());
$reset = 0;
foreach ($products as $product) {
if (preg_match('#/images/(.+)$#', $product->image_path, $matches)) {
$filename = $matches[1];
$oldPath = 'businesses/cannabrands/products/'.$product->id.'/'.$filename;
if (Storage::exists($oldPath)) {
$product->image_path = $oldPath;
$product->save();
$reset++;
}
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine();
$this->info("✓ Reset {$reset} products to old paths");
return 0;
}
}

View File

@@ -1,159 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Menu;
use Illuminate\Console\Command;
class RestoreBrandMenus extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'restore:brand-menus
{--business= : Business slug or ID to restore menus for}
{--dry-run : Preview what would be created without saving}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Restore the 3 default menus for all brands under a business (idempotent)';
/**
* The three default menus to create per brand.
*/
protected array $defaultMenus = [
[
'slug' => 'default',
'name' => 'Default Menu',
'description' => 'The default product menu for this brand',
'type' => 'catalog',
],
[
'slug' => 'promotions',
'name' => 'On Sale',
'description' => 'Products currently on promotion',
'type' => 'promotional',
],
[
'slug' => 'new-drops',
'name' => 'New & Featured',
'description' => 'New arrivals and featured products',
'type' => 'featured',
],
];
/**
* Execute the console command.
*/
public function handle(): int
{
$businessSlugOrId = $this->option('business');
$isDryRun = $this->option('dry-run');
if (! $businessSlugOrId) {
$this->error('Please specify a business with --business=<slug or id>');
return Command::FAILURE;
}
// Resolve business by slug first, then by ID if numeric
$business = Business::where('slug', $businessSlugOrId)->first();
if (! $business && is_numeric($businessSlugOrId)) {
$business = Business::find($businessSlugOrId);
}
if (! $business) {
$this->error("Business not found: {$businessSlugOrId}");
return Command::FAILURE;
}
$this->info("Restoring brand menus for business: {$business->name} (ID: {$business->id})");
if ($isDryRun) {
$this->warn('DRY RUN - no changes will be made');
}
$this->newLine();
// Load all brands for this business
$brands = Brand::where('business_id', $business->id)->get();
if ($brands->isEmpty()) {
$this->warn('No brands found for this business.');
return Command::SUCCESS;
}
$this->info("Found {$brands->count()} brand(s)");
$this->newLine();
$created = 0;
$skipped = 0;
foreach ($brands as $brand) {
$this->line("Brand: {$brand->name} (ID: {$brand->id})");
$this->line(str_repeat('─', 50));
foreach ($this->defaultMenus as $menuData) {
$exists = Menu::where('business_id', $business->id)
->where('brand_id', $brand->id)
->where('slug', $menuData['slug'])
->exists();
if ($exists) {
$this->line("{$menuData['name']} ({$menuData['slug']}) - already exists");
$skipped++;
continue;
}
if ($isDryRun) {
$this->line("{$menuData['name']} ({$menuData['slug']}) - would create");
$created++;
continue;
}
Menu::create([
'business_id' => $business->id,
'brand_id' => $brand->id,
'slug' => $menuData['slug'],
'name' => $menuData['name'],
'description' => $menuData['description'],
'type' => $menuData['type'],
'is_system' => false,
'status' => 'active',
'visibility' => 'public',
'position' => array_search($menuData, $this->defaultMenus) + 1,
]);
$this->line("{$menuData['name']} ({$menuData['slug']}) - CREATED");
$created++;
}
$this->newLine();
}
// Summary
$this->newLine();
$this->info('Summary');
$this->line(str_repeat('═', 50));
$this->line(" Created: {$created} menu(s)");
$this->line(" Skipped: {$skipped} (already exist)");
$this->line(str_repeat('─', 50));
if ($isDryRun) {
$this->warn('This was a dry run. Run without --dry-run to apply changes.');
}
return Command::SUCCESS;
}
}

View File

@@ -1,186 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Restore Cannabrands data from PostgreSQL SQL dumps.
*
* This command loads data from pre-exported SQL files in database/dumps/
* without requiring a MySQL connection. Data was originally imported from
* the MySQL hub_cannabrands database.
*
* Order of restoration matters due to foreign key constraints:
* 1. strains (no dependencies)
* 2. product_categories (self-referential via parent_id)
* 3. businesses (no dependencies)
* 4. users (no dependencies)
* 5. brands (depends on businesses)
* 6. locations (depends on businesses)
* 7. contacts (depends on businesses, locations)
* 8. products (depends on brands, strains, product_categories)
* 9. orders (depends on businesses)
* 10. order_items (depends on orders, products)
* 11. invoices (depends on orders, businesses)
* 12. business_user (depends on businesses, users)
* 13. brand_user (depends on brands, users)
* 14. model_has_roles (depends on users, roles)
* 15. ai_settings (depends on businesses)
* 16. orchestrator_sales_configs (depends on businesses)
* 17. orchestrator_marketing_configs (depends on businesses)
*/
class RestoreCannabrandsData extends Command
{
protected $signature = 'db:restore-cannabrands
{--fresh : Truncate tables before restoring}
{--tables= : Comma-separated list of specific tables to restore}';
protected $description = 'Restore Cannabrands data from PostgreSQL SQL dumps';
// Tables in dependency order
protected array $tables = [
'strains',
'product_categories',
'businesses',
'users',
'brands',
'locations',
'contacts',
'products',
'orders',
'order_items',
'invoices',
'business_user',
'brand_user',
'model_has_roles',
'ai_settings',
'orchestrator_sales_configs',
'orchestrator_marketing_configs',
];
protected string $dumpsPath;
public function __construct()
{
parent::__construct();
$this->dumpsPath = database_path('dumps');
}
public function handle(): int
{
$this->info('Restoring Cannabrands data from SQL dumps...');
// Check if dumps directory exists
if (! is_dir($this->dumpsPath)) {
$this->error("Dumps directory not found: {$this->dumpsPath}");
$this->error('Run the MySQL import seeders first to create the dumps.');
return Command::FAILURE;
}
// Determine which tables to restore
$tablesToRestore = $this->tables;
if ($this->option('tables')) {
$requestedTables = array_map('trim', explode(',', $this->option('tables')));
$tablesToRestore = array_intersect($this->tables, $requestedTables);
if (empty($tablesToRestore)) {
$this->error('No valid tables specified. Available tables: '.implode(', ', $this->tables));
return Command::FAILURE;
}
}
// Fresh option - truncate tables in reverse order
if ($this->option('fresh')) {
$this->warn('Truncating tables before restore...');
DB::statement('SET session_replication_role = replica;'); // Disable FK checks
foreach (array_reverse($tablesToRestore) as $table) {
$this->line("Truncating {$table}...");
DB::table($table)->truncate();
}
DB::statement('SET session_replication_role = DEFAULT;'); // Re-enable FK checks
}
// Restore each table
$restored = 0;
$errors = 0;
foreach ($tablesToRestore as $table) {
$dumpFile = "{$this->dumpsPath}/{$table}.sql";
if (! file_exists($dumpFile)) {
$this->warn("Dump file not found for {$table}: {$dumpFile}");
continue;
}
$this->line("Restoring {$table}...");
try {
$sql = file_get_contents($dumpFile);
if (empty(trim($sql))) {
$this->info(' -> 0 rows (empty file)');
$restored++;
continue;
}
// Disable FK checks for this session to allow loading in any order
DB::statement('SET session_replication_role = replica;');
// Execute all statements at once
DB::unprepared($sql);
// Re-enable FK checks
DB::statement('SET session_replication_role = DEFAULT;');
// Count rows
$count = DB::table($table)->count();
$this->info(" -> {$count} rows in {$table}");
$restored++;
} catch (\Exception $e) {
// Re-enable FK checks even on error
try {
DB::statement('SET session_replication_role = DEFAULT;');
} catch (\Exception $ignored) {
}
$this->error("Failed to restore {$table}: ".$e->getMessage());
$errors++;
}
}
// Reset sequences to max ID + 1 for each table
$this->info('Resetting sequence counters...');
foreach ($tablesToRestore as $table) {
$this->resetSequence($table);
}
$this->newLine();
$this->info("Restored {$restored} tables. Errors: {$errors}");
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* Reset the sequence for a table to max ID + 1.
*/
protected function resetSequence(string $table): void
{
try {
$maxId = DB::table($table)->max('id');
if ($maxId) {
$sequence = "{$table}_id_seq";
DB::statement("SELECT setval('{$sequence}', ?)", [$maxId]);
}
} catch (\Exception $e) {
// Sequence might not exist for this table
}
}
}

View File

@@ -1,108 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\RunMarketingAutomationJob;
use App\Models\Marketing\MarketingAutomation;
use Illuminate\Console\Command;
class RunDueMarketingAutomations extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'marketing:run-due-automations
{--business= : Only process automations for a specific business ID}
{--dry-run : Show which automations would run without executing them}
{--sync : Run synchronously instead of dispatching to queue}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Check and run all due marketing automations';
/**
* Execute the console command.
*/
public function handle(): int
{
$businessId = $this->option('business');
$dryRun = $this->option('dry-run');
$sync = $this->option('sync');
$this->info('Checking for due marketing automations...');
// Query active automations
$query = MarketingAutomation::where('is_active', true)
->whereIn('trigger_type', [
MarketingAutomation::TRIGGER_SCHEDULED_CANNAIQ_CHECK,
MarketingAutomation::TRIGGER_SCHEDULED_STORE_CHECK,
]);
if ($businessId) {
$query->where('business_id', $businessId);
}
$automations = $query->get();
if ($automations->isEmpty()) {
$this->info('No active automations found.');
return Command::SUCCESS;
}
$this->info("Found {$automations->count()} active automation(s).");
$dueCount = 0;
foreach ($automations as $automation) {
if (! $automation->isDue()) {
$this->line(" - <comment>{$automation->name}</comment>: Not due yet");
continue;
}
$dueCount++;
if ($dryRun) {
$this->line(" - <info>{$automation->name}</info>: Would run (dry-run mode)");
$this->line(" Trigger: {$automation->trigger_type_label}");
$this->line(" Frequency: {$automation->frequency_label}");
$this->line(' Last run: '.($automation->last_run_at?->diffForHumans() ?? 'Never'));
continue;
}
$this->line(" - <info>{$automation->name}</info>: Dispatching...");
if ($sync) {
// Run synchronously
try {
$job = new RunMarketingAutomationJob($automation->id);
$job->handle(app(\App\Services\Marketing\AutomationRunner::class));
$this->line(' <info>Completed</info>');
} catch (\Exception $e) {
$this->error(" Failed: {$e->getMessage()}");
}
} else {
// Dispatch to queue
RunMarketingAutomationJob::dispatch($automation->id);
$this->line(' <info>Dispatched to queue</info>');
}
}
if ($dryRun) {
$this->newLine();
$this->info("Dry run complete. {$dueCount} automation(s) would have been executed.");
} else {
$this->newLine();
$this->info("Done. {$dueCount} automation(s) ".($sync ? 'executed' : 'dispatched').'.');
}
return Command::SUCCESS;
}
}

View File

@@ -1,175 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Business;
use App\Services\Accounting\FixedAssetService;
use Carbon\Carbon;
use Illuminate\Console\Command;
/**
* Run monthly depreciation for fixed assets.
*
* This command calculates and posts depreciation entries for all
* eligible fixed assets. Can be run for a specific business or all
* businesses with Management Suite enabled.
*
* Safe to run multiple times in the same month - assets that have
* already been depreciated for the period will be skipped.
*/
class RunFixedAssetDepreciation extends Command
{
protected $signature = 'fixed-assets:run-depreciation
{business_id? : Specific business ID to run for}
{--period= : Period date (Y-m-d format, defaults to end of current month)}
{--dry-run : Show what would be depreciated without making changes}';
protected $description = 'Run monthly depreciation for fixed assets';
public function __construct(
protected FixedAssetService $assetService
) {
parent::__construct();
}
public function handle(): int
{
$businessId = $this->argument('business_id');
$periodOption = $this->option('period');
$dryRun = $this->option('dry-run');
// Parse period date
$periodDate = $periodOption
? Carbon::parse($periodOption)->endOfMonth()
: Carbon::now()->endOfMonth();
$this->info("Running depreciation for period: {$periodDate->format('Y-m')}");
if ($dryRun) {
$this->warn('DRY RUN MODE - No changes will be made');
}
// Get businesses to process
$businesses = $this->getBusinesses($businessId);
if ($businesses->isEmpty()) {
$this->warn('No businesses found to process.');
return Command::SUCCESS;
}
$totalRuns = 0;
$totalAmount = 0;
foreach ($businesses as $business) {
$this->line('');
$this->info("Processing: {$business->name}");
if ($dryRun) {
$results = $this->previewDepreciation($business, $periodDate);
} else {
$results = $this->assetService->runBatchDepreciation($business, $periodDate);
}
$count = $results->count();
$amount = $results->sum('depreciation_amount');
if ($count > 0) {
$this->line(" - Depreciated {$count} assets");
$this->line(" - Total amount: \${$amount}");
$totalRuns += $count;
$totalAmount += $amount;
} else {
$this->line(' - No assets to depreciate');
}
}
$this->line('');
$this->info('=== Summary ===');
$this->info("Total assets depreciated: {$totalRuns}");
$this->info("Total depreciation amount: \${$totalAmount}");
if ($dryRun) {
$this->warn('This was a dry run. Run without --dry-run to apply changes.');
}
return Command::SUCCESS;
}
/**
* Get businesses to process.
*/
protected function getBusinesses(?string $businessId): \Illuminate\Support\Collection
{
if ($businessId) {
$business = Business::find($businessId);
if (! $business) {
$this->error("Business with ID {$businessId} not found.");
return collect();
}
if (! $business->hasManagementSuite()) {
$this->warn("Business {$business->name} does not have Management Suite enabled.");
}
return collect([$business]);
}
// Get all businesses with Management Suite
return Business::whereHas('suites', function ($query) {
$query->where('key', 'management');
})->get();
}
/**
* Preview depreciation without making changes.
*/
protected function previewDepreciation(Business $business, Carbon $periodDate): \Illuminate\Support\Collection
{
$period = $periodDate->format('Y-m');
$assets = \App\Models\Accounting\FixedAsset::where('business_id', $business->id)
->where('status', \App\Models\Accounting\FixedAsset::STATUS_ACTIVE)
->where('category', '!=', \App\Models\Accounting\FixedAsset::CATEGORY_LAND)
->get();
$results = collect();
foreach ($assets as $asset) {
// Skip if already depreciated for this period
$existing = \App\Models\Accounting\FixedAssetDepreciationRun::where('fixed_asset_id', $asset->id)
->where('period', $period)
->where('is_reversed', false)
->exists();
if ($existing) {
continue;
}
// Skip if fully depreciated
if ($asset->book_value <= $asset->salvage_value) {
continue;
}
$depreciationAmount = $asset->monthly_depreciation;
$maxDepreciation = $asset->book_value - $asset->salvage_value;
$depreciationAmount = min($depreciationAmount, $maxDepreciation);
if ($depreciationAmount > 0) {
$results->push((object) [
'fixed_asset_id' => $asset->id,
'asset_name' => $asset->name,
'depreciation_amount' => $depreciationAmount,
]);
$this->line(" - {$asset->name}: \${$depreciationAmount}");
}
}
return $results;
}
}

View File

@@ -1,103 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Accounting\RecurringSchedulerService;
use Carbon\Carbon;
use Illuminate\Console\Command;
class RunRecurringSchedules extends Command
{
protected $signature = 'recurring:run
{--date= : The date to run schedules for (YYYY-MM-DD, default: today)}
{--business= : Specific business ID to run schedules for}
{--dry-run : Preview what would be generated without actually creating transactions}';
protected $description = 'Run due recurring schedules to generate AR invoices, AP bills, and journal entries';
public function __construct(
protected RecurringSchedulerService $schedulerService
) {
parent::__construct();
}
public function handle(): int
{
$dateString = $this->option('date');
$businessId = $this->option('business') ? (int) $this->option('business') : null;
$dryRun = $this->option('dry-run');
$date = $dateString ? Carbon::parse($dateString) : now();
$this->info("Running recurring schedules for {$date->toDateString()}...");
if ($businessId) {
$this->info("Filtering to business ID: {$businessId}");
}
// Get due schedules
$dueSchedules = $this->schedulerService->getDueSchedules($date, $businessId);
if ($dueSchedules->isEmpty()) {
$this->info('No schedules are due for execution.');
return self::SUCCESS;
}
$this->info("Found {$dueSchedules->count()} schedule(s) due for execution.");
if ($dryRun) {
$this->warn('DRY RUN MODE - No transactions will be created.');
$this->table(
['ID', 'Name', 'Type', 'Business', 'Next Run Date', 'Auto Post'],
$dueSchedules->map(fn ($s) => [
$s->id,
$s->name,
$s->type_label,
$s->business->name ?? 'N/A',
$s->next_run_date->toDateString(),
$s->auto_post ? 'Yes' : 'No',
])
);
return self::SUCCESS;
}
// Run all due schedules
$results = $this->schedulerService->runAllDue($date, $businessId);
// Output results
$this->newLine();
$this->info('Execution Summary:');
$this->line(" Processed: {$results['processed']}");
$this->line(" Successful: {$results['success']}");
$this->line(" Failed: {$results['failed']}");
if (! empty($results['generated'])) {
$this->newLine();
$this->info('Generated Transactions:');
$this->table(
['Schedule', 'Type', 'Result ID'],
collect($results['generated'])->map(fn ($g) => [
$g['schedule_name'],
$g['type'],
$g['result_id'],
])
);
}
if (! empty($results['errors'])) {
$this->newLine();
$this->error('Errors:');
foreach ($results['errors'] as $error) {
$this->line(" [{$error['schedule_id']}] {$error['schedule_name']}: {$error['error']}");
}
return self::FAILURE;
}
return self::SUCCESS;
}
}

View File

@@ -1,43 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Database\Console\Migrations\FreshCommand;
/**
* Override migrate:fresh to prevent accidental data loss.
*
* This command blocks migrate:fresh in all environments except when
* explicitly targeting a test database (DB_DATABASE=testing or *_test_*).
*/
class SafeFreshCommand extends FreshCommand
{
public function handle()
{
// Check both config and direct env (env var may not be in config yet)
$database = env('DB_DATABASE', config('database.connections.pgsql.database'));
// Allow migrate:fresh ONLY for test databases
$isTestDatabase = $database === 'testing'
|| str_contains($database, '_test_')
|| str_contains($database, 'testing_');
if (! $isTestDatabase) {
$this->components->error('migrate:fresh is BLOCKED to prevent data loss!');
$this->components->warn("Database: {$database}");
$this->newLine();
$this->components->bulletList([
'This command drops ALL tables and destroys ALL data.',
'It is blocked in local, dev, staging, and production.',
'For testing: DB_DATABASE=testing ./vendor/bin/sail artisan migrate:fresh',
'To seed existing data: php artisan db:seed --class=ProductionSyncSeeder',
]);
return 1;
}
$this->components->info("Running migrate:fresh on TEST database: {$database}");
return parent::handle();
}
}

View File

@@ -1,652 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Product;
use App\Models\PromoRecommendation;
use App\Services\Promo\PromoCalculator;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
class SeedBaselinePromos extends Command
{
protected $signature = 'promos:seed-baseline
{--brand=* : Specific brand IDs to seed}
{--business= : Business ID or slug to seed all brands for}
{--dry-run : Preview recommendations without saving}
{--force : Clear existing pending recommendations first}
{--types=* : Limit to specific types: edlp, percent_off, bogo, bxgy, bundle}';
protected $description = 'Generate baseline promotion recommendations for products (draft-only, not auto-activated)';
protected PromoCalculator $promoCalculator;
protected int $created = 0;
protected int $skipped = 0;
protected int $duplicates = 0;
protected array $priorityCounts = ['high' => 0, 'medium' => 0, 'low' => 0];
protected bool $isDryRun = false;
// Recommendation expiration (days)
protected const EXPIRES_AFTER_DAYS = 30;
public function handle(PromoCalculator $promoCalculator): int
{
// Check if promo_recommendations table exists
if (! \Schema::hasTable('promo_recommendations')) {
$this->error('The promo_recommendations table does not exist. Please run migrations first.');
return self::FAILURE;
}
$this->promoCalculator = $promoCalculator;
$this->isDryRun = $this->option('dry-run');
$brandIds = $this->option('brand');
$businessOption = $this->option('business');
$types = $this->option('types') ?: ['edlp', 'percent_off', 'bogo', 'bxgy', 'bundle'];
// Get brands to process
$brands = $this->getBrandsToProcess($brandIds, $businessOption);
if ($brands === null) {
return self::FAILURE;
}
if ($brands->isEmpty()) {
$this->info('No active brands found.');
return self::SUCCESS;
}
// Force mode: clear existing pending recommendations
if ($this->option('force') && ! $this->isDryRun) {
$this->clearPendingRecommendations($brandIds);
}
$this->info(sprintf(
'%s baseline promos for %d brand(s)...',
$this->isDryRun ? 'Previewing' : 'Seeding',
$brands->count()
));
$this->newLine();
// Log start of seeding
if (! $this->isDryRun) {
Log::info('Promo Engine V3: Starting baseline seed', [
'brands_count' => $brands->count(),
'types' => $types,
]);
}
foreach ($brands as $brand) {
$this->processBrand($brand, $types);
}
$this->printSummary();
// Log completion
if (! $this->isDryRun) {
Log::info('Promo Engine V3: Baseline seed complete', [
'created' => $this->created,
'skipped' => $this->skipped,
'duplicates' => $this->duplicates,
'priority_breakdown' => $this->priorityCounts,
]);
}
return self::SUCCESS;
}
/**
* Get brands to process based on options.
*/
protected function getBrandsToProcess(?array $brandIds, ?string $businessOption)
{
// If specific brand IDs provided
if (! empty($brandIds)) {
$brands = Brand::whereIn('id', $brandIds)->get();
if ($brands->isEmpty()) {
$this->error('No brands found with the provided IDs.');
return null;
}
return $brands;
}
// If business option provided (ID or slug)
if ($businessOption) {
$business = is_numeric($businessOption)
? Business::find($businessOption)
: Business::where('slug', $businessOption)->first();
if (! $business) {
$this->error("Business not found: {$businessOption}");
return null;
}
$this->info("Filtering to business: {$business->name}");
return Brand::where('business_id', $business->id)
->active()
->get();
}
// Default: all active brands
return Brand::active()->get();
}
protected function processBrand(Brand $brand, array $types): void
{
$this->info("Brand: {$brand->name} (ID: {$brand->id})");
$this->line(str_repeat('─', 50));
$products = Product::where('brand_id', $brand->id)
->where('is_active', true)
->get();
if ($products->isEmpty()) {
$this->warn(' No active products found.');
$this->newLine();
return;
}
$this->info(" Analyzing {$products->count()} active products...");
$this->newLine();
foreach ($products as $product) {
$this->processProduct($product, $brand, $types);
}
$this->newLine();
}
protected function processProduct(Product $product, Brand $brand, array $types): void
{
// Validate product has required pricing
if (! $this->hasValidPricing($product)) {
$this->line(" <fg=yellow>✗</> {$product->name}: missing pricing data");
$this->skipped++;
return;
}
$currentMargins = $this->promoCalculator->getCurrentMargins($product);
// Calculate inventory metrics for priority
$metrics = $this->calculateProductMetrics($product);
foreach ($types as $type) {
$this->generateRecommendation($product, $brand, $type, $currentMargins, $metrics);
}
}
protected function generateRecommendation(
Product $product,
Brand $brand,
string $type,
array $currentMargins,
array $metrics
): void {
// Check for existing pending recommendation
if ($this->hasPendingRecommendation($product, $type)) {
$this->duplicates++;
return;
}
$recommendation = match ($type) {
'edlp' => $this->generateEdlpRecommendation($product, $currentMargins, $metrics),
'percent_off' => $this->generatePercentOffRecommendation($product, $currentMargins, $metrics),
'bogo' => $this->generateBogoRecommendation($product, $currentMargins, $metrics),
'bxgy' => $this->generateBxgyRecommendation($product, $currentMargins, $metrics),
'bundle' => $this->generateBundleRecommendation($product, $currentMargins, $metrics),
default => null,
};
if (! $recommendation) {
return;
}
// Add common fields
$recommendation['business_id'] = $brand->business_id;
$recommendation['brand_id'] = $brand->id;
$recommendation['product_id'] = $product->id;
$recommendation['status'] = 'pending';
$recommendation['expires_at'] = Carbon::now()->addDays(self::EXPIRES_AFTER_DAYS);
// Output
$this->outputRecommendation($product, $recommendation);
// Save if not dry run
if (! $this->isDryRun) {
PromoRecommendation::create($recommendation);
}
$this->created++;
$this->priorityCounts[$recommendation['priority']]++;
}
protected function generateEdlpRecommendation(
Product $product,
array $currentMargins,
array $metrics
): ?array {
$minSafePrice = $this->promoCalculator->minSafeEdlpPrice($product);
$currentMsrp = (float) $product->msrp;
if ($minSafePrice <= 0 || $minSafePrice >= $currentMsrp) {
$this->skipped++;
return null;
}
// Suggest 10% above the minimum safe price (conservative)
$suggestedPrice = round($minSafePrice * 1.10, 2);
// Ensure suggested price is still a discount
if ($suggestedPrice >= $currentMsrp) {
$suggestedPrice = round(($minSafePrice + $currentMsrp) / 2, 2);
}
// Validate the suggestion
$result = $this->promoCalculator->checkEdlp($product, $suggestedPrice);
if (! $result->approved) {
$this->skipped++;
return null;
}
$discountPercent = round((1 - $suggestedPrice / $currentMsrp) * 100, 1);
return [
'recommendation_type' => 'edlp',
'parameters' => [
'suggested_value' => $suggestedPrice,
'min_safe_value' => $minSafePrice,
'current_msrp' => $currentMsrp,
'discount_percent' => $discountPercent,
],
'estimated_company_margin' => $result->companyMarginPercent(),
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
'priority' => $this->calculatePriority($metrics),
'priority_reason' => $this->getPriorityReason($metrics),
'confidence' => $this->calculateConfidence($currentMargins, $result),
'velocity_score' => $metrics['velocity_score'],
'days_of_supply' => $metrics['days_of_supply'],
'units_sold_30d' => $metrics['units_sold_30d'],
];
}
protected function generatePercentOffRecommendation(
Product $product,
array $currentMargins,
array $metrics
): ?array {
$maxSafePercent = $this->promoCalculator->maxSafePercentOff($product);
if ($maxSafePercent <= 5) {
$this->skipped++;
return null;
}
// Suggest 75% of max safe (conservative)
$suggestedPercent = round($maxSafePercent * 0.75, 0);
// Minimum 5% to be meaningful
if ($suggestedPercent < 5) {
$suggestedPercent = 5;
}
// Validate
$result = $this->promoCalculator->checkPercentOff($product, $suggestedPercent);
if (! $result->approved) {
$this->skipped++;
return null;
}
return [
'recommendation_type' => 'percent_off',
'parameters' => [
'suggested_value' => $suggestedPercent,
'max_safe_value' => $maxSafePercent,
],
'estimated_company_margin' => $result->companyMarginPercent(),
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
'priority' => $this->calculatePriority($metrics),
'priority_reason' => $this->getPriorityReason($metrics),
'confidence' => $this->calculateConfidence($currentMargins, $result),
'velocity_score' => $metrics['velocity_score'],
'days_of_supply' => $metrics['days_of_supply'],
'units_sold_30d' => $metrics['units_sold_30d'],
];
}
protected function generateBogoRecommendation(
Product $product,
array $currentMargins,
array $metrics
): ?array {
// Standard BOGO: Buy 1 Get 1 Free
$result = $this->promoCalculator->checkBogo($product, 1, 1, 100);
if (! $result->approved) {
$this->skipped++;
return null;
}
return [
'recommendation_type' => 'bogo',
'parameters' => [
'suggested_value' => null,
'buy_qty' => 1,
'get_qty' => 1,
'get_discount_percent' => 100,
],
'estimated_company_margin' => $result->companyMarginPercent(),
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
'priority' => $this->calculatePriority($metrics),
'priority_reason' => $this->getPriorityReason($metrics),
'confidence' => $this->calculateConfidence($currentMargins, $result),
'velocity_score' => $metrics['velocity_score'],
'days_of_supply' => $metrics['days_of_supply'],
'units_sold_30d' => $metrics['units_sold_30d'],
];
}
protected function generateBxgyRecommendation(
Product $product,
array $currentMargins,
array $metrics
): ?array {
// Try Buy 2 Get 1 Free first (more sustainable than BOGO)
$result = $this->promoCalculator->checkBogo($product, 2, 1, 100);
if (! $result->approved) {
// Try Buy 3 Get 1 Free
$result = $this->promoCalculator->checkBogo($product, 3, 1, 100);
if (! $result->approved) {
$this->skipped++;
return null;
}
$buyQty = 3;
} else {
$buyQty = 2;
}
return [
'recommendation_type' => 'bxgy',
'parameters' => [
'suggested_value' => null,
'buy_qty' => $buyQty,
'get_qty' => 1,
'get_discount_percent' => 100,
],
'estimated_company_margin' => $result->companyMarginPercent(),
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
'priority' => $this->calculatePriority($metrics),
'priority_reason' => $this->getPriorityReason($metrics),
'confidence' => $this->calculateConfidence($currentMargins, $result),
'velocity_score' => $metrics['velocity_score'],
'days_of_supply' => $metrics['days_of_supply'],
'units_sold_30d' => $metrics['units_sold_30d'],
];
}
protected function generateBundleRecommendation(
Product $product,
array $currentMargins,
array $metrics
): ?array {
// Create a 3-pack bundle with ~10% discount
$packSize = 3;
$regularTotal = (float) $product->wholesale_price * $packSize;
$suggestedPrice = round($regularTotal * 0.90, 2);
// Create a collection with quantity attribute for bundle validation
$bundleProducts = collect([
(clone $product)->setAttribute('bundle_quantity', $packSize),
]);
$result = $this->promoCalculator->checkBundle($bundleProducts, $suggestedPrice);
if (! $result->approved) {
$this->skipped++;
return null;
}
$minSafe = $this->promoCalculator->minSafeBundlePrice($bundleProducts);
return [
'recommendation_type' => 'bundle',
'parameters' => [
'suggested_value' => $suggestedPrice,
'min_safe_value' => $minSafe,
'pack_size' => $packSize,
'individual_price' => (float) $product->wholesale_price,
'regular_total' => $regularTotal,
],
'estimated_company_margin' => $result->companyMarginPercent(),
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
'priority' => $this->calculatePriority($metrics),
'priority_reason' => $this->getPriorityReason($metrics),
'confidence' => $this->calculateConfidence($currentMargins, $result),
'velocity_score' => $metrics['velocity_score'],
'days_of_supply' => $metrics['days_of_supply'],
'units_sold_30d' => $metrics['units_sold_30d'],
];
}
protected function hasValidPricing(Product $product): bool
{
return $product->cost_per_unit > 0
&& $product->wholesale_price > 0
&& $product->msrp_price > 0;
}
protected function hasPendingRecommendation(Product $product, string $type): bool
{
return PromoRecommendation::where('product_id', $product->id)
->where('recommendation_type', $type)
->pending()
->notExpired()
->exists();
}
protected function calculateProductMetrics(Product $product): array
{
// Calculate velocity score based on available data
$availableQty = $product->available_quantity ?? 0;
$unitsSold30d = 0; // Would come from order history if tracked
// Simple days of supply calculation
$daysOfSupply = null;
if ($unitsSold30d > 0 && $availableQty > 0) {
$dailyVelocity = $unitsSold30d / 30;
$daysOfSupply = (int) ($availableQty / $dailyVelocity);
} elseif ($availableQty > 0) {
// No sales data - assume slow mover
$daysOfSupply = 999;
}
// Determine velocity score
$velocityScore = match (true) {
$unitsSold30d >= 50 => 'fast',
$unitsSold30d >= 20 => 'medium',
$unitsSold30d >= 5 => 'slow',
default => 'stale',
};
return [
'velocity_score' => $velocityScore,
'days_of_supply' => $daysOfSupply,
'units_sold_30d' => $unitsSold30d,
'available_quantity' => $availableQty,
];
}
protected function calculatePriority(array $metrics): string
{
$daysOfSupply = $metrics['days_of_supply'];
$velocityScore = $metrics['velocity_score'];
// High priority: excess inventory or stale products
if ($daysOfSupply && $daysOfSupply > 90) {
return 'high';
}
if ($velocityScore === 'stale') {
return 'high';
}
// Medium priority: building inventory or slow movers
if ($daysOfSupply && $daysOfSupply > 45) {
return 'medium';
}
if ($velocityScore === 'slow') {
return 'medium';
}
// Low priority: healthy inventory
return 'low';
}
protected function getPriorityReason(array $metrics): string
{
$daysOfSupply = $metrics['days_of_supply'];
$velocityScore = $metrics['velocity_score'];
if ($daysOfSupply && $daysOfSupply > 90) {
return "High inventory ({$daysOfSupply} days of supply)";
}
if ($velocityScore === 'stale') {
return 'Stale inventory, no recent sales';
}
if ($velocityScore === 'slow') {
return 'Slow-moving product';
}
if ($daysOfSupply && $daysOfSupply > 45) {
return "Building inventory ({$daysOfSupply} days of supply)";
}
return 'Standard recommendation';
}
protected function calculateConfidence(array $currentMargins, $result): float
{
// Base confidence on data quality and margin headroom
$confidence = 0.50;
// Boost for complete pricing data
if ($currentMargins['valid']) {
$confidence += 0.15;
}
// Boost for healthy margin headroom
$minMargin = min($result->companyMarginPercent(), $result->dispensaryMarginPercent());
if ($minMargin >= 60) {
$confidence += 0.20;
} elseif ($minMargin >= 55) {
$confidence += 0.10;
}
// Cap at 0.95
return min(0.95, round($confidence, 2));
}
protected function outputRecommendation(Product $product, array $recommendation): void
{
$type = $recommendation['recommendation_type'];
$params = $recommendation['parameters'];
$margin = min(
$recommendation['estimated_company_margin'],
$recommendation['estimated_dispensary_margin']
);
$description = match ($type) {
'edlp' => sprintf(
'$%.2f → $%.2f',
$params['current_msrp'],
$params['suggested_value']
),
'percent_off' => sprintf('%.0f%% off (max: %.0f%%)', $params['suggested_value'], $params['max_safe_value']),
'bogo' => 'B1G1 Free',
'bxgy' => sprintf('B%dG%d Free', $params['buy_qty'], $params['get_qty']),
'bundle' => sprintf('%d-pack $%.2f', $params['pack_size'], $params['suggested_value']),
default => $type,
};
$priority = $recommendation['priority'];
$priorityColor = match ($priority) {
'high' => 'red',
'medium' => 'yellow',
default => 'green',
};
$this->line(sprintf(
' <fg=green>✓</> %-25s [%s] %s <fg=%s>(margin: %.0f%%, %s)</>',
substr($product->name, 0, 25),
strtoupper($type),
$description,
$priorityColor,
$margin,
$priority
));
}
protected function clearPendingRecommendations(?array $brandIds): void
{
$query = PromoRecommendation::pending();
if (! empty($brandIds)) {
$query->whereIn('brand_id', $brandIds);
}
$count = $query->count();
$query->delete();
$this->warn("Cleared {$count} existing pending recommendations.");
$this->newLine();
}
protected function printSummary(): void
{
$this->newLine();
$this->info('Summary');
$this->line(str_repeat('═', 50));
$label = $this->isDryRun ? 'Would create' : 'Created';
$this->line(sprintf(' %-20s %d recommendations', "{$label}:", $this->created));
$this->line(sprintf(' %-20s %d (insufficient margin)', 'Skipped:', $this->skipped));
$this->line(sprintf(' %-20s %d (already pending)', 'Duplicates:', $this->duplicates));
$this->line(str_repeat('─', 50));
$this->info('Priority breakdown:');
$this->line(sprintf(' <fg=red>High:</> %d', $this->priorityCounts['high']));
$this->line(sprintf(' <fg=yellow>Medium:</> %d', $this->priorityCounts['medium']));
$this->line(sprintf(' <fg=green>Low:</> %d', $this->priorityCounts['low']));
if ($this->isDryRun) {
$this->newLine();
$this->warn('This was a dry run. No recommendations were saved.');
$this->info('Run without --dry-run to save recommendations.');
}
}
}

View File

@@ -1,404 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use App\Models\BrandOrchestratorProfile;
use Illuminate\Console\Command;
/**
* Seed/Update BrandOrchestratorProfile records with brand-specific behavior presets.
*
* This command is IDEMPOTENT - safe to run multiple times.
* It uses updateOrCreate to either create new profiles or update existing ones.
*
* Usage:
* php artisan orchestrator:seed-brand-profiles
* php artisan orchestrator:seed-brand-profiles --force (skip confirmation)
*/
class SeedBrandOrchestratorProfiles extends Command
{
protected $signature = 'orchestrator:seed-brand-profiles
{--force : Skip confirmation prompt}';
protected $description = 'Seed or update BrandOrchestratorProfile records with brand-specific configurations';
public function handle(): int
{
$this->info('');
$this->info('╔══════════════════════════════════════════════════════════════╗');
$this->info('║ Brand Orchestrator Profile Seeder ║');
$this->info('╚══════════════════════════════════════════════════════════════╝');
$this->info('');
if (! $this->option('force') && ! $this->confirm('This will create/update BrandOrchestratorProfile records. Continue?')) {
$this->warn('Aborted.');
return self::SUCCESS;
}
$profiles = $this->getBrandProfiles();
$created = 0;
$updated = 0;
$skipped = 0;
foreach ($profiles as $brandName => $config) {
$brand = Brand::where('name', $brandName)->first();
if (! $brand) {
$this->warn(" ⚠ Brand not found: '{$brandName}' - skipping");
$skipped++;
continue;
}
$business = $brand->business;
if (! $business) {
$this->warn(" ⚠ Brand '{$brandName}' has no business - skipping");
$skipped++;
continue;
}
// Check if profile exists
$existingProfile = BrandOrchestratorProfile::where('brand_id', $brand->id)
->where('business_id', $business->id)
->first();
// Prepare data for update/create
$data = [
'behavior_profile' => $config['behavior_profile'],
'max_tasks_per_customer_per_run' => $config['max_tasks_per_customer_per_run'] ?? null,
'cooldown_hours' => $config['cooldown_hours'] ?? null,
'max_pending_per_customer' => $config['max_pending_per_customer'] ?? null,
'auto_approval_high_intent' => $config['auto_approval_high_intent'] ?? null,
'auto_approval_vip' => $config['auto_approval_vip'] ?? null,
'auto_approval_ghosted' => $config['auto_approval_ghosted'] ?? null,
'auto_approval_at_risk' => $config['auto_approval_at_risk'] ?? null,
'auto_approval_menu_followup_no_view' => $config['auto_approval_menu_followup_no_view'] ?? null,
'auto_approval_menu_followup_viewed_no_order' => $config['auto_approval_menu_followup_viewed_no_order'] ?? null,
'auto_approval_reactivation' => $config['auto_approval_reactivation'] ?? null,
'auto_approval_new_menu' => $config['auto_approval_new_menu'] ?? null,
];
BrandOrchestratorProfile::updateOrCreate(
[
'brand_id' => $brand->id,
'business_id' => $business->id,
],
$data
);
if ($existingProfile) {
$this->line(" ✓ <fg=yellow>Updated</> {$brandName} ({$config['behavior_profile']})");
$updated++;
} else {
$this->line(" ✓ <fg=green>Created</> {$brandName} ({$config['behavior_profile']})");
$created++;
}
}
$this->info('');
$this->info('══════════════════════════════════════════════════════════════');
$this->info(" Created: {$created} | Updated: {$updated} | Skipped: {$skipped}");
$this->info('══════════════════════════════════════════════════════════════');
$this->info('');
return self::SUCCESS;
}
/**
* Get brand-specific orchestrator profile configurations.
*
* Profile types:
* - aggressive: Lower cooldowns, higher task caps, more auto-approval
* - balanced: Uses global settings, light customization
* - conservative: Higher cooldowns, lower task caps, more manager review
*/
private function getBrandProfiles(): array
{
return [
// ═══════════════════════════════════════════════════════════════
// 1. THUNDER BUD - Value, high-volume prerolls
// "Volume mover" - assertive about followups, promos, reactivations
// ═══════════════════════════════════════════════════════════════
'Thunder Bud' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_AGGRESSIVE,
'max_tasks_per_customer_per_run' => 4,
'cooldown_hours' => 24,
'max_pending_per_customer' => 5,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => true,
'auto_approval_at_risk' => true,
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => true,
'auto_approval_new_menu' => true,
],
// ═══════════════════════════════════════════════════════════════
// 2. DOOBZ - Hash-infused prerolls, premium but still pushy
// Strong push on menu followups and reactivation, slightly less
// spammy than Thunder Bud on the same buyer
// ═══════════════════════════════════════════════════════════════
'Doobz' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_AGGRESSIVE,
'max_tasks_per_customer_per_run' => 3,
'cooldown_hours' => 36,
'max_pending_per_customer' => 4,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => true,
'auto_approval_at_risk' => true,
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => true,
'auto_approval_new_menu' => true,
],
// ═══════════════════════════════════════════════════════════════
// 3. TWISTIES - Rosin jam infused prerolls, craft-leaning
// Proactive with engaged buyers and menus, but keep ghosted
// accounts from getting hammered
// ═══════════════════════════════════════════════════════════════
'Twisties' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
'max_tasks_per_customer_per_run' => null, // use global
'cooldown_hours' => null,
'max_pending_per_customer' => null,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => false, // Keep ghosted from getting hammered
'auto_approval_at_risk' => true,
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => true,
'auto_approval_new_menu' => true,
],
// ═══════════════════════════════════════════════════════════════
// 4. HIGH EXPECTATIONS - Hash holes, top-shelf, limited
// Prestige line - still follow up, but prefer rep review on
// at-risk and reactivation flows
// ═══════════════════════════════════════════════════════════════
'High Expectations' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_CONSERVATIVE,
'max_tasks_per_customer_per_run' => 2,
'cooldown_hours' => 72,
'max_pending_per_customer' => 3,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => false,
'auto_approval_at_risk' => false, // Rep review
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => false, // Rep review
'auto_approval_new_menu' => true,
],
// ═══════════════════════════════════════════════════════════════
// 5. PROPER COCK - Premium solventless concentrates
// Similar to High Expectations: careful, high-touch, more
// manual control from reps
// ═══════════════════════════════════════════════════════════════
'Proper Cock' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_CONSERVATIVE,
'max_tasks_per_customer_per_run' => 2,
'cooldown_hours' => 72,
'max_pending_per_customer' => 3,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => false,
'auto_approval_at_risk' => false,
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => false,
'auto_approval_new_menu' => true,
],
// ═══════════════════════════════════════════════════════════════
// 6. HASH FACTORY - Artisan hash & rosin, craft brand
// ═══════════════════════════════════════════════════════════════
'Hash Factory' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
'max_tasks_per_customer_per_run' => 3,
'cooldown_hours' => 48,
'max_pending_per_customer' => 4,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => false,
'auto_approval_at_risk' => true,
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => true,
'auto_approval_new_menu' => true,
],
// ═══════════════════════════════════════════════════════════════
// 7. JUST VAPE - Hash rosin carts / disposables, needs growth
// Should behave similar to Thunder Bud on orchestration
// ═══════════════════════════════════════════════════════════════
'Just Vape' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_AGGRESSIVE,
'max_tasks_per_customer_per_run' => 4,
'cooldown_hours' => 24,
'max_pending_per_customer' => 5,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => true,
'auto_approval_at_risk' => true,
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => true,
'auto_approval_new_menu' => true,
],
// ═══════════════════════════════════════════════════════════════
// 8. CANNA RSO - RSO / medical-leaning product line
// Less aggressive, more thoughtful outreach
// ═══════════════════════════════════════════════════════════════
'Canna RSO' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_CONSERVATIVE,
'max_tasks_per_customer_per_run' => 2,
'cooldown_hours' => 72,
'max_pending_per_customer' => 3,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => false,
'auto_approval_at_risk' => false,
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => false,
'auto_approval_new_menu' => true,
],
// ═══════════════════════════════════════════════════════════════
// 9. OUTLAW CANNABIS - Balanced with full auto-approval
// ═══════════════════════════════════════════════════════════════
'Outlaw Cannabis' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
'max_tasks_per_customer_per_run' => 3,
'cooldown_hours' => 48,
'max_pending_per_customer' => 4,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => true,
'auto_approval_at_risk' => true,
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => true,
'auto_approval_new_menu' => true,
],
// ═══════════════════════════════════════════════════════════════
// 10. ALOHA TYMEMACHINE - Balanced with full auto-approval
// ═══════════════════════════════════════════════════════════════
'Aloha TymeMachine' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
'max_tasks_per_customer_per_run' => 3,
'cooldown_hours' => 48,
'max_pending_per_customer' => 4,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => true,
'auto_approval_at_risk' => true,
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => true,
'auto_approval_new_menu' => true,
],
// ═══════════════════════════════════════════════════════════════
// 11. DOINKS - Aggressive with full auto-approval
// ═══════════════════════════════════════════════════════════════
'Doinks' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_AGGRESSIVE,
'max_tasks_per_customer_per_run' => 4,
'cooldown_hours' => 24,
'max_pending_per_customer' => 5,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => true,
'auto_approval_at_risk' => true,
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => true,
'auto_approval_new_menu' => true,
],
// ═══════════════════════════════════════════════════════════════
// 12. NUVATA - Balanced, use global throttling
// ═══════════════════════════════════════════════════════════════
'Nuvata' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
'max_tasks_per_customer_per_run' => null,
'cooldown_hours' => null,
'max_pending_per_customer' => null,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => true,
'auto_approval_at_risk' => true,
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => true,
'auto_approval_new_menu' => true,
],
// ═══════════════════════════════════════════════════════════════
// 13. DAIRY2DANK - Balanced, use global throttling
// ═══════════════════════════════════════════════════════════════
'Dairy2Dank' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
'max_tasks_per_customer_per_run' => null,
'cooldown_hours' => null,
'max_pending_per_customer' => null,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => true,
'auto_approval_at_risk' => true,
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => true,
'auto_approval_new_menu' => true,
],
// ═══════════════════════════════════════════════════════════════
// 14. BLITZD - Balanced, use global throttling
// ═══════════════════════════════════════════════════════════════
'Blitzd' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
'max_tasks_per_customer_per_run' => null,
'cooldown_hours' => null,
'max_pending_per_customer' => null,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => true,
'auto_approval_at_risk' => true,
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => true,
'auto_approval_new_menu' => true,
],
// ═══════════════════════════════════════════════════════════════
// 15. WHITE LABEL CANNA - Balanced, use global throttling
// ═══════════════════════════════════════════════════════════════
'White Label Canna' => [
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
'max_tasks_per_customer_per_run' => null,
'cooldown_hours' => null,
'max_pending_per_customer' => null,
'auto_approval_high_intent' => true,
'auto_approval_vip' => true,
'auto_approval_ghosted' => true,
'auto_approval_at_risk' => true,
'auto_approval_menu_followup_no_view' => true,
'auto_approval_menu_followup_viewed_no_order' => true,
'auto_approval_reactivation' => true,
'auto_approval_new_menu' => true,
],
];
}
}

View File

@@ -1,158 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Batch;
use App\Models\BatchCoaFile;
use App\Models\Product;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class SeedCoaData extends Command
{
protected $signature = 'seed:coa-data';
protected $description = 'Add COA files to existing batches for testing';
public function handle(): int
{
$this->info('Seeding COA data for testing...');
// Get all active products with batches
$products = Product::with('batches')
->where('is_active', true)
->whereHas('batches')
->get();
if ($products->isEmpty()) {
$this->warn('No products with batches found. Run the main seeder first.');
return 1;
}
$this->info("Found {$products->count()} products with batches");
$coaCount = 0;
foreach ($products as $product) {
foreach ($product->batches as $batch) {
// Skip if batch already has COAs
if ($batch->coaFiles()->exists()) {
continue;
}
// Create 1-2 COA files per batch
$numCoas = rand(1, 2);
for ($i = 1; $i <= $numCoas; $i++) {
$isPrimary = ($i === 1);
// Create a dummy PDF file
$fileName = "COA-{$batch->batch_number}-{$i}.pdf";
$filePath = "businesses/{$product->brand->business->uuid}/batches/{$batch->id}/coas/{$fileName}";
// Create dummy PDF content (just for testing)
$pdfContent = $this->generateDummyPdf($batch, $product);
Storage::disk('local')->put($filePath, $pdfContent);
// Create COA file record
BatchCoaFile::create([
'batch_id' => $batch->id,
'file_name' => $fileName,
'file_path' => $filePath,
'file_size' => strlen($pdfContent),
'mime_type' => 'application/pdf',
'is_primary' => $isPrimary,
'display_order' => $i,
]);
$coaCount++;
}
$this->line(" Added {$numCoas} COA(s) for batch {$batch->batch_number}");
}
}
$this->info("✓ Created {$coaCount} COA files");
return 0;
}
private function generateDummyPdf(Batch $batch, Product $product): string
{
// Generate a simple text-based "PDF" for testing
// In a real system, you'd use a PDF library
return "%PDF-1.4
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj
<<
/Type /Pages
/Kids [3 0 R]
/Count 1
>>
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/Resources <<
/Font <<
/F1 <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
/MediaBox [0 0 612 792]
/Contents 4 0 R
>>
endobj
4 0 obj
<<
/Length 250
>>
stream
BT
/F1 12 Tf
50 700 Td
(CERTIFICATE OF ANALYSIS) Tj
0 -30 Td
(Batch Number: {$batch->batch_number}) Tj
0 -20 Td
(Product: {$product->name}) Tj
0 -20 Td
(Test Date: ".now()->format('Y-m-d').') Tj
0 -30 Td
(THC: 25.5%) Tj
0 -20 Td
(CBD: 0.8%) Tj
0 -20 Td
(Status: PASSED) Tj
ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000317 00000 n
trailer
<<
/Size 5
/Root 1 0 R
>>
startxref
619
%%EOF';
}
}

View File

@@ -1,225 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Batch;
use App\Models\Business;
use App\Models\Location;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class SeedTestOrders extends Command
{
protected $signature = 'seed:test-orders {--clean : Delete existing test orders first}';
protected $description = 'Create test orders at various statuses for testing the order flow';
public function handle(): int
{
if ($this->option('clean')) {
$this->info('Cleaning up existing test orders...');
$testOrders = Order::where('order_number', 'like', 'TEST-%')->get();
foreach ($testOrders as $order) {
// Delete order items first, then the order
$order->items()->delete();
$order->delete();
}
}
$this->info('Creating test orders at various statuses...');
// Get a buyer business (retailer) and location
$buyerBusiness = Business::where('business_type', 'retailer')->first();
if (! $buyerBusiness) {
$this->error('No buyer business found. Run the main seeder first.');
return 1;
}
$buyerLocation = Location::where('business_id', $buyerBusiness->id)->first();
if (! $buyerLocation) {
$this->error('No buyer location found. Run the main seeder first.');
return 1;
}
// Get a buyer user
$buyerUser = User::where('user_type', 'buyer')->first();
if (! $buyerUser) {
$this->error('No buyer user found. Run the main seeder first.');
return 1;
}
// Get products with batches and COAs
$products = Product::with(['brand.business', 'batches.coaFiles'])
->where('is_active', true)
->whereHas('batches.coaFiles')
->limit(10)
->get();
if ($products->isEmpty()) {
$this->error('No products with COAs found. Run seed:coa-data first.');
return 1;
}
$orders = [];
// 1. Order ready for pre-delivery review (after picking, before delivery)
$orders[] = $this->createTestOrder(
$buyerBusiness,
$buyerLocation,
$products->random(3),
'ready_for_delivery',
'TEST-PREDELIVERY-001',
'Order ready for pre-delivery review (Review #1)'
);
// 2. Order delivered and ready for post-delivery acceptance (Review #2)
$orders[] = $this->createTestOrder(
$buyerBusiness,
$buyerLocation,
$products->random(3),
'delivered',
'TEST-DELIVERED-001',
'Order delivered and ready for acceptance (Review #2)'
);
// 3. Order in progress (picking)
$orders[] = $this->createTestOrder(
$buyerBusiness,
$buyerLocation,
$products->random(2),
'in_progress',
'TEST-PICKING-001',
'Order currently being picked'
);
// 4. Order accepted and approved for delivery
$orders[] = $this->createTestOrder(
$buyerBusiness,
$buyerLocation,
$products->random(2),
'approved_for_delivery',
'TEST-APPROVED-001',
'Order approved for delivery (passed Review #1)'
);
// 5. Order out for delivery
$orders[] = $this->createTestOrder(
$buyerBusiness,
$buyerLocation,
$products->random(2),
'out_for_delivery',
'TEST-OUTDELIVERY-001',
'Order out for delivery'
);
$this->newLine();
$this->info('✓ Created '.count($orders).' test orders');
$this->newLine();
$this->table(
['Order Number', 'Status', 'Items', 'Description'],
collect($orders)->map(fn ($order) => [
$order->order_number,
$order->status,
$order->items->count(),
$this->getOrderDescription($order->order_number),
])
);
$this->newLine();
$this->info('You can now test the order flow in the UI:');
$this->line(' • Pre-delivery review: /b/'.$buyerBusiness->slug.'/orders/TEST-PREDELIVERY-001/pre-delivery-review');
$this->line(' • Post-delivery acceptance: /b/'.$buyerBusiness->slug.'/orders/TEST-DELIVERED-001/acceptance');
return 0;
}
private function createTestOrder(
Business $buyerBusiness,
Location $buyerLocation,
$products,
string $status,
string $orderNumber,
string $description
): Order {
return DB::transaction(function () use ($buyerBusiness, $buyerLocation, $products, $status, $orderNumber) {
// Get first product's seller business
$sellerBusiness = $products->first()->brand->business;
// Calculate totals
$subtotal = $products->sum(function ($product) {
return $product->wholesale_price * 5; // 5 units each
});
$surchargePercent = Order::getSurchargePercentage('net_30');
$surcharge = $subtotal * ($surchargePercent / 100);
$taxRate = $buyerBusiness->getTaxRate();
$tax = ($subtotal + $surcharge) * $taxRate;
$total = $subtotal + $surcharge + $tax;
// Create order
$order = Order::create([
'order_number' => $orderNumber,
'business_id' => $buyerBusiness->id,
'seller_business_id' => $sellerBusiness->id,
'location_id' => $buyerLocation->id,
'status' => $status,
'fulfillment_method' => 'delivery',
'payment_terms' => 'net_30',
'subtotal' => $subtotal,
'tax' => $tax,
'surcharge' => $surcharge,
'total' => $total,
'notes' => 'Test order for flow testing',
]);
// Create order items with batch allocation
foreach ($products as $product) {
$batch = $product->batches->first();
$quantity = 5;
// Allocate inventory
if ($batch) {
$batch->allocate($quantity);
}
OrderItem::create([
'order_id' => $order->id,
'product_id' => $product->id,
'batch_id' => $batch?->id,
'product_name' => $product->name,
'product_sku' => $product->sku,
'brand_name' => $product->brand->name,
'batch_number' => $batch?->batch_number,
'quantity' => $quantity,
'unit_price' => $product->wholesale_price,
'line_total' => $product->wholesale_price * $quantity,
]);
}
return $order->fresh(['items']);
});
}
private function getOrderDescription(string $orderNumber): string
{
return match (true) {
str_contains($orderNumber, 'PREDELIVERY') => 'Order ready for pre-delivery review (Review #1)',
str_contains($orderNumber, 'DELIVERED') => 'Order delivered and ready for acceptance (Review #2)',
str_contains($orderNumber, 'PICKING') => 'Order currently being picked',
str_contains($orderNumber, 'APPROVED') => 'Order approved for delivery (passed Review #1)',
str_contains($orderNumber, 'OUTDELIVERY') => 'Order out for delivery',
default => 'Test order',
};
}
}

View File

@@ -1,257 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Business;
use App\Models\CalendarEvent;
use App\Models\Conversation;
use App\Models\Crm\CrmTask;
use App\Models\SalesOpportunity;
use App\Models\User;
use App\Notifications\CrmDailyDigestNotification;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* CRM Daily Digest Command
*
* Sends daily summary emails to businesses with CRM enabled. This command
* is scheduled to run at 7 AM daily via the Laravel scheduler (Kernel.php).
*
* Digest Contents:
* - New conversations from the last 24 hours
* - Tasks due today
* - Overdue tasks (up to 10)
* - Today's calendar events
* - Pipeline summary stats
*
* Feature Gating:
* - Only processes businesses where has_crm = true
* - Only sends to businesses where crm_daily_digest_enabled = true
* - Skips businesses with no actionable items to report
*
* Recipients:
* - If business.crm_notification_emails is set: those specific emails
* - Otherwise: business owner or first admin (up to 3 recipients)
*
* Queue Configuration:
* - Notifications are queued on the 'crm' queue
* - Processed by Horizon's CRM worker pool
*
* Usage:
* php artisan crm:send-daily-digests # Normal run
* php artisan crm:send-daily-digests --dry-run # Preview without sending
* php artisan crm:send-daily-digests --business=123 # Single business
*
* Testing:
* ./vendor/bin/sail artisan crm:send-daily-digests --dry-run
*
* @see \App\Console\Kernel::schedule() for scheduler configuration
* @see \App\Notifications\CrmDailyDigestNotification for email template
*/
class SendCrmDailyDigest extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'crm:send-daily-digests
{--business= : Process only a specific business ID}
{--dry-run : Show what would be sent without actually sending}';
/**
* The console command description.
*/
protected $description = 'Send daily CRM digest emails to businesses with the feature enabled';
/**
* Execute the console command.
*/
public function handle(): int
{
$businessQuery = Business::where('has_crm', true)
->where('crm_daily_digest_enabled', true);
if ($businessId = $this->option('business')) {
$businessQuery->where('id', $businessId);
}
$businesses = $businessQuery->get();
if ($businesses->isEmpty()) {
$this->info('No businesses with CRM daily digest enabled.');
return self::SUCCESS;
}
$this->info("Processing daily digests for {$businesses->count()} business(es)...");
foreach ($businesses as $business) {
$this->processBusinessDigest($business);
}
$this->info('Daily digest processing complete.');
return self::SUCCESS;
}
/**
* Process and send the digest for a single business.
*/
protected function processBusinessDigest(Business $business): void
{
$this->line("Processing: {$business->name}");
// Get the digest data
$digestData = $this->gatherDigestData($business);
// Check if there's anything to report
if ($this->isDigestEmpty($digestData)) {
$this->line(' - No updates to report, skipping.');
return;
}
// Get recipients (business owner or first admin)
$recipients = $this->getDigestRecipients($business);
if ($recipients->isEmpty()) {
$this->warn(' - No recipients found, skipping.');
return;
}
if ($this->option('dry-run')) {
$this->info(" - [DRY RUN] Would send to: {$recipients->pluck('email')->join(', ')}");
$this->displayDigestSummary($digestData);
return;
}
// Send notifications
foreach ($recipients as $recipient) {
try {
$recipient->notify(new CrmDailyDigestNotification($business, $digestData));
$this->line(" - Sent to: {$recipient->email}");
Log::info('CRM daily digest sent', [
'business_id' => $business->id,
'user_id' => $recipient->id,
]);
} catch (\Exception $e) {
$this->error(" - Failed to send to {$recipient->email}: {$e->getMessage()}");
Log::error('Failed to send CRM daily digest', [
'business_id' => $business->id,
'user_id' => $recipient->id,
'error' => $e->getMessage(),
]);
}
}
}
/**
* Gather all the data for the digest.
*/
protected function gatherDigestData(Business $business): array
{
$yesterday = now()->subDay();
return [
// New conversations in the last 24 hours
'new_conversations' => Conversation::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
->where('created_at', '>=', $yesterday)
->with('primaryContact')
->get(),
// Tasks due today
'tasks_due_today' => CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->whereDate('due_at', today())
->with(['assignee', 'contact'])
->get(),
// Overdue tasks
'overdue_tasks' => CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->whereNotNull('due_at')
->where('due_at', '<', now())
->with(['assignee', 'contact'])
->limit(10)
->get(),
// Events today
'events_today' => CalendarEvent::where('seller_business_id', $business->id)
->whereDate('start_at', today())
->where('status', 'scheduled')
->with(['assignee', 'contact'])
->get(),
// Stage changes in the last 24 hours (opportunities moved)
'stage_changes' => SalesOpportunity::where('seller_business_id', $business->id)
->where('updated_at', '>=', $yesterday)
->whereColumn('stage_id', '!=', 'original_stage_id')
->with(['stage', 'business'])
->limit(10)
->get(),
// Summary stats
'stats' => [
'open_opportunities' => SalesOpportunity::where('seller_business_id', $business->id)
->where('status', 'open')
->count(),
'total_pipeline_value' => SalesOpportunity::where('seller_business_id', $business->id)
->where('status', 'open')
->sum('value'),
'open_tasks' => CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->count(),
],
];
}
/**
* Check if the digest has any meaningful content.
*/
protected function isDigestEmpty(array $data): bool
{
return $data['new_conversations']->isEmpty()
&& $data['tasks_due_today']->isEmpty()
&& $data['overdue_tasks']->isEmpty()
&& $data['events_today']->isEmpty();
}
/**
* Get the users who should receive the digest.
*/
protected function getDigestRecipients(Business $business): \Illuminate\Support\Collection
{
// If specific emails are set, find those users
if ($business->crm_notification_emails) {
$emails = array_map('trim', explode(',', $business->crm_notification_emails));
return User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->whereIn('email', $emails)
->get();
}
// Otherwise, send to the business owner or first admin
return User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->where(function ($q) {
$q->where('is_business_owner', true)
->orWhere('user_type', 'admin');
})
->limit(3)
->get();
}
/**
* Display a summary for dry-run mode.
*/
protected function displayDigestSummary(array $data): void
{
$this->line(" - New conversations: {$data['new_conversations']->count()}");
$this->line(" - Tasks due today: {$data['tasks_due_today']->count()}");
$this->line(" - Overdue tasks: {$data['overdue_tasks']->count()}");
$this->line(" - Events today: {$data['events_today']->count()}");
}
}

View File

@@ -1,113 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class SyncBrandMediaPaths extends Command
{
protected $signature = 'brands:sync-media-paths
{--dry-run : Preview changes without applying}
{--business= : Limit to specific business slug}';
protected $description = 'Sync brand logo_path and banner_path from MinIO storage';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$businessFilter = $this->option('business');
if ($dryRun) {
$this->warn('DRY RUN - No changes will be made');
}
$this->info('Scanning MinIO for brand media...');
$businessDirs = Storage::directories('businesses');
$updated = 0;
$skipped = 0;
foreach ($businessDirs as $businessDir) {
$businessSlug = basename($businessDir);
if ($businessFilter && $businessSlug !== $businessFilter) {
continue;
}
$brandsDir = $businessDir.'/brands';
if (! Storage::exists($brandsDir)) {
continue;
}
$brandDirs = Storage::directories($brandsDir);
foreach ($brandDirs as $brandDir) {
$brandSlug = basename($brandDir);
$brandingDir = $brandDir.'/branding';
if (! Storage::exists($brandingDir)) {
continue;
}
$brand = Brand::where('slug', $brandSlug)->first();
if (! $brand) {
$this->line(" <fg=yellow>?</> {$brandSlug} - not found in database");
$skipped++;
continue;
}
$files = Storage::files($brandingDir);
$logoPath = null;
$bannerPath = null;
foreach ($files as $file) {
$filename = strtolower(basename($file));
if (str_starts_with($filename, 'logo.')) {
$logoPath = $file;
} elseif (str_starts_with($filename, 'banner.')) {
$bannerPath = $file;
}
}
$changes = [];
if ($logoPath && $brand->logo_path !== $logoPath) {
$changes[] = "logo: {$logoPath}";
}
if ($bannerPath && $brand->banner_path !== $bannerPath) {
$changes[] = "banner: {$bannerPath}";
}
if (empty($changes)) {
$this->line(" <fg=green>✓</> {$brandSlug} - already synced");
continue;
}
if (! $dryRun) {
if ($logoPath) {
$brand->logo_path = $logoPath;
}
if ($bannerPath) {
$brand->banner_path = $bannerPath;
}
$brand->save();
}
$this->line(" <fg=blue>↻</> {$brandSlug} - ".implode(', ', $changes));
$updated++;
}
}
$this->newLine();
$this->info("Updated: {$updated} | Skipped: {$skipped}");
if ($dryRun && $updated > 0) {
$this->warn('Run without --dry-run to apply changes');
}
return Command::SUCCESS;
}
}

View File

@@ -26,151 +26,7 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule)
{
// ─────────────────────────────────────────────────────────────────────
// MINUTE-LEVEL JOBS
// ─────────────────────────────────────────────────────────────────────
// Check for scheduled broadcasts every minute
$schedule->job(new \App\Jobs\Marketing\ProcessScheduledBroadcastsJob)
->everyMinute()
->withoutOverlapping();
// Send CRM task and event reminders every minute
$schedule->job(new \App\Jobs\Crm\SendCrmRemindersJob)
->everyMinute()
->withoutOverlapping();
// ─────────────────────────────────────────────────────────────────────
// SALES ORCHESTRATOR - "HEAD OF SALES" AUTOMATION
// See: docs/HEAD_OF_SALES_ORCHESTRATOR.md
// ─────────────────────────────────────────────────────────────────────
// Generate sales tasks - runs hourly during business hours (weekdays 8AM-6PM)
// Monitors buyer behavior and creates actionable OrchestratorTask records
$schedule->command('orchestrator:generate-sales-tasks')
->hourlyAt(5)
->weekdays()
->between('08:00', '18:00')
->withoutOverlapping()
->runInBackground();
// Generate marketing tasks - runs hourly during business hours (weekdays 8AM-6PM)
// Creates campaign suggestions for marketing team
$schedule->command('orchestrator:generate-marketing-tasks')
->hourlyAt(15)
->weekdays()
->between('08:00', '18:00')
->withoutOverlapping()
->runInBackground();
// Evaluate outcomes - runs every 4 hours
// Links completed tasks to subsequent views/orders for learning loop
$schedule->command('orchestrator:evaluate-outcomes')
->everyFourHours()
->withoutOverlapping()
->runInBackground();
// Analyze timing - runs daily at 3 AM
// Determines best send times per brand based on historical outcomes
$schedule->command('orchestrator:analyze-timing')
->dailyAt('03:00')
->withoutOverlapping()
->runInBackground();
// TODO: Buyer scoring currently happens via BuyerScoringService on-demand.
// Consider creating orchestrator:score-buyers command for batch scoring if needed.
// $schedule->command('orchestrator:score-buyers')
// ->dailyAt('04:00')
// ->withoutOverlapping()
// ->runInBackground();
// Check Horizon health - runs every 5 minutes
// Monitors Redis/Horizon status, creates alerts on failure
$schedule->command('orchestrator:check-horizon')
->everyFiveMinutes()
->withoutOverlapping()
->runInBackground();
// Watchdog - runs every 15 minutes
// Monitors that all orchestrator commands are running on schedule
$schedule->command('orchestrator:watchdog')
->everyFifteenMinutes()
->withoutOverlapping()
->runInBackground();
// Self-audit - runs daily at 5 AM
// Checks for data integrity issues, impossible states, stale tasks
$schedule->command('orchestrator:self-audit')
->dailyAt('05:00')
->withoutOverlapping()
->runInBackground();
// Evaluate playbook performance - runs daily at 4 AM
// Updates 30-day metrics per playbook, can auto-quarantine underperformers
$schedule->command('orchestrator:evaluate-playbooks')
->dailyAt('04:00')
->withoutOverlapping()
->runInBackground();
// Daily report - runs weekdays at 7 AM
// Sends summary email to admins with task stats and alerts
$schedule->command('orchestrator:send-daily-report')
->weekdays()
->dailyAt('07:00')
->withoutOverlapping()
->runInBackground();
// ─────────────────────────────────────────────────────────────────────
// DASHBOARD METRICS PRE-CALCULATION
// ─────────────────────────────────────────────────────────────────────
// Pre-calculate dashboard metrics every 10 minutes
// Stores aggregations in Redis for instant page loads
$schedule->job(new \App\Jobs\CalculateDashboardMetrics)
->everyTenMinutes()
->withoutOverlapping()
->runInBackground();
// ─────────────────────────────────────────────────────────────────────
// BANNER ADS
// ─────────────────────────────────────────────────────────────────────
// Update banner ad statuses (activate scheduled, expire ended) - every minute
$schedule->job(new \App\Jobs\UpdateBannerAdStatuses)
->everyMinute()
->withoutOverlapping();
// Rollup daily banner ad stats - daily at 2 AM
$schedule->job(new \App\Jobs\RollupBannerAdStats)
->dailyAt('02:00')
->withoutOverlapping();
// ─────────────────────────────────────────────────────────────────────
// HOUSEKEEPING & MAINTENANCE
// ─────────────────────────────────────────────────────────────────────
// Clean up temporary files older than 24 hours (runs daily at 2 AM)
$schedule->command('media:cleanup-temp')
->dailyAt('02:00')
->withoutOverlapping();
// Prune old audit logs based on business settings (runs daily at 3 AM)
$schedule->command('audits:prune')
->dailyAt('03:00')
->withoutOverlapping();
// Send CRM daily digest emails at 7 AM
$schedule->command('crm:send-daily-digests')
->dailyAt('07:00')
->withoutOverlapping()
->onOneServer();
// Generate baseline promo recommendations (Promo Engine V3)
// Runs daily at 3:30 AM to generate margin-safe promo suggestions
$schedule->command('promos:seed-baseline')
->dailyAt('03:30')
->withoutOverlapping()
->runInBackground();
// $schedule->command('inspire')->hourly();
}
/**

View File

@@ -1,41 +0,0 @@
<?php
namespace App\Enums;
enum BannerAdStatus: string
{
case DRAFT = 'draft';
case ACTIVE = 'active';
case SCHEDULED = 'scheduled';
case PAUSED = 'paused';
case EXPIRED = 'expired';
public function label(): string
{
return match ($this) {
self::DRAFT => 'Draft',
self::ACTIVE => 'Active',
self::SCHEDULED => 'Scheduled',
self::PAUSED => 'Paused',
self::EXPIRED => 'Expired',
};
}
public function color(): string
{
return match ($this) {
self::DRAFT => 'gray',
self::ACTIVE => 'success',
self::SCHEDULED => 'info',
self::PAUSED => 'warning',
self::EXPIRED => 'danger',
};
}
public static function options(): array
{
return collect(self::cases())->mapWithKeys(fn (self $status) => [
$status->value => $status->label(),
])->toArray();
}
}

View File

@@ -1,51 +0,0 @@
<?php
namespace App\Enums;
enum BannerAdZone: string
{
case MARKETPLACE_HERO = 'marketplace_hero';
case MARKETPLACE_LEADERBOARD = 'marketplace_leaderboard';
case MARKETPLACE_SIDEBAR = 'marketplace_sidebar';
case MARKETPLACE_INLINE = 'marketplace_inline';
case BRAND_PAGE_BANNER = 'brand_page_banner';
case DEALS_PAGE_HERO = 'deals_page_hero';
public function label(): string
{
return match ($this) {
self::MARKETPLACE_HERO => 'Marketplace Hero (Full Width)',
self::MARKETPLACE_LEADERBOARD => 'Marketplace Leaderboard (728x90)',
self::MARKETPLACE_SIDEBAR => 'Marketplace Sidebar (300x250)',
self::MARKETPLACE_INLINE => 'Marketplace Inline (Between Products)',
self::BRAND_PAGE_BANNER => 'Brand Page Banner',
self::DEALS_PAGE_HERO => 'Deals Page Hero',
};
}
public function dimensions(): array
{
return match ($this) {
self::MARKETPLACE_HERO => ['width' => 1920, 'height' => 400, 'display' => '1920x400'],
self::MARKETPLACE_LEADERBOARD => ['width' => 728, 'height' => 90, 'display' => '728x90'],
self::MARKETPLACE_SIDEBAR => ['width' => 300, 'height' => 250, 'display' => '300x250'],
self::MARKETPLACE_INLINE => ['width' => 970, 'height' => 250, 'display' => '970x250'],
self::BRAND_PAGE_BANNER => ['width' => 1344, 'height' => 280, 'display' => '1344x280'],
self::DEALS_PAGE_HERO => ['width' => 1920, 'height' => 350, 'display' => '1920x350'],
};
}
public static function options(): array
{
return collect(self::cases())->mapWithKeys(fn (self $zone) => [
$zone->value => $zone->label().' - '.$zone->dimensions()['display'],
])->toArray();
}
public static function optionsSimple(): array
{
return collect(self::cases())->mapWithKeys(fn (self $zone) => [
$zone->value => $zone->label(),
])->toArray();
}
}

View File

@@ -1,41 +0,0 @@
<?php
namespace App\Events;
use App\Models\AgentStatus;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CrmAgentStatusChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public AgentStatus $agentStatus
) {}
public function broadcastOn(): array
{
return [new PrivateChannel("crm-inbox.{$this->agentStatus->business_id}")];
}
public function broadcastAs(): string
{
return 'agent.status';
}
public function broadcastWith(): array
{
return [
'user_id' => $this->agentStatus->user_id,
'user_name' => $this->agentStatus->user?->name,
'status' => $this->agentStatus->status,
'status_label' => AgentStatus::statuses()[$this->agentStatus->status] ?? $this->agentStatus->status,
'status_message' => $this->agentStatus->status_message,
'last_seen_at' => $this->agentStatus->last_seen_at?->toIso8601String(),
];
}
}

View File

@@ -1,80 +0,0 @@
<?php
namespace App\Events;
use App\Models\Crm\CrmChannelMessage;
use App\Models\Crm\CrmThread;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
class CrmThreadMessageSent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public CrmChannelMessage $message,
public CrmThread $thread
) {}
public function broadcastOn(): array
{
$channels = [
new PrivateChannel("crm-inbox.{$this->thread->business_id}"),
new PrivateChannel("crm-thread.{$this->thread->id}"),
];
// For marketplace B2B threads, also broadcast to buyer/seller businesses
if ($this->thread->buyer_business_id) {
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->buyer_business_id}");
}
if ($this->thread->seller_business_id) {
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->seller_business_id}");
}
return $channels;
}
public function broadcastAs(): string
{
return 'message.new';
}
public function broadcastWith(): array
{
return [
'message' => [
'id' => $this->message->id,
'thread_id' => $this->message->thread_id,
'body' => $this->message->body,
'body_html' => $this->message->body_html,
'direction' => $this->message->direction,
'channel_type' => $this->message->channel_type,
'sender_id' => $this->message->user_id,
'sender_name' => $this->message->user?->name ?? ($this->message->direction === 'inbound' ? $this->thread->contact?->getFullName() : 'System'),
'status' => $this->message->status,
'created_at' => $this->message->created_at->toIso8601String(),
'attachments' => $this->message->attachments->map(fn ($a) => [
'id' => $a->id,
'filename' => $a->original_filename ?? $a->filename,
'mime_type' => $a->mime_type,
'size' => $a->size,
'url' => Storage::disk($a->disk ?? 'minio')->url($a->path),
])->toArray(),
],
'thread' => [
'id' => $this->thread->id,
'subject' => $this->thread->subject,
'status' => $this->thread->status,
'priority' => $this->thread->priority,
'last_message_at' => $this->thread->last_message_at?->toIso8601String(),
'last_message_preview' => $this->message->body ? \Str::limit(strip_tags($this->message->body), 100) : null,
'last_message_direction' => $this->message->direction,
'last_channel_type' => $this->message->channel_type,
],
];
}
}

View File

@@ -1,73 +0,0 @@
<?php
namespace App\Events;
use App\Models\Crm\CrmThread;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CrmThreadUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public const UPDATE_ASSIGNED = 'assigned';
public const UPDATE_CLOSED = 'closed';
public const UPDATE_REOPENED = 'reopened';
public const UPDATE_SNOOZED = 'snoozed';
public const UPDATE_PRIORITY = 'priority';
public const UPDATE_STATUS = 'status';
public function __construct(
public CrmThread $thread,
public string $updateType
) {}
public function broadcastOn(): array
{
$channels = [
new PrivateChannel("crm-inbox.{$this->thread->business_id}"),
new PrivateChannel("crm-thread.{$this->thread->id}"),
];
// For marketplace B2B threads, also broadcast to buyer/seller businesses
if ($this->thread->buyer_business_id) {
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->buyer_business_id}");
}
if ($this->thread->seller_business_id) {
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->seller_business_id}");
}
return $channels;
}
public function broadcastAs(): string
{
return 'thread.updated';
}
public function broadcastWith(): array
{
return [
'thread' => [
'id' => $this->thread->id,
'subject' => $this->thread->subject,
'status' => $this->thread->status,
'priority' => $this->thread->priority,
'assigned_to' => $this->thread->assigned_to,
'assignee_name' => $this->thread->assignee?->name,
'snoozed_until' => $this->thread->snoozed_until?->toIso8601String(),
'last_message_at' => $this->thread->last_message_at?->toIso8601String(),
],
'update_type' => $this->updateType,
'updated_at' => now()->toIso8601String(),
];
}
}

View File

@@ -1,41 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CrmTypingIndicator implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $threadId,
public int $userId,
public string $userName,
public bool $isTyping
) {}
public function broadcastOn(): array
{
return [new PrivateChannel("crm-thread.{$this->threadId}")];
}
public function broadcastAs(): string
{
return 'typing';
}
public function broadcastWith(): array
{
return [
'user_id' => $this->userId,
'user_name' => $this->userName,
'is_typing' => $this->isTyping,
'timestamp' => now()->toIso8601String(),
];
}
}

View File

@@ -1,56 +0,0 @@
<?php
namespace App\Events;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class HighIntentBuyerDetected implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $sellerBusinessId,
public int $buyerBusinessId,
public IntentSignal $signal,
public ?BuyerEngagementScore $engagementScore = null
) {}
/**
* Get the channels the event should broadcast on.
*/
public function broadcastOn(): Channel
{
return new Channel("business.{$this->sellerBusinessId}.analytics");
}
/**
* Get the data to broadcast.
*/
public function broadcastWith(): array
{
return [
'buyer_business_id' => $this->buyerBusinessId,
'buyer_business_name' => $this->signal->buyerBusiness?->name,
'signal_type' => $this->signal->signal_type,
'signal_strength' => $this->signal->signal_strength,
'product_id' => $this->signal->subject_type === 'App\Models\Product' ? $this->signal->subject_id : null,
'total_engagement_score' => $this->engagementScore?->total_score,
'detected_at' => $this->signal->detected_at->toIso8601String(),
'context' => $this->signal->context,
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'high-intent-buyer-detected';
}
}

View File

@@ -1,79 +0,0 @@
<?php
namespace App\Events;
use App\Models\Crm\CrmChannelMessage;
use App\Models\Crm\CrmThread;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NewMarketplaceMessage implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public CrmChannelMessage $message,
public CrmThread $thread
) {}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
$channels = [];
if ($this->thread->buyer_business_id) {
$channels[] = new PrivateChannel("marketplace-chat.{$this->thread->buyer_business_id}");
}
if ($this->thread->seller_business_id) {
$channels[] = new PrivateChannel("marketplace-chat.{$this->thread->seller_business_id}");
}
return $channels;
}
/**
* Get the data to broadcast.
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'message' => [
'id' => $this->message->id,
'thread_id' => $this->message->thread_id,
'body' => $this->message->body,
'sender_id' => $this->message->sender_id,
'sender_name' => $this->message->sender
? trim($this->message->sender->first_name.' '.$this->message->sender->last_name)
: 'Unknown',
'direction' => $this->message->direction,
'created_at' => $this->message->created_at->toIso8601String(),
'attachments' => $this->message->attachments,
],
'thread' => [
'id' => $this->thread->id,
'subject' => $this->thread->subject,
'buyer_business_id' => $this->thread->buyer_business_id,
'seller_business_id' => $this->thread->seller_business_id,
'order_id' => $this->thread->order_id,
],
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'message.new';
}
}

View File

@@ -1,47 +0,0 @@
<?php
namespace App\Events;
use App\Models\TeamMessage;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TeamMessageSent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public TeamMessage $message
) {}
public function broadcastOn(): array
{
// Broadcast to the team conversation channel
return [
new PrivateChannel('team-conversation.'.$this->message->conversation_id),
];
}
public function broadcastAs(): string
{
return 'message.sent';
}
public function broadcastWith(): array
{
return [
'id' => $this->message->id,
'conversation_id' => $this->message->conversation_id,
'sender_id' => $this->message->sender_id,
'sender_name' => $this->message->getSenderName(),
'sender_initials' => $this->message->getSenderInitials(),
'body' => $this->message->body,
'type' => $this->message->type,
'metadata' => $this->message->metadata,
'created_at' => $this->message->created_at->toIso8601String(),
];
}
}

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use App\Models\Accounting\AccountingPeriod;
class PeriodLockedException extends \Exception
{
public function __construct(
string $message,
public readonly ?AccountingPeriod $period = null,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
public function getPeriod(): ?AccountingPeriod
{
return $this->period;
}
}

View File

@@ -1,455 +0,0 @@
<?php
namespace App\Filament\Pages;
use App\Models\AiSetting;
use App\Services\AiClient;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class AiSettings extends Page implements HasForms
{
use InteractsWithForms;
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-sparkles';
protected string $view = 'filament.pages.ai-settings';
protected static \UnitEnum|string|null $navigationGroup = 'System';
protected static ?string $navigationLabel = 'AI Settings (Old)';
protected static ?int $navigationSort = 100;
// Hide from navigation - replaced by AiConnectionResource
protected static bool $shouldRegisterNavigation = false;
/**
* Only Superadmins can access AI Settings.
* This page is hidden from navigation but still protected.
*/
public static function canAccess(): bool
{
return auth('admin')->user()?->canManageAi() ?? false;
}
public ?array $data = [];
public function mount(): void
{
$settings = AiSetting::getInstance();
$this->form->fill([
'is_enabled' => $settings->is_enabled ?? false,
'ai_provider' => $settings->ai_provider ?? '',
'anthropic_api_key' => '', // Never show the full key
'openai_api_key' => '', // Never show the full key
'perplexity_api_key' => '', // Never show the full key
'canva_api_key' => '', // Never show the full key
'jasper_api_key' => '', // Never show the full key
'anthropic_model' => $settings->anthropic_model ?? last(config('ai.providers.anthropic.models')),
'openai_model' => $settings->openai_model ?? last(config('ai.providers.openai.models')),
'perplexity_model' => $settings->perplexity_model ?? last(config('ai.providers.perplexity.models')),
'canva_model' => $settings->canva_model ?? last(config('ai.providers.canva.models')),
'jasper_model' => $settings->jasper_model ?? last(config('ai.providers.jasper.models')),
'max_tokens_per_request' => $settings->max_tokens_per_request ?? 4096,
]);
}
public function form(Schema $schema): Schema
{
$settings = AiSetting::getInstance();
return $schema
->schema([
Section::make('AI Copilot Configuration')
->description('Configure Cannabrands content suggestions for brand settings')
->schema([
Toggle::make('is_enabled')
->label('Enable AI Copilot')
->helperText('Enable Cannabrands content suggestions across the platform')
->default(false),
Select::make('ai_provider')
->label('AI Provider')
->options([
'anthropic' => 'Anthropic / Claude',
'openai' => 'OpenAI / ChatGPT',
'perplexity' => 'Perplexity',
'canva' => 'Canva',
'jasper' => 'Jasper',
])
->placeholder('Select an AI provider')
->required()
->live()
->helperText('Choose your preferred AI provider'),
// Anthropic fields (shown when provider is 'anthropic')
TextInput::make('anthropic_api_key')
->label('Anthropic API Key')
->helperText('Enter your Anthropic API key. Leave blank to keep existing key.')
->password()
->revealable()
->placeholder('sk-ant-...')
->visible(fn ($get) => $get('ai_provider') === 'anthropic'),
Select::make('anthropic_model')
->label('Default Model')
->options(fn () => array_combine(
config('ai.providers.anthropic.models'),
config('ai.providers.anthropic.models')
))
->default(fn () => last(config('ai.providers.anthropic.models')))
->required()
->helperText('Claude model to use')
->visible(fn ($get) => $get('ai_provider') === 'anthropic'),
// OpenAI fields (shown when provider is 'openai')
TextInput::make('openai_api_key')
->label('OpenAI API Key')
->helperText('Enter your OpenAI API key. Leave blank to keep existing key.')
->password()
->revealable()
->placeholder('sk-...')
->visible(fn ($get) => $get('ai_provider') === 'openai'),
Select::make('openai_model')
->label('Default Model')
->options(fn () => array_combine(
config('ai.providers.openai.models'),
config('ai.providers.openai.models')
))
->default(fn () => last(config('ai.providers.openai.models')))
->required()
->helperText('ChatGPT / GPT model to use')
->visible(fn ($get) => $get('ai_provider') === 'openai'),
// Perplexity fields (shown when provider is 'perplexity')
TextInput::make('perplexity_api_key')
->label('Perplexity API Key')
->helperText('Enter your Perplexity API key. Leave blank to keep existing key.')
->password()
->revealable()
->placeholder('pplx-...')
->visible(fn ($get) => $get('ai_provider') === 'perplexity'),
Select::make('perplexity_model')
->label('Default Model')
->options(fn () => array_combine(
config('ai.providers.perplexity.models'),
config('ai.providers.perplexity.models')
))
->default(fn () => last(config('ai.providers.perplexity.models')))
->required()
->helperText('Perplexity model to use')
->visible(fn ($get) => $get('ai_provider') === 'perplexity'),
// Canva fields (shown when provider is 'canva')
TextInput::make('canva_api_key')
->label('Canva API Key')
->helperText('Enter your Canva API key. Leave blank to keep existing key.')
->password()
->revealable()
->placeholder('canva-...')
->visible(fn ($get) => $get('ai_provider') === 'canva'),
Select::make('canva_model')
->label('Default Model')
->options(fn () => array_combine(
config('ai.providers.canva.models'),
config('ai.providers.canva.models')
))
->default(fn () => last(config('ai.providers.canva.models')))
->required()
->helperText('Canva model/feature to use')
->visible(fn ($get) => $get('ai_provider') === 'canva'),
// Jasper fields (shown when provider is 'jasper')
TextInput::make('jasper_api_key')
->label('Jasper API Key')
->helperText('Enter your Jasper API key. Leave blank to keep existing key.')
->password()
->revealable()
->placeholder('jasper-...')
->visible(fn ($get) => $get('ai_provider') === 'jasper'),
Select::make('jasper_model')
->label('Default Model')
->options(fn () => array_combine(
config('ai.providers.jasper.models'),
config('ai.providers.jasper.models')
))
->default(fn () => last(config('ai.providers.jasper.models')))
->required()
->helperText('Jasper model to use')
->visible(fn ($get) => $get('ai_provider') === 'jasper'),
TextInput::make('max_tokens_per_request')
->label('Max Tokens Per Request')
->helperText('Maximum number of tokens to request from the AI model')
->numeric()
->default(4096)
->required(),
// Existing Connections Summary
Placeholder::make('connections_summary')
->label('Existing Connections')
->content(fn () => view('filament.components.ai-connections-summary', [
'anthropic_configured' => $settings->anthropic_api_key_configured,
'openai_configured' => $settings->openai_api_key_configured,
'perplexity_configured' => $settings->perplexity_api_key_configured,
'canva_configured' => $settings->canva_api_key_configured,
'jasper_configured' => $settings->jasper_api_key_configured,
])),
]),
])
->statePath('data');
}
public function save(): void
{
$data = $this->form->getState();
$settings = AiSetting::getInstance();
// Update basic settings
$settings->is_enabled = $data['is_enabled'];
$settings->ai_provider = $data['ai_provider'];
$settings->max_tokens_per_request = $data['max_tokens_per_request'];
// Always save all model fields (preserve values when switching providers)
$settings->anthropic_model = $data['anthropic_model'] ?? $settings->anthropic_model;
$settings->openai_model = $data['openai_model'] ?? $settings->openai_model;
$settings->perplexity_model = $data['perplexity_model'] ?? $settings->perplexity_model;
$settings->canva_model = $data['canva_model'] ?? $settings->canva_model;
$settings->jasper_model = $data['jasper_model'] ?? $settings->jasper_model;
// Update API keys only if provided (don't overwrite with empty string)
if (! empty($data['anthropic_api_key'])) {
$settings->anthropic_api_key = $data['anthropic_api_key'];
}
if (! empty($data['openai_api_key'])) {
$settings->openai_api_key = $data['openai_api_key'];
}
if (! empty($data['perplexity_api_key'])) {
$settings->perplexity_api_key = $data['perplexity_api_key'];
}
if (! empty($data['canva_api_key'])) {
$settings->canva_api_key = $data['canva_api_key'];
}
if (! empty($data['jasper_api_key'])) {
$settings->jasper_api_key = $data['jasper_api_key'];
}
$settings->save();
// Clear the AI config cache
app(AiClient::class)->clearCache();
// Refresh the form with saved data
$this->mount();
Notification::make()
->title('AI Settings saved successfully')
->success()
->send();
}
public function testConnection(): void
{
$data = $this->form->getState();
$provider = $data['ai_provider'];
if (! $provider) {
Notification::make()
->title('Please select a provider first')
->warning()
->send();
return;
}
$settings = AiSetting::getInstance();
try {
$success = false;
$message = '';
switch ($provider) {
case 'anthropic':
if (empty($settings->anthropic_api_key)) {
throw new \Exception('Anthropic API key not configured');
}
$success = $this->testAnthropicConnection($settings->anthropic_api_key);
$message = $success ? 'Anthropic connection successful' : 'Anthropic connection failed';
break;
case 'openai':
if (empty($settings->openai_api_key)) {
throw new \Exception('OpenAI API key not configured');
}
$success = $this->testOpenAiConnection($settings->openai_api_key);
$message = $success ? 'OpenAI connection successful' : 'OpenAI connection failed';
break;
case 'perplexity':
if (empty($settings->perplexity_api_key)) {
throw new \Exception('Perplexity API key not configured');
}
$success = $this->testPerplexityConnection($settings->perplexity_api_key);
$message = $success ? 'Perplexity connection successful' : 'Perplexity connection failed';
break;
case 'canva':
if (empty($settings->canva_api_key)) {
throw new \Exception('Canva API key not configured');
}
$success = $this->testCanvaConnection($settings->canva_api_key);
$message = $success ? 'Canva connection successful' : 'Canva connection failed';
break;
case 'jasper':
if (empty($settings->jasper_api_key)) {
throw new \Exception('Jasper API key not configured');
}
$success = $this->testJasperConnection($settings->jasper_api_key);
$message = $success ? 'Jasper connection successful' : 'Jasper connection failed';
break;
default:
throw new \Exception('Unknown provider: '.$provider);
}
if ($success) {
Notification::make()
->title($message)
->success()
->send();
} else {
Notification::make()
->title($message)
->danger()
->send();
}
} catch (\Exception $e) {
Notification::make()
->title('Connection test failed')
->body($e->getMessage())
->danger()
->send();
}
}
private function testAnthropicConnection(string $apiKey): bool
{
try {
$client = new \GuzzleHttp\Client;
$response = $client->post('https://api.anthropic.com/v1/messages', [
'headers' => [
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
],
'json' => [
'model' => 'claude-3-5-sonnet-20241022',
'max_tokens' => 10,
'messages' => [
['role' => 'user', 'content' => 'Hi'],
],
],
'timeout' => 10,
]);
return $response->getStatusCode() === 200;
} catch (\Exception $e) {
return false;
}
}
private function testOpenAiConnection(string $apiKey): bool
{
try {
$client = new \GuzzleHttp\Client;
$response = $client->get('https://api.openai.com/v1/models', [
'headers' => [
'Authorization' => 'Bearer '.$apiKey,
],
'timeout' => 10,
]);
return $response->getStatusCode() === 200;
} catch (\Exception $e) {
return false;
}
}
private function testPerplexityConnection(string $apiKey): bool
{
try {
$client = new \GuzzleHttp\Client;
$response = $client->post('https://api.perplexity.ai/chat/completions', [
'headers' => [
'Authorization' => 'Bearer '.$apiKey,
'Content-Type' => 'application/json',
],
'json' => [
'model' => 'sonar-small',
'messages' => [
['role' => 'user', 'content' => 'Hi'],
],
],
'timeout' => 10,
]);
return $response->getStatusCode() === 200;
} catch (\Exception $e) {
return false;
}
}
private function testCanvaConnection(string $apiKey): bool
{
try {
$client = new \GuzzleHttp\Client;
$response = $client->get('https://api.canva.com/v1/users/me', [
'headers' => [
'Authorization' => 'Bearer '.$apiKey,
],
'timeout' => 10,
]);
return $response->getStatusCode() === 200;
} catch (\Exception $e) {
return false;
}
}
private function testJasperConnection(string $apiKey): bool
{
try {
$client = new \GuzzleHttp\Client;
$response = $client->get('https://api.jasper.ai/v1/account', [
'headers' => [
'Authorization' => 'Bearer '.$apiKey,
],
'timeout' => 10,
]);
return $response->getStatusCode() === 200;
} catch (\Exception $e) {
return false;
}
}
}

View File

@@ -1,203 +0,0 @@
<?php
namespace App\Filament\Pages;
use App\Services\Cannaiq\CannaiqClient;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\HtmlString;
class CannaiqSettings extends Page implements HasForms
{
use InteractsWithForms;
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-chart-bar-square';
protected string $view = 'filament.pages.cannaiq-settings';
protected static \UnitEnum|string|null $navigationGroup = 'Integrations';
protected static ?string $navigationLabel = 'CannaiQ';
protected static ?int $navigationSort = 1;
protected static ?string $title = 'CannaiQ Settings';
protected static ?string $slug = 'cannaiq-settings';
public ?array $data = [];
public static function canAccess(): bool
{
return auth('admin')->check();
}
public function mount(): void
{
$this->form->fill([
'base_url' => config('services.cannaiq.base_url'),
'api_key' => '', // Never show the actual key
'cache_ttl' => config('services.cannaiq.cache_ttl', 7200),
]);
}
public function form(Schema $schema): Schema
{
$apiKeyConfigured = ! empty(config('services.cannaiq.api_key'));
$baseUrl = config('services.cannaiq.base_url');
return $schema
->schema([
Section::make('CannaiQ Integration')
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
->schema([
Placeholder::make('status')
->label('Connection Status')
->content(function () use ($apiKeyConfigured, $baseUrl) {
$statusHtml = '<div class="space-y-2">';
// API Key status
if ($apiKeyConfigured) {
$statusHtml .= '<div class="flex items-center gap-2 text-success-600 dark:text-success-400">'.
'<span class="text-lg">&#10003;</span>'.
'<span>API Key configured</span>'.
'</div>';
} else {
$statusHtml .= '<div class="flex items-center gap-2 text-warning-600 dark:text-warning-400">'.
'<span class="text-lg">&#9888;</span>'.
'<span>API Key not configured (using trusted origin auth)</span>'.
'</div>';
}
// Base URL
$statusHtml .= '<div class="text-sm text-gray-500 dark:text-gray-400">'.
'Base URL: <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">'.$baseUrl.'</code>'.
'</div>';
$statusHtml .= '</div>';
return new HtmlString($statusHtml);
}),
Placeholder::make('features')
->label('Features Enabled')
->content(new HtmlString(
'<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 p-4">'.
'<ul class="list-disc list-inside text-sm text-gray-600 dark:text-gray-400 space-y-1">'.
'<li><strong>Brand Analysis</strong> - Market positioning, SKU velocity, shelf opportunities</li>'.
'<li><strong>Marketing Intelligence</strong> - Competitive insights and recommendations</li>'.
'<li><strong>Promo Recommendations</strong> - AI-powered promotional strategies</li>'.
'<li><strong>Store Playbook</strong> - Actionable insights for retail partners</li>'.
'</ul>'.
'</div>'
)),
]),
Section::make('Configuration')
->description('CannaiQ is configured via environment variables. Update your .env file to change these settings.')
->schema([
TextInput::make('base_url')
->label('Base URL')
->disabled()
->helperText('Set via CANNAIQ_BASE_URL environment variable'),
TextInput::make('cache_ttl')
->label('Cache TTL (seconds)')
->disabled()
->helperText('Set via CANNAIQ_CACHE_TTL environment variable. Default: 7200 (2 hours)'),
Placeholder::make('env_example')
->label('Environment Variables')
->content(new HtmlString(
'<div class="rounded-lg bg-gray-900 text-gray-100 p-4 font-mono text-sm overflow-x-auto">'.
'<div class="text-gray-400"># CannaiQ Configuration</div>'.
'<div>CANNAIQ_BASE_URL=https://cannaiq.co/api/v1</div>'.
'<div>CANNAIQ_API_KEY=your-api-key-here</div>'.
'<div>CANNAIQ_CACHE_TTL=7200</div>'.
'</div>'
)),
])
->collapsed(),
Section::make('Business Access')
->description('CannaiQ features must be enabled per-business in the Business settings.')
->schema([
Placeholder::make('business_info')
->label('')
->content(new HtmlString(
'<div class="rounded-lg border border-info-200 bg-info-50 dark:border-info-800 dark:bg-info-950 p-4">'.
'<div class="flex items-start gap-3">'.
'<span class="text-info-600 dark:text-info-400 text-lg">&#9432;</span>'.
'<div class="text-sm">'.
'<p class="font-medium text-info-800 dark:text-info-200">How to enable CannaiQ for a business:</p>'.
'<ol class="list-decimal list-inside mt-2 text-info-700 dark:text-info-300 space-y-1">'.
'<li>Go to <strong>Users &rarr; Businesses</strong></li>'.
'<li>Edit the business</li>'.
'<li>Go to the <strong>Integrations</strong> tab</li>'.
'<li>Toggle <strong>Enable CannaiQ</strong></li>'.
'</ol>'.
'</div>'.
'</div>'.
'</div>'
)),
]),
])
->statePath('data');
}
public function testConnection(): void
{
try {
$client = app(CannaiqClient::class);
// Try to fetch something from the API to verify connection
// We'll use a simple health check or fetch minimal data
$response = $client->getBrandAnalysis('test-brand', 'test-business');
// If we get here without exception, connection works
// (even if the response is empty/error from CannaiQ side)
Notification::make()
->title('Connection Test')
->body('Successfully connected to CannaiQ API')
->success()
->send();
} catch (\Exception $e) {
Notification::make()
->title('Connection Failed')
->body($e->getMessage())
->danger()
->send();
}
}
public function clearCache(): void
{
// Clear all CannaiQ-related cache keys
$patterns = [
'cannaiq:*',
'brand_analysis:*',
];
$cleared = 0;
foreach ($patterns as $pattern) {
// Note: This is a simplified clear - in production you might want
// to use Redis SCAN for pattern matching
Cache::forget($pattern);
$cleared++;
}
Notification::make()
->title('Cache Cleared')
->body('CannaiQ cache has been cleared')
->success()
->send();
}
}

View File

@@ -1,216 +0,0 @@
<?php
namespace App\Filament\Pages;
use App\Models\Activity;
use App\Models\Business;
use App\Models\CalendarEvent;
use App\Models\Crm\CrmTask;
use App\Models\SalesOpportunity;
use Filament\Actions\Action;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
class CrmSettings extends Page implements HasForms, HasTable
{
use InteractsWithForms;
use InteractsWithTable;
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-briefcase';
protected string $view = 'filament.pages.crm-settings';
protected static \UnitEnum|string|null $navigationGroup = 'Modules';
protected static ?string $navigationLabel = 'CRM Module';
protected static ?int $navigationSort = 10;
// Hide from navigation - CRM module settings are now in Business > Modules tab
protected static bool $shouldRegisterNavigation = false;
public ?array $data = [];
public function mount(): void
{
// Load default pipeline stages template
$this->form->fill([
'default_pipeline_stages' => $this->getDefaultPipelineStages(),
'ai_commands_enabled' => true,
'reminder_lead_time_minutes' => 30,
]);
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Section::make('CRM Module Configuration')
->description('Global settings for the CRM module')
->schema([
Toggle::make('ai_commands_enabled')
->label('Enable AI Commands')
->helperText('Allow Cannabrands command parsing from messages'),
TextInput::make('reminder_lead_time_minutes')
->label('Default Reminder Lead Time (minutes)')
->helperText('How many minutes before an event/task to send reminders')
->numeric()
->default(30)
->minValue(5)
->maxValue(1440),
]),
Section::make('Default Pipeline Stages')
->description('Template stages for new business pipelines')
->schema([
Repeater::make('default_pipeline_stages')
->schema([
TextInput::make('name')
->required()
->maxLength(50),
TextInput::make('color')
->type('color')
->default('#6366F1'),
TextInput::make('probability')
->numeric()
->suffix('%')
->default(50)
->minValue(0)
->maxValue(100),
])
->columns(3)
->collapsible()
->reorderable()
->addActionLabel('Add Stage')
->defaultItems(0),
]),
])
->statePath('data');
}
protected function getFormActions(): array
{
return [
Action::make('save')
->label('Save Settings')
->color('primary')
->action('saveSettingsAction'),
];
}
public function saveSettingsAction(): void
{
$data = $this->form->getState();
// Store settings in config cache or database
// For now, we'll just show a success notification
// In production, this would persist to a settings table
Notification::make()
->title('CRM Settings saved successfully')
->success()
->send();
}
protected function getDefaultPipelineStages(): array
{
return [
['name' => 'Lead', 'color' => '#94A3B8', 'probability' => 10],
['name' => 'Qualified', 'color' => '#3B82F6', 'probability' => 25],
['name' => 'Proposal', 'color' => '#8B5CF6', 'probability' => 50],
['name' => 'Negotiation', 'color' => '#F59E0B', 'probability' => 75],
['name' => 'Closed Won', 'color' => '#10B981', 'probability' => 100],
];
}
public function table(Table $table): Table
{
return $table
->heading('CRM Module Statistics by Business')
->columns([
TextColumn::make('business_name')
->label('Business')
->searchable()
->sortable(),
TextColumn::make('has_crm')
->label('CRM Enabled')
->badge()
->color(fn (bool $state): string => $state ? 'success' : 'gray')
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No'),
TextColumn::make('opportunities_count')
->label('Opportunities')
->numeric()
->sortable(),
TextColumn::make('tasks_count')
->label('Tasks')
->numeric()
->sortable(),
TextColumn::make('events_count')
->label('Events')
->numeric()
->sortable(),
TextColumn::make('activities_count')
->label('Activities')
->numeric()
->sortable(),
TextColumn::make('pipeline_value')
->label('Pipeline Value')
->money('USD')
->sortable(),
])
->query(fn () => $this->getCrmStats())
->defaultSort('business_name');
}
protected function getCrmStats()
{
// Get all seller businesses with CRM stats
return Business::where('business_type', 'seller')
->orWhere('business_type', 'both')
->select([
'businesses.id',
'businesses.name as business_name',
'businesses.has_crm',
])
->withCount([
'sellerOpportunities as opportunities_count',
])
->get()
->map(function ($business) {
// Add additional counts that can't be done via withCount due to custom foreign keys
$business->tasks_count = CrmTask::where('seller_business_id', $business->id)->count();
$business->events_count = CalendarEvent::where('seller_business_id', $business->id)->count();
$business->activities_count = Activity::where('seller_business_id', $business->id)->count();
$business->pipeline_value = SalesOpportunity::where('seller_business_id', $business->id)
->where('status', 'open')
->sum('value');
return $business;
});
}
public static function getNavigationBadge(): ?string
{
// Show count of businesses with CRM enabled
$count = Business::where('has_crm', true)->count();
return $count > 0 ? (string) $count : null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'success';
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,604 +0,0 @@
<?php
namespace App\Filament\Pages;
use App\Models\AutomationRunLog;
use App\Models\OrchestratorMarketingConfig;
use App\Models\OrchestratorTask;
use App\Models\SystemAlert;
use App\Services\OrchestratorGovernanceService;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class MarketingOrchestrator extends Page implements HasForms, HasTable
{
use InteractsWithForms;
use InteractsWithTable;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-megaphone';
protected static ?string $navigationLabel = 'Head of Marketing';
protected static string|\UnitEnum|null $navigationGroup = 'Orchestrator';
protected static ?int $navigationSort = 2;
protected static bool $shouldRegisterNavigation = true;
protected string $view = 'filament.pages.marketing-orchestrator';
protected static ?string $title = 'Marketing Orchestrator Head of Marketing';
protected static ?string $slug = 'orchestrator/head-of-marketing';
// Form state for playbook settings
public ?array $playbookData = [];
// Current view mode
public string $activeTab = 'tasks';
// Timeframe filter (7, 30, 90 days)
public int $timeframe = 30;
public function updatedTimeframe(): void
{
// Refresh data
}
public function mount(): void
{
try {
$config = OrchestratorMarketingConfig::getGlobal();
$this->form->fill($config->toArray());
} catch (\Exception $e) {
$this->form->fill([]);
}
}
public static function canAccess(): bool
{
$user = auth('admin')->user();
return $user && in_array($user->user_type, ['admin', 'superadmin']);
}
// ─────────────────────────────────────────────────────────────
// KPI Data
// ─────────────────────────────────────────────────────────────
public function getKpiData(): array
{
$startDate = now()->subDays($this->timeframe);
$createdCount = OrchestratorTask::marketing()
->where('created_at', '>=', $startDate)
->count();
$pendingCount = OrchestratorTask::marketing()
->pending()
->count();
$completedCount = OrchestratorTask::marketing()
->completed()
->where('completed_at', '>=', $startDate)
->count();
$dismissedCount = OrchestratorTask::marketing()
->dismissed()
->where('completed_at', '>=', $startDate)
->count();
// By playbook type
$byPlaybook = [
'campaign_blast' => OrchestratorTask::marketing()
->forType(OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE)
->where('created_at', '>=', $startDate)
->count(),
'segment_refinement' => OrchestratorTask::marketing()
->forType(OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT)
->where('created_at', '>=', $startDate)
->count(),
'launch_announcement' => OrchestratorTask::marketing()
->forType(OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT)
->where('created_at', '>=', $startDate)
->count(),
'holiday_campaign' => OrchestratorTask::marketing()
->forType(OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN)
->where('created_at', '>=', $startDate)
->count(),
'new_sku_feature' => OrchestratorTask::marketing()
->forType(OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE)
->where('created_at', '>=', $startDate)
->count(),
'nurture_sequence' => OrchestratorTask::marketing()
->forType(OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE)
->where('created_at', '>=', $startDate)
->count(),
];
return [
'timeframe' => $this->timeframe,
'created' => $createdCount,
'pending' => $pendingCount,
'completed' => $completedCount,
'dismissed' => $dismissedCount,
'by_playbook' => $byPlaybook,
];
}
// ─────────────────────────────────────────────────────────────
// Pending Tasks Table
// ─────────────────────────────────────────────────────────────
public function table(Table $table): Table
{
return $table
->query(
OrchestratorTask::query()
->marketing()
->pending()
->with(['business', 'brand', 'customer'])
)
->defaultSort('due_at', 'asc')
->columns([
TextColumn::make('type')
->label('Type')
->formatStateUsing(fn ($state) => match ($state) {
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE => 'Campaign Blast',
OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT => 'Segment Refinement',
OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT => 'Launch',
OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN => 'Holiday',
OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE => 'New SKU',
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE => 'Nurture',
default => $state,
})
->badge()
->color(fn ($state) => match ($state) {
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE => 'primary',
OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT => 'gray',
OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT => 'success',
OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN => 'info',
OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE => 'warning',
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE => 'gray',
default => 'gray',
}),
TextColumn::make('business.name')
->label('Seller')
->searchable()
->sortable(),
TextColumn::make('brand.name')
->label('Brand')
->placeholder('-')
->searchable(),
TextColumn::make('customer.name')
->label('Customer')
->placeholder('-')
->searchable(),
TextColumn::make('priority')
->badge()
->color(fn ($state) => match ($state) {
'high' => 'danger',
'normal' => 'gray',
'low' => 'gray',
default => 'gray',
}),
TextColumn::make('due_at')
->label('Due')
->dateTime('M j, g:i A')
->sortable()
->color(fn ($record) => $record->due_at && $record->due_at->isPast() ? 'danger' : null),
TextColumn::make('created_at')
->label('Created')
->since()
->sortable(),
])
->filters([
SelectFilter::make('type')
->options([
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE => 'Campaign Blast',
OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT => 'Segment Refinement',
OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT => 'Launch Announcement',
OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN => 'Holiday Campaign',
OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE => 'New SKU Feature',
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE => 'Nurture Sequence',
]),
SelectFilter::make('priority')
->options([
'high' => 'High',
'normal' => 'Normal',
'low' => 'Low',
]),
])
->actions([
\Filament\Actions\Action::make('complete')
->label('Done')
->icon('heroicon-o-check')
->color('success')
->requiresConfirmation()
->action(fn (OrchestratorTask $record) => $record->complete()),
\Filament\Actions\Action::make('dismiss')
->label('Dismiss')
->icon('heroicon-o-x-mark')
->color('gray')
->requiresConfirmation()
->action(fn (OrchestratorTask $record) => $record->dismiss()),
\Filament\Actions\Action::make('snooze')
->label('Snooze')
->icon('heroicon-o-clock')
->color('warning')
->form([
\Filament\Forms\Components\Select::make('days')
->label('Snooze for')
->options([
1 => '1 day',
3 => '3 days',
7 => '7 days',
])
->required(),
])
->action(fn (OrchestratorTask $record, array $data) => $record->snooze((int) $data['days'])),
])
->bulkActions([])
->emptyStateHeading('No pending marketing tasks')
->emptyStateDescription('The Marketing Orchestrator has no pending tasks at this time.');
}
// ─────────────────────────────────────────────────────────────
// Activity Log Data
// ─────────────────────────────────────────────────────────────
public function getActivityLogData(): \Illuminate\Database\Eloquent\Collection
{
return OrchestratorTask::marketing()
->resolved()
->with(['business', 'brand', 'customer', 'completedByUser'])
->orderByDesc('completed_at')
->limit(50)
->get();
}
// ─────────────────────────────────────────────────────────────
// Performance Data
// ─────────────────────────────────────────────────────────────
public function getPerformanceData(): array
{
$thirtyDaysAgo = now()->subDays(30);
$playbooks = [
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE => 'Campaign Blast',
OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT => 'Segment Refinement',
OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT => 'Launch Announcement',
OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN => 'Holiday Campaign',
OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE => 'New SKU Feature',
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE => 'Nurture Sequence',
];
$metrics = [];
foreach ($playbooks as $type => $label) {
$created = OrchestratorTask::marketing()
->forType($type)
->where('created_at', '>=', $thirtyDaysAgo)
->count();
$completed = OrchestratorTask::marketing()
->forType($type)
->completed()
->where('completed_at', '>=', $thirtyDaysAgo)
->count();
$dismissed = OrchestratorTask::marketing()
->forType($type)
->dismissed()
->where('completed_at', '>=', $thirtyDaysAgo)
->count();
$metrics[$type] = [
'label' => $label,
'created_30d' => $created,
'completed_30d' => $completed,
'dismissed_30d' => $dismissed,
'completion_rate' => $created > 0 ? round(($completed / $created) * 100, 1) : 0,
];
}
$totalCreated = OrchestratorTask::marketing()
->where('created_at', '>=', $thirtyDaysAgo)
->count();
$totalCompleted = OrchestratorTask::marketing()
->completed()
->where('completed_at', '>=', $thirtyDaysAgo)
->count();
return [
'by_playbook' => $metrics,
'total_created_30d' => $totalCreated,
'total_completed_30d' => $totalCompleted,
'overall_completion_rate' => $totalCreated > 0
? round(($totalCompleted / $totalCreated) * 100, 1)
: 0,
];
}
// ─────────────────────────────────────────────────────────────
// Governance & Health Data
// ─────────────────────────────────────────────────────────────
/**
* Get active system alerts for the dashboard.
*/
public function getActiveAlerts(): \Illuminate\Database\Eloquent\Collection
{
if (! \Schema::hasTable('system_alerts')) {
return collect();
}
return SystemAlert::unresolved()
->orderByRaw("CASE severity WHEN 'critical' THEN 1 WHEN 'warning' THEN 2 ELSE 3 END")
->orderByDesc('created_at')
->limit(10)
->get();
}
/**
* Get alert summary counts.
*/
public function getAlertSummary(): array
{
if (! \Schema::hasTable('system_alerts')) {
return ['critical' => 0, 'warning' => 0, 'info' => 0, 'total' => 0];
}
return SystemAlert::getSummaryCounts();
}
/**
* Get queue/Horizon health data.
*/
public function getQueueHealthData(): array
{
try {
$governance = app(OrchestratorGovernanceService::class);
return $governance->checkHorizonHealth();
} catch (\Exception $e) {
return [
'status' => 'unknown',
'checks' => [],
'alerts' => [],
'error' => $e->getMessage(),
];
}
}
/**
* Get automation health summary.
*/
public function getAutomationHealthData(): array
{
if (! \Schema::hasTable('automation_run_logs')) {
return [
'healthy' => 0,
'unhealthy' => 0,
'unknown' => 0,
'total' => 0,
'overall_status' => 'unknown',
'statuses' => [],
];
}
return AutomationRunLog::getAllStatuses();
}
/**
* Resolve an alert (action from UI).
*/
public function resolveAlert(int $alertId): void
{
$alert = SystemAlert::find($alertId);
if ($alert) {
$alert->resolve(auth()->id(), 'Resolved from marketing dashboard');
Notification::make()
->title('Alert resolved')
->success()
->send();
}
}
// ─────────────────────────────────────────────────────────────
// Playbook Settings Form
// ─────────────────────────────────────────────────────────────
public function form(Schema $form): Schema
{
return $form
->statePath('playbookData')
->schema([
Section::make('Global Settings')
->description('Control throttling and cooldown for marketing tasks')
->schema([
TextInput::make('max_tasks_per_brand_per_run')
->label('Max tasks per brand per run')
->numeric()
->default(5)
->minValue(1)
->maxValue(20),
TextInput::make('cooldown_days')
->label('Cooldown (days)')
->helperText('Minimum days between marketing touches to same customer')
->numeric()
->default(7)
->minValue(1)
->maxValue(30),
])
->columns(2),
Section::make('Campaign Blast Candidates')
->description('Find high-engagement customers for campaign blasts')
->schema([
Toggle::make('campaign_blast_enabled')
->label('Enabled')
->default(true),
TextInput::make('campaign_blast_min_engagement_score')
->label('Min engagement score')
->numeric()
->default(50)
->minValue(0)
->maxValue(100),
TextInput::make('campaign_blast_days_since_last_send')
->label('Days since last send')
->numeric()
->default(14)
->minValue(7)
->maxValue(60),
])
->columns(3),
Section::make('Segment Refinement')
->description('Suggest customer segmentation for brands with many customers')
->schema([
Toggle::make('segment_refinement_enabled')
->label('Enabled')
->default(true),
TextInput::make('segment_refinement_min_customers')
->label('Min customers')
->numeric()
->default(50)
->minValue(10)
->maxValue(500),
])
->columns(2),
Section::make('Launch Announcement')
->description('Suggest campaigns for new brands')
->schema([
Toggle::make('launch_announcement_enabled')
->label('Enabled')
->default(true),
TextInput::make('launch_announcement_days_new')
->label('Days new')
->helperText('Brands created within this window are "new"')
->numeric()
->default(30)
->minValue(7)
->maxValue(90),
])
->columns(2),
Section::make('Holiday Campaign')
->description('Suggest holiday-themed campaigns')
->schema([
Toggle::make('holiday_campaign_enabled')
->label('Enabled')
->default(true),
TextInput::make('holiday_campaign_days_before')
->label('Days before holiday')
->numeric()
->default(14)
->minValue(7)
->maxValue(30),
])
->columns(2),
Section::make('New SKU Feature')
->description('Suggest featuring new products')
->schema([
Toggle::make('new_sku_feature_enabled')
->label('Enabled')
->default(true),
TextInput::make('new_sku_feature_days_new')
->label('Days new')
->numeric()
->default(7)
->minValue(1)
->maxValue(30),
TextInput::make('new_sku_feature_min_products')
->label('Min products')
->numeric()
->default(3)
->minValue(1)
->maxValue(20),
])
->columns(3),
Section::make('Nurture Sequence')
->description('Suggest nurture sequences for new customers')
->schema([
Toggle::make('nurture_sequence_enabled')
->label('Enabled')
->default(true),
TextInput::make('nurture_sequence_days_since_first_order')
->label('Days since first order')
->numeric()
->default(30)
->minValue(7)
->maxValue(90),
TextInput::make('nurture_sequence_max_orders')
->label('Max orders')
->helperText('Only target customers with this many orders or fewer')
->numeric()
->default(2)
->minValue(1)
->maxValue(5),
])
->columns(3),
]);
}
public function savePlaybookSettings(): void
{
$data = $this->form->getState();
OrchestratorMarketingConfig::updateGlobal($data);
Notification::make()
->title('Marketing playbook settings saved')
->success()
->send();
}
protected function getHeaderActions(): array
{
return [
Action::make('run_playbooks')
->label('Run Playbooks Now')
->icon('heroicon-o-play')
->color('primary')
->requiresConfirmation()
->modalHeading('Run Marketing Orchestrator Playbooks')
->modalDescription('This will generate new marketing tasks based on current signals. Continue?')
->action(function () {
\Artisan::call('orchestrator:generate-marketing-tasks');
Notification::make()
->title('Playbooks executed')
->body('Marketing Orchestrator tasks have been generated.')
->success()
->send();
}),
];
}
}

View File

@@ -1,104 +0,0 @@
<?php
namespace App\Filament\Pages;
use Filament\Pages\Page;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
class MigrationHealth extends Page
{
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-circle-stack';
protected string $view = 'filament.pages.migration-health';
protected static \UnitEnum|string|null $navigationGroup = 'System';
protected static ?string $navigationLabel = 'Migrations';
protected static ?int $navigationSort = 97;
public array $migrations = [];
public bool $hasPending = false;
public int $totalMigrations = 0;
public int $ranMigrations = 0;
public int $pendingMigrations = 0;
public function mount(): void
{
$this->loadMigrations();
}
protected function loadMigrations(): void
{
// Get all migration files from database/migrations
$migrationsPath = database_path('migrations');
$files = File::files($migrationsPath);
// Get ran migrations from database
$ranMigrations = DB::table('migrations')
->select('migration', 'batch')
->get()
->keyBy('migration')
->toArray();
$migrations = [];
foreach ($files as $file) {
$filename = $file->getFilename();
// Skip non-PHP files
if (! str_ends_with($filename, '.php')) {
continue;
}
// Extract migration name (without .php extension)
$migrationName = str_replace('.php', '', $filename);
$ran = isset($ranMigrations[$migrationName]);
$batch = $ran ? $ranMigrations[$migrationName]->batch : null;
$migrations[] = [
'name' => $migrationName,
'ran' => $ran,
'batch' => $batch,
'ran_at' => null, // migrations table doesn't have ran_at by default
];
if (! $ran) {
$this->hasPending = true;
}
}
// Sort migrations by name (chronological order due to timestamp prefix)
usort($migrations, fn ($a, $b) => strcmp($a['name'], $b['name']));
$this->migrations = $migrations;
$this->totalMigrations = count($migrations);
$this->ranMigrations = count(array_filter($migrations, fn ($m) => $m['ran']));
$this->pendingMigrations = $this->totalMigrations - $this->ranMigrations;
}
public function getStatusColor(): string
{
return $this->hasPending ? 'warning' : 'success';
}
public function getStatusMessage(): string
{
if ($this->hasPending) {
return 'Pending migrations detected. Please back up your database and run php artisan migrate from the terminal.';
}
return 'All migrations are up to date.';
}
public function getStatusIcon(): string
{
return $this->hasPending ? 'heroicon-o-exclamation-triangle' : 'heroicon-o-check-circle';
}
}

View File

@@ -1,187 +0,0 @@
<?php
namespace App\Filament\Pages;
use App\Models\Ai\AiPromptLog;
use App\Models\Business;
use Filament\Pages\Page;
use Filament\Tables;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class ModuleUsageReport extends Page implements HasTable
{
use InteractsWithTable;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
protected static \UnitEnum|string|null $navigationGroup = 'AI Settings';
protected static ?int $navigationSort = 3;
protected static ?string $navigationLabel = 'Usage Reports';
protected static ?string $title = 'Module Usage Reports';
protected string $view = 'filament.pages.module-usage-report';
public string $dateRange = '30';
/**
* Only Superadmins can access Module Usage Reports.
* Admin Staff will not see this page in navigation.
*/
public static function canAccess(): bool
{
return auth('admin')->user()?->canManageAi() ?? false;
}
public function mount(): void
{
$this->dateRange = request()->get('days', '30');
}
public function table(Table $table): Table
{
return $table
->query(
Business::query()
->where('copilot_enabled', true)
->withCount([
'aiPromptLogs as copilot_requests' => function (Builder $query) {
$query->where('operation', 'copilot')
->where('created_at', '>=', now()->subDays((int) $this->dateRange));
},
])
->withSum([
'aiPromptLogs as copilot_tokens' => function (Builder $query) {
$query->where('operation', 'copilot')
->where('created_at', '>=', now()->subDays((int) $this->dateRange));
},
], 'total_tokens')
->withSum([
'aiPromptLogs as copilot_cost' => function (Builder $query) {
$query->where('operation', 'copilot')
->where('created_at', '>=', now()->subDays((int) $this->dateRange));
},
], 'estimated_cost')
)
->columns([
Tables\Columns\TextColumn::make('name')
->label('Business')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('copilot_tier')
->label('Tier')
->badge()
->color(fn (?string $state): string => match ($state) {
'basic' => 'info',
'premium' => 'success',
'custom' => 'warning',
default => 'gray',
}),
Tables\Columns\TextColumn::make('copilot_requests')
->label('Requests')
->numeric()
->sortable()
->alignEnd(),
Tables\Columns\TextColumn::make('copilot_tokens')
->label('Tokens')
->numeric()
->sortable()
->alignEnd()
->formatStateUsing(fn ($state) => number_format($state ?? 0)),
Tables\Columns\TextColumn::make('copilot_cost')
->label('Cost')
->money('usd')
->sortable()
->alignEnd(),
Tables\Columns\TextColumn::make('last_copilot_use')
->label('Last Used')
->getStateUsing(function (Business $record) {
$lastLog = AiPromptLog::forBusiness($record->id)
->where('operation', 'copilot')
->latest()
->first();
return $lastLog?->created_at;
})
->dateTime()
->sortable(query: function (Builder $query, string $direction) {
// This is a computed column, so we can't sort directly
return $query;
}),
])
->filters([
Tables\Filters\SelectFilter::make('copilot_tier')
->label('Tier')
->options([
'basic' => 'Basic',
'premium' => 'Premium',
'custom' => 'Custom',
]),
])
->recordUrl(fn (Business $record) => route('filament.admin.resources.businesses.edit', $record))
->defaultSort('copilot_requests', 'desc')
->striped()
->paginated([10, 25, 50, 100]);
}
public function getOverviewStats(): array
{
$days = (int) $this->dateRange;
$startDate = now()->subDays($days);
$stats = AiPromptLog::where('operation', 'copilot')
->where('created_at', '>=', $startDate)
->selectRaw('
COUNT(*) as total_requests,
SUM(total_tokens) as total_tokens,
SUM(estimated_cost) as total_cost,
AVG(latency_ms) as avg_latency,
COUNT(DISTINCT business_id) as active_businesses,
SUM(CASE WHEN is_error = true THEN 1 ELSE 0 END) as error_count
')
->first();
$totalBusinessesWithCopilot = Business::where('copilot_enabled', true)->count();
return [
'total_requests' => $stats->total_requests ?? 0,
'total_tokens' => $stats->total_tokens ?? 0,
'total_cost' => $stats->total_cost ?? 0,
'avg_latency' => $stats->avg_latency ?? 0,
'active_businesses' => $stats->active_businesses ?? 0,
'total_businesses' => $totalBusinessesWithCopilot,
'error_count' => $stats->error_count ?? 0,
'error_rate' => $stats->total_requests > 0
? round(($stats->error_count / $stats->total_requests) * 100, 2)
: 0,
];
}
public function getDailyUsage(): array
{
$days = (int) $this->dateRange;
return AiPromptLog::where('operation', 'copilot')
->where('created_at', '>=', now()->subDays($days))
->selectRaw('DATE(created_at) as date, COUNT(*) as requests, SUM(total_tokens) as tokens, SUM(estimated_cost) as cost')
->groupBy('date')
->orderBy('date')
->get()
->toArray();
}
public function updatedDateRange(): void
{
$this->resetTable();
}
}

View File

@@ -1,206 +0,0 @@
<?php
namespace App\Filament\Pages;
use Filament\Forms;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class NotificationSettings extends Page implements HasForms
{
use InteractsWithForms;
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-envelope';
protected static \UnitEnum|string|null $navigationGroup = 'System';
protected static ?string $navigationLabel = 'Notification Settings';
protected static ?int $navigationSort = 98;
public ?array $data = [];
public function mount(): void
{
$this->fillForm();
}
protected function fillForm(): void
{
$this->form->fill([
// Mail settings
'mail_driver' => config('mail.default'),
'mail_host' => config('mail.mailers.smtp.host'),
'mail_port' => config('mail.mailers.smtp.port'),
'mail_username' => config('mail.mailers.smtp.username'),
'mail_password' => config('mail.mailers.smtp.password'),
'mail_encryption' => config('mail.mailers.smtp.encryption'),
'mail_from_address' => config('mail.from.address'),
'mail_from_name' => config('mail.from.name'),
// SMS settings (Twilio example)
'sms_enabled' => env('SMS_ENABLED', false),
'sms_provider' => env('SMS_PROVIDER', 'twilio'),
'twilio_sid' => env('TWILIO_SID'),
'twilio_auth_token' => env('TWILIO_AUTH_TOKEN'),
'twilio_phone_number' => env('TWILIO_PHONE_NUMBER'),
// WhatsApp settings
'whatsapp_enabled' => env('WHATSAPP_ENABLED', false),
'whatsapp_provider' => env('WHATSAPP_PROVIDER', 'twilio'),
'whatsapp_business_number' => env('WHATSAPP_BUSINESS_NUMBER'),
]);
}
protected function getFormSchema(): array
{
return [
Forms\Components\Tabs::make('Notification Providers')
->tabs([
Forms\Components\Tabs\Tab::make('Email')
->icon('heroicon-o-envelope')
->schema([
Forms\Components\Section::make('Email Provider Configuration')
->description('Configure your email provider for sending transactional emails')
->schema([
Forms\Components\Select::make('mail_driver')
->label('Mail Driver')
->options([
'smtp' => 'SMTP',
'sendmail' => 'Sendmail',
'mailgun' => 'Mailgun',
'ses' => 'Amazon SES',
'postmark' => 'Postmark',
])
->required()
->reactive(),
Forms\Components\Grid::make(2)
->schema([
Forms\Components\TextInput::make('mail_host')
->label('SMTP Host')
->required()
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
Forms\Components\TextInput::make('mail_port')
->label('SMTP Port')
->required()
->numeric()
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
Forms\Components\TextInput::make('mail_username')
->label('Username')
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
Forms\Components\TextInput::make('mail_password')
->label('Password')
->password()
->revealable()
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
Forms\Components\Select::make('mail_encryption')
->label('Encryption')
->options([
'tls' => 'TLS',
'ssl' => 'SSL',
'' => 'None',
])
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
Forms\Components\TextInput::make('mail_from_address')
->label('From Address')
->email()
->required(),
Forms\Components\TextInput::make('mail_from_name')
->label('From Name')
->required(),
]),
]),
]),
Forms\Components\Tabs\Tab::make('SMS')
->icon('heroicon-o-device-phone-mobile')
->schema([
Forms\Components\Section::make('SMS Provider Configuration')
->description('Configure your SMS provider for sending text messages')
->schema([
Forms\Components\Toggle::make('sms_enabled')
->label('Enable SMS Notifications')
->reactive(),
Forms\Components\Select::make('sms_provider')
->label('SMS Provider')
->options([
'twilio' => 'Twilio',
'nexmo' => 'Vonage (Nexmo)',
'aws_sns' => 'AWS SNS',
])
->required()
->reactive()
->visible(fn ($get) => $get('sms_enabled')),
Forms\Components\Grid::make(2)
->schema([
Forms\Components\TextInput::make('twilio_sid')
->label('Twilio Account SID')
->required()
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
Forms\Components\TextInput::make('twilio_auth_token')
->label('Twilio Auth Token')
->password()
->revealable()
->required()
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
Forms\Components\TextInput::make('twilio_phone_number')
->label('Twilio Phone Number')
->tel()
->required()
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
]),
]),
]),
Forms\Components\Tabs\Tab::make('WhatsApp')
->icon('heroicon-o-chat-bubble-left-right')
->schema([
Forms\Components\Section::make('WhatsApp Configuration')
->description('Configure WhatsApp Business API for sending messages')
->schema([
Forms\Components\Toggle::make('whatsapp_enabled')
->label('Enable WhatsApp Notifications')
->reactive(),
Forms\Components\Select::make('whatsapp_provider')
->label('WhatsApp Provider')
->options([
'twilio' => 'Twilio WhatsApp',
'whatsapp_cloud' => 'WhatsApp Cloud API',
])
->required()
->reactive()
->visible(fn ($get) => $get('whatsapp_enabled')),
Forms\Components\TextInput::make('whatsapp_business_number')
->label('WhatsApp Business Number')
->tel()
->required()
->visible(fn ($get) => $get('whatsapp_enabled')),
]),
]),
])
->columnSpanFull(),
];
}
protected function getFormStatePath(): ?string
{
return 'data';
}
public function getView(): string
{
return 'filament.pages.notification-settings';
}
public function save(): void
{
// TODO: Save settings to environment file or database
// For now, this would require implementing a settings storage system
Notification::make()
->title('Settings saved')
->success()
->body('Note: These settings are read from .env file. To persist changes, update your .env file.')
->send();
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Filament\Pages;
use Filament\Pages\Page;
class Queues extends Page
{
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-circle-stack';
protected string $view = 'filament.pages.queues';
protected static \UnitEnum|string|null $navigationGroup = 'System';
protected static ?string $navigationLabel = 'Queues';
protected static ?int $navigationSort = 2;
public function mount(): void
{
$this->redirect('/admin/horizon', navigate: false);
}
public static function shouldRegisterNavigation(): bool
{
return false;
}
}

Some files were not shown because too many files have changed in this diff Show More