Compare commits
354 Commits
feature/re
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fb5747aa2 | ||
|
|
33c9420b00 | ||
|
|
37204edfd7 | ||
|
|
8d9725b501 | ||
|
|
6cf8ad1854 | ||
|
|
58f787feb0 | ||
|
|
970ce05846 | ||
|
|
672b0d5f6b | ||
|
|
4415194b28 | ||
|
|
213b0ef8f2 | ||
|
|
13dbe046e1 | ||
|
|
592df4de44 | ||
|
|
ae581b4d5c | ||
|
|
8a8f83cc0c | ||
|
|
722904d487 | ||
|
|
ddc84f6730 | ||
|
|
2c510844f0 | ||
|
|
105a1e8ce0 | ||
|
|
7e06ff3488 | ||
|
|
aed1e62c65 | ||
|
|
f9f1b8dc46 | ||
|
|
89d3a54988 | ||
|
|
0c60e5c519 | ||
|
|
1ecc4a916b | ||
|
|
d4ec8c16f3 | ||
|
|
f9d7573cb4 | ||
|
|
e48e9c9b82 | ||
|
|
afbb1ba79c | ||
|
|
5f0042e483 | ||
|
|
08f5a3adac | ||
|
|
e62ea5c809 | ||
|
|
8d43953cad | ||
|
|
a628f2b207 | ||
|
|
367daadfe9 | ||
|
|
329c01523a | ||
|
|
5fb26f901d | ||
|
|
6baadf5744 | ||
|
|
a3508c57a2 | ||
|
|
38cba2cd72 | ||
|
|
735e09ab90 | ||
|
|
05ef21cd71 | ||
|
|
65c65bf9cc | ||
|
|
e33f0d0182 | ||
|
|
c8faf2f2d6 | ||
|
|
50bb3fce77 | ||
|
|
c7fdc67060 | ||
|
|
c7e2b0e4ac | ||
|
|
0cf83744db | ||
|
|
defeeffa07 | ||
|
|
0fbf99c005 | ||
|
|
67eb679c7e | ||
|
|
3b7f3acaa6 | ||
|
|
3d1f3b1057 | ||
|
|
7a2748e904 | ||
|
|
4f2061cd00 | ||
|
|
8bb9044f2d | ||
|
|
7da52677d5 | ||
|
|
a049db38a9 | ||
|
|
bb60a772f9 | ||
|
|
95d92f27d3 | ||
|
|
f08910bbf4 | ||
|
|
e043137269 | ||
|
|
de988d9abd | ||
|
|
72df0cfe88 | ||
|
|
65a752f4d8 | ||
|
|
7d0230be5f | ||
|
|
75305a01b0 | ||
|
|
f2ce0dfee3 | ||
|
|
1222610080 | ||
|
|
c1d0cdf477 | ||
|
|
a55ea906ac | ||
|
|
70e274415d | ||
|
|
fca89475cc | ||
|
|
b33ebac9bf | ||
|
|
a88eeb7981 | ||
|
|
eed4df0c4a | ||
|
|
915b0407cf | ||
|
|
f173254700 | ||
|
|
539cd0e4e1 | ||
|
|
050a446ba0 | ||
|
|
8fe4213178 | ||
|
|
d7413784ea | ||
|
|
b6b049e321 | ||
|
|
11509c4af0 | ||
|
|
8651e5a9e6 | ||
|
|
e0d931d72c | ||
|
|
6c7a0d2a35 | ||
|
|
95684ffae0 | ||
|
|
b30f5db061 | ||
|
|
266bb3ff9c | ||
|
|
f227a53ac1 | ||
|
|
6d0adb0b02 | ||
|
|
61b2a2beb6 | ||
|
|
fdfe132545 | ||
|
|
c9e191ee7e | ||
|
|
d42c964c30 | ||
|
|
b8e7ebc3ac | ||
|
|
e156716002 | ||
|
|
b5c1d92397 | ||
|
|
72e96b7e0e | ||
|
|
4489377762 | ||
|
|
eedd4c9cef | ||
|
|
2370f31a18 | ||
|
|
27c8395d5a | ||
|
|
dbee401f61 | ||
|
|
b17bc590bb | ||
|
|
6ce5ca14e2 | ||
|
|
454b85ffb1 | ||
|
|
e13d7cd7ad | ||
|
|
f3436d35ec | ||
|
|
a46b44055e | ||
|
|
a3dda1520e | ||
|
|
4068bfc0b2 | ||
|
|
497523ee0c | ||
|
|
94d68f80e4 | ||
|
|
c091c3c168 | ||
|
|
7c54ece253 | ||
|
|
f7294fcf83 | ||
|
|
6d64d9527a | ||
|
|
08df003b20 | ||
|
|
59cd09eb5b | ||
|
|
3a6ab1c207 | ||
|
|
404a731bd9 | ||
|
|
2b30deed11 | ||
|
|
109d9cd39d | ||
|
|
aadd7a500a | ||
|
|
111ef20684 | ||
|
|
85fdb71f92 | ||
|
|
08e2eb3ac6 | ||
|
|
87e8384aca | ||
|
|
e56ad20568 | ||
|
|
fafb05e29b | ||
|
|
a322d7609b | ||
|
|
2aefba3619 | ||
|
|
b47fc35857 | ||
|
|
e5e1dea055 | ||
|
|
e5e485d636 | ||
|
|
3d383e0490 | ||
|
|
df188e21ce | ||
|
|
55016f7009 | ||
|
|
9cf89c7b1a | ||
|
|
0d810dff27 | ||
|
|
624a36d2c5 | ||
|
|
92e3e171e1 | ||
|
|
58ca83c8c2 | ||
|
|
7f175709a5 | ||
|
|
26a903bdd9 | ||
|
|
e871426817 | ||
|
|
c99511d696 | ||
|
|
963f00cd39 | ||
|
|
0db70220c7 | ||
|
|
4bcd0cca8a | ||
|
|
6c7d7016c9 | ||
|
|
6d92f37ea7 | ||
|
|
318d6b4fe8 | ||
|
|
9ea69447ec | ||
|
|
a24fbaac9a | ||
|
|
412a3beeed | ||
|
|
4e7f344941 | ||
|
|
d0e9369795 | ||
|
|
8f56f32e62 | ||
|
|
b8d307200b | ||
|
|
4e979c3158 | ||
|
|
085ca6c415 | ||
|
|
1d363d7157 | ||
|
|
71effd6f4c | ||
|
|
2198008b4c | ||
|
|
2320511cd3 | ||
|
|
6124e8fa07 | ||
|
|
23195d1887 | ||
|
|
d9e99b3091 | ||
|
|
e774093e94 | ||
|
|
697ba5f0f4 | ||
|
|
ef043bda0c | ||
|
|
0f419075cd | ||
|
|
9b3bb1d93b | ||
|
|
8b4f6a48ad | ||
|
|
f5d537cb67 | ||
|
|
fad91c5d7d | ||
|
|
7e2b3d4ce6 | ||
|
|
918d2a3a95 | ||
|
|
bff2199cb6 | ||
|
|
8b32be2c19 | ||
|
|
9ee02b6115 | ||
|
|
7c1ff57eb1 | ||
|
|
67c663faf4 | ||
|
|
691aeda2c2 | ||
|
|
0e4e7784d3 | ||
|
|
315a206542 | ||
|
|
d1ff2e8221 | ||
|
|
a2184e2de2 | ||
|
|
cf4a77c72a | ||
|
|
85d0ca2369 | ||
|
|
61fd09f6a8 | ||
|
|
ed20135cbe | ||
|
|
e6f33d4fa9 | ||
|
|
66da7b5a7a | ||
|
|
5dfef28a20 | ||
|
|
2e1eda8c5d | ||
|
|
58e35dc78e | ||
|
|
43b49aafd7 | ||
|
|
b265b407b1 | ||
|
|
4b71bbea6a | ||
|
|
398cd41361 | ||
|
|
17b0f65680 | ||
|
|
a4514f4985 | ||
|
|
3ba9ae86b4 | ||
|
|
261f00043e | ||
|
|
656ebd023b | ||
|
|
55ab18ee53 | ||
|
|
391bd6546b | ||
|
|
ef5af08609 | ||
|
|
8f171c0784 | ||
|
|
d8d2bc5fb1 | ||
|
|
11c67f491c | ||
|
|
f3b8281cf7 | ||
|
|
8ec47836d7 | ||
|
|
e4205cbc77 | ||
|
|
8f6701fb9c | ||
|
|
648d9d56ab | ||
|
|
577dd6c369 | ||
|
|
6015195885 | ||
|
|
7522cadce5 | ||
|
|
af899f39ca | ||
|
|
90b752cb8f | ||
|
|
3f049b505b | ||
|
|
daf9ec9134 | ||
|
|
ee757761e3 | ||
|
|
010e1f9259 | ||
|
|
154ecfb507 | ||
|
|
97a41afed1 | ||
|
|
3088d05825 | ||
|
|
93648ed001 | ||
|
|
88b201222f | ||
|
|
de402c03d5 | ||
|
|
b92ba4b86d | ||
|
|
f8f219f00b | ||
|
|
f16dac012d | ||
|
|
f566b83cc6 | ||
|
|
418da7a39e | ||
|
|
3c6fe92811 | ||
|
|
7d3243b67e | ||
|
|
8f6597f428 | ||
|
|
64d38b8b2f | ||
|
|
7aa366eda9 | ||
|
|
d7adaf0cba | ||
|
|
f8b5599b4b | ||
|
|
d6161817ad | ||
|
|
be8b039c23 | ||
|
|
8519c7dd40 | ||
|
|
096bbcc173 | ||
|
|
cdff325a3c | ||
|
|
8eb822ec81 | ||
|
|
ef0eb78d93 | ||
|
|
1dfdb74bc5 | ||
|
|
75352af8b0 | ||
|
|
544d33324a | ||
|
|
235981d908 | ||
|
|
fc1dcb5d77 | ||
|
|
91548b00de | ||
|
|
bcd6426e2e | ||
|
|
22e5be9a63 | ||
|
|
515957f7e7 | ||
|
|
18f98afc95 | ||
|
|
e3781747c1 | ||
|
|
8ef10be240 | ||
|
|
e239e6ece6 | ||
|
|
5e307d25e6 | ||
|
|
5c4ec7fd46 | ||
|
|
c1b487624c | ||
|
|
5d47a8dedf | ||
|
|
b6e89cfac1 | ||
|
|
4f272af661 | ||
|
|
f1820eb47d | ||
|
|
5b78f8db0f | ||
|
|
90f414ddf7 | ||
|
|
61b0977fc7 | ||
|
|
7954804998 | ||
|
|
88fc44cee6 | ||
|
|
e02cb0415a | ||
|
|
0307a7a310 | ||
|
|
991ca95c70 | ||
|
|
3905f86d6a | ||
|
|
ece7dc602d | ||
|
|
b093e088c8 | ||
|
|
22a62ba005 | ||
|
|
57c236dd27 | ||
|
|
97f38985a9 | ||
|
|
c698c621d3 | ||
|
|
e072b01d6a | ||
|
|
076b990573 | ||
|
|
e1f34935e4 | ||
|
|
ba286d830e | ||
|
|
95db76ed09 | ||
|
|
725891b975 | ||
|
|
04b0c7a991 | ||
|
|
9dcaf5bdd7 | ||
|
|
36fac08dd1 | ||
|
|
427d5c905f | ||
|
|
641b6dc74c | ||
|
|
1d0a3d3221 | ||
|
|
fba6cd69ad | ||
|
|
ce5c670bf2 | ||
|
|
578720130b | ||
|
|
12f7ba9949 | ||
|
|
3f8625fc0d | ||
|
|
02270569ed | ||
|
|
df84066b0e | ||
|
|
d035c3ba46 | ||
|
|
6577cd0c0a | ||
|
|
8dfd9076dc | ||
|
|
ff8a2c93b4 | ||
|
|
e98aaa034b | ||
|
|
a892514c3a | ||
|
|
559a1ee2fc | ||
|
|
664c081680 | ||
|
|
6517f2fa44 | ||
|
|
c9b99efbe0 | ||
|
|
8469ff5204 | ||
|
|
9be95461cb | ||
|
|
b24266cdc1 | ||
|
|
b96beddef5 | ||
|
|
1eabb951e5 | ||
|
|
486e6864b6 | ||
|
|
cd25979e13 | ||
|
|
382c1cc29e | ||
|
|
edad5712fc | ||
|
|
91b2002dd6 | ||
|
|
54e52add84 | ||
|
|
6290274719 | ||
|
|
5bbc740962 | ||
|
|
82cac8ebab | ||
|
|
dc668e24d5 | ||
|
|
910be9c14a | ||
|
|
aab2e65903 | ||
|
|
f85be8a676 | ||
|
|
36473e1c49 | ||
|
|
fe0c6b22af | ||
|
|
162b742092 | ||
|
|
a1922ee10e | ||
|
|
e28aa402d1 | ||
|
|
b33e71fecc | ||
|
|
cced67001e | ||
|
|
bc8cb45533 | ||
|
|
a48051f0bb | ||
|
|
84e81272a5 | ||
|
|
435a6b074c | ||
|
|
9a5d89fbdd | ||
|
|
4adc611e83 | ||
|
|
3c88bbfb4d | ||
|
|
3496421264 | ||
|
|
91f1ae217a | ||
|
|
5b7a2dd7bf | ||
|
|
c991d3f141 |
15
.env.example
15
.env.example
@@ -38,7 +38,7 @@ SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=reverb
|
||||
FILESYSTEM_DISK=local
|
||||
FILESYSTEM_DISK=minio
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
# Laravel Reverb (WebSocket Server for Real-Time Broadcasting)
|
||||
@@ -126,3 +126,16 @@ AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
# 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
|
||||
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -37,6 +37,7 @@ yarn-error.log
|
||||
*.gz
|
||||
*.sql.gz
|
||||
*.sql
|
||||
!database/dumps/*.sql
|
||||
|
||||
# Version files (generated at build time or locally)
|
||||
version.txt
|
||||
@@ -46,6 +47,9 @@ version.env
|
||||
.cannabrands-secrets/
|
||||
reverb-keys*
|
||||
|
||||
# Local Claude context (DO NOT COMMIT)
|
||||
CLAUDE.local.md
|
||||
|
||||
# Core dumps and debug files
|
||||
core
|
||||
core.*
|
||||
@@ -67,3 +71,15 @@ core.*
|
||||
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
|
||||
|
||||
@@ -1,18 +1,42 @@
|
||||
# Woodpecker CI/CD Pipeline for Cannabrands Hub
|
||||
# Documentation: https://woodpecker-ci.org/docs/intro
|
||||
#
|
||||
# 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)
|
||||
# 2-Environment Workflow (Optimized for small team):
|
||||
# - develop branch → dev.cannabrands.app (integration/testing)
|
||||
# - master branch → cannabrands.app (production)
|
||||
# - tags (2025.X) → cannabrands.app (versioned production releases)
|
||||
#
|
||||
# Pipeline Strategy:
|
||||
# - PRs: Run tests (lint, style, phpunit) IN PARALLEL
|
||||
# - Push to develop/master: Skip tests (already passed on PR), build + deploy
|
||||
# - Tags: Build versioned release
|
||||
#
|
||||
# Optimization Notes:
|
||||
# - php-lint, code-style, and tests run in parallel after composer install
|
||||
# - Uses parallel-lint for faster PHP syntax checking
|
||||
# - PostgreSQL tuned for CI (fsync disabled)
|
||||
# - Cache rebuild only on merge builds
|
||||
|
||||
when:
|
||||
- branch: [develop, master]
|
||||
event: push
|
||||
- event: [pull_request, tag]
|
||||
|
||||
# Install dependencies first (needed for php-lint to resolve traits/classes)
|
||||
# Use explicit git clone plugin to fix auth issues
|
||||
# The default clone was failing with "could not read Username"
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
depth: 50
|
||||
lfs: false
|
||||
partial: false
|
||||
|
||||
steps:
|
||||
# ============================================
|
||||
# DEPENDENCY INSTALLATION (Sequential)
|
||||
# ============================================
|
||||
|
||||
# Restore Composer cache
|
||||
restore-composer-cache:
|
||||
image: meltwater/drone-cache:dev
|
||||
@@ -26,18 +50,12 @@ steps:
|
||||
volumes:
|
||||
- /tmp/woodpecker-cache:/tmp/cache
|
||||
|
||||
# Install dependencies
|
||||
# Install dependencies (uses pre-built Laravel image with all extensions)
|
||||
composer-install:
|
||||
image: php:8.3-cli
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- restore-composer-cache
|
||||
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 pcntl
|
||||
- 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'
|
||||
@@ -59,17 +77,18 @@ steps:
|
||||
- |
|
||||
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!"
|
||||
- echo "✅ Composer dependencies ready!"
|
||||
|
||||
# Rebuild Composer cache
|
||||
# Rebuild Composer cache (only on merge builds, not PRs)
|
||||
rebuild-composer-cache:
|
||||
image: meltwater/drone-cache:dev
|
||||
depends_on:
|
||||
- composer-install
|
||||
settings:
|
||||
backend: "filesystem"
|
||||
rebuild: true
|
||||
@@ -79,30 +98,47 @@ steps:
|
||||
- "vendor"
|
||||
volumes:
|
||||
- /tmp/woodpecker-cache:/tmp/cache
|
||||
when:
|
||||
branch: [develop, master]
|
||||
event: push
|
||||
|
||||
# PHP Syntax Check (runs after composer install so traits/classes are available)
|
||||
# ============================================
|
||||
# PR CHECKS (Run in Parallel for Speed)
|
||||
# ============================================
|
||||
|
||||
# PHP Syntax Check - Uses parallel-lint for 5-10x speed improvement
|
||||
php-lint:
|
||||
image: php:8.3-cli
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
commands:
|
||||
- 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!"
|
||||
- echo "Checking PHP syntax (parallel)..."
|
||||
- ./vendor/bin/parallel-lint app routes database config --colors --blame
|
||||
- echo "✅ PHP syntax check complete!"
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# Run Laravel Pint (Code Style)
|
||||
# Run Laravel Pint (code style)
|
||||
code-style:
|
||||
image: php:8.3-cli
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
commands:
|
||||
- echo "Checking code style with Laravel Pint..."
|
||||
- ./vendor/bin/pint --test
|
||||
- echo "Code style check complete!"
|
||||
- echo "✅ Code style check complete!"
|
||||
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
|
||||
depends_on:
|
||||
- composer-install
|
||||
when:
|
||||
event: pull_request
|
||||
environment:
|
||||
APP_ENV: testing
|
||||
BROADCAST_CONNECTION: reverb
|
||||
@@ -126,46 +162,60 @@ steps:
|
||||
- echo "Setting up Laravel environment..."
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- echo "Waiting for PostgreSQL to be ready..."
|
||||
- |
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if pg_isready -h postgres -p 5432 -U testing 2>/dev/null; then
|
||||
echo "✅ PostgreSQL is ready!"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for postgres... attempt $i/10"
|
||||
sleep 3
|
||||
done
|
||||
- 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..."
|
||||
- echo "Running tests in parallel..."
|
||||
- php artisan test --parallel
|
||||
- echo "Tests complete!"
|
||||
- echo "✅ Tests complete!"
|
||||
|
||||
# Validate seeders that run in dev/staging environments
|
||||
# This prevents deployment failures caused by seeder errors (e.g., fake() crashes)
|
||||
# Uses APP_ENV=development to match K8s init container behavior
|
||||
validate-seeders:
|
||||
# ============================================
|
||||
# MERGE BUILD STEPS (Sequential, after PR passes)
|
||||
# ============================================
|
||||
|
||||
# Validate migrations before deployment
|
||||
# Only runs pending migrations - never fresh or seed
|
||||
validate-migrations:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
environment:
|
||||
APP_ENV: development
|
||||
APP_ENV: production
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: seeder_validation
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: testing
|
||||
DB_PASSWORD: testing
|
||||
CACHE_STORE: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
commands:
|
||||
- echo "Validating seeders (matches K8s init container)..."
|
||||
- echo "Validating migrations..."
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- echo "Creating seeder_validation database..."
|
||||
- PGPASSWORD=testing psql -h postgres -U testing -d testing -c "CREATE DATABASE seeder_validation;"
|
||||
- echo "Running migrate:fresh --seed with APP_ENV=development..."
|
||||
- php artisan migrate:fresh --seed --force
|
||||
- echo "✅ Seeder validation complete!"
|
||||
- echo "Running pending migrations only..."
|
||||
- php artisan migrate --force
|
||||
- echo "✅ Migration validation complete!"
|
||||
when:
|
||||
branch: [develop, master]
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for DEV environment (develop branch)
|
||||
build-image-dev:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
depends_on:
|
||||
- validate-migrations
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
@@ -185,17 +235,21 @@ steps:
|
||||
VITE_REVERB_HOST: "dev.cannabrands.app"
|
||||
VITE_REVERB_PORT: "443"
|
||||
VITE_REVERB_SCHEME: "https"
|
||||
cache_images:
|
||||
cache_from:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-dev
|
||||
cache_to: code.cannabrands.app/cannabrands/hub:buildcache-dev
|
||||
platforms: linux/amd64
|
||||
# Disable provenance attestations - can cause Gitea registry 500 errors
|
||||
provenance: false
|
||||
when:
|
||||
branch: develop
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Auto-deploy to dev.cannabrands.app (develop branch only)
|
||||
deploy-dev:
|
||||
image: bitnami/kubectl:latest
|
||||
depends_on:
|
||||
- build-image-dev
|
||||
environment:
|
||||
KUBECONFIG_CONTENT:
|
||||
from_secret: kubeconfig_dev
|
||||
@@ -228,11 +282,12 @@ steps:
|
||||
when:
|
||||
branch: develop
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for STAGING environment (master branch)
|
||||
build-image-staging:
|
||||
# Build and push Docker image for PRODUCTION (master branch)
|
||||
build-image-production:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
depends_on:
|
||||
- validate-migrations
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
@@ -241,23 +296,57 @@ steps:
|
||||
password:
|
||||
from_secret: gitea_token
|
||||
tags:
|
||||
- staging # Latest staging build → staging.cannabrands.app
|
||||
- latest # Latest production build
|
||||
- prod-${CI_COMMIT_SHA:0:7} # Unique prod tag with SHA
|
||||
- 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
|
||||
APP_VERSION: "production"
|
||||
cache_from:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-prod
|
||||
cache_to: code.cannabrands.app/cannabrands/hub:buildcache-prod
|
||||
platforms: linux/amd64
|
||||
# Disable provenance attestations - can cause Gitea registry 500 errors
|
||||
provenance: false
|
||||
when:
|
||||
branch: master
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for PRODUCTION (tagged releases)
|
||||
# Deploy to production (master branch)
|
||||
deploy-production:
|
||||
image: bitnami/kubectl:latest
|
||||
depends_on:
|
||||
- build-image-production
|
||||
environment:
|
||||
KUBECONFIG_CONTENT:
|
||||
from_secret: kubeconfig_prod
|
||||
commands:
|
||||
- echo "🚀 Deploying to PRODUCTION (cannabrands.app)..."
|
||||
- echo "Commit SHA ${CI_COMMIT_SHA:0:7}"
|
||||
- mkdir -p ~/.kube
|
||||
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
|
||||
- chmod 600 ~/.kube/config
|
||||
- |
|
||||
kubectl set image deployment/cannabrands-hub \
|
||||
app=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
migrate=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
-n cannabrands-prod
|
||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-prod --timeout=300s
|
||||
- |
|
||||
echo ""
|
||||
echo "✅ PRODUCTION deployment successful!"
|
||||
echo "Pod status:"
|
||||
kubectl get pods -n cannabrands-prod -l app=cannabrands-hub
|
||||
when:
|
||||
branch: master
|
||||
event: push
|
||||
|
||||
# Build and push Docker image for tagged releases (optional versioned releases)
|
||||
build-image-release:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
depends_on:
|
||||
- composer-install
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
@@ -274,9 +363,10 @@ steps:
|
||||
cache_images:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-prod
|
||||
platforms: linux/amd64
|
||||
# Disable provenance attestations - can cause Gitea registry 500 errors
|
||||
provenance: false
|
||||
when:
|
||||
event: tag
|
||||
status: success
|
||||
|
||||
# Success notification
|
||||
success:
|
||||
@@ -308,25 +398,10 @@ steps:
|
||||
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "master" ]; then
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "🧪 STAGING BUILD COMPLETE"
|
||||
echo "🚀 PRODUCTION DEPLOYED"
|
||||
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 "Site: https://cannabrands.app"
|
||||
echo "Image: prod-${CI_COMMIT_SHA:0:7}"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "develop" ]; then
|
||||
echo ""
|
||||
@@ -351,14 +426,18 @@ steps:
|
||||
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 "Ready for production? Open PR: develop → master"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
elif [ "${CI_PIPELINE_EVENT}" = "pull_request" ]; then
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "✅ PR CHECKS PASSED"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Ready to merge to master for production deployment."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
fi
|
||||
|
||||
# Services for tests
|
||||
# Services for tests (optimized for CI speed)
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
@@ -366,6 +445,9 @@ services:
|
||||
POSTGRES_USER: testing
|
||||
POSTGRES_PASSWORD: testing
|
||||
POSTGRES_DB: testing
|
||||
# CI-optimized settings via environment (faster writes, safe for ephemeral test DB)
|
||||
POSTGRES_INITDB_ARGS: "--data-checksums"
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
|
||||
@@ -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 LeafLink-style navigation ([9b72a8f](https://code.cannabrands.app/Cannabrands/hub/commit/9b72a8f3ba97924e94eed806cc014af538110ec2))
|
||||
* implement buyer-specific Nexus dashboard with Marketplace Platform-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))
|
||||
|
||||
337
CLAUDE.md
337
CLAUDE.md
@@ -4,6 +4,19 @@
|
||||
|
||||
**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
|
||||
@@ -41,7 +54,36 @@ 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. Styling - DaisyUI/Tailwind Only
|
||||
### 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://code.cannabrands.app/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"}'
|
||||
```
|
||||
|
||||
**Gitea Services:**
|
||||
- **Gitea:** `https://code.cannabrands.app`
|
||||
- **Woodpecker CI:** `https://ci.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
|
||||
❌ **NEVER use inline `style=""` attributes** in Blade templates
|
||||
✅ **ALWAYS use DaisyUI/Tailwind utility classes**
|
||||
**Why:** Consistency, maintainability, theme switching, and better performance
|
||||
@@ -54,6 +96,196 @@ ALL routes need auth + user type middleware except public pages
|
||||
|
||||
**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
|
||||
@@ -76,6 +308,35 @@ 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:**
|
||||
@@ -89,7 +350,7 @@ php artisan test --parallel # REQUIRED
|
||||
- ❌ **DO NOT** add "🤖 Generated with Claude Code" or "Co-Authored-By: Claude"
|
||||
- ✅ Write clean, professional commit messages without AI attribution
|
||||
|
||||
**Credentials:** `{buyer,seller,admin}@example.com` / `password`
|
||||
**Credentials:** See "Local Development Setup" section above
|
||||
|
||||
**Branches:** Never commit to `master`/`develop` directly - use feature branches
|
||||
|
||||
@@ -117,20 +378,72 @@ Product::where('is_active', true)->get(); // No business_id filter!
|
||||
|
||||
## Architecture Docs (Read When Needed)
|
||||
|
||||
**Custom Architecture:**
|
||||
- `.claude/DEPARTMENTS.md` - Department system, permissions, access control
|
||||
- `.claude/ROUTING.md` - Business slug routing, subdivision architecture
|
||||
- `.claude/PROCESSING.md` - Processing operations (Solventless vs BHO, conversions, wash batches)
|
||||
- `.claude/MODELS.md` - Key models, relationships, query patterns
|
||||
**🎯 START HERE:**
|
||||
- **`SYSTEM_ARCHITECTURE.md`** - Complete system guide covering ALL architectural patterns, security rules, modules, departments, performance, and development workflow
|
||||
|
||||
**Standard Docs:**
|
||||
- `docs/URL_STRUCTURE.md` - **READ BEFORE** routing changes
|
||||
- `docs/DATABASE.md` - **READ BEFORE** migrations
|
||||
- `docs/DEVELOPMENT.md` - Local setup
|
||||
**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
|
||||
- `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
|
||||
@@ -139,3 +452,5 @@ Product::where('is_active', true)->get(); // No business_id filter!
|
||||
✅ 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Cannabrands B2B Platform
|
||||
|
||||
A LeafLink-style cannabis marketplace platform built with Laravel, featuring business onboarding, compliance tracking, and multi-tenant architecture foundation.
|
||||
A comprehensive B2B cannabis marketplace platform built with Laravel, featuring business onboarding, compliance tracking, and multi-tenant architecture.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 LeafLink-style compliance focus
|
||||
- All features should maintain strong compliance and regulatory focus
|
||||
|
||||
---
|
||||
|
||||
|
||||
193
app/Console/Commands/Ai/AiStatsCommand.php
Normal file
193
app/Console/Commands/Ai/AiStatsCommand.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?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'],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
198
app/Console/Commands/Ai/GenerateBriefingsCommand.php
Normal file
198
app/Console/Commands/Ai/GenerateBriefingsCommand.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?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()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
128
app/Console/Commands/Ai/ProcessAiSuggestionsCommand.php
Normal file
128
app/Console/Commands/Ai/ProcessAiSuggestionsCommand.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
98
app/Console/Commands/AutoTuneMissingBrandProfiles.php
Normal file
98
app/Console/Commands/AutoTuneMissingBrandProfiles.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
61
app/Console/Commands/CalculateDashboardMetricsCommand.php
Normal file
61
app/Console/Commands/CalculateDashboardMetricsCommand.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
148
app/Console/Commands/CheckMediaFiles.php
Normal file
148
app/Console/Commands/CheckMediaFiles.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
28
app/Console/Commands/CleanupTempFiles.php
Normal file
28
app/Console/Commands/CleanupTempFiles.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
68
app/Console/Commands/ClearVarieties.php
Normal file
68
app/Console/Commands/ClearVarieties.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
59
app/Console/Commands/CreateSystemMenusCommand.php
Normal file
59
app/Console/Commands/CreateSystemMenusCommand.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\Business;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
@@ -40,16 +40,16 @@ class CreateTestInvoiceForApproval extends Command
|
||||
|
||||
$this->info("✓ Using buyer: {$buyer->name} ({$buyer->email})");
|
||||
|
||||
// Get any company
|
||||
$company = Company::first();
|
||||
// Get any business
|
||||
$business = Business::first();
|
||||
|
||||
if (! $company) {
|
||||
$this->error('No company found. Please seed database first.');
|
||||
if (! $business) {
|
||||
$this->error('No business found. Please seed database first.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("✓ Company: {$company->name}");
|
||||
$this->info("✓ Business: {$business->name}");
|
||||
|
||||
// Get some products that have inventory
|
||||
$products = Product::whereHas('inventoryItems', function ($q) {
|
||||
@@ -64,7 +64,7 @@ class CreateTestInvoiceForApproval extends Command
|
||||
$this->info("✓ Found {$products->count()} products for order");
|
||||
|
||||
// Create order
|
||||
$order = $this->createOrder($buyer, $company);
|
||||
$order = $this->createOrder($buyer, $business);
|
||||
$this->info("✓ Created order: {$order->order_number}");
|
||||
|
||||
// Add items to order
|
||||
@@ -127,11 +127,11 @@ class CreateTestInvoiceForApproval extends Command
|
||||
/**
|
||||
* Create a test order.
|
||||
*/
|
||||
protected function createOrder(User $buyer, Company $company): Order
|
||||
protected function createOrder(User $buyer, Business $business): Order
|
||||
{
|
||||
return Order::create([
|
||||
'order_number' => 'ORD-TEST-'.strtoupper(substr(md5(time()), 0, 10)),
|
||||
'company_id' => $company->id,
|
||||
'business_id' => $business->id,
|
||||
'user_id' => $buyer->id,
|
||||
'subtotal' => 0, // Will be calculated
|
||||
'tax' => 0,
|
||||
|
||||
85
app/Console/Commands/DevSetup.php
Normal file
85
app/Console/Commands/DevSetup.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
42
app/Console/Commands/DispatchScheduledCampaigns.php
Normal file
42
app/Console/Commands/DispatchScheduledCampaigns.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
91
app/Console/Commands/ExploreRemoteDatabase.php
Normal file
91
app/Console/Commands/ExploreRemoteDatabase.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
278
app/Console/Commands/ExportBusinessConfigSeeder.php
Normal file
278
app/Console/Commands/ExportBusinessConfigSeeder.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
176
app/Console/Commands/ExportCannabrandsData.php
Normal file
176
app/Console/Commands/ExportCannabrandsData.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
54
app/Console/Commands/GenerateInventoryItemHashids.php
Normal file
54
app/Console/Commands/GenerateInventoryItemHashids.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
635
app/Console/Commands/GenerateMarketingOrchestratorTasks.php
Normal file
635
app/Console/Commands/GenerateMarketingOrchestratorTasks.php
Normal file
@@ -0,0 +1,635 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
228
app/Console/Commands/GenerateMenuFollowups.php
Normal file
228
app/Console/Commands/GenerateMenuFollowups.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
1554
app/Console/Commands/GenerateSalesOrchestratorTasks.php
Normal file
1554
app/Console/Commands/GenerateSalesOrchestratorTasks.php
Normal file
File diff suppressed because it is too large
Load Diff
464
app/Console/Commands/ImportAlohaSales.php
Normal file
464
app/Console/Commands/ImportAlohaSales.php
Normal file
@@ -0,0 +1,464 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
144
app/Console/Commands/ImportProductsFromRemote.php
Normal file
144
app/Console/Commands/ImportProductsFromRemote.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
474
app/Console/Commands/ImportThunderBudBulk.php
Normal file
474
app/Console/Commands/ImportThunderBudBulk.php
Normal file
@@ -0,0 +1,474 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
564
app/Console/Commands/ImportThunderBudProduct.php
Normal file
564
app/Console/Commands/ImportThunderBudProduct.php
Normal file
@@ -0,0 +1,564 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
446
app/Console/Commands/ImportThunderBudSales.php
Normal file
446
app/Console/Commands/ImportThunderBudSales.php
Normal file
@@ -0,0 +1,446 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
143
app/Console/Commands/LinkVarietiesFromSku.php
Normal file
143
app/Console/Commands/LinkVarietiesFromSku.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
356
app/Console/Commands/ListFeatureRoutes.php
Normal file
356
app/Console/Commands/ListFeatureRoutes.php
Normal file
@@ -0,0 +1,356 @@
|
||||
<?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</>');
|
||||
}
|
||||
}
|
||||
216
app/Console/Commands/MigrateFlagsToSuites.php
Normal file
216
app/Console/Commands/MigrateFlagsToSuites.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
162
app/Console/Commands/MigrateImagesToMinIO.php
Normal file
162
app/Console/Commands/MigrateImagesToMinIO.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
126
app/Console/Commands/MigrateProductImagePaths.php
Normal file
126
app/Console/Commands/MigrateProductImagePaths.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
304
app/Console/Commands/OrchestratorAnalyzeTiming.php
Normal file
304
app/Console/Commands/OrchestratorAnalyzeTiming.php
Normal file
@@ -0,0 +1,304 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
235
app/Console/Commands/OrchestratorCheckHorizon.php
Normal file
235
app/Console/Commands/OrchestratorCheckHorizon.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?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());
|
||||
}
|
||||
}
|
||||
}
|
||||
255
app/Console/Commands/OrchestratorEvaluateOutcomes.php
Normal file
255
app/Console/Commands/OrchestratorEvaluateOutcomes.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
149
app/Console/Commands/OrchestratorEvaluatePlaybooks.php
Normal file
149
app/Console/Commands/OrchestratorEvaluatePlaybooks.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?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,
|
||||
};
|
||||
}
|
||||
}
|
||||
413
app/Console/Commands/OrchestratorSelfAudit.php
Normal file
413
app/Console/Commands/OrchestratorSelfAudit.php
Normal file
@@ -0,0 +1,413 @@
|
||||
<?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
|
||||
);
|
||||
}
|
||||
}
|
||||
120
app/Console/Commands/OrchestratorSendDailyReport.php
Normal file
120
app/Console/Commands/OrchestratorSendDailyReport.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?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']}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
204
app/Console/Commands/OrchestratorWatchdog.php
Normal file
204
app/Console/Commands/OrchestratorWatchdog.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<?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());
|
||||
}
|
||||
}
|
||||
}
|
||||
207
app/Console/Commands/PruneAudits.php
Normal file
207
app/Console/Commands/PruneAudits.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
43
app/Console/Commands/ResetProductImagePaths.php
Normal file
43
app/Console/Commands/ResetProductImagePaths.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
159
app/Console/Commands/RestoreBrandMenus.php
Normal file
159
app/Console/Commands/RestoreBrandMenus.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
186
app/Console/Commands/RestoreCannabrandsData.php
Normal file
186
app/Console/Commands/RestoreCannabrandsData.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?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
|
||||
}
|
||||
}
|
||||
}
|
||||
108
app/Console/Commands/RunDueMarketingAutomations.php
Normal file
108
app/Console/Commands/RunDueMarketingAutomations.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
175
app/Console/Commands/RunFixedAssetDepreciation.php
Normal file
175
app/Console/Commands/RunFixedAssetDepreciation.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
103
app/Console/Commands/RunRecurringSchedules.php
Normal file
103
app/Console/Commands/RunRecurringSchedules.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
43
app/Console/Commands/SafeFreshCommand.php
Normal file
43
app/Console/Commands/SafeFreshCommand.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
652
app/Console/Commands/SeedBaselinePromos.php
Normal file
652
app/Console/Commands/SeedBaselinePromos.php
Normal file
@@ -0,0 +1,652 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
404
app/Console/Commands/SeedBrandOrchestratorProfiles.php
Normal file
404
app/Console/Commands/SeedBrandOrchestratorProfiles.php
Normal file
@@ -0,0 +1,404 @@
|
||||
<?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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
257
app/Console/Commands/SendCrmDailyDigest.php
Normal file
257
app/Console/Commands/SendCrmDailyDigest.php
Normal file
@@ -0,0 +1,257 @@
|
||||
<?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()}");
|
||||
}
|
||||
}
|
||||
113
app/Console/Commands/SyncBrandMediaPaths.php
Normal file
113
app/Console/Commands/SyncBrandMediaPaths.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,137 @@ 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();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
24
app/Exceptions/PeriodLockedException.php
Normal file
24
app/Exceptions/PeriodLockedException.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
455
app/Filament/Pages/AiSettings.php
Normal file
455
app/Filament/Pages/AiSettings.php
Normal file
@@ -0,0 +1,455 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
203
app/Filament/Pages/CannaiqSettings.php
Normal file
203
app/Filament/Pages/CannaiqSettings.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?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">✓</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">⚠</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">ⓘ</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 → 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();
|
||||
}
|
||||
}
|
||||
216
app/Filament/Pages/CrmSettings.php
Normal file
216
app/Filament/Pages/CrmSettings.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
1294
app/Filament/Pages/HeadOfSalesOrchestrator.php
Normal file
1294
app/Filament/Pages/HeadOfSalesOrchestrator.php
Normal file
File diff suppressed because it is too large
Load Diff
604
app/Filament/Pages/MarketingOrchestrator.php
Normal file
604
app/Filament/Pages/MarketingOrchestrator.php
Normal file
@@ -0,0 +1,604 @@
|
||||
<?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();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
104
app/Filament/Pages/MigrationHealth.php
Normal file
104
app/Filament/Pages/MigrationHealth.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
187
app/Filament/Pages/ModuleUsageReport.php
Normal file
187
app/Filament/Pages/ModuleUsageReport.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,16 @@
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class NotificationSettings extends Page
|
||||
class NotificationSettings extends Page implements HasForms
|
||||
{
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-envelope';
|
||||
use InteractsWithForms;
|
||||
|
||||
protected string $view = 'filament.pages.notification-settings';
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-envelope';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
@@ -22,6 +23,11 @@ class NotificationSettings extends Page
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->fillForm();
|
||||
}
|
||||
|
||||
protected function fillForm(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
// Mail settings
|
||||
@@ -48,134 +54,142 @@ class NotificationSettings extends Page
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Form $form): Form
|
||||
protected function getFormSchema(): array
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
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(),
|
||||
])
|
||||
->statePath('data');
|
||||
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
|
||||
|
||||
28
app/Filament/Pages/Queues.php
Normal file
28
app/Filament/Pages/Queues.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
208
app/Filament/Pages/SiteBranding.php
Normal file
208
app/Filament/Pages/SiteBranding.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\SiteSetting;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
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\HtmlString;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
|
||||
class SiteBranding extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-paint-brush';
|
||||
|
||||
protected static ?string $navigationLabel = 'Site Branding';
|
||||
|
||||
protected static ?string $title = 'Site Branding';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Platform Settings';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected string $view = 'filament.pages.site-branding';
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'site_name' => SiteSetting::get('site_name', 'Cannabrands Hub'),
|
||||
'favicon' => SiteSetting::get('favicon_path') ? [SiteSetting::get('favicon_path')] : [],
|
||||
'logo_light' => SiteSetting::get('logo_light_path') ? [SiteSetting::get('logo_light_path')] : [],
|
||||
'logo_dark' => SiteSetting::get('logo_dark_path') ? [SiteSetting::get('logo_dark_path')] : [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Site Identity')
|
||||
->description('Configure the site name and branding assets.')
|
||||
->schema([
|
||||
TextInput::make('site_name')
|
||||
->label('Site Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Displayed in browser tabs and emails.'),
|
||||
]),
|
||||
|
||||
Section::make('Favicon')
|
||||
->description('The small icon displayed in browser tabs. Recommended: 32x32 or 64x64 PNG/ICO.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('current_favicon')
|
||||
->label('Current')
|
||||
->content(function () {
|
||||
$path = SiteSetting::get('favicon_path');
|
||||
if (! $path) {
|
||||
return new HtmlString(
|
||||
'<div class="flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">'.
|
||||
'<span class="text-gray-400 text-xs">Not set</span>'.
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return new HtmlString(
|
||||
'<div class="inline-flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-lg">'.
|
||||
'<img src="'.SiteSetting::getFaviconUrl().'" alt="Favicon" class="w-8 h-8">'.
|
||||
'</div>'
|
||||
);
|
||||
}),
|
||||
FileUpload::make('favicon')
|
||||
->label('Upload New')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('branding')
|
||||
->visibility('public')
|
||||
->acceptedFileTypes(['image/png', 'image/x-icon', 'image/ico', 'image/vnd.microsoft.icon'])
|
||||
->maxSize(512)
|
||||
->imagePreviewHeight('64')
|
||||
->helperText('Upload a PNG or ICO file (max 512KB).'),
|
||||
]),
|
||||
|
||||
Section::make('Logos')
|
||||
->description('Upload logo variants for different backgrounds.')
|
||||
->schema([
|
||||
Section::make('Logo (Light/White)')
|
||||
->description('For dark backgrounds (sidebar, etc.)')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('current_logo_light')
|
||||
->label('Current')
|
||||
->content(function () {
|
||||
$path = SiteSetting::get('logo_light_path');
|
||||
if (! $path) {
|
||||
return new HtmlString(
|
||||
'<div class="flex items-center justify-center h-16 w-40 bg-gray-800 rounded-lg border-2 border-dashed border-gray-600">'.
|
||||
'<span class="text-gray-400 text-xs">Not set</span>'.
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return new HtmlString(
|
||||
'<div class="inline-flex items-center justify-center h-16 px-4 bg-gray-800 rounded-lg">'.
|
||||
'<img src="'.SiteSetting::getLogoLightUrl().'" alt="Logo Light" class="h-8 max-w-[150px] object-contain">'.
|
||||
'</div>'
|
||||
);
|
||||
}),
|
||||
FileUpload::make('logo_light')
|
||||
->label('Upload New')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('branding')
|
||||
->visibility('public')
|
||||
->maxSize(2048)
|
||||
->imagePreviewHeight('100'),
|
||||
]),
|
||||
|
||||
Section::make('Logo (Dark)')
|
||||
->description('For light backgrounds.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('current_logo_dark')
|
||||
->label('Current')
|
||||
->content(function () {
|
||||
$path = SiteSetting::get('logo_dark_path');
|
||||
if (! $path) {
|
||||
return new HtmlString(
|
||||
'<div class="flex items-center justify-center h-16 w-40 bg-gray-100 rounded-lg border-2 border-dashed border-gray-300">'.
|
||||
'<span class="text-gray-400 text-xs">Not set</span>'.
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return new HtmlString(
|
||||
'<div class="inline-flex items-center justify-center h-16 px-4 bg-gray-100 rounded-lg">'.
|
||||
'<img src="'.SiteSetting::getLogoDarkUrl().'" alt="Logo Dark" class="h-8 max-w-[150px] object-contain">'.
|
||||
'</div>'
|
||||
);
|
||||
}),
|
||||
FileUpload::make('logo_dark')
|
||||
->label('Upload New')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('branding')
|
||||
->visibility('public')
|
||||
->maxSize(2048)
|
||||
->imagePreviewHeight('100'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
|
||||
// Save site name
|
||||
SiteSetting::set('site_name', $data['site_name']);
|
||||
|
||||
// Save file paths
|
||||
$this->saveFileSetting('favicon_path', $data['favicon'] ?? []);
|
||||
$this->saveFileSetting('logo_light_path', $data['logo_light'] ?? []);
|
||||
$this->saveFileSetting('logo_dark_path', $data['logo_dark'] ?? []);
|
||||
|
||||
// Clear cache
|
||||
SiteSetting::clearCache();
|
||||
|
||||
Notification::make()
|
||||
->title('Branding settings saved')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
protected function saveFileSetting(string $key, array $files): void
|
||||
{
|
||||
$path = ! empty($files) ? $files[0] : null;
|
||||
|
||||
// Handle TemporaryUploadedFile objects
|
||||
if ($path instanceof TemporaryUploadedFile) {
|
||||
$path = $path->store('branding', 'public');
|
||||
}
|
||||
|
||||
SiteSetting::set($key, $path);
|
||||
}
|
||||
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return [
|
||||
Forms\Components\Actions\Action::make('save')
|
||||
->label('Save Changes')
|
||||
->submit('save'),
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Filament/Pages/Telescope.php
Normal file
26
app/Filament/Pages/Telescope.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class Telescope extends Page
|
||||
{
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-magnifying-glass';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $navigationLabel = 'Telescope Debug Tool';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->redirect('/telescope', navigate: false);
|
||||
}
|
||||
|
||||
public function getView(): string
|
||||
{
|
||||
return 'filament.pages.telescope';
|
||||
}
|
||||
}
|
||||
161
app/Filament/Pages/UsageDashboard.php
Normal file
161
app/Filament/Pages/UsageDashboard.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessUsageCounter;
|
||||
use App\Models\PlanUsageMetric;
|
||||
use App\Models\UsageMetric;
|
||||
use Carbon\Carbon;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Usage Dashboard - Global view of usage across all businesses
|
||||
*
|
||||
* Shows:
|
||||
* - High-level cards with total usage across platform
|
||||
* - Table of businesses with their usage vs included amounts
|
||||
* - Visual status indicators (OK, Warning, Over)
|
||||
*
|
||||
* This is READ-ONLY analytics. It does NOT enforce limits.
|
||||
*
|
||||
* @see docs/USAGE_BASED_BILLING.md
|
||||
*/
|
||||
class UsageDashboard extends Page
|
||||
{
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar-square';
|
||||
|
||||
protected static ?string $navigationLabel = 'Usage Analytics';
|
||||
|
||||
protected static ?string $title = 'Usage Analytics';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Platform Settings';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
protected string $view = 'filament.pages.usage-dashboard';
|
||||
|
||||
public function getViewData(): array
|
||||
{
|
||||
$periodStart = Carbon::now()->startOfMonth()->toDateString();
|
||||
$metrics = UsageMetric::active()->ordered()->get();
|
||||
|
||||
// Get all current usage counters
|
||||
$counters = BusinessUsageCounter::with(['business.plan', 'usageMetric'])
|
||||
->where('period_start', $periodStart)
|
||||
->get();
|
||||
|
||||
// Build summary cards
|
||||
$summaryCards = $this->buildSummaryCards($counters, $metrics);
|
||||
|
||||
// Build business usage table data
|
||||
$businessUsage = $this->buildBusinessUsageData($counters, $metrics);
|
||||
|
||||
return [
|
||||
'summaryCards' => $summaryCards,
|
||||
'businessUsage' => $businessUsage,
|
||||
'metrics' => $metrics,
|
||||
'periodLabel' => Carbon::now()->format('F Y'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildSummaryCards(Collection $counters, Collection $metrics): array
|
||||
{
|
||||
$cards = [];
|
||||
|
||||
foreach ($metrics as $metric) {
|
||||
$total = $counters->where('usage_metric_id', $metric->id)->sum('quantity');
|
||||
$cards[] = [
|
||||
'name' => $metric->name,
|
||||
'slug' => $metric->slug,
|
||||
'total' => number_format($total),
|
||||
'unit' => $metric->unit_label,
|
||||
'icon' => $this->getMetricIcon($metric->slug),
|
||||
];
|
||||
}
|
||||
|
||||
return $cards;
|
||||
}
|
||||
|
||||
protected function buildBusinessUsageData(Collection $counters, Collection $metrics): array
|
||||
{
|
||||
$businesses = Business::with('plan')
|
||||
->whereHas('plan')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$isEnterprise = $business->is_enterprise_plan || ($business->plan && $business->plan->is_enterprise);
|
||||
|
||||
$metricsData = [];
|
||||
foreach ($metrics as $metric) {
|
||||
$counter = $counters
|
||||
->where('business_id', $business->id)
|
||||
->where('usage_metric_id', $metric->id)
|
||||
->first();
|
||||
|
||||
$usage = $counter?->quantity ?? 0;
|
||||
|
||||
// Get included amount from plan
|
||||
$included = null;
|
||||
$percentage = null;
|
||||
$status = 'unlimited';
|
||||
|
||||
if (! $isEnterprise && $business->plan) {
|
||||
$planMetric = PlanUsageMetric::where('plan_id', $business->plan_id)
|
||||
->where('usage_metric_id', $metric->id)
|
||||
->first();
|
||||
|
||||
if ($planMetric && $planMetric->included_per_month !== null) {
|
||||
$included = $planMetric->included_per_month;
|
||||
$percentage = $included > 0 ? round(($usage / $included) * 100, 1) : 100;
|
||||
|
||||
if ($percentage >= 100) {
|
||||
$status = 'over';
|
||||
} elseif ($percentage >= 80) {
|
||||
$status = 'warning';
|
||||
} else {
|
||||
$status = 'ok';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$metricsData[$metric->slug] = [
|
||||
'usage' => $usage,
|
||||
'included' => $included,
|
||||
'percentage' => $percentage,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'business' => $business,
|
||||
'plan_code' => $business->plan?->code ?? 'none',
|
||||
'is_enterprise' => $isEnterprise,
|
||||
'metrics' => $metricsData,
|
||||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function getMetricIcon(string $slug): string
|
||||
{
|
||||
return match ($slug) {
|
||||
'menus_sent' => 'heroicon-o-document-text',
|
||||
'conversations' => 'heroicon-o-chat-bubble-left-right',
|
||||
'promos_active' => 'heroicon-o-megaphone',
|
||||
'contacts' => 'heroicon-o-users',
|
||||
'buyers' => 'heroicon-o-building-storefront',
|
||||
'ai_actions' => 'heroicon-o-sparkles',
|
||||
'campaigns' => 'heroicon-o-paper-airplane',
|
||||
'products' => 'heroicon-o-cube',
|
||||
default => 'heroicon-o-chart-bar',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections;
|
||||
|
||||
use App\Filament\Resources\AiConnections\Pages\CreateAiConnection;
|
||||
use App\Filament\Resources\AiConnections\Pages\EditAiConnection;
|
||||
use App\Filament\Resources\AiConnections\Pages\ListAiConnections;
|
||||
use App\Filament\Resources\AiConnections\Schemas\AiConnectionForm;
|
||||
use App\Filament\Resources\AiConnections\Tables\AiConnectionsTable;
|
||||
use App\Models\AiConnection;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class AiConnectionResource extends Resource
|
||||
{
|
||||
protected static ?string $model = AiConnection::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||
|
||||
protected static ?string $navigationLabel = 'AI Settings';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'AI Settings';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
/**
|
||||
* Only Superadmins can access AI Connection settings.
|
||||
* Admin Staff will not see this resource in navigation.
|
||||
*/
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth('admin')->user()?->canManageAi() ?? false;
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return AiConnectionForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return AiConnectionsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListAiConnections::route('/'),
|
||||
'create' => CreateAiConnection::route('/create'),
|
||||
'edit' => EditAiConnection::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getRecordRouteBindingEloquentQuery()
|
||||
->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Pages;
|
||||
|
||||
use App\Filament\Resources\AiConnections\AiConnectionResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateAiConnection extends CreateRecord
|
||||
{
|
||||
protected static string $resource = AiConnectionResource::class;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Pages;
|
||||
|
||||
use App\Filament\Resources\AiConnections\AiConnectionResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
use Filament\Actions\RestoreAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditAiConnection extends EditRecord
|
||||
{
|
||||
protected static string $resource = AiConnectionResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
ForceDeleteAction::make(),
|
||||
RestoreAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Pages;
|
||||
|
||||
use App\Filament\Resources\AiConnections\AiConnectionResource;
|
||||
use App\Filament\Widgets\AiStatsOverview;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAiConnections extends ListRecords
|
||||
{
|
||||
protected static string $resource = AiConnectionResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
AiStatsOverview::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Schemas;
|
||||
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class AiConnectionForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Connection Details')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Friendly name for this connection (e.g., "Primary OpenAI", "Anthropic Drafts")'),
|
||||
|
||||
Select::make('provider')
|
||||
->required()
|
||||
->options([
|
||||
'anthropic' => 'Anthropic / Claude',
|
||||
'openai' => 'OpenAI / ChatGPT',
|
||||
'perplexity' => 'Perplexity',
|
||||
'canva' => 'Canva',
|
||||
'jasper' => 'Jasper',
|
||||
])
|
||||
->live()
|
||||
->helperText('AI provider for this connection'),
|
||||
|
||||
TextInput::make('api_key')
|
||||
->required()
|
||||
->password()
|
||||
->revealable()
|
||||
->maxLength(1000)
|
||||
->helperText('API key for this provider (stored encrypted)')
|
||||
->placeholder('Enter API key...'),
|
||||
|
||||
Select::make('model')
|
||||
->options(function ($get) {
|
||||
$provider = $get('provider');
|
||||
if (! $provider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$models = config("ai.providers.{$provider}.models", []);
|
||||
|
||||
return array_combine($models, $models);
|
||||
})
|
||||
->helperText('Specific model to use')
|
||||
->placeholder('Select a model')
|
||||
->visible(fn ($get) => ! empty($get('provider'))),
|
||||
|
||||
TextInput::make('max_tokens')
|
||||
->numeric()
|
||||
->default(4096)
|
||||
->helperText('Maximum tokens for requests')
|
||||
->placeholder('e.g., 4096'),
|
||||
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->helperText('Whether this connection is active and can be used')
|
||||
->default(true),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Tables;
|
||||
|
||||
use App\Models\AiConnection;
|
||||
use App\Services\AiConnectionTestService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ForceDeleteBulkAction;
|
||||
use Filament\Actions\RestoreBulkAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class AiConnectionsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('medium'),
|
||||
|
||||
BadgeColumn::make('provider')
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'anthropic' => 'Anthropic',
|
||||
'openai' => 'OpenAI',
|
||||
'perplexity' => 'Perplexity',
|
||||
'canva' => 'Canva',
|
||||
'jasper' => 'Jasper',
|
||||
default => $state,
|
||||
})
|
||||
->colors([
|
||||
'primary' => 'anthropic',
|
||||
'success' => 'openai',
|
||||
'warning' => 'perplexity',
|
||||
'danger' => 'canva',
|
||||
'info' => 'jasper',
|
||||
]),
|
||||
|
||||
TextColumn::make('model')
|
||||
->placeholder('Default')
|
||||
->limit(30),
|
||||
|
||||
BadgeColumn::make('status')
|
||||
->colors([
|
||||
'success' => 'ok',
|
||||
'danger' => 'error',
|
||||
'gray' => 'disabled',
|
||||
]),
|
||||
|
||||
TextColumn::make('recent_usage_stats.requests')
|
||||
->label('Requests')
|
||||
->numeric()
|
||||
->suffix(' reqs')
|
||||
->description('Last 30 days'),
|
||||
|
||||
TextColumn::make('recent_usage_stats.total_tokens')
|
||||
->label('Tokens')
|
||||
->numeric()
|
||||
->description('Last 30 days'),
|
||||
|
||||
TextColumn::make('last_used_at')
|
||||
->dateTime()
|
||||
->since()
|
||||
->placeholder('Never'),
|
||||
|
||||
IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->trueColor('success')
|
||||
->falseColor('gray'),
|
||||
])
|
||||
->filters([
|
||||
TrashedFilter::make(),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('test')
|
||||
->label('Test')
|
||||
->icon('heroicon-m-bolt')
|
||||
->color('warning')
|
||||
->action(function (AiConnection $record) {
|
||||
$result = AiConnectionTestService::testConnection($record);
|
||||
|
||||
$record->update([
|
||||
'last_tested_at' => now(),
|
||||
'status' => $result->success ? 'ok' : 'error',
|
||||
'last_error' => $result->success ? null : $result->message,
|
||||
]);
|
||||
|
||||
if ($result->success) {
|
||||
Notification::make()
|
||||
->title('Connection test successful')
|
||||
->body("Response time: {$result->responseTime}ms")
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('Connection test failed')
|
||||
->body($result->message)
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
ForceDeleteBulkAction::make(),
|
||||
RestoreBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
265
app/Filament/Resources/AiContentRuleResource.php
Normal file
265
app/Filament/Resources/AiContentRuleResource.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\AiContentRuleResource\Pages;
|
||||
use App\Models\Ai\AiContentRule;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class AiContentRuleResource extends Resource
|
||||
{
|
||||
protected static ?string $model = AiContentRule::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'AI Settings';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
protected static ?string $navigationLabel = 'Content Rules';
|
||||
|
||||
protected static ?string $modelLabel = 'Content Rule';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Content Rules';
|
||||
|
||||
/**
|
||||
* Only Superadmins can access AI Content Rules.
|
||||
* Admin Staff will not see this resource in navigation.
|
||||
*/
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth('admin')->user()?->canManageAi() ?? false;
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Content Type')
|
||||
->schema([
|
||||
TextInput::make('content_type_key')
|
||||
->label('Content Type Key')
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->unique(ignoreRecord: true)
|
||||
->helperText('Format: context.field (e.g., product.short_description)')
|
||||
->placeholder('product.short_description')
|
||||
->disabled(fn ($record) => $record && ! $record->is_custom),
|
||||
|
||||
TextInput::make('label')
|
||||
->label('Display Label')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Human-readable name for this content type'),
|
||||
|
||||
Textarea::make('description')
|
||||
->label('Description')
|
||||
->rows(2)
|
||||
->maxLength(500)
|
||||
->helperText('Help text shown to admins'),
|
||||
])
|
||||
->columns(1),
|
||||
|
||||
Section::make('Character Limits')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('min_length')
|
||||
->label('Minimum Length')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->suffix('characters')
|
||||
->helperText('Leave empty for no minimum'),
|
||||
|
||||
TextInput::make('max_length')
|
||||
->label('Maximum Length')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->suffix('characters')
|
||||
->helperText('Leave empty for no maximum'),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Writing Style')
|
||||
->schema([
|
||||
Select::make('tone')
|
||||
->label('Tone')
|
||||
->options(AiContentRule::TONES)
|
||||
->required()
|
||||
->default('professional')
|
||||
->helperText('The writing style AI should use'),
|
||||
|
||||
Textarea::make('system_prompt')
|
||||
->label('System Prompt')
|
||||
->rows(4)
|
||||
->helperText('Additional instructions for AI. This is appended to the base system prompt.'),
|
||||
|
||||
TagsInput::make('examples')
|
||||
->label('Example Outputs')
|
||||
->helperText('Sample outputs to guide AI. Press Enter after each example.')
|
||||
->placeholder('Add an example...'),
|
||||
]),
|
||||
|
||||
Section::make('Context Fields')
|
||||
->schema([
|
||||
TagsInput::make('context_fields')
|
||||
->label('Available Context Fields')
|
||||
->helperText('Data fields that can be used for personalization (e.g., product_name, brand_name)')
|
||||
->placeholder('Add a field...'),
|
||||
]),
|
||||
|
||||
Section::make('Settings')
|
||||
->schema([
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->default(true)
|
||||
->helperText('Inactive rules will use config defaults'),
|
||||
|
||||
Toggle::make('is_custom')
|
||||
->label('Custom Rule')
|
||||
->default(true)
|
||||
->helperText('Custom rules are admin-created, non-custom are synced from config')
|
||||
->disabled(),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('content_type_key')
|
||||
->label('Content Type')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->copyable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('label')
|
||||
->label('Label')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('limit_string')
|
||||
->label('Limits')
|
||||
->badge()
|
||||
->color('info'),
|
||||
|
||||
Tables\Columns\TextColumn::make('tone')
|
||||
->label('Tone')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'professional' => 'success',
|
||||
'casual' => 'info',
|
||||
'technical' => 'warning',
|
||||
'marketing' => 'primary',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
Tables\Columns\IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\IconColumn::make('is_custom')
|
||||
->label('Custom')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-pencil')
|
||||
->falseIcon('heroicon-o-cog')
|
||||
->trueColor('warning')
|
||||
->falseColor('gray'),
|
||||
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->label('Updated')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('tone')
|
||||
->options(AiContentRule::TONES),
|
||||
|
||||
Tables\Filters\TernaryFilter::make('is_active')
|
||||
->label('Active'),
|
||||
|
||||
Tables\Filters\TernaryFilter::make('is_custom')
|
||||
->label('Custom'),
|
||||
|
||||
Tables\Filters\SelectFilter::make('context')
|
||||
->label('Context')
|
||||
->options([
|
||||
'product' => 'Product',
|
||||
'brand' => 'Brand',
|
||||
'email' => 'Email',
|
||||
'menu' => 'Menu',
|
||||
])
|
||||
->query(function ($query, array $data) {
|
||||
if (! empty($data['value'])) {
|
||||
$query->where('content_type_key', 'like', $data['value'].'.%');
|
||||
}
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make()
|
||||
->visible(fn ($record) => $record->is_custom),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make()
|
||||
->before(function ($records) {
|
||||
// Only allow deleting custom rules
|
||||
return $records->filter(fn ($record) => $record->is_custom);
|
||||
}),
|
||||
]),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('sync_from_config')
|
||||
->label('Sync from Config')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Sync Content Rules from Config')
|
||||
->modalDescription('This will create database entries for any content types defined in config that don\'t already exist. Existing customizations will not be overwritten.')
|
||||
->action(function () {
|
||||
$result = AiContentRule::syncFromConfig();
|
||||
|
||||
\Filament\Notifications\Notification::make()
|
||||
->title('Sync Complete')
|
||||
->body('Created '.count($result['created']).' new rules, skipped '.count($result['skipped']).' existing rules.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->defaultSort('content_type_key');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListAiContentRules::route('/'),
|
||||
'create' => Pages\CreateAiContentRule::route('/create'),
|
||||
'edit' => Pages\EditAiContentRule::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiContentRuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AiContentRuleResource;
|
||||
use App\Services\Ai\AiContentTypeRegistry;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateAiContentRule extends CreateRecord
|
||||
{
|
||||
protected static string $resource = AiContentRuleResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data['is_custom'] = true;
|
||||
$data['created_by'] = auth()->id();
|
||||
$data['updated_by'] = auth()->id();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
// Clear the content type registry cache
|
||||
app(AiContentTypeRegistry::class)->clearCache();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiContentRuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AiContentRuleResource;
|
||||
use App\Services\Ai\AiContentTypeRegistry;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditAiContentRule extends EditRecord
|
||||
{
|
||||
protected static string $resource = AiContentRuleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->visible(fn () => $this->record->is_custom),
|
||||
];
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$data['updated_by'] = auth()->id();
|
||||
|
||||
// Mark as custom if it was originally from config and is being modified
|
||||
if (! $this->record->is_custom) {
|
||||
$data['is_custom'] = true;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
// Clear the content type registry cache
|
||||
app(AiContentTypeRegistry::class)->clearCache();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiContentRuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AiContentRuleResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAiContentRules extends ListRecords
|
||||
{
|
||||
protected static string $resource = AiContentRuleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ class BatchResource extends Resource
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = 'Inventory';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@@ -141,8 +143,9 @@ class BatchResource extends Resource
|
||||
$query = parent::getEloquentQuery();
|
||||
|
||||
// Scope to user's business unless they're a super admin
|
||||
if (! auth()->user()->hasRole('super_admin')) {
|
||||
$query->where('business_id', auth()->user()->business_id);
|
||||
$user = auth()->user();
|
||||
if ($user && ! $user->hasRole('Super Admin')) {
|
||||
$query->where('business_id', $user->business_id);
|
||||
}
|
||||
|
||||
return $query;
|
||||
|
||||
@@ -11,7 +11,7 @@ class CreateBatch extends CreateRecord
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data['business_id'] = auth()->user()->business_id;
|
||||
$data['business_id'] = auth()->user()->primaryBusiness()?->id;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ class BatchResource extends Resource
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = 'Inventory';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'batch_number';
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BrandAiProfiles;
|
||||
|
||||
use App\Filament\Resources\BrandAiProfiles\Pages\CreateBrandAiProfile;
|
||||
use App\Filament\Resources\BrandAiProfiles\Pages\EditBrandAiProfile;
|
||||
use App\Filament\Resources\BrandAiProfiles\Pages\ListBrandAiProfiles;
|
||||
use App\Filament\Resources\BrandAiProfiles\Schemas\BrandAiProfileForm;
|
||||
use App\Filament\Resources\BrandAiProfiles\Tables\BrandAiProfilesTable;
|
||||
use App\Models\BrandAiProfile;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class BrandAiProfileResource extends Resource
|
||||
{
|
||||
protected static ?string $model = BrandAiProfile::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedSparkles;
|
||||
|
||||
protected static ?string $navigationLabel = 'Brand AI Tuning';
|
||||
|
||||
protected static ?string $modelLabel = 'Brand AI Profile';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Brand AI Profiles';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'AI Settings';
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
/**
|
||||
* Only Superadmins can access Brand AI Tuning.
|
||||
*/
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth('admin')->user()?->canManageAi() ?? false;
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return BrandAiProfileForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return BrandAiProfilesTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListBrandAiProfiles::route('/'),
|
||||
'create' => CreateBrandAiProfile::route('/create'),
|
||||
'edit' => EditBrandAiProfile::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BrandAiProfiles\Pages;
|
||||
|
||||
use App\Filament\Resources\BrandAiProfiles\BrandAiProfileResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateBrandAiProfile extends CreateRecord
|
||||
{
|
||||
protected static string $resource = BrandAiProfileResource::class;
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('edit', ['record' => $this->record]);
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
// Initialize default structures for JSON fields if not set
|
||||
$data['emotional_triggers'] = $data['emotional_triggers'] ?? [];
|
||||
$data['vocabulary_do'] = $data['vocabulary_do'] ?? [];
|
||||
$data['vocabulary_dont'] = $data['vocabulary_dont'] ?? [];
|
||||
$data['product_type_rules'] = $data['product_type_rules'] ?? [];
|
||||
$data['extra'] = $data['extra'] ?? [];
|
||||
|
||||
// Initialize tone_overrides with defaults
|
||||
$data['tone_overrides'] = array_merge([
|
||||
'sentence_length' => 'medium',
|
||||
'energy_level' => 'medium',
|
||||
'slang_allowed' => false,
|
||||
'notes' => '',
|
||||
], $data['tone_overrides'] ?? []);
|
||||
|
||||
// Initialize emoji_style with defaults
|
||||
$data['emoji_style'] = array_merge([
|
||||
'level' => 'medium',
|
||||
'examples' => [],
|
||||
], $data['emoji_style'] ?? []);
|
||||
|
||||
// Initialize name_usage_rules with defaults
|
||||
$data['name_usage_rules'] = array_merge([
|
||||
'non_seo' => 'never_first_word',
|
||||
'seo' => 'allowed_first_word',
|
||||
], $data['name_usage_rules'] ?? []);
|
||||
|
||||
// Initialize seo_overrides with defaults
|
||||
$data['seo_overrides'] = array_merge([
|
||||
'tone' => 'maintain_voice',
|
||||
'keyword_density' => 'light',
|
||||
'notes' => '',
|
||||
], $data['seo_overrides'] ?? []);
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BrandAiProfiles\Pages;
|
||||
|
||||
use App\Filament\Resources\BrandAiProfiles\BrandAiProfileResource;
|
||||
use App\Services\AI\BrandAiProfileGenerator;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditBrandAiProfile extends EditRecord
|
||||
{
|
||||
protected static string $resource = BrandAiProfileResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('autoTune')
|
||||
->label('Regenerate with AI')
|
||||
->icon('heroicon-o-sparkles')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Regenerate AI Profile')
|
||||
->modalDescription('This will use AI to regenerate all tuning settings based on the brand\'s products and existing content. Current settings will be overwritten.')
|
||||
->modalSubmitActionLabel('Regenerate')
|
||||
->action(function () {
|
||||
$this->autoTuneWithAi();
|
||||
}),
|
||||
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-tune the profile using AI.
|
||||
*/
|
||||
protected function autoTuneWithAi(): void
|
||||
{
|
||||
$brand = $this->record->brand;
|
||||
|
||||
if (! $brand) {
|
||||
Notification::make()
|
||||
->title('Error')
|
||||
->body('No brand associated with this profile.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$generator = app(BrandAiProfileGenerator::class);
|
||||
$profile = $generator->generateForBrand($brand);
|
||||
|
||||
// Refresh the record to get updated values
|
||||
$this->record->refresh();
|
||||
$this->fillForm();
|
||||
|
||||
Notification::make()
|
||||
->title('AI Profile Regenerated')
|
||||
->body("Successfully regenerated AI tuning profile for {$brand->name}.")
|
||||
->success()
|
||||
->send();
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->title('Generation Failed')
|
||||
->body('AI generation failed: '.$e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BrandAiProfiles\Pages;
|
||||
|
||||
use App\Filament\Resources\BrandAiProfiles\BrandAiProfileResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBrandAiProfiles extends ListRecords
|
||||
{
|
||||
protected static string $resource = BrandAiProfileResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label('Create AI Profile'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BrandAiProfiles\Schemas;
|
||||
|
||||
use App\Models\Brand;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class BrandAiProfileForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
// Brand Spec Snapshot (visible only when editing with specification)
|
||||
Section::make('Brand Spec Snapshot')
|
||||
->description('Current brand personality traits at a glance')
|
||||
->schema([
|
||||
Placeholder::make('_specification_preview')
|
||||
->label('')
|
||||
->content(function ($record) {
|
||||
if (! $record || ! $record->specification) {
|
||||
return new HtmlString('<p class="text-gray-500 italic">No specification defined yet.</p>');
|
||||
}
|
||||
|
||||
$lines = preg_split('/\r\n|\r|\n/', $record->specification);
|
||||
$bullets = collect($lines)
|
||||
->filter(fn ($line) => trim($line) !== '')
|
||||
->map(fn ($line) => '<li>'.e(trim($line)).'</li>')
|
||||
->implode('');
|
||||
|
||||
return new HtmlString('<ul class="list-disc pl-5 space-y-1">'.$bullets.'</ul>');
|
||||
}),
|
||||
])
|
||||
->collapsible()
|
||||
->visible(fn ($record) => $record && $record->specification),
|
||||
|
||||
// Brand Selection Section
|
||||
Section::make('Brand Selection')
|
||||
->schema([
|
||||
Select::make('brand_id')
|
||||
->label('Brand')
|
||||
->relationship('brand', 'name', function ($query) {
|
||||
return $query->with('business')
|
||||
->whereHas('business', function ($q) {
|
||||
$q->where('status', 'approved');
|
||||
});
|
||||
})
|
||||
->getOptionLabelFromRecordUsing(fn ($record) => "{$record->business->name} - {$record->name}")
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->helperText('One AI profile per brand.')
|
||||
->live()
|
||||
->afterStateUpdated(function ($state, callable $set) {
|
||||
if ($state) {
|
||||
$brand = Brand::find($state);
|
||||
if ($brand) {
|
||||
$set('business_id', $brand->business_id);
|
||||
$set('_brand_voice_display', Brand::BRAND_VOICES[$brand->brand_voice] ?? 'Not Set');
|
||||
$set('_brand_audience_display', Brand::BRAND_AUDIENCES[$brand->brand_audience] ?? 'Not Set');
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Hidden field to store business_id
|
||||
Select::make('business_id')
|
||||
->relationship('business', 'name')
|
||||
->hidden()
|
||||
->dehydrated(),
|
||||
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Placeholder::make('_brand_voice_display')
|
||||
->label('Brand Voice (from Brand)')
|
||||
->content(function ($record) {
|
||||
if ($record && $record->brand) {
|
||||
return Brand::BRAND_VOICES[$record->brand->brand_voice] ?? 'Not Set';
|
||||
}
|
||||
|
||||
return 'Select a brand';
|
||||
}),
|
||||
|
||||
Placeholder::make('_brand_audience_display')
|
||||
->label('Audience Type (from Brand)')
|
||||
->content(function ($record) {
|
||||
if ($record && $record->brand) {
|
||||
return Brand::BRAND_AUDIENCES[$record->brand->brand_audience] ?? 'Not Set';
|
||||
}
|
||||
|
||||
return 'Select a brand';
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
|
||||
// Brand Specification Section
|
||||
Section::make('Brand Specification')
|
||||
->description('Quick-reference traits that define the brand personality')
|
||||
->schema([
|
||||
Textarea::make('specification')
|
||||
->label('Brand Specification')
|
||||
->placeholder("rebellious\nattitude-heavy\nfast-moving\nlate-night energy\npunchy\nedgy humor")
|
||||
->helperText('One trait per line, e.g. "rebellious", "late-night energy", "punchy".')
|
||||
->rows(6)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
// Voice & Tone Section
|
||||
Section::make('Voice & Tone')
|
||||
->description('Define how the AI should write for this brand')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('voice')
|
||||
->label('Voice')
|
||||
->placeholder('e.g., Playful, educational, no fluff')
|
||||
->helperText('How the brand speaks - its personality'),
|
||||
|
||||
TextInput::make('audience')
|
||||
->label('Target Audience')
|
||||
->placeholder('e.g., 21–40, budget-conscious cannabis consumers')
|
||||
->helperText('Who the content is written for'),
|
||||
]),
|
||||
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
Select::make('emoji_level')
|
||||
->label('Emoji Level')
|
||||
->options([
|
||||
'none' => 'None - No emojis',
|
||||
'low' => 'Low - Sparingly (1-2 max)',
|
||||
'medium' => 'Medium - Occasional (2-4)',
|
||||
'high' => 'High - Liberal use',
|
||||
])
|
||||
->default('low')
|
||||
->required(),
|
||||
|
||||
TextInput::make('tone')
|
||||
->label('Tone')
|
||||
->placeholder('e.g., confident, friendly expert')
|
||||
->helperText('The emotional quality of the writing'),
|
||||
|
||||
TextInput::make('writing_style')
|
||||
->label('Writing Style')
|
||||
->placeholder('e.g., short, punchy sentences')
|
||||
->helperText('How sentences are structured'),
|
||||
]),
|
||||
]),
|
||||
|
||||
// Vocabulary Section
|
||||
Section::make('Vocabulary')
|
||||
->description('Words and phrases to use or avoid')
|
||||
->schema([
|
||||
TagsInput::make('banned_words')
|
||||
->label('Banned Words')
|
||||
->placeholder('Add banned word or phrase...')
|
||||
->helperText('Words/phrases the AI must NEVER use for this brand')
|
||||
->splitKeys(['Tab', 'Enter'])
|
||||
->columnSpanFull(),
|
||||
|
||||
TagsInput::make('required_phrases')
|
||||
->label('Required Phrases')
|
||||
->placeholder('Add preferred phrase...')
|
||||
->helperText('Words/phrases the AI should use when appropriate')
|
||||
->splitKeys(['Tab', 'Enter'])
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
// Content Rules Section
|
||||
Section::make('Content Rules')
|
||||
->description('Additional rules for content generation')
|
||||
->schema([
|
||||
Textarea::make('content_rules_display')
|
||||
->label('Content Rules (JSON)')
|
||||
->helperText('Custom rules in JSON format. Example: {"avoid-medical-claims": true, "instagram-safe": true}')
|
||||
->rows(3)
|
||||
->afterStateHydrated(function ($component, $state, $record) {
|
||||
if ($record && $record->content_rules) {
|
||||
$component->state(json_encode($record->content_rules, JSON_PRETTY_PRINT));
|
||||
}
|
||||
})
|
||||
->dehydrateStateUsing(function ($state) {
|
||||
if (empty($state)) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($state, true);
|
||||
|
||||
return json_last_error() === JSON_ERROR_NONE ? $decoded : null;
|
||||
})
|
||||
->statePath('content_rules')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
// Metadata Section
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
Placeholder::make('last_tuned_at')
|
||||
->label('Last Tuned')
|
||||
->content(function ($record) {
|
||||
if ($record && $record->last_tuned_at) {
|
||||
return $record->last_tuned_at->format('M j, Y g:i A');
|
||||
}
|
||||
|
||||
return 'Never';
|
||||
}),
|
||||
])
|
||||
->collapsed(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BrandAiProfiles\Tables;
|
||||
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class BrandAiProfilesTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('brand.name')
|
||||
->label('Brand')
|
||||
->sortable()
|
||||
->searchable()
|
||||
->weight('bold'),
|
||||
|
||||
TextColumn::make('business.name')
|
||||
->label('Business')
|
||||
->sortable()
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('specification_summary')
|
||||
->label('Brand Spec')
|
||||
->tooltip(fn ($record) => $record->specification)
|
||||
->wrap()
|
||||
->limit(80),
|
||||
|
||||
TextColumn::make('emoji_level')
|
||||
->label('Emoji')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'none' => 'gray',
|
||||
'low' => 'info',
|
||||
'medium' => 'warning',
|
||||
'high' => 'success',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn ($state) => ucfirst($state ?? 'low')),
|
||||
|
||||
TextColumn::make('voice')
|
||||
->label('Voice')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('audience')
|
||||
->label('Audience')
|
||||
->limit(30)
|
||||
->tooltip(fn ($state) => $state)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('last_tuned_at')
|
||||
->label('Last Tuned')
|
||||
->dateTime('M j, Y')
|
||||
->sortable()
|
||||
->placeholder('Never'),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('brand_id')
|
||||
->label('Brand')
|
||||
->relationship('brand', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
SelectFilter::make('emoji_level')
|
||||
->label('Emoji Level')
|
||||
->options([
|
||||
'none' => 'None',
|
||||
'low' => 'Low',
|
||||
'medium' => 'Medium',
|
||||
'high' => 'High',
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->defaultSort('last_tuned_at', 'desc');
|
||||
}
|
||||
}
|
||||
220
app/Filament/Resources/BrandIntelligenceConnectionResource.php
Normal file
220
app/Filament/Resources/BrandIntelligenceConnectionResource.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\BrandIntelligenceConnectionResource\Pages;
|
||||
use App\Models\BrandIntelligenceConnection;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
class BrandIntelligenceConnectionResource extends Resource
|
||||
{
|
||||
protected static ?string $model = BrandIntelligenceConnection::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
|
||||
|
||||
protected static ?string $navigationLabel = 'Brand Intelligence';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = 'Integrations';
|
||||
|
||||
protected static ?int $navigationSort = 3; // After SMS Provider (which is 2)
|
||||
|
||||
protected static ?string $modelLabel = 'Brand Intelligence Connection';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Brand Intelligence Connections';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Connection Details')
|
||||
->description('Configure the external crawler API connection.')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label('Store / Client Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->placeholder('Deeply Rooted')
|
||||
->helperText('Friendly name for this connection'),
|
||||
|
||||
Select::make('provider')
|
||||
->label('Provider')
|
||||
->options(BrandIntelligenceConnection::PROVIDERS)
|
||||
->default('crawler')
|
||||
->required()
|
||||
->helperText('Intelligence data provider'),
|
||||
|
||||
TextInput::make('from_domain')
|
||||
->label('Allowed Domain (reference)')
|
||||
->maxLength(255)
|
||||
->placeholder('*.deeplyrooted.com')
|
||||
->helperText('Optional: Domain pattern for your reference'),
|
||||
|
||||
TextInput::make('api_key')
|
||||
->label('API Key')
|
||||
->password()
|
||||
->revealable()
|
||||
->maxLength(1000)
|
||||
->placeholder('Enter API key...')
|
||||
->helperText('Paste the API Permission key from the crawler dashboard'),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Status')
|
||||
->description('Connection status and testing.')
|
||||
->schema([
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->helperText('Enable this connection for fetching intelligence data')
|
||||
->default(true),
|
||||
|
||||
Toggle::make('is_verified')
|
||||
->label('Verified')
|
||||
->helperText('Set automatically when connection test succeeds')
|
||||
->disabled(),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Test Status')
|
||||
->schema([
|
||||
Placeholder::make('test_status')
|
||||
->label('Last Test')
|
||||
->content(function (?BrandIntelligenceConnection $record) {
|
||||
if (! $record || ! $record->last_tested_at) {
|
||||
return 'Never tested';
|
||||
}
|
||||
|
||||
$status = $record->is_verified ? '✅ Verified' : '❌ Failed';
|
||||
$result = $record->last_test_status ?? 'No result';
|
||||
|
||||
return "{$status} - {$record->last_tested_at->diffForHumans()} - {$result}";
|
||||
}),
|
||||
])
|
||||
->visible(fn (?BrandIntelligenceConnection $record) => $record !== null),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('provider')
|
||||
->label('Provider')
|
||||
->formatStateUsing(fn (string $state): string => BrandIntelligenceConnection::PROVIDERS[$state] ?? ucfirst($state))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('Store / Client Name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->trueColor('success')
|
||||
->falseColor('gray'),
|
||||
|
||||
Tables\Columns\IconColumn::make('is_verified')
|
||||
->label('Verified')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-shield-check')
|
||||
->falseIcon('heroicon-o-shield-exclamation')
|
||||
->trueColor('success')
|
||||
->falseColor('warning'),
|
||||
|
||||
Tables\Columns\TextColumn::make('last_tested_at')
|
||||
->label('Last Tested')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->placeholder('Never'),
|
||||
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->label('Updated')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TernaryFilter::make('is_active')
|
||||
->label('Active'),
|
||||
Tables\Filters\TernaryFilter::make('is_verified')
|
||||
->label('Verified'),
|
||||
])
|
||||
->actions([
|
||||
Action::make('test')
|
||||
->label('Test')
|
||||
->icon('heroicon-o-signal')
|
||||
->color('info')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Test Connection')
|
||||
->modalDescription('Test the API connection to verify credentials are valid.')
|
||||
->action(function (BrandIntelligenceConnection $record) {
|
||||
// TODO: Replace this stub with a real HTTP call once the crawler
|
||||
// health endpoint is finalized:
|
||||
//
|
||||
// use Illuminate\Support\Facades\Http;
|
||||
//
|
||||
// $response = Http::withToken($record->api_key)
|
||||
// ->timeout(5)
|
||||
// ->get(config('services.crawler.health_endpoint'));
|
||||
//
|
||||
// $ok = $response->successful();
|
||||
// $record->recordTestResult($ok, $ok ? 'success' : 'failed: '.$response->status());
|
||||
|
||||
// For now, mark as success to avoid errors until the endpoint is ready
|
||||
$record->recordTestResult(true, 'success');
|
||||
|
||||
\Filament\Notifications\Notification::make()
|
||||
->title('Connection Test Successful')
|
||||
->body('API connection verified successfully.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->emptyStateHeading('No Brand Intelligence Connections')
|
||||
->emptyStateDescription('Create a Brand Intelligence connection to link Cannabrands to the crawler API.')
|
||||
->emptyStateIcon('heroicon-o-chart-bar')
|
||||
->defaultSort('updated_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListBrandIntelligenceConnections::route('/'),
|
||||
'create' => Pages\CreateBrandIntelligenceConnection::route('/create'),
|
||||
'edit' => Pages\EditBrandIntelligenceConnection::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BrandIntelligenceConnectionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BrandIntelligenceConnectionResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateBrandIntelligenceConnection extends CreateRecord
|
||||
{
|
||||
protected static string $resource = BrandIntelligenceConnectionResource::class;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BrandIntelligenceConnectionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BrandIntelligenceConnectionResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditBrandIntelligenceConnection extends EditRecord
|
||||
{
|
||||
protected static string $resource = BrandIntelligenceConnectionResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BrandIntelligenceConnectionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BrandIntelligenceConnectionResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBrandIntelligenceConnections extends ListRecords
|
||||
{
|
||||
protected static string $resource = BrandIntelligenceConnectionResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->label('New Brand Intelligence Connection'),
|
||||
];
|
||||
}
|
||||
}
|
||||
310
app/Filament/Resources/BrandOrchestratorProfileResource.php
Normal file
310
app/Filament/Resources/BrandOrchestratorProfileResource.php
Normal file
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\BrandOrchestratorProfileResource\Pages;
|
||||
use App\Models\Brand;
|
||||
use App\Models\BrandOrchestratorProfile;
|
||||
use App\Models\OrchestratorSalesConfig;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Radio;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
class BrandOrchestratorProfileResource extends Resource
|
||||
{
|
||||
protected static ?string $model = BrandOrchestratorProfile::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedAdjustmentsHorizontal;
|
||||
|
||||
protected static ?string $navigationLabel = 'Brand Profiles';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = 'Orchestrator';
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
protected static ?string $modelLabel = 'Brand Profile';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Brand Profiles';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
// Get global config for showing defaults
|
||||
try {
|
||||
$globalConfig = OrchestratorSalesConfig::getGlobal();
|
||||
} catch (\Exception $e) {
|
||||
$globalConfig = new OrchestratorSalesConfig;
|
||||
}
|
||||
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Brand Selection')
|
||||
->description('Select the business and brand for this profile.')
|
||||
->schema([
|
||||
Select::make('business_id')
|
||||
->label('Business')
|
||||
->relationship('business', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->live()
|
||||
->afterStateUpdated(fn (Set $set) => $set('brand_id', null)),
|
||||
|
||||
Select::make('brand_id')
|
||||
->label('Brand')
|
||||
->options(function (Get $get) {
|
||||
$businessId = $get('business_id');
|
||||
if (! $businessId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Brand::where('business_id', $businessId)
|
||||
->pluck('name', 'id');
|
||||
})
|
||||
->searchable()
|
||||
->required()
|
||||
->disabled(fn (Get $get) => ! $get('business_id')),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Behavior Profile')
|
||||
->description('Choose a preset profile that determines the overall aggressiveness of the orchestrator for this brand.')
|
||||
->schema([
|
||||
Radio::make('behavior_profile')
|
||||
->label('Profile Type')
|
||||
->options([
|
||||
BrandOrchestratorProfile::PROFILE_CONSERVATIVE => 'Conservative - Higher cooldowns, lower task caps, more manager review',
|
||||
BrandOrchestratorProfile::PROFILE_BALANCED => 'Balanced - Uses global settings (default)',
|
||||
BrandOrchestratorProfile::PROFILE_AGGRESSIVE => 'Aggressive - Lower cooldowns, higher task caps, more auto-approval',
|
||||
])
|
||||
->default(BrandOrchestratorProfile::PROFILE_BALANCED)
|
||||
->required()
|
||||
->helperText('Preset profiles apply sensible defaults. Use overrides below for fine-tuning.'),
|
||||
]),
|
||||
|
||||
Section::make('Throttling Overrides')
|
||||
->description('Override global throttling settings for this brand. Leave empty to use global defaults.')
|
||||
->schema([
|
||||
TextInput::make('max_tasks_per_customer_per_run')
|
||||
->label('Max Tasks Per Customer Per Run')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->maxValue(20)
|
||||
->placeholder('Global: '.($globalConfig->getMaxTasksPerCustomerPerRun() ?? 3))
|
||||
->helperText('Maximum orchestrator tasks to create for a single customer in one run.'),
|
||||
|
||||
TextInput::make('cooldown_hours')
|
||||
->label('Cooldown Hours')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->maxValue(168)
|
||||
->placeholder('Global: '.($globalConfig->cooldown_hours ?? 24))
|
||||
->helperText('Minimum hours between orchestrator touches for the same customer.'),
|
||||
|
||||
TextInput::make('max_pending_per_customer')
|
||||
->label('Max Pending Per Customer')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->maxValue(10)
|
||||
->placeholder('Global: '.($globalConfig->getMaxPendingPerCustomer() ?? 3))
|
||||
->helperText('Maximum visible pending tasks per customer.'),
|
||||
])
|
||||
->columns(3),
|
||||
|
||||
Section::make('Auto-Approval Overrides')
|
||||
->description('Override which playbooks auto-approve for this brand. Leave empty to use global settings.')
|
||||
->schema([
|
||||
Toggle::make('auto_approval_high_intent')
|
||||
->label('High Intent Buyer')
|
||||
->helperText('Global: '.($globalConfig->auto_approval_high_intent ? 'Yes' : 'No'))
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('auto_approval_vip')
|
||||
->label('VIP Buyer')
|
||||
->helperText('Global: '.($globalConfig->auto_approval_vip ? 'Yes' : 'No'))
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('auto_approval_ghosted')
|
||||
->label('Ghosted Buyer')
|
||||
->helperText('Global: '.($globalConfig->auto_approval_ghosted ? 'Yes' : 'No'))
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('auto_approval_at_risk')
|
||||
->label('At-Risk Account')
|
||||
->helperText('Global: '.($globalConfig->auto_approval_at_risk ? 'Yes' : 'No'))
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('auto_approval_menu_followup_no_view')
|
||||
->label('Menu Followup (No View)')
|
||||
->helperText('Global: '.($globalConfig->auto_approval_menu_followup_no_view ?? true ? 'Yes' : 'No'))
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('auto_approval_menu_followup_viewed_no_order')
|
||||
->label('Menu Followup (Viewed, No Order)')
|
||||
->helperText('Global: '.($globalConfig->auto_approval_menu_followup_viewed_no_order ?? true ? 'Yes' : 'No'))
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('auto_approval_reactivation')
|
||||
->label('Reactivation')
|
||||
->helperText('Global: '.($globalConfig->auto_approval_reactivation_no_order_30d ?? true ? 'Yes' : 'No'))
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('auto_approval_new_menu')
|
||||
->label('New Menu Promotion')
|
||||
->helperText('Global: '.($globalConfig->auto_approval_promotion_broadcast_suggestion ?? true ? 'Yes' : 'No'))
|
||||
->nullable(),
|
||||
])
|
||||
->columns(4),
|
||||
|
||||
Section::make('Playbook Enable/Disable')
|
||||
->description('Disable specific playbooks for this brand. Leave empty to use global settings.')
|
||||
->schema([
|
||||
Toggle::make('playbook_no_view_enabled')
|
||||
->label('No View Followup')
|
||||
->helperText('Global: '.($globalConfig->no_view_enabled ?? true ? 'Enabled' : 'Disabled'))
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('playbook_viewed_no_order_enabled')
|
||||
->label('Viewed No Order Followup')
|
||||
->helperText('Global: '.($globalConfig->viewed_no_order_enabled ?? true ? 'Enabled' : 'Disabled'))
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('playbook_reactivation_enabled')
|
||||
->label('Reactivation')
|
||||
->helperText('Global: '.($globalConfig->reactivation_enabled ?? true ? 'Enabled' : 'Disabled'))
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('playbook_new_menu_enabled')
|
||||
->label('New Menu Promotion')
|
||||
->helperText('Global: '.($globalConfig->new_menu_enabled ?? true ? 'Enabled' : 'Disabled'))
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('playbook_high_intent_enabled')
|
||||
->label('High Intent Buyer')
|
||||
->helperText('Global: '.($globalConfig->high_intent_enabled ?? true ? 'Enabled' : 'Disabled'))
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('playbook_vip_enabled')
|
||||
->label('VIP Buyer')
|
||||
->helperText('Global: '.($globalConfig->vip_buyer_enabled ?? true ? 'Enabled' : 'Disabled'))
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('playbook_ghosted_enabled')
|
||||
->label('Ghosted Buyer')
|
||||
->helperText('Global: '.($globalConfig->ghosted_buyer_enabled ?? true ? 'Enabled' : 'Disabled'))
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('playbook_at_risk_enabled')
|
||||
->label('At-Risk Account')
|
||||
->helperText('Global: '.($globalConfig->at_risk_enabled ?? true ? 'Enabled' : 'Disabled'))
|
||||
->nullable(),
|
||||
])
|
||||
->columns(4)
|
||||
->collapsed(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('business.name')
|
||||
->label('Business')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('brand.name')
|
||||
->label('Brand')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\BadgeColumn::make('behavior_profile')
|
||||
->label('Profile')
|
||||
->colors([
|
||||
'danger' => BrandOrchestratorProfile::PROFILE_AGGRESSIVE,
|
||||
'success' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||
'info' => BrandOrchestratorProfile::PROFILE_CONSERVATIVE,
|
||||
])
|
||||
->formatStateUsing(fn (string $state): string => ucfirst($state)),
|
||||
|
||||
Tables\Columns\TextColumn::make('max_tasks_per_customer_per_run')
|
||||
->label('Max Tasks/Customer')
|
||||
->placeholder('Global')
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('cooldown_hours')
|
||||
->label('Cooldown (hrs)')
|
||||
->placeholder('Global')
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\IconColumn::make('auto_approval_high_intent')
|
||||
->label('Auto High Intent')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\IconColumn::make('auto_approval_at_risk')
|
||||
->label('Auto At-Risk')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->label('Last Updated')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('behavior_profile')
|
||||
->label('Profile')
|
||||
->options(BrandOrchestratorProfile::PROFILES),
|
||||
|
||||
Tables\Filters\SelectFilter::make('business_id')
|
||||
->label('Business')
|
||||
->relationship('business', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->defaultSort('updated_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListBrandOrchestratorProfiles::route('/'),
|
||||
'create' => Pages\CreateBrandOrchestratorProfile::route('/create'),
|
||||
'edit' => Pages\EditBrandOrchestratorProfile::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BrandOrchestratorProfileResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BrandOrchestratorProfileResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateBrandOrchestratorProfile extends CreateRecord
|
||||
{
|
||||
protected static string $resource = BrandOrchestratorProfileResource::class;
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BrandOrchestratorProfileResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BrandOrchestratorProfileResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditBrandOrchestratorProfile extends EditRecord
|
||||
{
|
||||
protected static string $resource = BrandOrchestratorProfileResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BrandOrchestratorProfileResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BrandOrchestratorProfileResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBrandOrchestratorProfiles extends ListRecords
|
||||
{
|
||||
protected static string $resource = BrandOrchestratorProfileResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->label('New Brand Profile'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ class BrandResource extends Resource
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $navigationLabel = 'Brands';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
|
||||
@@ -28,9 +28,27 @@ class BrandForm
|
||||
->required(),
|
||||
TextInput::make('slug')
|
||||
->required(),
|
||||
Select::make('brand_voice')
|
||||
->label('Brand Voice')
|
||||
->options(\App\Models\Brand::BRAND_VOICES)
|
||||
->default('playful')
|
||||
->required()
|
||||
->helperText('Controls your brand\'s personality. Combined with Audience Type, it enables 45 unique AI content styles.'),
|
||||
Select::make('brand_audience')
|
||||
->label('Audience Type')
|
||||
->options(\App\Models\Brand::BRAND_AUDIENCES)
|
||||
->default('consumer')
|
||||
->required()
|
||||
->helperText('Who the AI writes content for'),
|
||||
Textarea::make('description')
|
||||
->minLength(100)
|
||||
->maxLength(150)
|
||||
->helperText('100–150 characters required.')
|
||||
->columnSpanFull(),
|
||||
TextInput::make('tagline'),
|
||||
TextInput::make('tagline')
|
||||
->minLength(30)
|
||||
->maxLength(45)
|
||||
->helperText('30–45 characters required.'),
|
||||
FileUpload::make('logo_path')
|
||||
->label('Brand Logo')
|
||||
->image()
|
||||
@@ -57,8 +75,25 @@ class BrandForm
|
||||
->required()
|
||||
->numeric()
|
||||
->default(0),
|
||||
TextInput::make('meta_title'),
|
||||
Textarea::make('meta_description')
|
||||
Textarea::make('long_description')
|
||||
->label('Long Description')
|
||||
->minLength(400)
|
||||
->maxLength(500)
|
||||
->helperText('400–500 characters required.')
|
||||
->columnSpanFull(),
|
||||
Textarea::make('brand_announcement')
|
||||
->label('Brand Announcement')
|
||||
->maxLength(500)
|
||||
->helperText('Up to 500 characters (optional).')
|
||||
->columnSpanFull(),
|
||||
TextInput::make('seo_title')
|
||||
->label('SEO Title')
|
||||
->maxLength(70)
|
||||
->helperText('Up to 70 characters (optional).'),
|
||||
Textarea::make('seo_description')
|
||||
->label('SEO Description')
|
||||
->maxLength(160)
|
||||
->helperText('Up to 160 characters (optional).')
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ namespace App\Filament\Resources\BusinessModuleResource\Schemas;
|
||||
|
||||
use App\Models\Module;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Grid;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Get;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class BusinessModuleForm
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user