Compare commits
212 Commits
fix/ci-git
...
feat/omnic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e7e1b5934 | ||
|
|
3ac4358c0b | ||
|
|
cc997cfa20 | ||
|
|
37dd49f9ec | ||
|
|
e16281e237 | ||
|
|
64479a5c84 | ||
|
|
5b1b085e06 | ||
|
|
e0caa83325 | ||
|
|
90bc7f3907 | ||
|
|
b7fb6c5a66 | ||
|
|
0d38f6dc5e | ||
|
|
8c4b424eb6 | ||
|
|
2cf335d019 | ||
|
|
9f0678a17c | ||
|
|
ad9c41dd28 | ||
|
|
1732bcbee2 | ||
|
|
96276cc118 | ||
|
|
dc69033ca4 | ||
|
|
bcf25eba38 | ||
|
|
9116d9b055 | ||
|
|
b7a3b5c924 | ||
|
|
5b9be3368a | ||
|
|
5c7ea61937 | ||
|
|
29a8bdc85f | ||
|
|
8116de4659 | ||
|
|
578753235d | ||
|
|
8eef5c265e | ||
|
|
1fe1749d6f | ||
|
|
a9c7b3034c | ||
|
|
0d17575f56 | ||
|
|
9366f099ec | ||
|
|
b3edc4bf87 | ||
|
|
00aa796daf | ||
|
|
9153d4e950 | ||
|
|
c7250e26e2 | ||
|
|
49677fefdc | ||
|
|
bebb3874f9 | ||
|
|
a79ffe343f | ||
|
|
283420e898 | ||
|
|
6dd53f17ae | ||
|
|
08dc3b389a | ||
|
|
57e81c002d | ||
|
|
523ea5093e | ||
|
|
a77a5b1b11 | ||
|
|
3842ffd893 | ||
|
|
c0c3c2a754 | ||
|
|
486c16d0fa | ||
|
|
1c2afe416f | ||
|
|
cf30040161 | ||
|
|
df48d581ee | ||
|
|
f489b8e789 | ||
|
|
88768334aa | ||
|
|
55ec2b833d | ||
|
|
b503cc284f | ||
|
|
550da56b4e | ||
|
|
327aec34cc | ||
|
|
14cb5194e8 | ||
|
|
a33de047fd | ||
|
|
04f09f2cd4 | ||
|
|
d87d22ab27 | ||
|
|
d7fa02aeff | ||
|
|
c3f81b10f1 | ||
|
|
2424e35435 | ||
| a48b76a1f4 | |||
| 2417dedce2 | |||
| a6e934e4a4 | |||
|
|
0aa2cf4ee3 | ||
| fdba05140b | |||
| 0b29cac5eb | |||
| cc7cf86ea9 | |||
| 7143222cd0 | |||
|
|
e6c8fd8c3c | ||
|
|
cea7ca5119 | ||
|
|
a849e9cd34 | ||
|
|
fcb0a158ea | ||
|
|
7614ed0fdd | ||
|
|
6c96aaa11b | ||
| 7b5f3db26a | |||
|
|
51047fc315 | ||
|
|
dff1475550 | ||
| 9fdeaaa7b2 | |||
|
|
f1827aba18 | ||
|
|
39aa92d116 | ||
|
|
7e82c3d343 | ||
|
|
7020f51ac7 | ||
| 737eed473e | |||
|
|
4c8412a47b | ||
|
|
093bcb6e58 | ||
|
|
5fc6e008a5 | ||
|
|
0591eabfee | ||
|
|
3451a4b86a | ||
|
|
9c321b86c1 | ||
|
|
1f08ea8f12 | ||
|
|
de3faece35 | ||
|
|
370bb99e8f | ||
|
|
62f71d5c8d | ||
|
|
239a0ff2c0 | ||
|
|
660f982d71 | ||
|
|
3321f8e593 | ||
|
|
3984307e44 | ||
|
|
9c5b8f3cfb | ||
|
|
d2a3a05ea1 | ||
|
|
eac1d4cb0a | ||
|
|
a0c0dafe34 | ||
|
|
91b7e0c0e0 | ||
|
|
c2692a3e86 | ||
|
|
ad2c680cda | ||
|
|
d46d587687 | ||
|
|
f06bc254c8 | ||
|
|
ad517a6332 | ||
|
|
6cb74eab7f | ||
|
|
2ea43f7c8b | ||
|
|
90ae8dcf23 | ||
|
|
9648247fe3 | ||
|
|
fd30bb4f27 | ||
|
|
9e83341c89 | ||
|
|
93e521f440 | ||
|
|
ec9853c571 | ||
|
|
636bdafc9e | ||
|
|
c7d6ee5e21 | ||
|
|
496ca61489 | ||
|
|
a812380b32 | ||
|
|
9bb0f6d373 | ||
|
|
df7c41887c | ||
|
|
00410478c0 | ||
|
|
a943a412ac | ||
|
|
6550ecff12 | ||
|
|
c72c73e88c | ||
|
|
d4ec430790 | ||
|
|
5cce19d849 | ||
|
|
6ae2be604f | ||
|
|
11edda5411 | ||
|
|
44d21fa146 | ||
|
|
798476e991 | ||
|
|
bad6c24597 | ||
|
|
5b7898f478 | ||
|
|
9cc582b869 | ||
|
|
ac70cc0247 | ||
|
|
eb95528b76 | ||
|
|
879d1c61df | ||
|
|
0af6db4461 | ||
|
|
0f5901e55f | ||
|
|
8fcc3629bd | ||
|
|
0b54c251bc | ||
|
|
8995c60d88 | ||
|
|
c4e178a900 | ||
|
|
6688bbf8a1 | ||
|
|
bb5f2c8aaa | ||
|
|
a9d0f328a8 | ||
|
|
3b769905b7 | ||
|
|
f7727d8c17 | ||
|
|
6d7eb4f151 | ||
|
|
0c260f69b0 | ||
|
|
63b9372372 | ||
|
|
aaff332937 | ||
|
|
964548ba38 | ||
|
|
cf05d8cad1 | ||
|
|
05dca8f847 | ||
|
|
27328c9106 | ||
|
|
b3dd9a8e23 | ||
|
|
1cd6c15cb3 | ||
|
|
3554578554 | ||
|
|
3962807fc6 | ||
|
|
32054ddcce | ||
|
|
5905699ca1 | ||
|
|
eb8e2a89c4 | ||
|
|
8286aebf4e | ||
|
|
4cff4af841 | ||
|
|
8abcd3291e | ||
|
|
a7c3eb4183 | ||
|
|
1ed62fe0de | ||
|
|
160b312ca5 | ||
|
|
6d22a99259 | ||
|
|
febfd75016 | ||
|
|
fbb72f902b | ||
|
|
fd11ae0fe0 | ||
|
|
16c5c455fa | ||
|
|
df587fdda3 | ||
|
|
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 | ||
|
|
08f5a3adac | ||
|
|
e62ea5c809 | ||
|
|
8d43953cad | ||
|
|
a628f2b207 | ||
|
|
367daadfe9 | ||
|
|
b33ebac9bf |
@@ -8,8 +8,8 @@ node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Composer
|
||||
/vendor
|
||||
# Composer (NOT excluded - Dockerfile.fast needs pre-built vendor)
|
||||
# /vendor
|
||||
|
||||
# Environment
|
||||
.env
|
||||
@@ -58,7 +58,7 @@ docker-compose.*.yml
|
||||
# Build artifacts
|
||||
/public/hot
|
||||
/public/storage
|
||||
/public/build
|
||||
# /public/build - NOT excluded, Dockerfile.fast needs pre-built assets
|
||||
|
||||
# Misc
|
||||
.env.backup
|
||||
|
||||
53
.env.example
53
.env.example
@@ -24,12 +24,13 @@ LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# PostgreSQL: 10.100.6.50:5432
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=pgsql
|
||||
DB_HOST=10.100.6.50
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=cannabrands_app
|
||||
DB_USERNAME=sail
|
||||
DB_PASSWORD=password
|
||||
DB_DATABASE=cannabrands_dev
|
||||
DB_USERNAME=cannabrands
|
||||
DB_PASSWORD=SpDyCannaBrands2024
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
@@ -66,9 +67,10 @@ CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
# Redis: 10.100.9.50:6379
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_HOST=10.100.9.50
|
||||
REDIS_PASSWORD=SpDyR3d1s2024!
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
@@ -88,43 +90,18 @@ MAIL_FROM_NAME="${APP_NAME}"
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ LOCAL DEVELOPMENT (Docker MinIO) │
|
||||
# │ MinIO (S3-Compatible Storage) │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
# Use local MinIO container for development (versioning enabled)
|
||||
# Access MinIO Console: http://localhost:9001 (minioadmin/minioadmin)
|
||||
# Server: 10.100.9.80:9000 | Console: 10.100.9.80:9001
|
||||
FILESYSTEM_DISK=minio
|
||||
AWS_ACCESS_KEY_ID=minioadmin
|
||||
AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
AWS_ACCESS_KEY_ID=cannabrands-app
|
||||
AWS_SECRET_ACCESS_KEY=cdbdcd0c7b6f3994d4ab09f68eaff98665df234f
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=media
|
||||
AWS_ENDPOINT=http://minio:9000
|
||||
AWS_URL=http://localhost:9000/media
|
||||
AWS_BUCKET=cannabrands
|
||||
AWS_ENDPOINT=http://10.100.9.80:9000
|
||||
AWS_URL=http://10.100.9.80:9000/cannabrands
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ STAGING/DEVELOP (media-dev bucket) │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
# FILESYSTEM_DISK=minio
|
||||
# AWS_ACCESS_KEY_ID=<staging-access-key>
|
||||
# AWS_SECRET_ACCESS_KEY=<staging-secret-key>
|
||||
# AWS_DEFAULT_REGION=us-east-1
|
||||
# AWS_BUCKET=media-dev
|
||||
# AWS_ENDPOINT=https://cdn.cannabrands.app
|
||||
# AWS_URL=https://cdn.cannabrands.app/media-dev
|
||||
# AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ PRODUCTION (media bucket) │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
# FILESYSTEM_DISK=minio
|
||||
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
|
||||
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
|
||||
# AWS_DEFAULT_REGION=us-east-1
|
||||
# AWS_BUCKET=media
|
||||
# AWS_ENDPOINT=https://cdn.cannabrands.app
|
||||
# AWS_URL=https://cdn.cannabrands.app/media
|
||||
# AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
# Woodpecker CI/CD Pipeline for Cannabrands Hub
|
||||
# Documentation: https://woodpecker-ci.org/docs/intro
|
||||
# Optimized for fast deploys (~8-10 min)
|
||||
#
|
||||
# 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)
|
||||
# Optimizations:
|
||||
# - Parallel composer + frontend builds
|
||||
# - Split tests (unit + feature run in parallel)
|
||||
# - Dependency caching (npm + composer)
|
||||
# - Single-stage Dockerfile.fast
|
||||
# - Kaniko layer caching
|
||||
#
|
||||
# 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
|
||||
# External Services:
|
||||
# - PostgreSQL: 10.100.6.50:5432 (cannabrands_dev)
|
||||
# - Redis: 10.100.9.50:6379
|
||||
# - MinIO: 10.100.9.80:9000
|
||||
# - Docker Registry: git.spdy.io (for k8s pulls)
|
||||
|
||||
when:
|
||||
- branch: [develop, master]
|
||||
event: push
|
||||
- event: [pull_request, tag]
|
||||
|
||||
# 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
|
||||
@@ -34,422 +29,265 @@ clone:
|
||||
|
||||
steps:
|
||||
# ============================================
|
||||
# DEPENDENCY INSTALLATION (Sequential)
|
||||
# PARALLEL: Composer + Frontend (with caching)
|
||||
# ============================================
|
||||
|
||||
# Restore Composer cache
|
||||
restore-composer-cache:
|
||||
image: meltwater/drone-cache:dev
|
||||
settings:
|
||||
backend: "filesystem"
|
||||
restore: true
|
||||
cache_key: "composer-{{ checksum \"composer.lock\" }}"
|
||||
archive_format: "gzip"
|
||||
mount:
|
||||
- "vendor"
|
||||
volumes:
|
||||
- /tmp/woodpecker-cache:/tmp/cache
|
||||
|
||||
# Install dependencies (uses pre-built Laravel image with all extensions)
|
||||
composer-install:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- restore-composer-cache
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
commands:
|
||||
- echo "Creating minimal .env for package discovery..."
|
||||
- |
|
||||
cat > .env << 'EOF'
|
||||
APP_NAME="Cannabrands Hub"
|
||||
APP_ENV=testing
|
||||
APP_ENV=production
|
||||
APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
APP_DEBUG=true
|
||||
CACHE_STORE=array
|
||||
SESSION_DRIVER=array
|
||||
QUEUE_CONNECTION=sync
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=testing
|
||||
DB_USERNAME=testing
|
||||
DB_PASSWORD=testing
|
||||
EOF
|
||||
- echo "Checking for cached dependencies..."
|
||||
- |
|
||||
if [ -d "vendor" ] && [ -f "vendor/autoload.php" ]; then
|
||||
echo "✅ Restored vendor from cache"
|
||||
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!"
|
||||
# Restore composer cache if available
|
||||
- mkdir -p /root/.composer/cache
|
||||
- if [ -d .composer-cache ]; then cp -r .composer-cache/* /root/.composer/cache/ 2>/dev/null || true; fi
|
||||
- composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
||||
# Save cache for next build
|
||||
- mkdir -p .composer-cache && cp -r /root/.composer/cache/* .composer-cache/ 2>/dev/null || true
|
||||
- echo "✅ Composer done"
|
||||
|
||||
# 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
|
||||
cache_key: "composer-{{ checksum \"composer.lock\" }}"
|
||||
archive_format: "gzip"
|
||||
mount:
|
||||
- "vendor"
|
||||
volumes:
|
||||
- /tmp/woodpecker-cache:/tmp/cache
|
||||
when:
|
||||
branch: [develop, master]
|
||||
event: push
|
||||
build-frontend:
|
||||
image: 10.100.9.70:5000/library/node:22-alpine
|
||||
environment:
|
||||
VITE_REVERB_APP_KEY: 6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU=
|
||||
VITE_REVERB_HOST: dev.cannabrands.app
|
||||
VITE_REVERB_PORT: "443"
|
||||
VITE_REVERB_SCHEME: https
|
||||
npm_config_cache: .npm-cache
|
||||
commands:
|
||||
# Use cached node_modules if available
|
||||
- npm ci --prefer-offline
|
||||
- npm run build
|
||||
- echo "✅ Frontend built"
|
||||
|
||||
# ============================================
|
||||
# PR CHECKS (Run in Parallel for Speed)
|
||||
# PR CHECKS (Parallel: lint, style, tests)
|
||||
# ============================================
|
||||
|
||||
# PHP Syntax Check - Uses parallel-lint for 5-10x speed improvement
|
||||
php-lint:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
commands:
|
||||
- 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)
|
||||
code-style:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
image: 10.100.9.70:5000/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!"
|
||||
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
|
||||
# Split tests: Unit tests (fast, no DB)
|
||||
tests-unit:
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
when:
|
||||
event: pull_request
|
||||
environment:
|
||||
APP_ENV: testing
|
||||
BROADCAST_CONNECTION: reverb
|
||||
CACHE_STORE: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: testing
|
||||
DB_PASSWORD: testing
|
||||
REDIS_HOST: redis
|
||||
REVERB_APP_ID: test-app-id
|
||||
REVERB_APP_KEY: test-key
|
||||
REVERB_APP_SECRET: test-secret
|
||||
REVERB_HOST: localhost
|
||||
REVERB_PORT: 8080
|
||||
REVERB_SCHEME: http
|
||||
DB_CONNECTION: sqlite
|
||||
DB_DATABASE: ":memory:"
|
||||
commands:
|
||||
- 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 in parallel..."
|
||||
- php artisan test --parallel
|
||||
- echo "✅ Tests complete!"
|
||||
- php artisan test --testsuite=Unit --parallel
|
||||
- echo "✅ Unit tests passed"
|
||||
|
||||
# ============================================
|
||||
# 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
|
||||
# Split tests: Feature tests (with DB)
|
||||
tests-feature:
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
when:
|
||||
event: pull_request
|
||||
environment:
|
||||
APP_ENV: production
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: testing
|
||||
DB_PASSWORD: testing
|
||||
APP_ENV: testing
|
||||
CACHE_STORE: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: 10.100.6.50
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: cannabrands_test
|
||||
DB_USERNAME: cannabrands
|
||||
DB_PASSWORD: SpDyCannaBrands2024
|
||||
REDIS_HOST: 10.100.9.50
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: SpDyR3d1s2024!
|
||||
commands:
|
||||
- echo "Validating migrations..."
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- echo "Running pending migrations only..."
|
||||
- php artisan migrate --force
|
||||
- echo "✅ Migration validation complete!"
|
||||
- php artisan test --testsuite=Feature --parallel
|
||||
- echo "✅ Feature tests passed"
|
||||
|
||||
# ============================================
|
||||
# BUILD & DEPLOY
|
||||
# ============================================
|
||||
|
||||
# Create Docker config for registry auth (runs before Kaniko)
|
||||
setup-registry-auth:
|
||||
image: alpine
|
||||
depends_on:
|
||||
- composer-install
|
||||
- build-frontend
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: registry_user
|
||||
REGISTRY_PASSWORD:
|
||||
from_secret: registry_password
|
||||
commands:
|
||||
- mkdir -p /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker
|
||||
- |
|
||||
cat > /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker/config.json << EOF
|
||||
{"auths":{"registry.spdy.io":{"username":"$REGISTRY_USER","password":"$REGISTRY_PASSWORD"}}}
|
||||
EOF
|
||||
- echo "Auth config created"
|
||||
when:
|
||||
branch: [develop, master]
|
||||
event: push
|
||||
|
||||
# Build and push Docker image for DEV environment (develop branch)
|
||||
build-image-dev:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
image: 10.100.9.70:5000/kaniko-project/executor:debug
|
||||
depends_on:
|
||||
- validate-migrations
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
from_secret: gitea_token
|
||||
tags:
|
||||
- dev # Latest dev build → dev.cannabrands.app
|
||||
- dev-${CI_COMMIT_SHA:0:7} # Unique dev tag with SHA
|
||||
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
|
||||
- ${CI_COMMIT_BRANCH} # Branch name (develop)
|
||||
build_args:
|
||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
||||
APP_VERSION: "dev"
|
||||
VITE_REVERB_APP_KEY: "6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU="
|
||||
VITE_REVERB_HOST: "dev.cannabrands.app"
|
||||
VITE_REVERB_PORT: "443"
|
||||
VITE_REVERB_SCHEME: "https"
|
||||
cache_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
|
||||
- setup-registry-auth
|
||||
commands:
|
||||
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
|
||||
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
|
||||
--destination=registry.spdy.io/cannabrands/hub:dev \
|
||||
--destination=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=APP_VERSION=dev \
|
||||
--registry-mirror=10.100.9.70:5000 \
|
||||
--insecure-registry=10.100.9.70:5000 \
|
||||
--cache=true \
|
||||
--cache-ttl=168h \
|
||||
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache
|
||||
when:
|
||||
branch: develop
|
||||
event: push
|
||||
|
||||
# Auto-deploy to dev.cannabrands.app (develop branch only)
|
||||
deploy-dev:
|
||||
image: bitnami/kubectl:latest
|
||||
image: 10.100.9.70:5000/bitnami/kubectl:latest
|
||||
depends_on:
|
||||
- build-image-dev
|
||||
environment:
|
||||
KUBECONFIG_CONTENT:
|
||||
from_secret: kubeconfig_dev
|
||||
commands:
|
||||
- echo "🚀 Auto-deploying to dev.cannabrands.app..."
|
||||
- echo "Commit SHA${CI_COMMIT_SHA:0:7}"
|
||||
- echo ""
|
||||
# Setup kubeconfig
|
||||
- mkdir -p ~/.kube
|
||||
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
|
||||
- chmod 600 ~/.kube/config
|
||||
# Update deployment to use new SHA-tagged image (both app and init containers)
|
||||
- |
|
||||
kubectl set image deployment/cannabrands-hub \
|
||||
app=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
migrate=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
app=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
migrate=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
-n cannabrands-dev
|
||||
# Wait for rollout to complete (timeout 5 minutes)
|
||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-dev --timeout=300s
|
||||
# Verify deployment health
|
||||
- |
|
||||
echo ""
|
||||
echo "✅ Deployment successful!"
|
||||
echo "Pod status:"
|
||||
kubectl get pods -n cannabrands-dev -l app=cannabrands-hub
|
||||
echo ""
|
||||
echo "Image deployed:"
|
||||
kubectl get deployment cannabrands-hub -n cannabrands-dev -o jsonpath='{.spec.template.spec.containers[0].image}'
|
||||
echo ""
|
||||
- echo "✅ Deployed to dev.cannabrands.app"
|
||||
when:
|
||||
branch: develop
|
||||
event: push
|
||||
|
||||
# Build and push Docker image for PRODUCTION (master branch)
|
||||
build-image-production:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
image: 10.100.9.70:5000/kaniko-project/executor:debug
|
||||
depends_on:
|
||||
- validate-migrations
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
from_secret: gitea_token
|
||||
tags:
|
||||
- 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: "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
|
||||
- setup-registry-auth
|
||||
commands:
|
||||
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
|
||||
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
|
||||
--destination=git.spdy.io/cannabrands/hub:latest \
|
||||
--destination=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=APP_VERSION=production \
|
||||
--cache=true \
|
||||
--cache-ttl=168h \
|
||||
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache \
|
||||
--insecure \
|
||||
--insecure-pull \
|
||||
--skip-tls-verify
|
||||
when:
|
||||
branch: master
|
||||
event: push
|
||||
|
||||
# Deploy to production (master branch)
|
||||
deploy-production:
|
||||
image: bitnami/kubectl:latest
|
||||
image: 10.100.9.70:5000/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} \
|
||||
app=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
migrate=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
-n cannabrands-prod
|
||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-prod --timeout=300s
|
||||
- |
|
||||
echo ""
|
||||
echo "✅ PRODUCTION deployment successful!"
|
||||
echo "Pod status:"
|
||||
kubectl get pods -n cannabrands-prod -l app=cannabrands-hub
|
||||
- echo "✅ Deployed to cannabrands.app"
|
||||
when:
|
||||
branch: master
|
||||
event: push
|
||||
|
||||
# Build and push Docker image for tagged releases (optional versioned releases)
|
||||
build-image-release:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
# For tags, setup auth first
|
||||
setup-registry-auth-release:
|
||||
image: alpine
|
||||
depends_on:
|
||||
- composer-install
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
from_secret: gitea_token
|
||||
tags:
|
||||
- ${CI_COMMIT_TAG} # CalVer tag (e.g., 2025.10.1)
|
||||
- latest # Latest stable release
|
||||
build_args:
|
||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
||||
APP_VERSION: "${CI_COMMIT_TAG}"
|
||||
cache_images:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-prod
|
||||
platforms: linux/amd64
|
||||
# Disable provenance attestations - can cause Gitea registry 500 errors
|
||||
provenance: false
|
||||
- build-frontend
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: registry_user
|
||||
REGISTRY_PASSWORD:
|
||||
from_secret: registry_password
|
||||
commands:
|
||||
- mkdir -p /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker
|
||||
- |
|
||||
cat > /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker/config.json << EOF
|
||||
{"auths":{"git.spdy.io":{"username":"$REGISTRY_USER","password":"$REGISTRY_PASSWORD"}}}
|
||||
EOF
|
||||
when:
|
||||
event: tag
|
||||
|
||||
# Success notification
|
||||
success:
|
||||
image: alpine:latest
|
||||
when:
|
||||
- evaluate: 'CI_PIPELINE_STATUS == "success"'
|
||||
build-image-release:
|
||||
image: 10.100.9.70:5000/kaniko-project/executor:debug
|
||||
depends_on:
|
||||
- setup-registry-auth-release
|
||||
commands:
|
||||
- echo "✅ Pipeline completed successfully!"
|
||||
- echo "All checks passed for commit ${CI_COMMIT_SHA:0:7}"
|
||||
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
|
||||
- |
|
||||
if [ "${CI_PIPELINE_EVENT}" = "tag" ]; then
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "🎉 PRODUCTION RELEASE BUILD COMPLETE"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Version: ${CI_COMMIT_TAG}"
|
||||
echo "Registry: code.cannabrands.app/cannabrands/hub"
|
||||
echo ""
|
||||
echo "Available as:"
|
||||
echo " - code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
|
||||
echo " - code.cannabrands.app/cannabrands/hub:latest"
|
||||
echo ""
|
||||
echo "🚀 Deploy to PRODUCTION (cannabrands.app):"
|
||||
echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
|
||||
echo " docker-compose -f docker-compose.production.yml up -d"
|
||||
echo ""
|
||||
echo "⚠️ This is a CUSTOMER-FACING release!"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "master" ]; then
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "🚀 PRODUCTION DEPLOYED"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
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 ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "🚀 DEV BUILD + AUTO-DEPLOY COMPLETE"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Branch: develop"
|
||||
echo "Commit: ${CI_COMMIT_SHA:0:7}"
|
||||
echo ""
|
||||
echo "✅ Built & Tagged:"
|
||||
echo " - code.cannabrands.app/cannabrands/hub:dev"
|
||||
echo " - code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7}"
|
||||
echo " - code.cannabrands.app/cannabrands/hub:sha-${CI_COMMIT_SHA:0:7}"
|
||||
echo ""
|
||||
echo "✅ Auto-Deployed to Kubernetes:"
|
||||
echo " - Environment: dev.cannabrands.app"
|
||||
echo " - Namespace: cannabrands-dev"
|
||||
echo " - Image: dev-${CI_COMMIT_SHA:0:7}"
|
||||
echo ""
|
||||
echo "🧪 Test your changes:"
|
||||
echo " - Visit: https://dev.cannabrands.app"
|
||||
echo " - Login: admin@example.com / password"
|
||||
echo " - Check: https://dev.cannabrands.app/telescope"
|
||||
echo ""
|
||||
echo "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 (optimized for CI speed)
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
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
|
||||
commands:
|
||||
- redis-server --bind 0.0.0.0
|
||||
/kaniko/executor \
|
||||
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
|
||||
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
|
||||
--destination=git.spdy.io/cannabrands/hub:${CI_COMMIT_TAG} \
|
||||
--destination=git.spdy.io/cannabrands/hub:latest \
|
||||
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=APP_VERSION=${CI_COMMIT_TAG} \
|
||||
--cache=true \
|
||||
--cache-ttl=168h \
|
||||
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache \
|
||||
--insecure \
|
||||
--insecure-pull \
|
||||
--skip-tls-verify
|
||||
when:
|
||||
event: tag
|
||||
|
||||
65
CLAUDE.md
65
CLAUDE.md
@@ -65,15 +65,72 @@ ALL routes need auth + user type middleware except public pages
|
||||
**Creating PRs via Gitea API:**
|
||||
```bash
|
||||
# Requires GITEA_TOKEN environment variable
|
||||
curl -X POST "https://code.cannabrands.app/api/v1/repos/Cannabrands/hub/pulls" \
|
||||
curl -X POST "https://git.spdy.io/api/v1/repos/Cannabrands/hub/pulls" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title": "PR title", "body": "Description", "head": "feature-branch", "base": "develop"}'
|
||||
```
|
||||
|
||||
**Gitea Services:**
|
||||
- **Gitea:** `https://code.cannabrands.app`
|
||||
- **Woodpecker CI:** `https://ci.cannabrands.app`
|
||||
**Infrastructure Services:**
|
||||
|
||||
| Service | Host | Notes |
|
||||
|---------|------|-------|
|
||||
| **Gitea** | `https://git.spdy.io` | Git repository |
|
||||
| **Woodpecker CI** | `https://ci.spdy.io` | CI/CD pipelines |
|
||||
| **Docker Registry** | `10.100.9.70:5000` | Local registry (insecure) |
|
||||
|
||||
**PostgreSQL (Dev)**
|
||||
```
|
||||
Host: 10.100.6.50
|
||||
Port: 5432
|
||||
Database: cannabrands_dev
|
||||
Username: cannabrands
|
||||
Password: SpDyCannaBrands2024
|
||||
URL: postgresql://cannabrands:SpDyCannaBrands2024@10.100.6.50:5432/cannabrands_dev
|
||||
```
|
||||
|
||||
**PostgreSQL (CI)** - Ephemeral container for isolated tests
|
||||
```
|
||||
Host: postgres (service name)
|
||||
Port: 5432
|
||||
Database: testing
|
||||
Username: testing
|
||||
Password: testing
|
||||
```
|
||||
|
||||
**Redis**
|
||||
```
|
||||
Host: 10.100.9.50
|
||||
Port: 6379
|
||||
Password: SpDyR3d1s2024!
|
||||
URL: redis://:SpDyR3d1s2024!@10.100.9.50:6379
|
||||
```
|
||||
|
||||
**MinIO (S3-Compatible Storage)**
|
||||
```
|
||||
Endpoint: 10.100.9.80:9000
|
||||
Console: 10.100.9.80:9001
|
||||
Region: us-east-1
|
||||
Path Style: true
|
||||
Bucket: cannabrands
|
||||
Access Key: cannabrands-app
|
||||
Secret Key: cdbdcd0c7b6f3994d4ab09f68eaff98665df234f
|
||||
```
|
||||
|
||||
**Gitea Container Registry** (for CI image pushes)
|
||||
```
|
||||
Registry: git.spdy.io
|
||||
User: kelly@spdy.io
|
||||
Token: c89fa0eeb417343b171f11de6b8e4292b2f50e2b
|
||||
Scope: write:package
|
||||
```
|
||||
Woodpecker secrets: `registry_user`, `registry_password`
|
||||
|
||||
**CI/CD Notes:**
|
||||
- Uses **Kaniko** for Docker builds (no Docker daemon, avoids DNS issues)
|
||||
- Images pushed to `git.spdy.io/cannabrands/hub` (k8s can pull without insecure config)
|
||||
- Base images pulled from local registry `10.100.9.70:5000` (Kaniko handles insecure)
|
||||
- Deploy: `develop` → dev.cannabrands.app, `master` → cannabrands.app
|
||||
|
||||
### 8. User-Business Relationship (Pivot Table)
|
||||
Users connect to businesses via `business_user` pivot table (many-to-many).
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# ============================================
|
||||
|
||||
# ==================== Stage 1: Node Builder ====================
|
||||
FROM node:22-alpine AS node-builder
|
||||
FROM 10.100.9.70:5000/library/node:22-alpine AS node-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -35,10 +35,10 @@ RUN npm run build
|
||||
|
||||
# ==================== Stage 2: Composer Builder ====================
|
||||
# Pin to PHP 8.4 - composer:2 uses latest PHP which may not be supported by dependencies yet
|
||||
FROM php:8.4-cli-alpine AS composer-builder
|
||||
FROM 10.100.9.70:5000/library/php:8.4-cli-alpine AS composer-builder
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2.8 /usr/bin/composer /usr/bin/composer
|
||||
COPY --from=10.100.9.70:5000/library/composer:2.8 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -60,7 +60,7 @@ RUN composer install \
|
||||
--optimize-autoloader
|
||||
|
||||
# ==================== Stage 3: Production Runtime ====================
|
||||
FROM php:8.3-fpm-alpine
|
||||
FROM 10.100.9.70:5000/library/php:8.3-fpm-alpine
|
||||
|
||||
LABEL maintainer="CannaBrands Team"
|
||||
|
||||
|
||||
93
Dockerfile.fast
Normal file
93
Dockerfile.fast
Normal file
@@ -0,0 +1,93 @@
|
||||
# ============================================
|
||||
# Fast Production Dockerfile
|
||||
# Single-stage build using CI pre-built assets
|
||||
# Saves time by skipping multi-stage node/composer builders
|
||||
# ============================================
|
||||
#
|
||||
# This Dockerfile expects:
|
||||
# - vendor/ already populated (from CI composer-install step)
|
||||
# - public/build/ already populated (from CI build-frontend step)
|
||||
#
|
||||
# Build time: ~5-7 min (vs 15-20 min with multi-stage Dockerfile)
|
||||
# ============================================
|
||||
|
||||
FROM 10.100.9.70:5000/library/php:8.3-fpm-alpine
|
||||
|
||||
LABEL maintainer="CannaBrands Team"
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
nginx \
|
||||
supervisor \
|
||||
postgresql-dev \
|
||||
libpng-dev \
|
||||
libjpeg-turbo-dev \
|
||||
freetype-dev \
|
||||
libzip-dev \
|
||||
icu-dev \
|
||||
icu-data-full \
|
||||
zip \
|
||||
unzip \
|
||||
git \
|
||||
curl \
|
||||
bash
|
||||
|
||||
# Install build dependencies for PHP extensions
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
autoconf \
|
||||
g++ \
|
||||
make
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||
&& docker-php-ext-install -j$(nproc) \
|
||||
pdo_pgsql \
|
||||
pgsql \
|
||||
gd \
|
||||
zip \
|
||||
intl \
|
||||
pcntl \
|
||||
bcmath \
|
||||
opcache
|
||||
|
||||
# Install Redis extension
|
||||
RUN pecl install redis \
|
||||
&& docker-php-ext-enable redis \
|
||||
&& apk del .build-deps
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
ARG GIT_COMMIT_SHA=unknown
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
# Copy application code
|
||||
COPY --chown=www-data:www-data . .
|
||||
|
||||
# Copy pre-built frontend assets (built in CI step)
|
||||
# These are already in public/build from the build-frontend step
|
||||
|
||||
# Copy pre-installed vendor (from CI composer-install step)
|
||||
# Already included in COPY . .
|
||||
|
||||
# Create version metadata file
|
||||
RUN echo "VERSION=${APP_VERSION}" > /var/www/html/version.env && \
|
||||
echo "COMMIT=${GIT_COMMIT_SHA}" >> /var/www/html/version.env && \
|
||||
chown www-data:www-data /var/www/html/version.env
|
||||
|
||||
# Copy production configurations
|
||||
COPY docker/production/nginx/default.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/production/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
COPY docker/production/php/php.ini /usr/local/etc/php/conf.d/99-custom.ini
|
||||
|
||||
# Remove default PHP-FPM pool config and use our custom one
|
||||
RUN rm -f /usr/local/etc/php-fpm.d/www.conf /usr/local/etc/php-fpm.d/www.conf.default
|
||||
COPY docker/production/php/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf
|
||||
|
||||
# Create supervisor log directory and fix permissions
|
||||
RUN mkdir -p /var/log/supervisor \
|
||||
&& chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache \
|
||||
&& chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
41
app/Events/CrmAgentStatusChanged.php
Normal file
41
app/Events/CrmAgentStatusChanged.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\AgentStatus;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CrmAgentStatusChanged implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public AgentStatus $agentStatus
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel("crm-inbox.{$this->agentStatus->business_id}")];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'agent.status';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => $this->agentStatus->user_id,
|
||||
'user_name' => $this->agentStatus->user?->name,
|
||||
'status' => $this->agentStatus->status,
|
||||
'status_label' => AgentStatus::statuses()[$this->agentStatus->status] ?? $this->agentStatus->status,
|
||||
'status_message' => $this->agentStatus->status_message,
|
||||
'last_seen_at' => $this->agentStatus->last_seen_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
80
app/Events/CrmThreadMessageSent.php
Normal file
80
app/Events/CrmThreadMessageSent.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Crm\CrmChannelMessage;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CrmThreadMessageSent implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public CrmChannelMessage $message,
|
||||
public CrmThread $thread
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
$channels = [
|
||||
new PrivateChannel("crm-inbox.{$this->thread->business_id}"),
|
||||
new PrivateChannel("crm-thread.{$this->thread->id}"),
|
||||
];
|
||||
|
||||
// For marketplace B2B threads, also broadcast to buyer/seller businesses
|
||||
if ($this->thread->buyer_business_id) {
|
||||
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->buyer_business_id}");
|
||||
}
|
||||
if ($this->thread->seller_business_id) {
|
||||
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->seller_business_id}");
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'message.new';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'message' => [
|
||||
'id' => $this->message->id,
|
||||
'thread_id' => $this->message->thread_id,
|
||||
'body' => $this->message->body,
|
||||
'body_html' => $this->message->body_html,
|
||||
'direction' => $this->message->direction,
|
||||
'channel_type' => $this->message->channel_type,
|
||||
'sender_id' => $this->message->user_id,
|
||||
'sender_name' => $this->message->user?->name ?? ($this->message->direction === 'inbound' ? $this->thread->contact?->getFullName() : 'System'),
|
||||
'status' => $this->message->status,
|
||||
'created_at' => $this->message->created_at->toIso8601String(),
|
||||
'attachments' => $this->message->attachments->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'filename' => $a->original_filename ?? $a->filename,
|
||||
'mime_type' => $a->mime_type,
|
||||
'size' => $a->size,
|
||||
'url' => Storage::disk($a->disk ?? 'minio')->url($a->path),
|
||||
])->toArray(),
|
||||
],
|
||||
'thread' => [
|
||||
'id' => $this->thread->id,
|
||||
'subject' => $this->thread->subject,
|
||||
'status' => $this->thread->status,
|
||||
'priority' => $this->thread->priority,
|
||||
'last_message_at' => $this->thread->last_message_at?->toIso8601String(),
|
||||
'last_message_preview' => $this->message->body ? \Str::limit(strip_tags($this->message->body), 100) : null,
|
||||
'last_message_direction' => $this->message->direction,
|
||||
'last_channel_type' => $this->message->channel_type,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
73
app/Events/CrmThreadUpdated.php
Normal file
73
app/Events/CrmThreadUpdated.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CrmThreadUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public const UPDATE_ASSIGNED = 'assigned';
|
||||
|
||||
public const UPDATE_CLOSED = 'closed';
|
||||
|
||||
public const UPDATE_REOPENED = 'reopened';
|
||||
|
||||
public const UPDATE_SNOOZED = 'snoozed';
|
||||
|
||||
public const UPDATE_PRIORITY = 'priority';
|
||||
|
||||
public const UPDATE_STATUS = 'status';
|
||||
|
||||
public function __construct(
|
||||
public CrmThread $thread,
|
||||
public string $updateType
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
$channels = [
|
||||
new PrivateChannel("crm-inbox.{$this->thread->business_id}"),
|
||||
new PrivateChannel("crm-thread.{$this->thread->id}"),
|
||||
];
|
||||
|
||||
// For marketplace B2B threads, also broadcast to buyer/seller businesses
|
||||
if ($this->thread->buyer_business_id) {
|
||||
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->buyer_business_id}");
|
||||
}
|
||||
if ($this->thread->seller_business_id) {
|
||||
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->seller_business_id}");
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'thread.updated';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'thread' => [
|
||||
'id' => $this->thread->id,
|
||||
'subject' => $this->thread->subject,
|
||||
'status' => $this->thread->status,
|
||||
'priority' => $this->thread->priority,
|
||||
'assigned_to' => $this->thread->assigned_to,
|
||||
'assignee_name' => $this->thread->assignee?->name,
|
||||
'snoozed_until' => $this->thread->snoozed_until?->toIso8601String(),
|
||||
'last_message_at' => $this->thread->last_message_at?->toIso8601String(),
|
||||
],
|
||||
'update_type' => $this->updateType,
|
||||
'updated_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
41
app/Events/CrmTypingIndicator.php
Normal file
41
app/Events/CrmTypingIndicator.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CrmTypingIndicator implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $threadId,
|
||||
public int $userId,
|
||||
public string $userName,
|
||||
public bool $isTyping
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel("crm-thread.{$this->threadId}")];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'typing';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => $this->userId,
|
||||
'user_name' => $this->userName,
|
||||
'is_typing' => $this->isTyping,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
79
app/Events/NewMarketplaceMessage.php
Normal file
79
app/Events/NewMarketplaceMessage.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Crm\CrmChannelMessage;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class NewMarketplaceMessage implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public CrmChannelMessage $message,
|
||||
public CrmThread $thread
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
$channels = [];
|
||||
|
||||
if ($this->thread->buyer_business_id) {
|
||||
$channels[] = new PrivateChannel("marketplace-chat.{$this->thread->buyer_business_id}");
|
||||
}
|
||||
|
||||
if ($this->thread->seller_business_id) {
|
||||
$channels[] = new PrivateChannel("marketplace-chat.{$this->thread->seller_business_id}");
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to broadcast.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'message' => [
|
||||
'id' => $this->message->id,
|
||||
'thread_id' => $this->message->thread_id,
|
||||
'body' => $this->message->body,
|
||||
'sender_id' => $this->message->sender_id,
|
||||
'sender_name' => $this->message->sender
|
||||
? trim($this->message->sender->first_name.' '.$this->message->sender->last_name)
|
||||
: 'Unknown',
|
||||
'direction' => $this->message->direction,
|
||||
'created_at' => $this->message->created_at->toIso8601String(),
|
||||
'attachments' => $this->message->attachments,
|
||||
],
|
||||
'thread' => [
|
||||
'id' => $this->thread->id,
|
||||
'subject' => $this->thread->subject,
|
||||
'buyer_business_id' => $this->thread->buyer_business_id,
|
||||
'seller_business_id' => $this->thread->seller_business_id,
|
||||
'order_id' => $this->thread->order_id,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's broadcast name.
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'message.new';
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -210,7 +210,7 @@ class AiContentRuleResource extends Resource
|
||||
])
|
||||
->query(function ($query, array $data) {
|
||||
if (! empty($data['value'])) {
|
||||
$query->where('content_type_key', 'like', $data['value'].'.%');
|
||||
$query->where('content_type_key', 'ilike', $data['value'].'.%');
|
||||
}
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -701,17 +701,6 @@ class BusinessResource extends Resource
|
||||
}),
|
||||
]),
|
||||
|
||||
// ===== CANNAIQ SECTION =====
|
||||
// CannaiQ Marketing Intelligence Engine
|
||||
Section::make('CannaiQ')
|
||||
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
|
||||
->schema([
|
||||
Toggle::make('cannaiq_enabled')
|
||||
->label('Enable CannaiQ')
|
||||
->helperText('When enabled, this business gets access to Intelligence and Promos features under the Growth menu.')
|
||||
->default(false),
|
||||
]),
|
||||
|
||||
// ===== SUITE ASSIGNMENT SECTION =====
|
||||
// Suites control feature access (menus, screens, capabilities)
|
||||
Section::make('Suite Assignment')
|
||||
@@ -863,6 +852,40 @@ class BusinessResource extends Resource
|
||||
]),
|
||||
]),
|
||||
|
||||
// ===== INTEGRATIONS TAB =====
|
||||
// Third-party service integrations
|
||||
Tab::make('Integrations')
|
||||
->icon('heroicon-o-link')
|
||||
->schema([
|
||||
// ===== CANNAIQ SECTION =====
|
||||
Section::make('CannaiQ')
|
||||
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
|
||||
->schema([
|
||||
Toggle::make('cannaiq_enabled')
|
||||
->label('Enable CannaiQ')
|
||||
->helperText('When enabled, this business gets access to Brand Analysis, Intelligence, and Promos features.')
|
||||
->default(false),
|
||||
|
||||
Forms\Components\Placeholder::make('cannaiq_info')
|
||||
->label('')
|
||||
->content(new \Illuminate\Support\HtmlString(
|
||||
'<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 p-4 text-sm">'.
|
||||
'<div class="font-medium text-gray-700 dark:text-gray-300 mb-2">CannaiQ Features</div>'.
|
||||
'<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-1">'.
|
||||
'<li>Brand Analysis - Market positioning, SKU velocity, shelf opportunities</li>'.
|
||||
'<li>Marketing Intelligence - Competitive insights and recommendations</li>'.
|
||||
'<li>Promo Recommendations - AI-powered promotional strategies</li>'.
|
||||
'</ul>'.
|
||||
'<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">'.
|
||||
'<a href="https://cannaiq.co" target="_blank" class="text-primary-600 hover:text-primary-500 dark:text-primary-400 font-medium inline-flex items-center gap-1">'.
|
||||
'Visit CannaiQ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>'.
|
||||
'</a>'.
|
||||
'</div>'.
|
||||
'</div>'
|
||||
)),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ===== LEGACY MODULES TAB =====
|
||||
// These flags are kept for backward compatibility.
|
||||
// The recommended way to configure access is via Suites above.
|
||||
@@ -1766,8 +1789,8 @@ class BusinessResource extends Resource
|
||||
})
|
||||
->description(fn ($record) => $record->parent ? 'Managed by '.$record->parent->name : null)
|
||||
->searchable(query: function ($query, $search) {
|
||||
return $query->where('name', 'like', "%{$search}%")
|
||||
->orWhere('dba_name', 'like', "%{$search}%");
|
||||
return $query->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('dba_name', 'ilike', "%{$search}%");
|
||||
})
|
||||
->sortable(query: fn ($query, $direction) => $query->orderBy('parent_id')->orderBy('name', $direction)),
|
||||
TextColumn::make('types.label')
|
||||
@@ -1887,9 +1910,9 @@ class BusinessResource extends Resource
|
||||
return $query->whereHas('users', function ($q) use ($search) {
|
||||
$q->wherePivot('is_primary', true)
|
||||
->where(function ($q2) use ($search) {
|
||||
$q2->where('first_name', 'like', "%{$search}%")
|
||||
->orWhere('last_name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
$q2->where('first_name', 'ilike', "%{$search}%")
|
||||
->orWhere('last_name', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%");
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -1920,9 +1943,9 @@ class BusinessResource extends Resource
|
||||
})
|
||||
->searchable(query: function ($query, $search) {
|
||||
return $query->whereHas('users', function ($q) use ($search) {
|
||||
$q->where('first_name', 'like', "%{$search}%")
|
||||
->orWhere('last_name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
$q->where('first_name', 'ilike', "%{$search}%")
|
||||
->orWhere('last_name', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%");
|
||||
});
|
||||
}),
|
||||
TextColumn::make('users_count')
|
||||
|
||||
@@ -116,8 +116,8 @@ class DatabaseBackupResource extends Resource
|
||||
})
|
||||
->searchable(query: function ($query, $search) {
|
||||
return $query->whereHas('creator', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
$q->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%");
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class ProductsTable
|
||||
ImageColumn::make('image_path')
|
||||
->label('Image')
|
||||
->circular()
|
||||
->defaultImageUrl(url('/images/placeholder-product.png'))
|
||||
->defaultImageUrl(\Storage::disk('minio')->url('defaults/placeholder-product.svg'))
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('name')
|
||||
|
||||
@@ -215,7 +215,7 @@ class UserResource extends Resource
|
||||
})
|
||||
->searchable(query: function ($query, $search) {
|
||||
return $query->whereHas('businesses', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%");
|
||||
$q->where('name', 'ilike', "%{$search}%");
|
||||
});
|
||||
}),
|
||||
TextColumn::make('status')
|
||||
|
||||
@@ -26,8 +26,8 @@ class ApVendorController extends Controller
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%");
|
||||
$q->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('code', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ class ApVendorController extends Controller
|
||||
$prefix = substr($prefix, 0, 6);
|
||||
|
||||
$count = ApVendor::where('business_id', $businessId)
|
||||
->where('code', 'like', "{$prefix}%")
|
||||
->where('code', 'ilike', "{$prefix}%")
|
||||
->count();
|
||||
|
||||
return $count > 0 ? "{$prefix}-{$count}" : $prefix;
|
||||
|
||||
103
app/Http/Controllers/Api/AgentStatusController.php
Normal file
103
app/Http/Controllers/Api/AgentStatusController.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Events\CrmAgentStatusChanged;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AgentStatus;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AgentStatusController extends Controller
|
||||
{
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'business_id' => 'required|integer|exists:businesses,id',
|
||||
'status' => ['required', Rule::in(array_keys(AgentStatus::statuses()))],
|
||||
'status_message' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Verify user belongs to the business
|
||||
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$agentStatus = AgentStatus::getOrCreate($user->id, $validated['business_id']);
|
||||
$oldStatus = $agentStatus->status;
|
||||
$agentStatus->setStatus($validated['status'], $validated['status_message'] ?? null);
|
||||
|
||||
// Broadcast status change if it changed
|
||||
if ($oldStatus !== $validated['status']) {
|
||||
broadcast(new CrmAgentStatusChanged($agentStatus->fresh()))->toOthers();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'status' => $agentStatus->status,
|
||||
'status_label' => AgentStatus::statuses()[$agentStatus->status],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat to maintain online status
|
||||
*/
|
||||
public function heartbeat(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'business_id' => 'required|integer|exists:businesses,id',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Verify user belongs to the business
|
||||
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$agentStatus = AgentStatus::where('user_id', $user->id)
|
||||
->where('business_id', $validated['business_id'])
|
||||
->first();
|
||||
|
||||
if ($agentStatus) {
|
||||
$agentStatus->updateLastSeen();
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team members' statuses for a business
|
||||
*/
|
||||
public function team(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'business_id' => 'required|integer|exists:businesses,id',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Verify user belongs to the business
|
||||
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$statuses = AgentStatus::where('business_id', $validated['business_id'])
|
||||
->where('status', '!=', AgentStatus::STATUS_OFFLINE)
|
||||
->where('last_seen_at', '>=', now()->subMinutes(5))
|
||||
->with('user:id,name')
|
||||
->get()
|
||||
->map(fn ($s) => [
|
||||
'user_id' => $s->user_id,
|
||||
'user_name' => $s->user?->name,
|
||||
'status' => $s->status,
|
||||
'status_message' => $s->status_message,
|
||||
'last_seen_at' => $s->last_seen_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
return response()->json(['team' => $statuses]);
|
||||
}
|
||||
}
|
||||
247
app/Http/Controllers/Api/MarketplaceChatController.php
Normal file
247
app/Http/Controllers/Api/MarketplaceChatController.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use App\Services\MarketplaceChatService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MarketplaceChatController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected MarketplaceChatService $chatService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List threads for the current business
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$businessId = $request->input('business_id');
|
||||
|
||||
if (! $businessId) {
|
||||
return response()->json(['error' => 'business_id is required'], 400);
|
||||
}
|
||||
|
||||
$business = Business::find($businessId);
|
||||
|
||||
if (! $business || ! $user->businesses->contains('id', $businessId)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$threads = $this->chatService->getThreadsForUser($user, $business);
|
||||
|
||||
return response()->json([
|
||||
'threads' => $threads->map(fn ($thread) => $this->formatThread($thread, $business)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single thread with messages
|
||||
*/
|
||||
public function show(Request $request, CrmThread $thread): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $this->chatService->canAccessThread($thread, $user)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$beforeId = $request->input('before_id');
|
||||
$limit = min($request->input('limit', 50), 100);
|
||||
|
||||
$messages = $this->chatService->getMessages($thread, $limit, $beforeId);
|
||||
|
||||
// Mark as read
|
||||
$this->chatService->markAsRead($thread, $user);
|
||||
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
return response()->json([
|
||||
'thread' => $this->formatThread($thread, $business),
|
||||
'messages' => $messages->map(fn ($msg) => $this->formatMessage($msg)),
|
||||
'has_more' => $messages->count() === $limit,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new thread or get existing one
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'buyer_business_id' => 'required|integer|exists:businesses,id',
|
||||
'seller_business_id' => 'required|integer|exists:businesses,id',
|
||||
'order_id' => 'nullable|integer|exists:orders,id',
|
||||
'initial_message' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||
|
||||
// Verify user belongs to one of the businesses
|
||||
if (! in_array($validated['buyer_business_id'], $userBusinessIds)
|
||||
&& ! in_array($validated['seller_business_id'], $userBusinessIds)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$buyerBusiness = Business::findOrFail($validated['buyer_business_id']);
|
||||
$sellerBusiness = Business::findOrFail($validated['seller_business_id']);
|
||||
$order = isset($validated['order_id'])
|
||||
? \App\Models\Order::find($validated['order_id'])
|
||||
: null;
|
||||
|
||||
$thread = $this->chatService->getOrCreateThread($buyerBusiness, $sellerBusiness, $order);
|
||||
|
||||
// Send initial message if provided
|
||||
if (! empty($validated['initial_message'])) {
|
||||
$this->chatService->sendMessage($thread, $user, $validated['initial_message']);
|
||||
}
|
||||
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
return response()->json([
|
||||
'thread' => $this->formatThread($thread->fresh(), $business),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message in a thread
|
||||
*/
|
||||
public function sendMessage(Request $request, CrmThread $thread): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $this->chatService->canAccessThread($thread, $user)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'body' => 'required|string|max:5000',
|
||||
'attachments' => 'nullable|array',
|
||||
'attachments.*.url' => 'required_with:attachments|string',
|
||||
'attachments.*.name' => 'required_with:attachments|string',
|
||||
'attachments.*.type' => 'nullable|string',
|
||||
'attachments.*.size' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$message = $this->chatService->sendMessage(
|
||||
$thread,
|
||||
$user,
|
||||
$validated['body'],
|
||||
$validated['attachments'] ?? []
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => $this->formatMessage($message),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark thread as read
|
||||
*/
|
||||
public function markAsRead(Request $request, CrmThread $thread): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $this->chatService->canAccessThread($thread, $user)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$this->chatService->markAsRead($thread, $user);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count for user
|
||||
*/
|
||||
public function unreadCount(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$businessId = $request->input('business_id');
|
||||
|
||||
if (! $businessId) {
|
||||
return response()->json(['error' => 'business_id is required'], 400);
|
||||
}
|
||||
|
||||
$business = Business::find($businessId);
|
||||
|
||||
if (! $business || ! $user->businesses->contains('id', $businessId)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$count = $this->chatService->getUnreadCount($user, $business);
|
||||
|
||||
return response()->json(['unread_count' => $count]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format thread for JSON response
|
||||
*/
|
||||
protected function formatThread(CrmThread $thread, ?Business $currentBusiness): array
|
||||
{
|
||||
$otherBusiness = $currentBusiness
|
||||
? $this->chatService->getOtherBusiness($thread, $currentBusiness)
|
||||
: null;
|
||||
|
||||
$lastMessage = $thread->messages->first();
|
||||
|
||||
return [
|
||||
'id' => $thread->id,
|
||||
'subject' => $thread->subject,
|
||||
'status' => $thread->status,
|
||||
'buyer_business' => $thread->buyerBusiness ? [
|
||||
'id' => $thread->buyerBusiness->id,
|
||||
'name' => $thread->buyerBusiness->name,
|
||||
'slug' => $thread->buyerBusiness->slug,
|
||||
] : null,
|
||||
'seller_business' => $thread->sellerBusiness ? [
|
||||
'id' => $thread->sellerBusiness->id,
|
||||
'name' => $thread->sellerBusiness->name,
|
||||
'slug' => $thread->sellerBusiness->slug,
|
||||
] : null,
|
||||
'other_business' => $otherBusiness ? [
|
||||
'id' => $otherBusiness->id,
|
||||
'name' => $otherBusiness->name,
|
||||
'slug' => $otherBusiness->slug,
|
||||
] : null,
|
||||
'order' => $thread->order ? [
|
||||
'id' => $thread->order->id,
|
||||
'order_number' => $thread->order->order_number,
|
||||
] : null,
|
||||
'last_message' => $lastMessage ? [
|
||||
'body' => \Str::limit($lastMessage->body, 100),
|
||||
'sender_name' => $lastMessage->sender
|
||||
? trim($lastMessage->sender->first_name.' '.$lastMessage->sender->last_name)
|
||||
: 'Unknown',
|
||||
'created_at' => $lastMessage->created_at->toIso8601String(),
|
||||
] : null,
|
||||
'last_message_at' => $thread->last_message_at?->toIso8601String(),
|
||||
'created_at' => $thread->created_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format message for JSON response
|
||||
*/
|
||||
protected function formatMessage(mixed $message): array
|
||||
{
|
||||
return [
|
||||
'id' => $message->id,
|
||||
'thread_id' => $message->thread_id,
|
||||
'body' => $message->body,
|
||||
'sender_id' => $message->sender_id,
|
||||
'sender_name' => $message->sender
|
||||
? trim($message->sender->first_name.' '.$message->sender->last_name)
|
||||
: 'Unknown',
|
||||
'direction' => $message->direction,
|
||||
'attachments' => $message->attachments,
|
||||
'created_at' => $message->created_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Api/PushSubscriptionController.php
Normal file
58
app/Http/Controllers/Api/PushSubscriptionController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use NotificationChannels\WebPush\PushSubscription;
|
||||
|
||||
class PushSubscriptionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store a new push subscription
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'endpoint' => 'required|url',
|
||||
'keys.p256dh' => 'required|string',
|
||||
'keys.auth' => 'required|string',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Delete existing subscription for this endpoint
|
||||
PushSubscription::where('endpoint', $validated['endpoint'])->delete();
|
||||
|
||||
// Create new subscription
|
||||
$subscription = $user->updatePushSubscription(
|
||||
$validated['endpoint'],
|
||||
$validated['keys']['p256dh'],
|
||||
$validated['keys']['auth']
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Push subscription saved',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a push subscription
|
||||
*/
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'endpoint' => 'required|url',
|
||||
]);
|
||||
|
||||
PushSubscription::where('endpoint', $validated['endpoint'])
|
||||
->where('subscribable_id', $request->user()->id)
|
||||
->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Push subscription removed',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Models\Crm\CrmMeetingBooking;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use App\Services\Crm\CrmSlaService;
|
||||
use App\Services\Dashboard\CommandCenterService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
@@ -19,6 +20,10 @@ class DashboardController extends Controller
|
||||
*/
|
||||
private const DASHBOARD_CACHE_TTL = 300;
|
||||
|
||||
public function __construct(
|
||||
protected CommandCenterService $commandCenterService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Main dashboard redirect - automatically routes to business context
|
||||
* Redirects to /s/{business}/dashboard based on user's primary business
|
||||
@@ -40,104 +45,25 @@ class DashboardController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Overview - Main overview page
|
||||
* Dashboard Overview - Revenue Command Center
|
||||
*
|
||||
* NOTE: All metrics are pre-calculated by CalculateDashboardMetrics job
|
||||
* and stored in Redis. This method only reads from Redis for instant response.
|
||||
* Single source of truth for all seller dashboard metrics.
|
||||
* Uses CommandCenterService which provides:
|
||||
* - DB/service as source of truth
|
||||
* - Redis as cache layer
|
||||
* - Explicit scoping (business|brand|user) per metric
|
||||
*/
|
||||
public function overview(Request $request, Business $business)
|
||||
{
|
||||
// Read pre-calculated metrics from Redis
|
||||
$redisKey = "dashboard:{$business->id}:overview";
|
||||
$cachedMetrics = \Illuminate\Support\Facades\Redis::get($redisKey);
|
||||
$user = $request->user();
|
||||
|
||||
if ($cachedMetrics) {
|
||||
$data = json_decode($cachedMetrics, true);
|
||||
// Get all Command Center data via the single service
|
||||
$commandCenterData = $this->commandCenterService->getData($business, $user);
|
||||
|
||||
// Map cached data to view variables
|
||||
$revenueLast30 = $data['kpis']['revenue_last_30'] ?? 0;
|
||||
$ordersLast30 = $data['kpis']['orders_last_30'] ?? 0;
|
||||
$unitsSoldLast30 = $data['kpis']['units_sold_last_30'] ?? 0;
|
||||
$averageOrderValueLast30 = $data['kpis']['average_order_value_last_30'] ?? 0;
|
||||
$revenueGrowth = $data['kpis']['revenue_growth'] ?? 0;
|
||||
$ordersGrowth = $data['kpis']['orders_growth'] ?? 0;
|
||||
$unitsGrowth = $data['kpis']['units_growth'] ?? 0;
|
||||
$aovGrowth = $data['kpis']['aov_growth'] ?? 0;
|
||||
$activeBrandCount = $data['kpis']['active_brand_count'] ?? 0;
|
||||
$activeBuyerCount = $data['kpis']['active_buyer_count'] ?? 0;
|
||||
$activeInventoryAlertsCount = $data['kpis']['active_inventory_alerts_count'] ?? 0;
|
||||
$activePromotionCount = $data['kpis']['active_promotion_count'] ?? 0;
|
||||
|
||||
// Convert arrays to objects and parse timestamps back to Carbon
|
||||
$topProducts = collect($data['top_products'] ?? [])->map(fn ($item) => (object) $item);
|
||||
$topBrands = collect($data['top_brands'] ?? [])->map(fn ($item) => (object) $item);
|
||||
$needsAttention = collect($data['needs_attention'] ?? [])->map(function ($item) {
|
||||
if (isset($item['timestamp']) && is_string($item['timestamp'])) {
|
||||
$item['timestamp'] = \Carbon\Carbon::parse($item['timestamp']);
|
||||
}
|
||||
|
||||
return $item; // Keep as array since view uses array syntax
|
||||
});
|
||||
$recentActivity = collect($data['recent_activity'] ?? [])->map(function ($item) {
|
||||
if (isset($item['timestamp']) && is_string($item['timestamp'])) {
|
||||
$item['timestamp'] = \Carbon\Carbon::parse($item['timestamp']);
|
||||
}
|
||||
|
||||
return $item; // Keep as array since view uses array syntax
|
||||
});
|
||||
} else {
|
||||
// No cached data - dispatch job and return empty state
|
||||
\App\Jobs\CalculateDashboardMetrics::dispatch($business->id);
|
||||
|
||||
$revenueLast30 = 0;
|
||||
$ordersLast30 = 0;
|
||||
$unitsSoldLast30 = 0;
|
||||
$averageOrderValueLast30 = 0;
|
||||
$revenueGrowth = 0;
|
||||
$ordersGrowth = 0;
|
||||
$unitsGrowth = 0;
|
||||
$aovGrowth = 0;
|
||||
$activeBrandCount = 0;
|
||||
$activeBuyerCount = 0;
|
||||
$activeInventoryAlertsCount = 0;
|
||||
$activePromotionCount = 0;
|
||||
$topProducts = collect([]);
|
||||
$topBrands = collect([]);
|
||||
$needsAttention = collect([]);
|
||||
$recentActivity = collect([]);
|
||||
}
|
||||
|
||||
// Orchestrator Widget Data (if enabled)
|
||||
$orchestratorWidget = (new \App\Http\Controllers\Seller\OrchestratorController)->getWidgetData($business);
|
||||
|
||||
// Hub Tiles Data (CRM, Tasks, Calendar, etc.)
|
||||
$hubTiles = $this->getHubTilesData($business, $request->user());
|
||||
|
||||
// Sales Inbox - unified view of items needing attention
|
||||
$salesInbox = $this->getSalesInboxData($business, $request->user());
|
||||
|
||||
return view('seller.dashboard.overview', compact(
|
||||
'business',
|
||||
'revenueLast30',
|
||||
'ordersLast30',
|
||||
'unitsSoldLast30',
|
||||
'averageOrderValueLast30',
|
||||
'revenueGrowth',
|
||||
'ordersGrowth',
|
||||
'unitsGrowth',
|
||||
'aovGrowth',
|
||||
'activeBrandCount',
|
||||
'activeBuyerCount',
|
||||
'activeInventoryAlertsCount',
|
||||
'activePromotionCount',
|
||||
'topProducts',
|
||||
'topBrands',
|
||||
'needsAttention',
|
||||
'recentActivity',
|
||||
'orchestratorWidget',
|
||||
'hubTiles',
|
||||
'salesInbox'
|
||||
));
|
||||
return view('seller.dashboard.overview', [
|
||||
'business' => $business,
|
||||
'commandCenter' => $commandCenterData,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1248,7 +1174,7 @@ class DashboardController extends Controller
|
||||
$overdueTasks = CrmTask::where('seller_business_id', $business->id)
|
||||
->whereNull('completed_at')
|
||||
->where('due_at', '<', now())
|
||||
->with(['contact:id,first_name,last_name,company_name'])
|
||||
->with(['contact:id,first_name,last_name'])
|
||||
->orderBy('due_at', 'asc')
|
||||
->limit(10)
|
||||
->get();
|
||||
@@ -1256,7 +1182,7 @@ class DashboardController extends Controller
|
||||
foreach ($overdueTasks as $task) {
|
||||
$daysOverdue = now()->diffInDays($task->due_at, false);
|
||||
$contactName = $task->contact
|
||||
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: $task->contact->company_name
|
||||
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: 'Contact'
|
||||
: 'Unknown';
|
||||
$overdue[] = [
|
||||
'type' => 'task',
|
||||
@@ -1295,7 +1221,7 @@ class DashboardController extends Controller
|
||||
->whereNull('completed_at')
|
||||
->where('due_at', '>=', now())
|
||||
->where('due_at', '<=', now()->addDays(7))
|
||||
->with(['contact:id,first_name,last_name,company_name'])
|
||||
->with(['contact:id,first_name,last_name'])
|
||||
->orderBy('due_at', 'asc')
|
||||
->limit(10)
|
||||
->get();
|
||||
@@ -1303,7 +1229,7 @@ class DashboardController extends Controller
|
||||
foreach ($upcomingTasks as $task) {
|
||||
$daysUntilDue = now()->diffInDays($task->due_at, false);
|
||||
$contactName = $task->contact
|
||||
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: $task->contact->company_name
|
||||
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: 'Contact'
|
||||
: 'Unknown';
|
||||
$upcoming[] = [
|
||||
'type' => 'task',
|
||||
@@ -1318,7 +1244,7 @@ class DashboardController extends Controller
|
||||
->where('status', 'scheduled')
|
||||
->where('start_at', '>=', now())
|
||||
->where('start_at', '<=', now()->addDays(7))
|
||||
->with(['contact:id,first_name,last_name,company_name'])
|
||||
->with(['contact:id,first_name,last_name'])
|
||||
->orderBy('start_at', 'asc')
|
||||
->limit(5)
|
||||
->get();
|
||||
@@ -1326,7 +1252,7 @@ class DashboardController extends Controller
|
||||
foreach ($upcomingMeetings as $meeting) {
|
||||
$daysUntil = now()->diffInDays($meeting->start_at, false);
|
||||
$contactName = $meeting->contact
|
||||
? trim($meeting->contact->first_name.' '.$meeting->contact->last_name) ?: $meeting->contact->company_name
|
||||
? trim($meeting->contact->first_name.' '.$meeting->contact->last_name) ?: 'Contact'
|
||||
: 'Unknown';
|
||||
$upcoming[] = [
|
||||
'type' => 'meeting',
|
||||
|
||||
@@ -22,9 +22,9 @@ class MarketplaceController extends Controller
|
||||
// Search filter (name, SKU, description)
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('sku', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
$q->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('sku', 'ilike', "%{$search}%")
|
||||
->orWhere('description', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,18 @@ class OrderController extends Controller
|
||||
|
||||
$orders = $query->paginate(20)->withQueryString();
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $orders->map(fn ($o) => [
|
||||
'order_number' => $o->order_number,
|
||||
'name' => $o->order_number.' - '.$o->business->name,
|
||||
'customer' => $o->business->name,
|
||||
'status' => $o->status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.orders.index', compact('orders', 'business'));
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,9 @@ class DivisionAccountingController extends Controller
|
||||
// Search filter
|
||||
if ($search = $request->get('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
$q->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('code', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ class BatchController extends Controller
|
||||
->where('quantity_available', '>', 0)
|
||||
->where('is_active', true)
|
||||
->where('is_quarantined', false)
|
||||
->with('component')
|
||||
->with('product')
|
||||
->orderBy('batch_number')
|
||||
->get()
|
||||
->map(function ($batch) {
|
||||
|
||||
@@ -60,8 +60,11 @@ class BrandController extends Controller
|
||||
'website_url' => $brand->website_url,
|
||||
'preview_url' => route('seller.business.brands.preview', [$business->slug, $brand]),
|
||||
'dashboard_url' => route('seller.business.brands.dashboard', [$business->slug, $brand]),
|
||||
'profile_url' => route('seller.business.brands.profile', [$business->slug, $brand]),
|
||||
'stats_url' => route('seller.business.brands.stats', [$business->slug, $brand]),
|
||||
'edit_url' => route('seller.business.brands.edit', [$business->slug, $brand]),
|
||||
'stores_url' => route('seller.business.brands.stores.index', [$business->slug, $brand]),
|
||||
'orders_url' => route('seller.business.brands.orders', [$business->slug, $brand]),
|
||||
'isNewBrand' => $brand->created_at && $brand->created_at->diffInDays(now()) <= 30,
|
||||
];
|
||||
})->values();
|
||||
@@ -333,11 +336,14 @@ class BrandController extends Controller
|
||||
{
|
||||
$perPage = $request->get('per_page', 50);
|
||||
$productsPaginator = $brand->products()
|
||||
->whereNotNull('hashid')
|
||||
->where('hashid', '!=', '')
|
||||
->with('images')
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($perPage);
|
||||
|
||||
$products = $productsPaginator->getCollection()
|
||||
->filter(fn ($product) => ! empty($product->hashid))
|
||||
->map(function ($product) use ($business, $brand) {
|
||||
$product->setRelation('brand', $brand);
|
||||
|
||||
@@ -354,7 +360,8 @@ class BrandController extends Controller
|
||||
'edit_url' => route('seller.business.products.edit', [$business->slug, $product->hashid]),
|
||||
'preview_url' => route('seller.business.products.preview', [$business->slug, $product->hashid]),
|
||||
];
|
||||
});
|
||||
})
|
||||
->values();
|
||||
|
||||
return [
|
||||
'products' => $products,
|
||||
@@ -763,6 +770,11 @@ class BrandController extends Controller
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$salesStats = $this->calculateBrandStats($brand, $ninetyDaysAgo, now());
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// STORE INTELLIGENCE (90 days)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$storeStats = $this->calculateStoreStats($brand, 90);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PRODUCT VELOCITY DATA
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -876,6 +888,7 @@ class BrandController extends Controller
|
||||
'isBrandManager' => $isBrandManager,
|
||||
// Core stats
|
||||
'salesStats' => $salesStats,
|
||||
'storeStats' => $storeStats,
|
||||
'productCategories' => $productCategories,
|
||||
'productVelocity' => $productVelocity,
|
||||
// Product states
|
||||
@@ -1948,4 +1961,182 @@ class BrandController extends Controller
|
||||
'visibilityIssues' => $visibilityIssues,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Display brand market analysis / intelligence page.
|
||||
*
|
||||
* v4 endpoint with optional store_id filtering for per-store projections.
|
||||
*/
|
||||
public function analysis(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// CannaiQ must be enabled to access Brand Analysis
|
||||
if (! $business->cannaiq_enabled) {
|
||||
return view('seller.brands.analysis-disabled', [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
]);
|
||||
}
|
||||
|
||||
// v4: Get optional store_id filter for shelf value projections
|
||||
$storeId = $request->query('store_id');
|
||||
|
||||
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
|
||||
$analysis = $analysisService->getAnalysis($brand, $business, $storeId);
|
||||
|
||||
// Load all brands for the brand selector
|
||||
$brands = $business->brands()
|
||||
->where('is_active', true)
|
||||
->withCount('products')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Build store list from placement data for store selector
|
||||
$storeList = [];
|
||||
if ((bool) $business->cannaiq_enabled) {
|
||||
$placementStores = $analysis->placement['stores'] ?? $analysis->placement ?? [];
|
||||
$whitespaceStores = $analysis->placement['whitespaceStores'] ?? [];
|
||||
|
||||
foreach ($placementStores as $store) {
|
||||
$storeList[] = [
|
||||
'id' => $store['storeId'] ?? '',
|
||||
'name' => $store['storeName'] ?? 'Unknown',
|
||||
'state' => $store['state'] ?? null,
|
||||
];
|
||||
}
|
||||
foreach ($whitespaceStores as $store) {
|
||||
$storeList[] = [
|
||||
'id' => $store['storeId'] ?? '',
|
||||
'name' => $store['storeName'] ?? 'Unknown',
|
||||
'state' => $store['state'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return view('seller.brands.analysis', [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
'brands' => $brands,
|
||||
'analysis' => $analysis,
|
||||
'cannaiqEnabled' => (bool) $business->cannaiq_enabled,
|
||||
'storeList' => $storeList,
|
||||
'selectedStoreId' => $storeId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh brand analysis data (clears cache and re-fetches).
|
||||
*/
|
||||
public function analysisRefresh(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// CannaiQ must be enabled to refresh analysis
|
||||
if (! $business->cannaiq_enabled) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'CannaiQ is not enabled for this business. Please contact support.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return back()->with('error', 'CannaiQ is not enabled for this business.');
|
||||
}
|
||||
|
||||
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
|
||||
$analysis = $analysisService->refreshAnalysis($brand, $business);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Analysis data refreshed',
|
||||
'data' => $analysis->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.analysis', [$business->slug, $brand->hashid])
|
||||
->with('success', 'Analysis data refreshed successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store-level playbook for a specific store.
|
||||
*
|
||||
* Returns targeted recommendations for a single retail account.
|
||||
*/
|
||||
public function storePlaybook(Request $request, Business $business, Brand $brand, string $storeId)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
if (! $business->cannaiq_enabled) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'CannaiQ is not enabled for this business',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return back()->with('error', 'CannaiQ is not enabled for this business');
|
||||
}
|
||||
|
||||
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
|
||||
$playbook = $analysisService->getStorePlaybook($brand, $business, $storeId);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $playbook,
|
||||
]);
|
||||
}
|
||||
|
||||
// For non-JSON requests, redirect to analysis page with store selected
|
||||
return redirect()
|
||||
->route('seller.business.brands.analysis', [
|
||||
$business->slug,
|
||||
$brand->hashid,
|
||||
'store_id' => $storeId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate store/distribution metrics for the brand.
|
||||
*
|
||||
* Returns metrics about store penetration, SKU stock rate, and average SKUs per store.
|
||||
*/
|
||||
private function calculateStoreStats(Brand $brand, int $days = 90): array
|
||||
{
|
||||
// Count unique buyer businesses (stores) that ordered this brand in current period
|
||||
$currentStores = \App\Models\Order::whereHas('items.product', fn ($q) => $q->where('brand_id', $brand->id))
|
||||
->where('created_at', '>=', now()->subDays($days))
|
||||
->distinct('business_id')
|
||||
->count('business_id');
|
||||
|
||||
// Previous period for comparison
|
||||
$previousStores = \App\Models\Order::whereHas('items.product', fn ($q) => $q->where('brand_id', $brand->id))
|
||||
->whereBetween('created_at', [now()->subDays($days * 2), now()->subDays($days)])
|
||||
->distinct('business_id')
|
||||
->count('business_id');
|
||||
|
||||
// SKU stock rate: % of brand's active SKUs that have been ordered
|
||||
$activeSkus = $brand->products()->where('is_active', true)->count();
|
||||
$orderedSkus = \App\Models\OrderItem::whereHas('product', fn ($q) => $q->where('brand_id', $brand->id))
|
||||
->whereHas('order', fn ($q) => $q->where('created_at', '>=', now()->subDays($days)))
|
||||
->distinct('product_id')
|
||||
->count('product_id');
|
||||
|
||||
$stockRate = $activeSkus > 0 ? round(($orderedSkus / $activeSkus) * 100, 1) : 0;
|
||||
|
||||
// Avg SKUs per store
|
||||
$avgSkusPerStore = $currentStores > 0 ? round($orderedSkus / $currentStores, 1) : 0;
|
||||
|
||||
return [
|
||||
'currentStores' => $currentStores,
|
||||
'storeChange' => $currentStores - $previousStores,
|
||||
'stockRate' => $stockRate,
|
||||
'avgSkusPerStore' => $avgSkusPerStore,
|
||||
'orderedSkus' => $orderedSkus,
|
||||
'activeSkus' => $activeSkus,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@ class BrandManagerSettingsController extends Controller
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('first_name', 'like', "%{$search}%")
|
||||
->orWhere('last_name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
$q->where('first_name', 'ilike', "%{$search}%")
|
||||
->orWhere('last_name', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
556
app/Http/Controllers/Seller/BrandStoresController.php
Normal file
556
app/Http/Controllers/Seller/BrandStoresController.php
Normal file
@@ -0,0 +1,556 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Services\Cannaiq\CannaiqClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BrandStoresController extends Controller
|
||||
{
|
||||
protected CannaiqClient $cannaiq;
|
||||
|
||||
public function __construct(CannaiqClient $cannaiq)
|
||||
{
|
||||
$this->cannaiq = $cannaiq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page 1: Stores Dashboard - List of stores (buyer businesses) for a brand
|
||||
*/
|
||||
public function index(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// Cache dashboard data for 15 minutes
|
||||
$cacheKey = "brand:{$brand->id}:stores:dashboard";
|
||||
$dashboardData = Cache::remember($cacheKey, 900, fn () => $this->calculateStoresDashboardData($brand, $business));
|
||||
|
||||
// Fetch and merge CannaiQ data (cached separately for 10 minutes)
|
||||
$cannaiqCacheKey = "brand:{$brand->id}:cannaiq:stores";
|
||||
$cannaiqData = Cache::remember($cannaiqCacheKey, 600, fn () => $this->fetchCannaiqStoreMetrics($brand));
|
||||
|
||||
// Merge CannaiQ data into store rows
|
||||
$stores = $this->mergeCannaiqData($dashboardData['stores'], $cannaiqData);
|
||||
|
||||
return view('seller.brands.stores.index', [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
'stores' => $stores,
|
||||
'kpis' => $dashboardData['kpis'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Page 2: Order Management - SKU-level view for one store
|
||||
*/
|
||||
public function show(Request $request, Business $business, Brand $brand, Business $retailStore)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// Get all stores for the dropdown switcher
|
||||
$dashboardCacheKey = "brand:{$brand->id}:stores:dashboard";
|
||||
$dashboardData = Cache::remember($dashboardCacheKey, 900, fn () => $this->calculateStoresDashboardData($brand, $business));
|
||||
|
||||
// Cache store detail data for 10 minutes
|
||||
$cacheKey = "brand:{$brand->id}:store:{$retailStore->id}:detail";
|
||||
$storeData = Cache::remember($cacheKey, 600, fn () => $this->calculateStoreDetailData($brand, $business, $retailStore));
|
||||
|
||||
return view('seller.brands.stores.show', [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
'store' => $retailStore,
|
||||
'stores' => $dashboardData['stores'],
|
||||
'products' => $storeData['products'],
|
||||
'kpis' => $storeData['kpis'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Page 3: Order Management - Store-level summary with enhanced columns
|
||||
* Shows all stores for a brand with CannaiQ metrics when available
|
||||
*/
|
||||
public function orders(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// Cache dashboard data for 15 minutes
|
||||
$cacheKey = "brand:{$brand->id}:orders:dashboard";
|
||||
$dashboardData = Cache::remember($cacheKey, 900, fn () => $this->calculateOrdersDashboardData($brand, $business));
|
||||
|
||||
// Fetch and merge CannaiQ data (cached separately for 10 minutes)
|
||||
$cannaiqCacheKey = "brand:{$brand->id}:cannaiq:stores";
|
||||
$cannaiqData = Cache::remember($cannaiqCacheKey, 600, fn () => $this->fetchCannaiqStoreMetrics($brand));
|
||||
|
||||
// Merge CannaiQ data into store rows
|
||||
$stores = $this->mergeCannaiqData($dashboardData['stores'], $cannaiqData);
|
||||
|
||||
// Get all brands for the brand switcher dropdown
|
||||
$brands = Brand::where(function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
if ($business->parent_id) {
|
||||
$query->orWhere('business_id', $business->parent_id);
|
||||
}
|
||||
})
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn ($b) => [
|
||||
'hashid' => $b->hashid,
|
||||
'name' => $b->name,
|
||||
'orders_url' => route('seller.business.brands.orders', [$business->slug, $b->hashid]),
|
||||
]);
|
||||
|
||||
return view('seller.brands.stores.orders', [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
'brands' => $brands,
|
||||
'stores' => $stores,
|
||||
'kpis' => $dashboardData['kpis'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate order management dashboard data (store-level with enhanced metrics)
|
||||
*/
|
||||
private function calculateOrdersDashboardData(Brand $brand, Business $business): array
|
||||
{
|
||||
$fourWeeksAgo = now()->subWeeks(4);
|
||||
|
||||
// Get all product IDs for this brand
|
||||
$brandProductIds = $brand->products()->pluck('id');
|
||||
|
||||
if ($brandProductIds->isEmpty()) {
|
||||
return [
|
||||
'stores' => collect(),
|
||||
'kpis' => [
|
||||
'total_sales_4wk' => 0,
|
||||
'total_oos' => 0,
|
||||
'potential_sales' => 0,
|
||||
'store_count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Single aggregation query for store-level sales (4 weeks)
|
||||
$storesSales = OrderItem::whereIn('product_id', $brandProductIds)
|
||||
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->where('orders.created_at', '>=', $fourWeeksAgo)
|
||||
->whereNotIn('orders.status', ['cancelled', 'rejected'])
|
||||
->select([
|
||||
'orders.business_id as store_id',
|
||||
DB::raw('SUM(order_items.line_total) as total_sales'),
|
||||
DB::raw('SUM(order_items.quantity) as total_units'),
|
||||
DB::raw('COUNT(DISTINCT orders.id) as order_count'),
|
||||
DB::raw('COUNT(DISTINCT order_items.product_id) as active_skus'),
|
||||
])
|
||||
->groupBy('orders.business_id')
|
||||
->get()
|
||||
->keyBy('store_id');
|
||||
|
||||
if ($storesSales->isEmpty()) {
|
||||
return [
|
||||
'stores' => collect(),
|
||||
'kpis' => [
|
||||
'total_sales_4wk' => 0,
|
||||
'total_oos' => 0,
|
||||
'potential_sales' => 0,
|
||||
'store_count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Load store businesses
|
||||
$storeIds = $storesSales->keys();
|
||||
$stores = Business::whereIn('id', $storeIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Calculate metrics
|
||||
$daysPeriod = 28; // 4 weeks
|
||||
$totalSkusAvailable = $brand->products()->where('is_active', true)->count();
|
||||
$avgPrice = $brand->products()->avg('wholesale_price') ?? 0; // Calculate once outside loop
|
||||
|
||||
// Build store rows with enhanced columns
|
||||
$storeRows = $storesSales->map(function ($sales, $storeId) use ($stores, $daysPeriod, $totalSkusAvailable, $avgPrice) {
|
||||
$store = $stores->get($storeId);
|
||||
if (! $store) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$activeSkus = $sales->active_skus;
|
||||
$oosSkus = max(0, $totalSkusAvailable - $activeSkus);
|
||||
$oosPercent = $totalSkusAvailable > 0 ? round(($oosSkus / $totalSkusAvailable) * 100, 0) : 0;
|
||||
$avgDailyUnits = $daysPeriod > 0 ? round($sales->total_units / $daysPeriod, 1) : 0;
|
||||
|
||||
// Calculate lost opportunity (simplified)
|
||||
$lostOpportunity = $oosSkus * $avgPrice * 7;
|
||||
|
||||
return [
|
||||
'id' => $store->id,
|
||||
'slug' => $store->slug,
|
||||
'name' => $store->name,
|
||||
'address' => $this->formatStoreAddress($store),
|
||||
'business_type' => $store->business_type,
|
||||
'tags' => [], // CannaiQ: will provide "must_win" etc.
|
||||
'active_skus' => $activeSkus,
|
||||
'oos_skus' => $oosSkus,
|
||||
'oos_percent' => $oosPercent,
|
||||
'avg_daily_units' => $avgDailyUnits,
|
||||
'avg_days_on_hand' => null, // CannaiQ
|
||||
'total_sales' => round($sales->total_sales, 2),
|
||||
'avg_margin_3mo' => null, // CannaiQ
|
||||
'lost_opportunity' => round($lostOpportunity, 2),
|
||||
'categories' => null, // CannaiQ: category breakdown for mini charts
|
||||
'order_count' => $sales->order_count,
|
||||
];
|
||||
})->filter()->sortByDesc('total_sales')->values();
|
||||
|
||||
// Calculate summary KPIs
|
||||
$kpis = [
|
||||
'total_sales_4wk' => $storeRows->sum('total_sales'),
|
||||
'total_oos' => $storeRows->sum('oos_skus'),
|
||||
'potential_sales' => $storeRows->sum('lost_opportunity'),
|
||||
'store_count' => $storeRows->count(),
|
||||
];
|
||||
|
||||
return [
|
||||
'stores' => $storeRows,
|
||||
'kpis' => $kpis,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate aggregated stores dashboard data
|
||||
*/
|
||||
private function calculateStoresDashboardData(Brand $brand, Business $business): array
|
||||
{
|
||||
$fourWeeksAgo = now()->subWeeks(4);
|
||||
|
||||
// Get all product IDs for this brand
|
||||
$brandProductIds = $brand->products()->pluck('id');
|
||||
|
||||
if ($brandProductIds->isEmpty()) {
|
||||
return [
|
||||
'stores' => collect(),
|
||||
'kpis' => [
|
||||
'total_sales_4wk' => 0,
|
||||
'total_oos' => 0,
|
||||
'potential_sales' => 0,
|
||||
'store_count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Single aggregation query for store-level sales (4 weeks)
|
||||
$storesSales = OrderItem::whereIn('product_id', $brandProductIds)
|
||||
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->where('orders.created_at', '>=', $fourWeeksAgo)
|
||||
->whereNotIn('orders.status', ['cancelled', 'rejected'])
|
||||
->select([
|
||||
'orders.business_id as store_id',
|
||||
DB::raw('SUM(order_items.line_total) as total_sales'),
|
||||
DB::raw('SUM(order_items.quantity) as total_units'),
|
||||
DB::raw('COUNT(DISTINCT orders.id) as order_count'),
|
||||
DB::raw('COUNT(DISTINCT order_items.product_id) as active_skus'),
|
||||
])
|
||||
->groupBy('orders.business_id')
|
||||
->get()
|
||||
->keyBy('store_id');
|
||||
|
||||
if ($storesSales->isEmpty()) {
|
||||
return [
|
||||
'stores' => collect(),
|
||||
'kpis' => [
|
||||
'total_sales_4wk' => 0,
|
||||
'total_oos' => 0,
|
||||
'potential_sales' => 0,
|
||||
'store_count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Load store businesses
|
||||
$storeIds = $storesSales->keys();
|
||||
$stores = Business::whereIn('id', $storeIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Calculate metrics
|
||||
$daysPeriod = 28; // 4 weeks
|
||||
$totalSkusAvailable = $brand->products()->where('is_active', true)->count();
|
||||
$avgPrice = $brand->products()->avg('wholesale_price') ?? 0; // Calculate once outside loop
|
||||
|
||||
// Build store rows
|
||||
$storeRows = $storesSales->map(function ($sales, $storeId) use ($stores, $daysPeriod, $totalSkusAvailable, $avgPrice) {
|
||||
$store = $stores->get($storeId);
|
||||
if (! $store) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$activeSkus = $sales->active_skus;
|
||||
$oosSkus = max(0, $totalSkusAvailable - $activeSkus); // Products not ordered = potentially OOS
|
||||
$oosPercent = $totalSkusAvailable > 0 ? round(($oosSkus / $totalSkusAvailable) * 100, 1) : 0;
|
||||
$avgDailyUnits = $daysPeriod > 0 ? round($sales->total_units / $daysPeriod, 1) : 0;
|
||||
|
||||
// Calculate lost opportunity (simplified: OOS SKUs * avg price * estimated days)
|
||||
$lostOpportunity = $oosSkus * $avgPrice * 7; // 7 days estimated
|
||||
|
||||
return [
|
||||
'id' => $store->id,
|
||||
'slug' => $store->slug,
|
||||
'name' => $store->name,
|
||||
'address' => $this->formatStoreAddress($store),
|
||||
'business_type' => $store->business_type,
|
||||
'active_skus' => $activeSkus,
|
||||
'oos_skus' => $oosSkus,
|
||||
'oos_percent' => $oosPercent,
|
||||
'avg_daily_units' => $avgDailyUnits,
|
||||
'avg_days_on_hand' => null, // Requires CannaiQ data
|
||||
'total_sales' => round($sales->total_sales, 2),
|
||||
'avg_margin' => null, // Requires CannaiQ data
|
||||
'lost_opportunity' => round($lostOpportunity, 2),
|
||||
'order_count' => $sales->order_count,
|
||||
];
|
||||
})->filter()->sortByDesc('total_sales')->values();
|
||||
|
||||
// Calculate summary KPIs
|
||||
$kpis = [
|
||||
'total_sales_4wk' => $storeRows->sum('total_sales'),
|
||||
'total_oos' => $storeRows->sum('oos_skus'),
|
||||
'potential_sales' => $storeRows->sum('lost_opportunity'),
|
||||
'store_count' => $storeRows->count(),
|
||||
];
|
||||
|
||||
return [
|
||||
'stores' => $storeRows,
|
||||
'kpis' => $kpis,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate store detail data (SKU-level)
|
||||
*/
|
||||
private function calculateStoreDetailData(Brand $brand, Business $business, Business $store): array
|
||||
{
|
||||
$fourWeeksAgo = now()->subWeeks(4);
|
||||
|
||||
// Get all active products for this brand
|
||||
$brandProducts = $brand->products()
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
if ($brandProducts->isEmpty()) {
|
||||
return [
|
||||
'products' => collect(),
|
||||
'kpis' => [
|
||||
'total_sales' => 0,
|
||||
'total_units' => 0,
|
||||
'oos_count' => 0,
|
||||
'low_stock_count' => 0,
|
||||
'total_lost_opportunity' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Get sales per product for this store
|
||||
$productSales = OrderItem::whereIn('product_id', $brandProducts->pluck('id'))
|
||||
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->where('orders.business_id', $store->id)
|
||||
->where('orders.created_at', '>=', $fourWeeksAgo)
|
||||
->whereNotIn('orders.status', ['cancelled', 'rejected'])
|
||||
->select([
|
||||
'order_items.product_id',
|
||||
DB::raw('SUM(order_items.line_total) as total_sales'),
|
||||
DB::raw('SUM(order_items.quantity) as total_units'),
|
||||
])
|
||||
->groupBy('order_items.product_id')
|
||||
->get()
|
||||
->keyBy('product_id');
|
||||
|
||||
$daysPeriod = 28;
|
||||
|
||||
// Build product rows
|
||||
$productRows = $brandProducts->map(function ($product) use ($productSales, $daysPeriod, $store, $brand) {
|
||||
$sales = $productSales->get($product->id);
|
||||
|
||||
$totalUnits = $sales->total_units ?? 0;
|
||||
$totalSales = $sales->total_sales ?? 0;
|
||||
$avgDailyUnits = $daysPeriod > 0 ? round($totalUnits / $daysPeriod, 2) : 0;
|
||||
|
||||
// Determine stock status based on recent orders
|
||||
// No orders in 4 weeks = likely OOS
|
||||
$stockStatus = 'in_stock';
|
||||
$daysSinceOos = null;
|
||||
|
||||
if (! $sales || $totalUnits === 0) {
|
||||
$stockStatus = 'oos';
|
||||
$daysSinceOos = 28; // Assume OOS for full period if no orders
|
||||
}
|
||||
|
||||
// Calculate lost opportunity
|
||||
$lostOpportunity = 0;
|
||||
if ($stockStatus === 'oos' && $avgDailyUnits > 0) {
|
||||
$unitPrice = $product->wholesale_price ?? 0;
|
||||
$lostOpportunity = $avgDailyUnits * ($daysSinceOos ?? 7) * $unitPrice;
|
||||
}
|
||||
|
||||
// Calculate units to order (target 14 days of stock)
|
||||
$unitsToOrder = null;
|
||||
if ($avgDailyUnits > 0) {
|
||||
$unitsToOrder = (int) ceil($avgDailyUnits * 14);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'hashid' => $product->hashid,
|
||||
'name' => $product->name,
|
||||
'sku' => $product->sku,
|
||||
'brand_name' => $brand->name,
|
||||
'dispensary_name' => $store->name,
|
||||
'vendor' => $brand->business?->name ?? '-',
|
||||
'total_sales' => round($totalSales, 2),
|
||||
'total_units' => $totalUnits,
|
||||
'avg_daily_units' => $avgDailyUnits,
|
||||
'margin_dollars' => null, // Requires CannaiQ data
|
||||
'margin_percent' => null, // Requires CannaiQ data
|
||||
'stock_level' => null, // Requires CannaiQ data
|
||||
'days_of_stock' => null, // Requires CannaiQ data
|
||||
'days_since_oos' => $daysSinceOos,
|
||||
'lost_opportunity' => round($lostOpportunity, 2),
|
||||
'units_to_order' => $unitsToOrder,
|
||||
'price' => $product->wholesale_price,
|
||||
'discount' => null, // Requires CannaiQ data
|
||||
'measure' => $product->weight_display ?? $product->weight_unit ?? 'unit',
|
||||
'stock_status' => $stockStatus,
|
||||
'image_url' => $product->getImageUrl('thumb'),
|
||||
];
|
||||
})->sortByDesc('total_sales')->values();
|
||||
|
||||
// Calculate KPIs
|
||||
$kpis = [
|
||||
'total_sales' => $productRows->sum('total_sales'),
|
||||
'total_units' => $productRows->sum('total_units'),
|
||||
'oos_count' => $productRows->where('stock_status', 'oos')->count(),
|
||||
'low_stock_count' => $productRows->where('stock_status', 'low')->count(),
|
||||
'total_lost_opportunity' => $productRows->sum('lost_opportunity'),
|
||||
];
|
||||
|
||||
return [
|
||||
'products' => $productRows,
|
||||
'kpis' => $kpis,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format store address for display
|
||||
*/
|
||||
private function formatStoreAddress(Business $store): string
|
||||
{
|
||||
$parts = array_filter([
|
||||
$store->address,
|
||||
$store->city,
|
||||
$store->state,
|
||||
]);
|
||||
|
||||
return implode(', ', $parts) ?: 'No address';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CannaiQ store metrics for a brand
|
||||
*/
|
||||
private function fetchCannaiqStoreMetrics(Brand $brand): array
|
||||
{
|
||||
try {
|
||||
// Use brand slug or name for CannaiQ lookup
|
||||
$brandSlug = $brand->slug ?? $brand->name;
|
||||
|
||||
// Fetch aggregated store metrics from CannaiQ
|
||||
$response = $this->cannaiq->getBrandStoreMetrics($brandSlug);
|
||||
|
||||
if (isset($response['error'])) {
|
||||
Log::warning('CannaiQ: Failed to fetch store metrics for brand', [
|
||||
'brand' => $brandSlug,
|
||||
'error' => $response['message'] ?? 'Unknown error',
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $response['stores'] ?? [];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching store metrics', [
|
||||
'brand' => $brand->slug,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge CannaiQ data into internal store rows
|
||||
* Matches stores by name (fuzzy) or cannaiq_store_id if available
|
||||
*/
|
||||
private function mergeCannaiqData($stores, array $cannaiqData): \Illuminate\Support\Collection
|
||||
{
|
||||
if (empty($cannaiqData)) {
|
||||
return $stores;
|
||||
}
|
||||
|
||||
// Index CannaiQ data by normalized store name for fuzzy matching
|
||||
$cannaiqByName = [];
|
||||
foreach ($cannaiqData as $storeId => $data) {
|
||||
$normalizedName = $this->normalizeStoreName($data['name'] ?? '');
|
||||
if ($normalizedName) {
|
||||
$cannaiqByName[$normalizedName] = $data;
|
||||
}
|
||||
}
|
||||
|
||||
return $stores->map(function ($store) use ($cannaiqByName) {
|
||||
// Try to match by normalized name
|
||||
$normalizedName = $this->normalizeStoreName($store['name']);
|
||||
$cannaiq = $cannaiqByName[$normalizedName] ?? null;
|
||||
|
||||
if ($cannaiq) {
|
||||
// Merge CannaiQ data into store row
|
||||
$store['tags'] = $cannaiq['tags'] ?? $store['tags'];
|
||||
$store['avg_days_on_hand'] = $cannaiq['avg_days_on_hand'] ?? $store['avg_days_on_hand'];
|
||||
$store['avg_margin_3mo'] = $cannaiq['avg_margin_3mo'] ?? $store['avg_margin_3mo'];
|
||||
$store['categories'] = $cannaiq['categories'] ?? $store['categories'];
|
||||
|
||||
// Override OOS if CannaiQ has more accurate data
|
||||
if (isset($cannaiq['oos_skus'])) {
|
||||
$store['oos_skus'] = $cannaiq['oos_skus'];
|
||||
}
|
||||
|
||||
// Override lost opportunity if CannaiQ has it
|
||||
if (isset($cannaiq['lost_opportunity'])) {
|
||||
$store['lost_opportunity'] = $cannaiq['lost_opportunity'];
|
||||
}
|
||||
}
|
||||
|
||||
return $store;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize store name for fuzzy matching
|
||||
*/
|
||||
private function normalizeStoreName(string $name): string
|
||||
{
|
||||
// Lowercase, remove common suffixes, trim whitespace
|
||||
$name = strtolower(trim($name));
|
||||
$name = preg_replace('/\s+(inc|llc|dispensary|cannabis|co|company)\.?$/i', '', $name);
|
||||
$name = preg_replace('/[^a-z0-9]/', '', $name);
|
||||
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
415
app/Http/Controllers/Seller/ChatController.php
Normal file
415
app/Http/Controllers/Seller/ChatController.php
Normal file
@@ -0,0 +1,415 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmActiveView;
|
||||
use App\Models\Crm\CrmChannel;
|
||||
use App\Models\Crm\CrmInternalNote;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use App\Models\User;
|
||||
use App\Services\Crm\CrmAiService;
|
||||
use App\Services\Crm\CrmChannelService;
|
||||
use App\Services\Crm\CrmSlaService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ChatController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected CrmChannelService $channelService,
|
||||
protected CrmSlaService $slaService,
|
||||
protected CrmAiService $aiService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Unified chat inbox view (Chatwoot-style)
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$query = CrmThread::forBusiness($business->id)
|
||||
->with(['contact', 'assignee', 'brand', 'channel', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||
->withCount('messages');
|
||||
|
||||
// Filters
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
if ($request->filled('assigned_to')) {
|
||||
if ($request->assigned_to === 'unassigned') {
|
||||
$query->unassigned();
|
||||
} else {
|
||||
$query->assignedTo($request->assigned_to);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('department')) {
|
||||
$query->forDepartment($request->department);
|
||||
}
|
||||
|
||||
if ($request->filled('brand_id')) {
|
||||
$query->forBrand($request->brand_id);
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('subject', 'ilike', "%{$request->search}%")
|
||||
->orWhere('last_message_preview', 'ilike', "%{$request->search}%")
|
||||
->orWhereHas('contact', fn ($c) => $c->where('name', 'ilike', "%{$request->search}%"));
|
||||
});
|
||||
}
|
||||
|
||||
$threads = $query->orderByDesc('last_message_at')->paginate(50);
|
||||
|
||||
// Get team members for assignment dropdown
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
|
||||
// Get available channels
|
||||
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||
|
||||
// Get brands for filter dropdown
|
||||
$brands = \App\Models\Brand::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get departments for filter dropdown
|
||||
$departments = CrmChannel::DEPARTMENTS;
|
||||
|
||||
// Get contacts for new conversation modal
|
||||
// Include: 1) Customer contacts (from businesses that ordered), 2) Own business contacts (coworkers)
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
// Add the seller's own business ID to include coworkers
|
||||
$allBusinessIds = $customerBusinessIds->push($business->id)->unique();
|
||||
|
||||
$contacts = \App\Models\Contact::whereIn('business_id', $allBusinessIds)
|
||||
->with('business:id,name')
|
||||
->orderBy('first_name')
|
||||
->limit(200)
|
||||
->get();
|
||||
|
||||
return view('seller.chat.index', compact(
|
||||
'business',
|
||||
'threads',
|
||||
'teamMembers',
|
||||
'channels',
|
||||
'brands',
|
||||
'departments',
|
||||
'contacts'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Get thread data for inline loading
|
||||
*/
|
||||
public function getThread(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
if ($thread->business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
$thread->load([
|
||||
'contact',
|
||||
'account',
|
||||
'assignee',
|
||||
'brand',
|
||||
'channel',
|
||||
'messages.attachments',
|
||||
'messages.user',
|
||||
'deals',
|
||||
'internalNotes.user',
|
||||
'tags.tag',
|
||||
]);
|
||||
|
||||
// Mark as read
|
||||
$thread->markAsRead($request->user());
|
||||
|
||||
// Start viewing (collision detection)
|
||||
CrmActiveView::startViewing($thread, $request->user());
|
||||
|
||||
// Get other viewers
|
||||
$otherViewers = CrmActiveView::getActiveViewers($thread, $request->user()->id);
|
||||
|
||||
// Get SLA status
|
||||
$slaStatus = $this->slaService->getThreadSlaStatus($thread);
|
||||
|
||||
// Get AI suggestions
|
||||
$suggestions = $thread->aiSuggestions()->pending()->notExpired()->get();
|
||||
|
||||
// Get available channels for reply
|
||||
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||
|
||||
// Get team members for assignment dropdown
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
|
||||
return response()->json([
|
||||
'thread' => $thread,
|
||||
'otherViewers' => $otherViewers->map(fn ($v) => [
|
||||
'id' => $v->user->id,
|
||||
'name' => $v->user->name,
|
||||
'type' => $v->view_type,
|
||||
]),
|
||||
'slaStatus' => $slaStatus,
|
||||
'suggestions' => $suggestions,
|
||||
'channels' => $channels,
|
||||
'teamMembers' => $teamMembers,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Send reply in thread
|
||||
*/
|
||||
public function reply(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
if ($thread->business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'body' => 'required|string|max:10000',
|
||||
'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app',
|
||||
]);
|
||||
|
||||
$contact = $thread->contact;
|
||||
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
|
||||
? $contact->email
|
||||
: $contact->phone;
|
||||
|
||||
if (! $to) {
|
||||
return response()->json(['error' => 'Contact does not have required contact info for this channel.'], 422);
|
||||
}
|
||||
|
||||
$success = $this->channelService->sendMessage(
|
||||
businessId: $business->id,
|
||||
channelType: $validated['channel_type'],
|
||||
to: $to,
|
||||
body: $validated['body'],
|
||||
subject: null,
|
||||
threadId: $thread->id,
|
||||
contactId: $contact->id,
|
||||
userId: $request->user()->id,
|
||||
attachments: []
|
||||
);
|
||||
|
||||
if (! $success) {
|
||||
return response()->json(['error' => 'Failed to send message.'], 500);
|
||||
}
|
||||
|
||||
// Auto-assign thread to sender if unassigned
|
||||
if ($thread->assigned_to === null) {
|
||||
$thread->assigned_to = $request->user()->id;
|
||||
$thread->save();
|
||||
}
|
||||
|
||||
// Handle SLA
|
||||
$this->slaService->handleOutboundMessage($thread);
|
||||
|
||||
// Reload messages
|
||||
$thread->load(['messages.attachments', 'messages.user']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'messages' => $thread->messages,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Create new thread
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'contact_id' => 'required|exists:contacts,id',
|
||||
'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app',
|
||||
'body' => 'required|string|max:10000',
|
||||
]);
|
||||
|
||||
// Get allowed business IDs (customers + own business for coworkers)
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
$allBusinessIds = $customerBusinessIds->push($business->id)->unique();
|
||||
|
||||
// SECURITY: Verify contact belongs to a customer business or own business (coworker)
|
||||
$contact = \App\Models\Contact::whereIn('business_id', $allBusinessIds)
|
||||
->findOrFail($validated['contact_id']);
|
||||
|
||||
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
|
||||
? $contact->email
|
||||
: $contact->phone;
|
||||
|
||||
if (! $to) {
|
||||
return response()->json(['error' => 'Contact does not have the required contact info for this channel.'], 422);
|
||||
}
|
||||
|
||||
// Create thread
|
||||
$thread = CrmThread::create([
|
||||
'business_id' => $business->id,
|
||||
'contact_id' => $contact->id,
|
||||
'account_id' => $contact->account_id,
|
||||
'status' => 'open',
|
||||
'priority' => 'normal',
|
||||
'last_channel_type' => $validated['channel_type'],
|
||||
'assigned_to' => $request->user()->id,
|
||||
]);
|
||||
|
||||
// Send the message
|
||||
$success = $this->channelService->sendMessage(
|
||||
businessId: $business->id,
|
||||
channelType: $validated['channel_type'],
|
||||
to: $to,
|
||||
body: $validated['body'],
|
||||
subject: null,
|
||||
threadId: $thread->id,
|
||||
contactId: $contact->id,
|
||||
userId: $request->user()->id,
|
||||
attachments: []
|
||||
);
|
||||
|
||||
if (! $success) {
|
||||
$thread->delete();
|
||||
|
||||
return response()->json(['error' => 'Failed to send message.'], 500);
|
||||
}
|
||||
|
||||
$thread->load(['contact', 'messages']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'thread' => $thread,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Assign thread
|
||||
*/
|
||||
public function assign(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
if ($thread->business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'assigned_to' => 'nullable|exists:users,id',
|
||||
]);
|
||||
|
||||
if ($validated['assigned_to']) {
|
||||
$assignee = User::where('id', $validated['assigned_to'])
|
||||
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->first();
|
||||
|
||||
if (! $assignee) {
|
||||
return response()->json(['error' => 'Invalid user.'], 422);
|
||||
}
|
||||
|
||||
$thread->assignTo($assignee, $request->user());
|
||||
} else {
|
||||
$thread->assigned_to = null;
|
||||
$thread->save();
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Close thread
|
||||
*/
|
||||
public function close(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
if ($thread->business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
$thread->close($request->user());
|
||||
|
||||
return response()->json(['success' => true, 'status' => 'closed']);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Reopen thread
|
||||
*/
|
||||
public function reopen(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
if ($thread->business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
$thread->reopen($request->user());
|
||||
$this->slaService->resumeTimers($thread);
|
||||
|
||||
return response()->json(['success' => true, 'status' => 'open']);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Add internal note
|
||||
*/
|
||||
public function addNote(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
if ($thread->business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'content' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
$note = CrmInternalNote::create([
|
||||
'business_id' => $business->id,
|
||||
'user_id' => $request->user()->id,
|
||||
'notable_type' => CrmThread::class,
|
||||
'notable_id' => $thread->id,
|
||||
'content' => $validated['content'],
|
||||
]);
|
||||
|
||||
$note->load('user');
|
||||
|
||||
return response()->json(['success' => true, 'note' => $note]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Generate AI reply
|
||||
*/
|
||||
public function generateAiReply(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
if ($thread->business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
$suggestion = $this->aiService->generateReplyDraft($thread, $request->input('tone', 'professional'));
|
||||
|
||||
if (! $suggestion) {
|
||||
return response()->json(['error' => 'Failed to generate reply.'], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'content' => $suggestion->content,
|
||||
'suggestion_id' => $suggestion->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Heartbeat for active viewing
|
||||
*/
|
||||
public function heartbeat(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
if ($thread->business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
CrmActiveView::startViewing($thread, $request->user(), $request->input('view_type', 'viewing'));
|
||||
|
||||
$otherViewers = CrmActiveView::getActiveViewers($thread, $request->user()->id);
|
||||
|
||||
return response()->json([
|
||||
'other_viewers' => $otherViewers->map(fn ($v) => [
|
||||
'id' => $v->user->id,
|
||||
'name' => $v->user->name,
|
||||
'type' => $v->view_type,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,12 @@ class ConversationController extends Controller
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->whereHas('contact', function ($c) use ($search) {
|
||||
$c->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%")
|
||||
->orWhere('phone', 'like', "%{$search}%");
|
||||
$c->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%")
|
||||
->orWhere('phone', 'ilike', "%{$search}%");
|
||||
})
|
||||
->orWhereHas('messages', function ($m) use ($search) {
|
||||
$m->where('message_body', 'like', "%{$search}%");
|
||||
$m->where('message_body', 'ilike', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,18 +10,23 @@ use App\Models\Crm\CrmEvent;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Location;
|
||||
use App\Models\SalesOpportunity;
|
||||
use App\Models\SendMenuLog;
|
||||
use App\Services\Cannaiq\CannaiqClient;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display accounts listing
|
||||
* Display accounts listing - only buyers who have ordered from this seller
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$query = Business::where('type', 'buyer')
|
||||
->whereHas('orders', function ($q) use ($business) {
|
||||
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
|
||||
})
|
||||
->with(['contacts']);
|
||||
|
||||
// Search filter
|
||||
@@ -43,6 +48,18 @@ class AccountController extends Controller
|
||||
|
||||
$accounts = $query->orderBy('name')->paginate(25);
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $accounts->map(fn ($a) => [
|
||||
'slug' => $a->slug,
|
||||
'name' => $a->name,
|
||||
'email' => $a->business_email,
|
||||
'status' => $a->status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.crm.accounts.index', compact('business', 'accounts'));
|
||||
}
|
||||
|
||||
@@ -90,7 +107,7 @@ class AccountController extends Controller
|
||||
'status' => 'approved', // Auto-approve customers created by sellers
|
||||
]);
|
||||
|
||||
// Create primary contact if provided
|
||||
// Create contact if provided
|
||||
if (! empty($validated['contact_name'])) {
|
||||
$account->contacts()->create([
|
||||
'first_name' => explode(' ', $validated['contact_name'])[0],
|
||||
@@ -98,7 +115,6 @@ class AccountController extends Controller
|
||||
'email' => $validated['contact_email'] ?? null,
|
||||
'phone' => $validated['contact_phone'] ?? null,
|
||||
'title' => $validated['contact_title'] ?? null,
|
||||
'is_primary' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -165,35 +181,55 @@ class AccountController extends Controller
|
||||
{
|
||||
$account->load(['contacts']);
|
||||
|
||||
// Get orders for this account from this seller (with invoices)
|
||||
$orders = $account->orders()
|
||||
// Location filtering
|
||||
$locationId = $request->query('location');
|
||||
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
|
||||
|
||||
// Load all locations for this account with contacts pivot
|
||||
$locations = $account->locations()
|
||||
->with(['contacts' => function ($q) {
|
||||
$q->wherePivot('role', 'buyer');
|
||||
}])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Base order query for this seller
|
||||
$baseOrderQuery = fn () => $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->with(['invoice'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
});
|
||||
|
||||
// Get quotes for this account
|
||||
$quotes = CrmQuote::where('business_id', $business->id)
|
||||
->where('account_id', $account->id)
|
||||
->with(['contact', 'items'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
// Get orders (filtered by location if selected)
|
||||
$ordersQuery = $baseOrderQuery();
|
||||
if ($selectedLocation) {
|
||||
$ordersQuery->where('location_id', $selectedLocation->id);
|
||||
}
|
||||
$orders = $ordersQuery->with(['invoice', 'location'])->latest()->limit(10)->get();
|
||||
|
||||
// Get invoices for this account (via orders)
|
||||
$invoices = Invoice::whereHas('order', function ($q) use ($business, $account) {
|
||||
// Get quotes for this account (filtered by location if selected)
|
||||
$quotesQuery = CrmQuote::where('business_id', $business->id)
|
||||
->where('account_id', $account->id);
|
||||
if ($selectedLocation) {
|
||||
$quotesQuery->where('location_id', $selectedLocation->id);
|
||||
}
|
||||
$quotes = $quotesQuery->with(['contact', 'items'])->latest()->limit(10)->get();
|
||||
|
||||
// Base invoice query
|
||||
$baseInvoiceQuery = fn () => Invoice::whereHas('order', function ($q) use ($business, $account) {
|
||||
$q->where('business_id', $account->id)
|
||||
->whereHas('items.product.brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
});
|
||||
})
|
||||
->with(['order', 'payments'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
});
|
||||
|
||||
// Get invoices (filtered by location if selected)
|
||||
$invoicesQuery = $baseInvoiceQuery();
|
||||
if ($selectedLocation) {
|
||||
$invoicesQuery->whereHas('order', function ($q) use ($selectedLocation) {
|
||||
$q->where('location_id', $selectedLocation->id);
|
||||
});
|
||||
}
|
||||
$invoices = $invoicesQuery->with(['order', 'payments'])->latest()->limit(10)->get();
|
||||
|
||||
// Get opportunities for this account from this seller
|
||||
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
|
||||
@@ -234,13 +270,17 @@ class AccountController extends Controller
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Compute stats for this account with efficient queries
|
||||
$orderStats = $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
|
||||
->first();
|
||||
// Compute stats - if location selected, show location-specific stats
|
||||
if ($selectedLocation) {
|
||||
$orderStats = $baseOrderQuery()
|
||||
->where('location_id', $selectedLocation->id)
|
||||
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
|
||||
->first();
|
||||
} else {
|
||||
$orderStats = $baseOrderQuery()
|
||||
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
|
||||
->first();
|
||||
}
|
||||
|
||||
$opportunityStats = SalesOpportunity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
@@ -248,14 +288,14 @@ class AccountController extends Controller
|
||||
->selectRaw('COUNT(*) as open_count, COALESCE(SUM(value), 0) as pipeline_value')
|
||||
->first();
|
||||
|
||||
// Financial stats from invoices
|
||||
$financialStats = Invoice::whereHas('order', function ($q) use ($business, $account) {
|
||||
$q->where('business_id', $account->id)
|
||||
->whereHas('items.product.brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
});
|
||||
})
|
||||
->selectRaw('
|
||||
// Financial stats from invoices (location-filtered if applicable)
|
||||
$financialStatsQuery = $baseInvoiceQuery();
|
||||
if ($selectedLocation) {
|
||||
$financialStatsQuery->whereHas('order', function ($q) use ($selectedLocation) {
|
||||
$q->where('location_id', $selectedLocation->id);
|
||||
});
|
||||
}
|
||||
$financialStats = $financialStatsQuery->selectRaw('
|
||||
COALESCE(SUM(amount_due), 0) as outstanding_balance,
|
||||
COALESCE(SUM(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN amount_due ELSE 0 END), 0) as past_due_amount,
|
||||
COUNT(CASE WHEN amount_due > 0 THEN 1 END) as open_invoice_count,
|
||||
@@ -285,12 +325,69 @@ class AccountController extends Controller
|
||||
'past_due_amount' => $financialStats->past_due_amount ?? 0,
|
||||
'open_invoice_count' => $financialStats->open_invoice_count ?? 0,
|
||||
'oldest_past_due_days' => $financialStats->oldest_past_due_date
|
||||
? now()->diffInDays($financialStats->oldest_past_due_date)
|
||||
? (int) ceil(abs(now()->diffInDays($financialStats->oldest_past_due_date)))
|
||||
: null,
|
||||
'last_payment_amount' => $lastPayment->amount ?? null,
|
||||
'last_payment_date' => $lastPayment->payment_date ?? null,
|
||||
];
|
||||
|
||||
// Calculate unattributed orders/invoices (those without location_id)
|
||||
$unattributedOrdersCount = $baseOrderQuery()->whereNull('location_id')->count();
|
||||
$unattributedInvoicesCount = $baseInvoiceQuery()
|
||||
->whereHas('order', function ($q) {
|
||||
$q->whereNull('location_id');
|
||||
})
|
||||
->count();
|
||||
|
||||
// Calculate per-location stats for location tiles
|
||||
$locationStats = [];
|
||||
if ($locations->count() > 0) {
|
||||
$locationIds = $locations->pluck('id')->toArray();
|
||||
|
||||
// Order stats by location
|
||||
$ordersByLocation = $baseOrderQuery()
|
||||
->whereIn('location_id', $locationIds)
|
||||
->selectRaw('location_id, COUNT(*) as orders_count, COALESCE(SUM(total), 0) as revenue')
|
||||
->groupBy('location_id')
|
||||
->get()
|
||||
->keyBy('location_id');
|
||||
|
||||
// Invoice stats by location
|
||||
$invoicesByLocation = Invoice::whereHas('order', function ($q) use ($business, $account, $locationIds) {
|
||||
$q->where('business_id', $account->id)
|
||||
->whereIn('location_id', $locationIds)
|
||||
->whereHas('items.product.brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
});
|
||||
})
|
||||
->selectRaw('
|
||||
(SELECT location_id FROM orders WHERE orders.id = invoices.order_id) as location_id,
|
||||
COALESCE(SUM(amount_due), 0) as outstanding,
|
||||
COALESCE(SUM(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN amount_due ELSE 0 END), 0) as past_due,
|
||||
COUNT(CASE WHEN amount_due > 0 THEN 1 END) as open_invoices
|
||||
')
|
||||
->groupByRaw('(SELECT location_id FROM orders WHERE orders.id = invoices.order_id)')
|
||||
->get()
|
||||
->keyBy('location_id');
|
||||
|
||||
foreach ($locations as $location) {
|
||||
$orderData = $ordersByLocation->get($location->id);
|
||||
$invoiceData = $invoicesByLocation->get($location->id);
|
||||
|
||||
$ordersCount = $orderData->orders_count ?? 0;
|
||||
$openInvoices = $invoiceData->open_invoices ?? 0;
|
||||
|
||||
$locationStats[$location->id] = [
|
||||
'orders' => $ordersCount,
|
||||
'revenue' => $orderData->revenue ?? 0,
|
||||
'outstanding' => $invoiceData->outstanding ?? 0,
|
||||
'past_due' => $invoiceData->past_due ?? 0,
|
||||
'open_invoices' => $openInvoices,
|
||||
'has_attributed_data' => ($ordersCount + $openInvoices) > 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return view('seller.crm.accounts.show', compact(
|
||||
'business',
|
||||
'account',
|
||||
@@ -303,7 +400,12 @@ class AccountController extends Controller
|
||||
'tasks',
|
||||
'conversationEvents',
|
||||
'sendHistory',
|
||||
'activities'
|
||||
'activities',
|
||||
'locations',
|
||||
'selectedLocation',
|
||||
'locationStats',
|
||||
'unattributedOrdersCount',
|
||||
'unattributedInvoicesCount'
|
||||
));
|
||||
}
|
||||
|
||||
@@ -312,9 +414,26 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function contacts(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$contacts = $account->contacts()->paginate(25);
|
||||
// Location filtering
|
||||
$locationId = $request->query('location');
|
||||
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
|
||||
|
||||
return view('seller.crm.accounts.contacts', compact('business', 'account', 'contacts'));
|
||||
// Base query for contacts
|
||||
$contactsQuery = $account->contacts();
|
||||
|
||||
// If location selected, filter to contacts assigned to that location
|
||||
if ($selectedLocation) {
|
||||
$contactsQuery->whereHas('locations', function ($q) use ($selectedLocation) {
|
||||
$q->where('locations.id', $selectedLocation->id);
|
||||
});
|
||||
}
|
||||
|
||||
$contacts = $contactsQuery->paginate(25);
|
||||
|
||||
// Load locations for the scope bar
|
||||
$locations = $account->locations()->orderBy('name')->get();
|
||||
|
||||
return view('seller.crm.accounts.contacts', compact('business', 'account', 'contacts', 'locations', 'selectedLocation'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,7 +441,21 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function opportunities(Request $request, Business $business, Business $account)
|
||||
{
|
||||
return view('seller.crm.accounts.opportunities', compact('business', 'account'));
|
||||
// Location filtering (note: opportunities don't have location_id yet, so we just pass the context)
|
||||
$locationId = $request->query('location');
|
||||
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
|
||||
|
||||
// Load opportunities for this account
|
||||
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->with(['stage', 'brand', 'owner'])
|
||||
->latest()
|
||||
->paginate(25);
|
||||
|
||||
// Load locations for the scope bar
|
||||
$locations = $account->locations()->orderBy('name')->get();
|
||||
|
||||
return view('seller.crm.accounts.opportunities', compact('business', 'account', 'opportunities', 'locations', 'selectedLocation'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,15 +463,28 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function orders(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$orders = $account->orders()
|
||||
// Location filtering
|
||||
$locationId = $request->query('location');
|
||||
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
|
||||
|
||||
$ordersQuery = $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->with(['items.product.brand'])
|
||||
});
|
||||
|
||||
// Filter by location if selected
|
||||
if ($selectedLocation) {
|
||||
$ordersQuery->where('location_id', $selectedLocation->id);
|
||||
}
|
||||
|
||||
$orders = $ordersQuery->with(['items.product.brand', 'location'])
|
||||
->latest()
|
||||
->paginate(25);
|
||||
|
||||
return view('seller.crm.accounts.orders', compact('business', 'account', 'orders'));
|
||||
// Load locations for the scope bar
|
||||
$locations = $account->locations()->orderBy('name')->get();
|
||||
|
||||
return view('seller.crm.accounts.orders', compact('business', 'account', 'orders', 'locations', 'selectedLocation'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,13 +492,20 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function activity(Request $request, Business $business, Business $account)
|
||||
{
|
||||
// Location filtering (note: activities don't have location_id yet, so we just pass the context)
|
||||
$locationId = $request->query('location');
|
||||
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
|
||||
|
||||
$activities = Activity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->with(['causer'])
|
||||
->latest()
|
||||
->paginate(50);
|
||||
|
||||
return view('seller.crm.accounts.activity', compact('business', 'account', 'activities'));
|
||||
// Load locations for the scope bar
|
||||
$locations = $account->locations()->orderBy('name')->get();
|
||||
|
||||
return view('seller.crm.accounts.activity', compact('business', 'account', 'activities', 'locations', 'selectedLocation'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,7 +513,22 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function tasks(Request $request, Business $business, Business $account)
|
||||
{
|
||||
return view('seller.crm.accounts.tasks', compact('business', 'account'));
|
||||
// Location filtering (note: tasks don't have location_id yet, so we just pass the context)
|
||||
$locationId = $request->query('location');
|
||||
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
|
||||
|
||||
// Load tasks for this account
|
||||
$tasks = CrmTask::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->with(['assignee', 'opportunity'])
|
||||
->orderByRaw('completed_at IS NOT NULL')
|
||||
->orderBy('due_at')
|
||||
->paginate(25);
|
||||
|
||||
// Load locations for the scope bar
|
||||
$locations = $account->locations()->orderBy('name')->get();
|
||||
|
||||
return view('seller.crm.accounts.tasks', compact('business', 'account', 'tasks', 'locations', 'selectedLocation'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -397,14 +565,8 @@ class AccountController extends Controller
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'title' => 'nullable|string|max:100',
|
||||
'is_primary' => 'boolean',
|
||||
]);
|
||||
|
||||
// If setting as primary, unset other primary contacts
|
||||
if ($validated['is_primary'] ?? false) {
|
||||
$account->contacts()->update(['is_primary' => false]);
|
||||
}
|
||||
|
||||
$contact = $account->contacts()->create($validated);
|
||||
|
||||
// Return JSON for AJAX requests
|
||||
@@ -453,14 +615,11 @@ class AccountController extends Controller
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'title' => 'nullable|string|max:100',
|
||||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
// If setting as primary, unset other primary contacts
|
||||
if ($validated['is_primary'] ?? false) {
|
||||
$account->contacts()->where('id', '!=', $contact->id)->update(['is_primary' => false]);
|
||||
}
|
||||
// Handle checkbox - if not sent, default to false
|
||||
$validated['is_active'] = $request->boolean('is_active');
|
||||
|
||||
$contact->update($validated);
|
||||
|
||||
@@ -485,4 +644,167 @@ class AccountController extends Controller
|
||||
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
|
||||
->with('success', 'Contact deleted successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show location edit form
|
||||
*/
|
||||
public function editLocation(Request $request, Business $business, Business $account, Location $location)
|
||||
{
|
||||
// Verify location belongs to this account
|
||||
if ($location->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Load contacts that can be assigned to this location
|
||||
$contacts = $account->contacts()->orderBy('first_name')->get();
|
||||
|
||||
// Load currently assigned contacts with their roles
|
||||
$locationContacts = $location->contacts()->get();
|
||||
|
||||
// Available roles for location contacts
|
||||
$contactRoles = [
|
||||
'buyer' => 'Buyer',
|
||||
'ap' => 'Accounts Payable',
|
||||
'marketing' => 'Marketing',
|
||||
'gm' => 'General Manager',
|
||||
'inventory' => 'Inventory Manager',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
// CannaiQ platforms
|
||||
$cannaiqPlatforms = [
|
||||
'dutchie' => 'Dutchie',
|
||||
'jane' => 'Jane',
|
||||
'weedmaps' => 'Weedmaps',
|
||||
'leafly' => 'Leafly',
|
||||
'iheartjane' => 'iHeartJane',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
return view('seller.crm.accounts.locations-edit', compact(
|
||||
'business',
|
||||
'account',
|
||||
'location',
|
||||
'contacts',
|
||||
'locationContacts',
|
||||
'contactRoles',
|
||||
'cannaiqPlatforms'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update location
|
||||
*/
|
||||
public function updateLocation(Request $request, Business $business, Business $account, Location $location)
|
||||
{
|
||||
// Verify location belongs to this account
|
||||
if ($location->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:50',
|
||||
'zipcode' => 'nullable|string|max:20',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'is_active' => 'boolean',
|
||||
'cannaiq_platform' => 'nullable|string|max:50',
|
||||
'cannaiq_store_slug' => 'nullable|string|max:255',
|
||||
'cannaiq_store_id' => 'nullable|string|max:100',
|
||||
'cannaiq_store_name' => 'nullable|string|max:255',
|
||||
'contact_roles' => 'nullable|array',
|
||||
'contact_roles.*.contact_id' => 'required|exists:contacts,id',
|
||||
'contact_roles.*.role' => 'required|string|max:50',
|
||||
'contact_roles.*.is_primary' => 'boolean',
|
||||
]);
|
||||
|
||||
// Handle checkbox
|
||||
$validated['is_active'] = $request->boolean('is_active');
|
||||
|
||||
// Clear CannaiQ fields if platform is cleared
|
||||
if (empty($validated['cannaiq_platform'])) {
|
||||
$validated['cannaiq_store_slug'] = null;
|
||||
$validated['cannaiq_store_id'] = null;
|
||||
$validated['cannaiq_store_name'] = null;
|
||||
}
|
||||
|
||||
// Update location
|
||||
$location->update([
|
||||
'name' => $validated['name'],
|
||||
'address' => $validated['address'] ?? null,
|
||||
'city' => $validated['city'] ?? null,
|
||||
'state' => $validated['state'] ?? null,
|
||||
'zipcode' => $validated['zipcode'] ?? null,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'email' => $validated['email'] ?? null,
|
||||
'is_active' => $validated['is_active'],
|
||||
'cannaiq_platform' => $validated['cannaiq_platform'] ?? null,
|
||||
'cannaiq_store_slug' => $validated['cannaiq_store_slug'] ?? null,
|
||||
'cannaiq_store_id' => $validated['cannaiq_store_id'] ?? null,
|
||||
'cannaiq_store_name' => $validated['cannaiq_store_name'] ?? null,
|
||||
]);
|
||||
|
||||
// Sync location contacts
|
||||
if (isset($validated['contact_roles'])) {
|
||||
$syncData = [];
|
||||
foreach ($validated['contact_roles'] as $contactRole) {
|
||||
// Verify contact belongs to this account
|
||||
$contact = Contact::where('business_id', $account->id)
|
||||
->where('id', $contactRole['contact_id'])
|
||||
->first();
|
||||
|
||||
if ($contact) {
|
||||
$syncData[$contact->id] = [
|
||||
'role' => $contactRole['role'],
|
||||
'is_primary' => $contactRole['is_primary'] ?? false,
|
||||
];
|
||||
}
|
||||
}
|
||||
$location->contacts()->sync($syncData);
|
||||
} else {
|
||||
$location->contacts()->detach();
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
|
||||
->with('success', 'Location updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search CannaiQ stores for linking
|
||||
*/
|
||||
public function searchCannaiqStores(Request $request, Business $business, Business $account, Location $location)
|
||||
{
|
||||
// Verify location belongs to this account
|
||||
if ($location->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'platform' => 'required|string|max:50',
|
||||
'query' => 'required|string|min:2|max:100',
|
||||
]);
|
||||
|
||||
try {
|
||||
$client = app(CannaiqClient::class);
|
||||
$results = $client->searchStores(
|
||||
platform: $request->input('platform'),
|
||||
query: $request->input('query')
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'stores' => $results,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to search stores: '.$e->getMessage(),
|
||||
'stores' => [],
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,18 @@ class ContactController extends Controller
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $contacts->map(fn ($c) => [
|
||||
'hashid' => $c->hashid,
|
||||
'name' => $c->getFullName(),
|
||||
'email' => $c->email,
|
||||
'account' => $c->business?->name,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Get accounts for filter dropdown
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
|
||||
@@ -172,8 +172,9 @@ class CrmCalendarController extends Controller
|
||||
]);
|
||||
$allEvents = $allEvents->merge($bookings);
|
||||
|
||||
// 4. CRM Tasks with due dates (shown as all-day markers)
|
||||
// 4. CRM Tasks with due dates (shown as all-day markers) - only show user's assigned tasks
|
||||
$tasks = CrmTask::forSellerBusiness($business->id)
|
||||
->where('assigned_to', $user->id)
|
||||
->incomplete()
|
||||
->whereNotNull('due_at')
|
||||
->whereBetween('due_at', [$startDate, $endDate])
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\ChatQuickReply;
|
||||
use App\Models\Crm\CrmChannel;
|
||||
use App\Models\Crm\CrmMessageTemplate;
|
||||
use App\Models\Crm\CrmPipeline;
|
||||
@@ -649,4 +650,81 @@ class CrmSettingsController extends Controller
|
||||
|
||||
return back()->with('success', 'Role deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick replies list
|
||||
*/
|
||||
public function quickReplies(Request $request, Business $business)
|
||||
{
|
||||
$quickReplies = ChatQuickReply::where('business_id', $business->id)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('label')
|
||||
->get();
|
||||
|
||||
$categories = $quickReplies->pluck('category')->filter()->unique()->values();
|
||||
|
||||
return view('seller.crm.settings.quick-replies.index', [
|
||||
'business' => $business,
|
||||
'quickReplies' => $quickReplies,
|
||||
'categories' => $categories,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store new quick reply
|
||||
*/
|
||||
public function storeQuickReply(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'label' => 'required|string|max:100',
|
||||
'message' => 'required|string|max:2000',
|
||||
'category' => 'nullable|string|max:50',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$validated['business_id'] = $business->id;
|
||||
$validated['is_active'] = $request->boolean('is_active', true);
|
||||
$validated['sort_order'] = ChatQuickReply::where('business_id', $business->id)->max('sort_order') + 1;
|
||||
|
||||
ChatQuickReply::create($validated);
|
||||
|
||||
return back()->with('success', 'Quick reply created.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update quick reply
|
||||
*/
|
||||
public function updateQuickReply(Request $request, Business $business, ChatQuickReply $quickReply)
|
||||
{
|
||||
if ($quickReply->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'label' => 'required|string|max:100',
|
||||
'message' => 'required|string|max:2000',
|
||||
'category' => 'nullable|string|max:50',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$validated['is_active'] = $request->boolean('is_active', true);
|
||||
|
||||
$quickReply->update($validated);
|
||||
|
||||
return back()->with('success', 'Quick reply updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete quick reply
|
||||
*/
|
||||
public function destroyQuickReply(Request $request, Business $business, ChatQuickReply $quickReply)
|
||||
{
|
||||
if ($quickReply->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$quickReply->delete();
|
||||
|
||||
return back()->with('success', 'Quick reply deleted.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,8 @@ class DealController extends Controller
|
||||
->get();
|
||||
|
||||
// Limit accounts for dropdown - most recent 100
|
||||
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
|
||||
// Get businesses that have placed orders containing this seller's products
|
||||
$accounts = Business::whereHas('orders', function ($q) use ($business) {
|
||||
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
|
||||
})
|
||||
->select('id', 'name')
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\InvoiceMail;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmDeal;
|
||||
use App\Models\Crm\CrmInvoice;
|
||||
use App\Models\Crm\CrmInvoiceItem;
|
||||
use App\Models\Crm\CrmInvoicePayment;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class InvoiceController extends Controller
|
||||
{
|
||||
@@ -42,8 +46,8 @@ class InvoiceController extends Controller
|
||||
// Stats - single efficient query with conditional aggregation
|
||||
$invoiceStats = CrmInvoice::forBusiness($business->id)
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN amount_due ELSE 0 END) as outstanding,
|
||||
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN amount_due ELSE 0 END) as overdue
|
||||
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN balance_due ELSE 0 END) as outstanding,
|
||||
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN balance_due ELSE 0 END) as overdue
|
||||
")
|
||||
->first();
|
||||
|
||||
@@ -70,7 +74,7 @@ class InvoiceController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$invoice->load(['contact', 'account', 'quote', 'creator', 'items.product', 'payments']);
|
||||
$invoice->load(['contact', 'account', 'quote', 'creator', 'items.product', 'payments.recordedBy']);
|
||||
|
||||
return view('seller.crm.invoices.show', compact('invoice', 'business'));
|
||||
}
|
||||
@@ -80,23 +84,76 @@ class InvoiceController extends Controller
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
// Limit contacts for dropdown - most recent 100
|
||||
$contacts = \App\Models\Contact::where('business_id', $business->id)
|
||||
->select('id', 'first_name', 'last_name', 'email', 'company_name')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(100)
|
||||
// Get all approved buyer businesses as potential customers (matching quotes)
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->with('locations:id,business_id,name,is_primary')
|
||||
->orderBy('name')
|
||||
->select(['id', 'name', 'slug'])
|
||||
->get();
|
||||
|
||||
// Get open deals for linking
|
||||
$deals = CrmDeal::forBusiness($business->id)->open()->get();
|
||||
|
||||
// Limit quotes to accepted without invoices
|
||||
$quotes = CrmQuote::forBusiness($business->id)
|
||||
->where('status', CrmQuote::STATUS_ACCEPTED)
|
||||
->whereDoesntHave('invoice')
|
||||
->select('id', 'quote_number', 'title', 'total', 'contact_id')
|
||||
->with('contact:id,first_name,last_name')
|
||||
->select('id', 'quote_number', 'title', 'total', 'contact_id', 'account_id', 'location_id')
|
||||
->with(['contact:id,first_name,last_name', 'items.product'])
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
return view('seller.crm.invoices.create', compact('contacts', 'quotes', 'business'));
|
||||
// Transform quotes for Alpine.js (avoid complex closures in Blade @json)
|
||||
$quotesForJs = $quotes->map(fn ($q) => [
|
||||
'id' => $q->id,
|
||||
'account_id' => $q->account_id,
|
||||
'contact_id' => $q->contact_id,
|
||||
'location_id' => $q->location_id,
|
||||
'items' => $q->items->map(fn ($i) => [
|
||||
'product_id' => $i->product_id,
|
||||
'description' => $i->description,
|
||||
'quantity' => $i->quantity,
|
||||
'unit_price' => $i->unit_price,
|
||||
'discount_percent' => $i->discount_percent ?? 0,
|
||||
])->values(),
|
||||
])->values();
|
||||
|
||||
// Pre-fill from URL parameters
|
||||
$selectedAccount = null;
|
||||
$selectedLocation = null;
|
||||
$selectedContact = null;
|
||||
$locationContacts = collect();
|
||||
|
||||
if ($request->filled('account_id')) {
|
||||
$selectedAccount = $accounts->firstWhere('id', $request->account_id);
|
||||
}
|
||||
|
||||
if ($request->filled('location_id') && $selectedAccount) {
|
||||
$selectedLocation = $selectedAccount->locations->firstWhere('id', $request->location_id);
|
||||
}
|
||||
|
||||
// Pre-fill from quote if provided
|
||||
$quote = null;
|
||||
if ($request->filled('quote_id')) {
|
||||
$quote = $quotes->firstWhere('id', $request->quote_id);
|
||||
if ($quote) {
|
||||
$selectedAccount = $accounts->firstWhere('id', $quote->account_id);
|
||||
}
|
||||
}
|
||||
|
||||
return view('seller.crm.invoices.create', compact(
|
||||
'accounts',
|
||||
'deals',
|
||||
'quotes',
|
||||
'quotesForJs',
|
||||
'business',
|
||||
'selectedAccount',
|
||||
'selectedLocation',
|
||||
'selectedContact',
|
||||
'locationContacts',
|
||||
'quote'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,21 +165,28 @@ class InvoiceController extends Controller
|
||||
'title' => 'required|string|max:255',
|
||||
'contact_id' => 'required|exists:contacts,id',
|
||||
'account_id' => 'nullable|exists:businesses,id',
|
||||
'location_id' => 'nullable|exists:business_locations,id',
|
||||
'quote_id' => 'nullable|exists:crm_quotes,id',
|
||||
'deal_id' => 'nullable|exists:crm_deals,id',
|
||||
'due_date' => 'required|date|after_or_equal:today',
|
||||
'tax_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'discount_type' => 'nullable|in:fixed,percentage',
|
||||
'discount_value' => 'nullable|numeric|min:0',
|
||||
'notes' => 'nullable|string|max:2000',
|
||||
'payment_terms' => 'nullable|string|max:1000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'nullable|exists:products,id',
|
||||
'items.*.description' => 'required|string|max:500',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.unit_price' => 'required|numeric|min:0',
|
||||
'items.*.discount_percent' => 'nullable|numeric|min:0|max:100',
|
||||
]);
|
||||
|
||||
// SECURITY: Verify contact belongs to business
|
||||
\App\Models\Contact::where('id', $validated['contact_id'])
|
||||
->where('business_id', $business->id)
|
||||
->firstOrFail();
|
||||
// SECURITY: Verify contact belongs to the account if account is provided
|
||||
$contact = \App\Models\Contact::findOrFail($validated['contact_id']);
|
||||
if (! empty($validated['account_id']) && $contact->business_id !== (int) $validated['account_id']) {
|
||||
return back()->withErrors(['contact_id' => 'Contact must belong to the selected account.']);
|
||||
}
|
||||
|
||||
// SECURITY: Verify quote belongs to business if provided
|
||||
if (! empty($validated['quote_id'])) {
|
||||
@@ -131,22 +195,33 @@ class InvoiceController extends Controller
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
// SECURITY: Verify deal belongs to business if provided
|
||||
if (! empty($validated['deal_id'])) {
|
||||
CrmDeal::where('id', $validated['deal_id'])
|
||||
->where('business_id', $business->id)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
$invoiceNumber = CrmInvoice::generateInvoiceNumber($business->id);
|
||||
|
||||
$invoice = CrmInvoice::create([
|
||||
'business_id' => $business->id,
|
||||
'contact_id' => $validated['contact_id'],
|
||||
'account_id' => $validated['account_id'],
|
||||
'quote_id' => $validated['quote_id'],
|
||||
'location_id' => $validated['location_id'] ?? null,
|
||||
'quote_id' => $validated['quote_id'] ?? null,
|
||||
'deal_id' => $validated['deal_id'] ?? null,
|
||||
'created_by' => $request->user()->id,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'title' => $validated['title'],
|
||||
'status' => CrmInvoice::STATUS_DRAFT,
|
||||
'issue_date' => now(),
|
||||
'invoice_date' => now(),
|
||||
'due_date' => $validated['due_date'],
|
||||
'tax_rate' => $validated['tax_rate'] ?? 0,
|
||||
'discount_type' => $validated['discount_type'],
|
||||
'discount_value' => $validated['discount_value'] ?? 0,
|
||||
'notes' => $validated['notes'],
|
||||
'payment_terms' => $validated['payment_terms'],
|
||||
'terms' => $validated['payment_terms'],
|
||||
'currency' => 'USD',
|
||||
]);
|
||||
|
||||
@@ -154,10 +229,12 @@ class InvoiceController extends Controller
|
||||
foreach ($validated['items'] as $index => $item) {
|
||||
CrmInvoiceItem::create([
|
||||
'invoice_id' => $invoice->id,
|
||||
'product_id' => $item['product_id'] ?? null,
|
||||
'description' => $item['description'],
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unit_price'],
|
||||
'sort_order' => $index,
|
||||
'discount_percent' => $item['discount_percent'] ?? 0,
|
||||
'position' => $index,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -167,6 +244,135 @@ class InvoiceController extends Controller
|
||||
->with('success', 'Invoice created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit invoice form
|
||||
*/
|
||||
public function edit(Request $request, Business $business, CrmInvoice $invoice)
|
||||
{
|
||||
if ($invoice->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $invoice->canBeEdited()) {
|
||||
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||
->withErrors(['error' => 'This invoice cannot be edited.']);
|
||||
}
|
||||
|
||||
$invoice->load(['contact', 'account', 'items.product']);
|
||||
|
||||
// Get all approved buyer businesses
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->with('locations:id,business_id,name,is_primary')
|
||||
->orderBy('name')
|
||||
->select(['id', 'name', 'slug'])
|
||||
->get();
|
||||
|
||||
// Get open deals for linking
|
||||
$deals = CrmDeal::forBusiness($business->id)->open()->get();
|
||||
|
||||
// No quotes dropdown in edit - already linked
|
||||
$quotes = collect();
|
||||
|
||||
$selectedAccount = $invoice->account;
|
||||
$selectedLocation = $invoice->location ?? null;
|
||||
$selectedContact = $invoice->contact;
|
||||
$locationContacts = collect();
|
||||
|
||||
return view('seller.crm.invoices.edit', compact(
|
||||
'invoice',
|
||||
'accounts',
|
||||
'deals',
|
||||
'quotes',
|
||||
'business',
|
||||
'selectedAccount',
|
||||
'selectedLocation',
|
||||
'selectedContact',
|
||||
'locationContacts'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update invoice
|
||||
*/
|
||||
public function update(Request $request, Business $business, CrmInvoice $invoice)
|
||||
{
|
||||
if ($invoice->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $invoice->canBeEdited()) {
|
||||
return back()->withErrors(['error' => 'This invoice cannot be edited.']);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'contact_id' => 'required|exists:contacts,id',
|
||||
'account_id' => 'nullable|exists:businesses,id',
|
||||
'location_id' => 'nullable|exists:business_locations,id',
|
||||
'deal_id' => 'nullable|exists:crm_deals,id',
|
||||
'due_date' => 'required|date',
|
||||
'tax_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'discount_type' => 'nullable|in:fixed,percentage',
|
||||
'discount_value' => 'nullable|numeric|min:0',
|
||||
'notes' => 'nullable|string|max:2000',
|
||||
'payment_terms' => 'nullable|string|max:1000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'nullable|exists:products,id',
|
||||
'items.*.description' => 'required|string|max:500',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.unit_price' => 'required|numeric|min:0',
|
||||
'items.*.discount_percent' => 'nullable|numeric|min:0|max:100',
|
||||
]);
|
||||
|
||||
// SECURITY: Verify contact belongs to the account if account is provided
|
||||
$contact = \App\Models\Contact::findOrFail($validated['contact_id']);
|
||||
if (! empty($validated['account_id']) && $contact->business_id !== (int) $validated['account_id']) {
|
||||
return back()->withErrors(['contact_id' => 'Contact must belong to the selected account.']);
|
||||
}
|
||||
|
||||
// SECURITY: Verify deal belongs to business if provided
|
||||
if (! empty($validated['deal_id'])) {
|
||||
CrmDeal::where('id', $validated['deal_id'])
|
||||
->where('business_id', $business->id)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
$invoice->update([
|
||||
'contact_id' => $validated['contact_id'],
|
||||
'account_id' => $validated['account_id'],
|
||||
'location_id' => $validated['location_id'] ?? null,
|
||||
'deal_id' => $validated['deal_id'] ?? null,
|
||||
'title' => $validated['title'],
|
||||
'due_date' => $validated['due_date'],
|
||||
'tax_rate' => $validated['tax_rate'] ?? 0,
|
||||
'discount_type' => $validated['discount_type'],
|
||||
'discount_value' => $validated['discount_value'] ?? 0,
|
||||
'notes' => $validated['notes'],
|
||||
'terms' => $validated['payment_terms'],
|
||||
]);
|
||||
|
||||
// Delete existing items and recreate
|
||||
$invoice->items()->delete();
|
||||
|
||||
foreach ($validated['items'] as $index => $item) {
|
||||
CrmInvoiceItem::create([
|
||||
'invoice_id' => $invoice->id,
|
||||
'product_id' => $item['product_id'] ?? null,
|
||||
'description' => $item['description'],
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unit_price'],
|
||||
'discount_percent' => $item['discount_percent'] ?? 0,
|
||||
'position' => $index,
|
||||
]);
|
||||
}
|
||||
|
||||
$invoice->calculateTotals();
|
||||
|
||||
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||
->with('success', 'Invoice updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send invoice to contact
|
||||
*/
|
||||
@@ -180,9 +386,31 @@ class InvoiceController extends Controller
|
||||
return back()->withErrors(['error' => 'This invoice cannot be sent.']);
|
||||
}
|
||||
|
||||
$invoice->send($request->user());
|
||||
$validated = $request->validate([
|
||||
'to' => 'required|email',
|
||||
'cc' => 'nullable|string',
|
||||
'message' => 'nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
// TODO: Send email notification to contact
|
||||
// Generate PDF
|
||||
$invoice->load(['contact', 'account', 'location', 'deal', 'quote', 'order', 'items.product.brand', 'creator']);
|
||||
$pdf = Pdf::loadView('pdfs.crm-invoice', [
|
||||
'invoice' => $invoice,
|
||||
'business' => $business,
|
||||
]);
|
||||
|
||||
// Send email
|
||||
$ccEmails = [];
|
||||
if (! empty($validated['cc'])) {
|
||||
$ccEmails = array_filter(array_map('trim', explode(',', $validated['cc'])));
|
||||
}
|
||||
|
||||
Mail::to($validated['to'])
|
||||
->cc($ccEmails)
|
||||
->send(new InvoiceMail($invoice, $business, $validated['message'] ?? null, $pdf->output()));
|
||||
|
||||
// Update status
|
||||
$invoice->send($request->user());
|
||||
|
||||
return back()->with('success', 'Invoice sent successfully.');
|
||||
}
|
||||
@@ -259,8 +487,33 @@ class InvoiceController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// TODO: Generate PDF
|
||||
return back()->with('info', 'PDF generation coming soon.');
|
||||
$invoice->load(['contact', 'account', 'location', 'deal', 'quote', 'order', 'items.product.brand', 'creator']);
|
||||
|
||||
$pdf = Pdf::loadView('pdfs.crm-invoice', [
|
||||
'invoice' => $invoice,
|
||||
'business' => $business,
|
||||
]);
|
||||
|
||||
return $pdf->download($invoice->invoice_number.'.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* View invoice PDF inline
|
||||
*/
|
||||
public function pdf(Request $request, Business $business, CrmInvoice $invoice)
|
||||
{
|
||||
if ($invoice->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$invoice->load(['contact', 'account', 'location', 'deal', 'quote', 'order', 'items.product.brand', 'creator']);
|
||||
|
||||
$pdf = Pdf::loadView('pdfs.crm-invoice', [
|
||||
'invoice' => $invoice,
|
||||
'business' => $business,
|
||||
]);
|
||||
|
||||
return $pdf->stream($invoice->invoice_number.'.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,19 @@ class LeadController extends Controller
|
||||
|
||||
$leads = $query->latest()->paginate(25);
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $leads->map(fn ($l) => [
|
||||
'hashid' => $l->hashid,
|
||||
'name' => $l->company_name,
|
||||
'contact' => $l->contact_name,
|
||||
'email' => $l->contact_email,
|
||||
'status' => $l->status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.crm.leads.index', compact('business', 'leads'));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ use App\Models\Contact;
|
||||
use App\Models\Crm\CrmDeal;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use App\Models\Crm\CrmQuoteItem;
|
||||
use App\Models\Location;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Services\Accounting\ArService;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -37,13 +37,26 @@ class QuoteController extends Controller
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('quote_number', 'like', "%{$request->search}%")
|
||||
->orWhere('title', 'like', "%{$request->search}%");
|
||||
$q->where('quote_number', 'ilike', "%{$request->search}%")
|
||||
->orWhere('title', 'ilike', "%{$request->search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$quotes = $query->orderByDesc('created_at')->paginate(25);
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $quotes->map(fn ($q) => [
|
||||
'id' => $q->id,
|
||||
'name' => $q->quote_number.' - '.($q->title ?? 'Untitled'),
|
||||
'contact' => $q->contact?->name ?? '-',
|
||||
'status' => $q->status,
|
||||
'total' => '$'.number_format($q->total, 2),
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.crm.quotes.index', compact('quotes', 'business'));
|
||||
}
|
||||
|
||||
@@ -52,11 +65,13 @@ class QuoteController extends Controller
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
// Get buyer businesses that have contacts (potential and existing customers)
|
||||
// Get all approved buyer businesses as potential customers
|
||||
// Contacts are loaded dynamically via /search/contacts?customer_id={account_id}
|
||||
// Include locations for delivery address selection
|
||||
// Note: We don't filter by whereHas('contacts') because newly created customers
|
||||
// may not have contacts yet - contacts can be added after selecting the account
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->whereHas('contacts')
|
||||
->where('status', 'approved')
|
||||
->with('locations:id,business_id,name,is_primary')
|
||||
->orderBy('name')
|
||||
->select(['id', 'name', 'slug'])
|
||||
@@ -69,7 +84,77 @@ class QuoteController extends Controller
|
||||
? CrmDeal::forBusiness($business->id)->find($request->deal_id)
|
||||
: null;
|
||||
|
||||
return view('seller.crm.quotes.create', compact('accounts', 'deals', 'deal', 'business'));
|
||||
// Pre-fill from URL parameters (coming from customer dashboard)
|
||||
$selectedAccount = null;
|
||||
$selectedLocation = null;
|
||||
$selectedContact = null;
|
||||
$locationContacts = collect();
|
||||
|
||||
// Handle clear actions
|
||||
if ($request->has('clearAccount')) {
|
||||
// Redirect without any prefills
|
||||
return redirect()->route('seller.business.crm.quotes.create', $business);
|
||||
}
|
||||
if ($request->has('clearLocation')) {
|
||||
// Keep account but clear location
|
||||
return redirect()->route('seller.business.crm.quotes.create', [$business, 'account_id' => $request->account_id]);
|
||||
}
|
||||
if ($request->has('clearContact')) {
|
||||
// Keep account and location but clear contact
|
||||
$params = ['account_id' => $request->account_id];
|
||||
if ($request->location_id) {
|
||||
$params['location_id'] = $request->location_id;
|
||||
}
|
||||
|
||||
return redirect()->route('seller.business.crm.quotes.create', array_merge([$business], $params));
|
||||
}
|
||||
|
||||
// Pre-fill account
|
||||
if ($request->filled('account_id')) {
|
||||
$selectedAccount = $accounts->firstWhere('id', $request->account_id);
|
||||
}
|
||||
|
||||
// Pre-fill location (must belong to selected account)
|
||||
if ($selectedAccount && $request->filled('location_id')) {
|
||||
$selectedLocation = $selectedAccount->locations->firstWhere('id', $request->location_id);
|
||||
}
|
||||
|
||||
// If location selected, get contacts assigned to that location
|
||||
if ($selectedLocation) {
|
||||
$locationContacts = $selectedLocation->contacts()
|
||||
->with('pivot')
|
||||
->get()
|
||||
->map(fn ($c) => [
|
||||
'value' => $c->id,
|
||||
'label' => $c->getFullName().($c->email ? " ({$c->email})" : ''),
|
||||
'is_primary' => $c->pivot->is_primary ?? false,
|
||||
'role' => $c->pivot->role ?? 'buyer',
|
||||
]);
|
||||
|
||||
// Try to find primary buyer for this location
|
||||
$primaryBuyer = $locationContacts->firstWhere('is_primary', true)
|
||||
?? $locationContacts->firstWhere('role', 'buyer');
|
||||
|
||||
if ($primaryBuyer && ! $request->filled('contact_id')) {
|
||||
$selectedContact = Contact::find($primaryBuyer['value']);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fill contact if explicitly provided
|
||||
if ($request->filled('contact_id')) {
|
||||
$selectedContact = Contact::find($request->contact_id);
|
||||
}
|
||||
|
||||
return view('seller.crm.quotes.create', compact(
|
||||
'accounts',
|
||||
'deals',
|
||||
'deal',
|
||||
'business',
|
||||
'selectedAccount',
|
||||
'selectedLocation',
|
||||
'selectedContact',
|
||||
'locationContacts'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +173,6 @@ class QuoteController extends Controller
|
||||
'tax_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'terms' => 'nullable|string|max:5000',
|
||||
'notes' => 'nullable|string|max:2000',
|
||||
'signature_requested' => 'boolean',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'nullable|exists:products,id',
|
||||
'items.*.description' => 'required|string|max:500',
|
||||
@@ -124,13 +208,13 @@ class QuoteController extends Controller
|
||||
'quote_number' => $quoteNumber,
|
||||
'title' => $validated['title'],
|
||||
'status' => CrmQuote::STATUS_DRAFT,
|
||||
'quote_date' => now(),
|
||||
'valid_until' => $validated['valid_until'] ?? now()->addDays($business->crm_quote_validity_days ?? 30),
|
||||
'discount_type' => $validated['discount_type'],
|
||||
'discount_value' => $validated['discount_value'],
|
||||
'tax_rate' => $validated['tax_rate'] ?? 0,
|
||||
'terms' => $validated['terms'] ?? $business->crm_default_terms,
|
||||
'notes' => $validated['notes'],
|
||||
'signature_requested' => $validated['signature_requested'] ?? false,
|
||||
'currency' => 'USD',
|
||||
]);
|
||||
|
||||
@@ -180,16 +264,9 @@ class QuoteController extends Controller
|
||||
return back()->withErrors(['error' => 'This quote cannot be edited.']);
|
||||
}
|
||||
|
||||
$quote->load('items');
|
||||
$quote->load(['items.product', 'contact', 'account', 'deal']);
|
||||
|
||||
$contacts = Contact::where('business_id', $business->id)->get();
|
||||
$accounts = Business::whereHas('ordersAsCustomer')->get();
|
||||
$deals = CrmDeal::forBusiness($business->id)->open()->get();
|
||||
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
return view('seller.crm.quotes.edit', compact('quote', 'contacts', 'accounts', 'deals', 'products', 'business'));
|
||||
return view('seller.crm.quotes.edit', compact('quote', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,8 +40,30 @@ class TaskController extends Controller
|
||||
$tasksQuery->where('type', $request->type);
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('q')) {
|
||||
$search = $request->q;
|
||||
$tasksQuery->where(function ($q) use ($search) {
|
||||
$q->where('title', 'ILIKE', "%{$search}%")
|
||||
->orWhere('details', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$tasks = $tasksQuery->paginate(25);
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $tasks->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'name' => $t->title,
|
||||
'type' => $t->type,
|
||||
'assignee' => $t->assignee?->name ?? 'Unassigned',
|
||||
'due_at' => $t->due_at?->format('M j, Y'),
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Get stats with single efficient query
|
||||
$statsQuery = CrmTask::where('seller_business_id', $business->id)
|
||||
->selectRaw('
|
||||
@@ -75,7 +97,19 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
return view('seller.crm.tasks.create', compact('business'));
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
|
||||
// Prefill from query params (when creating task from contact/account/etc)
|
||||
$prefill = [
|
||||
'title' => $request->get('title'),
|
||||
'business_id' => $request->get('business_id'),
|
||||
'contact_id' => $request->get('contact_id'),
|
||||
'opportunity_id' => $request->get('opportunity_id'),
|
||||
'conversation_id' => $request->get('conversation_id'),
|
||||
'order_id' => $request->get('order_id'),
|
||||
];
|
||||
|
||||
return view('seller.crm.tasks.create', compact('business', 'teamMembers', 'prefill'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,17 +2,24 @@
|
||||
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Events\CrmTypingIndicator;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AgentStatus;
|
||||
use App\Models\Business;
|
||||
use App\Models\ChatQuickReply;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Crm\CrmActiveView;
|
||||
use App\Models\Crm\CrmChannel;
|
||||
use App\Models\Crm\CrmInternalNote;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use App\Models\SalesRepAssignment;
|
||||
use App\Models\User;
|
||||
use App\Services\Crm\CrmAiService;
|
||||
use App\Services\Crm\CrmChannelService;
|
||||
use App\Services\Crm\CrmSlaService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ThreadController extends Controller
|
||||
{
|
||||
@@ -164,9 +171,9 @@ class ThreadController extends Controller
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('subject', 'like', "%{$request->search}%")
|
||||
->orWhere('last_message_preview', 'like', "%{$request->search}%")
|
||||
->orWhereHas('contact', fn ($c) => $c->where('name', 'like', "%{$request->search}%"));
|
||||
$q->where('subject', 'ilike', "%{$request->search}%")
|
||||
->orWhere('last_message_preview', 'ilike', "%{$request->search}%")
|
||||
->orWhereHas('contact', fn ($c) => $c->where('name', 'ilike', "%{$request->search}%"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -446,4 +453,354 @@ class ThreadController extends Controller
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API Endpoints for Real-Time Inbox
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API: Get threads list for real-time updates
|
||||
*/
|
||||
public function apiIndex(Request $request, Business $business): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$query = CrmThread::forBusiness($business->id)
|
||||
->with(['contact:id,first_name,last_name,email,phone', 'assignee:id,name', 'channel:id,type,name', 'account:id,name'])
|
||||
->withCount('messages');
|
||||
|
||||
// Apply "my accounts" filter for sales reps
|
||||
if ($request->boolean('my_accounts')) {
|
||||
$query->forSalesRep($business->id, $user->id);
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ($request->filled('status') && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// Channel filter
|
||||
if ($request->filled('channel') && $request->channel !== 'all') {
|
||||
$query->where('last_channel_type', $request->channel);
|
||||
}
|
||||
|
||||
// Assigned filter
|
||||
if ($request->filled('assigned')) {
|
||||
if ($request->assigned === 'me') {
|
||||
$query->where('assigned_to', $user->id);
|
||||
} elseif ($request->assigned === 'unassigned') {
|
||||
$query->whereNull('assigned_to');
|
||||
} elseif (is_numeric($request->assigned)) {
|
||||
$query->where('assigned_to', $request->assigned);
|
||||
}
|
||||
}
|
||||
|
||||
// Search
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('subject', 'ilike', "%{$search}%")
|
||||
->orWhere('last_message_preview', 'ilike', "%{$search}%")
|
||||
->orWhereHas('contact', fn ($c) => $c->whereRaw("CONCAT(first_name, ' ', last_name) ILIKE ?", ["%{$search}%"]))
|
||||
->orWhereHas('account', fn ($a) => $a->where('name', 'ilike', "%{$search}%"));
|
||||
});
|
||||
}
|
||||
|
||||
$threads = $query->orderByDesc('last_message_at')
|
||||
->limit($request->input('limit', 50))
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'threads' => $threads->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'subject' => $t->subject,
|
||||
'status' => $t->status,
|
||||
'priority' => $t->priority,
|
||||
'is_read' => $t->is_read,
|
||||
'last_message_at' => $t->last_message_at?->toIso8601String(),
|
||||
'last_message_preview' => $t->last_message_preview,
|
||||
'last_message_direction' => $t->last_message_direction,
|
||||
'last_channel_type' => $t->last_channel_type,
|
||||
'contact' => $t->contact ? [
|
||||
'id' => $t->contact->id,
|
||||
'name' => $t->contact->getFullName(),
|
||||
'email' => $t->contact->email,
|
||||
'phone' => $t->contact->phone,
|
||||
] : null,
|
||||
'account' => $t->account ? [
|
||||
'id' => $t->account->id,
|
||||
'name' => $t->account->name,
|
||||
] : null,
|
||||
'assignee' => $t->assignee ? [
|
||||
'id' => $t->assignee->id,
|
||||
'name' => $t->assignee->name,
|
||||
] : null,
|
||||
'messages_count' => $t->messages_count,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Get messages for a thread
|
||||
*/
|
||||
public function apiMessages(Request $request, Business $business, CrmThread $thread): JsonResponse
|
||||
{
|
||||
if ($thread->business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$query = $thread->messages()
|
||||
->with(['user:id,name', 'attachments'])
|
||||
->orderBy('created_at', 'asc');
|
||||
|
||||
// Pagination for infinite scroll
|
||||
if ($request->filled('before_id')) {
|
||||
$query->where('id', '<', $request->before_id);
|
||||
}
|
||||
|
||||
$messages = $query->limit($request->input('limit', 50))->get();
|
||||
|
||||
// Mark thread as read
|
||||
if ($messages->isNotEmpty()) {
|
||||
$thread->markAsRead($request->user());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'messages' => $messages->map(fn ($m) => [
|
||||
'id' => $m->id,
|
||||
'body' => $m->body,
|
||||
'body_html' => $m->body_html,
|
||||
'direction' => $m->direction,
|
||||
'channel_type' => $m->channel_type,
|
||||
'sender_id' => $m->user_id,
|
||||
'sender_name' => $m->user?->name ?? ($m->direction === 'inbound' ? $thread->contact?->getFullName() : 'System'),
|
||||
'status' => $m->status,
|
||||
'created_at' => $m->created_at->toIso8601String(),
|
||||
'attachments' => $m->attachments->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'filename' => $a->original_filename ?? $a->filename,
|
||||
'mime_type' => $a->mime_type,
|
||||
'size' => $a->size,
|
||||
'url' => Storage::disk($a->disk ?? 'minio')->url($a->path),
|
||||
]),
|
||||
]),
|
||||
'has_more' => $messages->count() === $request->input('limit', 50),
|
||||
'thread' => [
|
||||
'id' => $thread->id,
|
||||
'subject' => $thread->subject,
|
||||
'status' => $thread->status,
|
||||
'contact' => $thread->contact ? [
|
||||
'id' => $thread->contact->id,
|
||||
'name' => $thread->contact->getFullName(),
|
||||
'email' => $thread->contact->email,
|
||||
'phone' => $thread->contact->phone,
|
||||
] : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Send typing indicator
|
||||
*/
|
||||
public function typing(Request $request, Business $business, CrmThread $thread): JsonResponse
|
||||
{
|
||||
if ($thread->business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'is_typing' => 'required|boolean',
|
||||
]);
|
||||
|
||||
broadcast(new CrmTypingIndicator(
|
||||
threadId: $thread->id,
|
||||
userId: $request->user()->id,
|
||||
userName: $request->user()->name,
|
||||
isTyping: $validated['is_typing']
|
||||
))->toOthers();
|
||||
|
||||
// Update active view type
|
||||
CrmActiveView::startViewing(
|
||||
$thread,
|
||||
$request->user(),
|
||||
$validated['is_typing'] ? CrmActiveView::VIEW_TYPE_TYPING : CrmActiveView::VIEW_TYPE_VIEWING
|
||||
);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Get quick replies
|
||||
*/
|
||||
public function quickReplies(Request $request, Business $business): JsonResponse
|
||||
{
|
||||
$quickReplies = ChatQuickReply::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderByDesc('usage_count')
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->groupBy('category');
|
||||
|
||||
return response()->json([
|
||||
'quick_replies' => $quickReplies,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Use a quick reply (increment usage count)
|
||||
*/
|
||||
public function useQuickReply(Request $request, Business $business, ChatQuickReply $quickReply): JsonResponse
|
||||
{
|
||||
if ($quickReply->business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
// Increment usage count
|
||||
$quickReply->increment('usage_count');
|
||||
|
||||
// Process template variables
|
||||
$message = $quickReply->message;
|
||||
|
||||
if ($request->filled('contact_id')) {
|
||||
$contact = Contact::find($request->contact_id);
|
||||
if ($contact) {
|
||||
$message = str_replace(
|
||||
['{{name}}', '{{first_name}}', '{{last_name}}', '{{company}}'],
|
||||
[$contact->getFullName(), $contact->first_name, $contact->last_name, $contact->business?->name ?? ''],
|
||||
$message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => $message,
|
||||
'label' => $quickReply->label,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Get contact details with email engagement
|
||||
*/
|
||||
public function apiContact(Request $request, Business $business, CrmThread $thread): JsonResponse
|
||||
{
|
||||
if ($thread->business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$contact = $thread->contact;
|
||||
if (! $contact) {
|
||||
return response()->json(['contact' => null]);
|
||||
}
|
||||
|
||||
// Get recent email engagement
|
||||
$emailEngagement = [];
|
||||
if (class_exists(\App\Models\Analytics\EmailInteraction::class)) {
|
||||
$emailEngagement = \App\Models\Analytics\EmailInteraction::where(function ($q) use ($contact) {
|
||||
$q->where('recipient_email', $contact->email);
|
||||
if ($contact->user_id) {
|
||||
$q->orWhere('recipient_user_id', $contact->user_id);
|
||||
}
|
||||
})
|
||||
->whereNotNull('first_opened_at')
|
||||
->with('emailCampaign:id,subject')
|
||||
->orderByDesc('first_opened_at')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn ($i) => [
|
||||
'id' => $i->id,
|
||||
'campaign_subject' => $i->emailCampaign?->subject ?? 'Unknown Campaign',
|
||||
'opened_at' => $i->first_opened_at?->toIso8601String(),
|
||||
'open_count' => $i->open_count,
|
||||
'clicked_at' => $i->first_clicked_at?->toIso8601String(),
|
||||
'click_count' => $i->click_count,
|
||||
]);
|
||||
}
|
||||
|
||||
// Get recent orders from this contact's account
|
||||
$recentOrders = [];
|
||||
if ($thread->account_id) {
|
||||
$recentOrders = \App\Models\Order::where('business_id', $thread->account_id)
|
||||
->whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->orderByDesc('created_at')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($o) => [
|
||||
'id' => $o->id,
|
||||
'hashid' => $o->hashid,
|
||||
'total' => $o->total,
|
||||
'status' => $o->status,
|
||||
'created_at' => $o->created_at->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'contact' => [
|
||||
'id' => $contact->id,
|
||||
'name' => $contact->getFullName(),
|
||||
'email' => $contact->email,
|
||||
'phone' => $contact->phone,
|
||||
'title' => $contact->title,
|
||||
'contact_type' => $contact->contact_type,
|
||||
],
|
||||
'account' => $thread->account ? [
|
||||
'id' => $thread->account->id,
|
||||
'name' => $thread->account->name,
|
||||
'address' => $thread->account->full_address ?? null,
|
||||
] : null,
|
||||
'email_engagement' => $emailEngagement,
|
||||
'recent_orders' => $recentOrders,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified inbox view (Chatwoot-style)
|
||||
*/
|
||||
public function unified(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Get initial threads
|
||||
$query = CrmThread::forBusiness($business->id)
|
||||
->with(['contact:id,first_name,last_name,email,phone', 'assignee:id,name', 'account:id,name'])
|
||||
->withCount('messages')
|
||||
->orderByDesc('last_message_at')
|
||||
->limit(50);
|
||||
|
||||
$threads = $query->get();
|
||||
|
||||
// Get team members
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->select('id', 'name')
|
||||
->get();
|
||||
|
||||
// Get agent status
|
||||
$agentStatus = AgentStatus::where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
// Get quick replies
|
||||
$quickReplies = ChatQuickReply::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderByDesc('usage_count')
|
||||
->get()
|
||||
->groupBy('category');
|
||||
|
||||
// Get channels
|
||||
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||
|
||||
// Check if user has sales rep assignments (for "My Accounts" filter)
|
||||
$hasSalesRepAssignments = SalesRepAssignment::where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->exists();
|
||||
|
||||
return view('seller.crm.inbox.unified', compact(
|
||||
'business',
|
||||
'threads',
|
||||
'teamMembers',
|
||||
'agentStatus',
|
||||
'quickReplies',
|
||||
'channels',
|
||||
'hasSalesRepAssignments'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Display a listing of invoices for the business.
|
||||
*/
|
||||
public function index(Business $business)
|
||||
public function index(Business $business, Request $request)
|
||||
{
|
||||
// Get brand IDs for this business (single query, reused for filtering)
|
||||
$brandIds = $business->brands()->pluck('id');
|
||||
@@ -138,11 +138,47 @@ class InvoiceController extends Controller
|
||||
->where('due_date', '<', now())->count(),
|
||||
];
|
||||
|
||||
// Apply search filter - search by customer business name or invoice number
|
||||
$search = $request->input('search');
|
||||
if ($search) {
|
||||
$baseQuery->where(function ($query) use ($search) {
|
||||
$query->where('invoice_number', 'ilike', "%{$search}%")
|
||||
->orWhereHas('business', function ($q) use ($search) {
|
||||
$q->where('name', 'ilike', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
$status = $request->input('status');
|
||||
if ($status === 'unpaid') {
|
||||
$baseQuery->where('payment_status', 'unpaid');
|
||||
} elseif ($status === 'paid') {
|
||||
$baseQuery->where('payment_status', 'paid');
|
||||
} elseif ($status === 'overdue') {
|
||||
$baseQuery->where('payment_status', '!=', 'paid')
|
||||
->where('due_date', '<', now());
|
||||
}
|
||||
|
||||
// Paginate with only the relations needed for display
|
||||
$invoices = (clone $baseQuery)
|
||||
->with(['business:id,name,primary_contact_email,business_email', 'order:id,contact_id,user_id', 'order.contact:id,first_name,last_name,email', 'order.user:id,email'])
|
||||
->latest()
|
||||
->paginate(25);
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $invoices->map(fn ($i) => [
|
||||
'hashid' => $i->hashid,
|
||||
'name' => $i->invoice_number.' - '.$i->business->name,
|
||||
'invoice_number' => $i->invoice_number,
|
||||
'customer' => $i->business->name,
|
||||
'status' => $i->payment_status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.invoices.index', compact('business', 'invoices', 'stats'));
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@ class ApVendorsController extends Controller
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%")
|
||||
->orWhere('contact_email', 'like', "%{$search}%");
|
||||
$q->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('code', 'ilike', "%{$search}%")
|
||||
->orWhere('contact_email', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ class ApVendorsController extends Controller
|
||||
|
||||
// Check for uniqueness
|
||||
$count = ApVendor::where('business_id', $businessId)
|
||||
->where('code', 'like', "{$prefix}%")
|
||||
->where('code', 'ilike', "{$prefix}%")
|
||||
->count();
|
||||
|
||||
return $count > 0 ? "{$prefix}-{$count}" : $prefix;
|
||||
|
||||
@@ -42,8 +42,8 @@ class ChartOfAccountsController extends Controller
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('account_number', 'like', "%{$search}%")
|
||||
->orWhere('name', 'like', "%{$search}%");
|
||||
$q->where('account_number', 'ilike', "%{$search}%")
|
||||
->orWhere('name', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -63,8 +63,8 @@ class RequisitionsApprovalController extends Controller
|
||||
// Search
|
||||
if ($search = $request->get('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('requisition_number', 'like', "%{$search}%")
|
||||
->orWhere('notes', 'like', "%{$search}%");
|
||||
$q->where('requisition_number', 'ilike', "%{$search}%")
|
||||
->orWhere('notes', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class BiomassController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('lot_number', 'like', '%'.$request->search.'%');
|
||||
$query->where('lot_number', 'ilike', '%'.$request->search.'%');
|
||||
}
|
||||
|
||||
$biomassLots = $query->paginate(25);
|
||||
|
||||
@@ -26,7 +26,7 @@ class MaterialLotController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('lot_number', 'like', '%'.$request->search.'%');
|
||||
$query->where('lot_number', 'ilike', '%'.$request->search.'%');
|
||||
}
|
||||
|
||||
$materialLots = $query->paginate(25);
|
||||
|
||||
@@ -28,7 +28,7 @@ class ProcessingSalesOrderController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('order_number', 'like', '%'.$request->search.'%');
|
||||
$query->where('order_number', 'ilike', '%'.$request->search.'%');
|
||||
}
|
||||
|
||||
$salesOrders = $query->paginate(25);
|
||||
|
||||
@@ -25,7 +25,7 @@ class ProcessingShipmentController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('shipment_number', 'like', '%'.$request->search.'%');
|
||||
$query->where('shipment_number', 'ilike', '%'.$request->search.'%');
|
||||
}
|
||||
|
||||
$shipments = $query->paginate(25);
|
||||
|
||||
@@ -29,6 +29,11 @@ class ProductController extends Controller
|
||||
// Get brand IDs to filter by (respects brand context switcher)
|
||||
$brandIds = BrandSwitcherController::getFilteredBrandIds();
|
||||
|
||||
// Get all brands for the business for the filter dropdown
|
||||
$brands = \App\Models\Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
// Calculate missing BOM count for health alert
|
||||
$missingBomCount = Product::whereIn('brand_id', $brandIds)
|
||||
->where('is_assembly', true)
|
||||
@@ -106,7 +111,7 @@ class ProductController extends Controller
|
||||
'hashid' => $variety->hashid,
|
||||
'name' => $variety->name,
|
||||
'sku' => $variety->sku ?? 'N/A',
|
||||
'price' => $variety->wholesale_price ?? 0,
|
||||
'price' => $variety->effective_price ?? $variety->wholesale_price ?? 0,
|
||||
'status' => $variety->is_active ? 'active' : 'inactive',
|
||||
'image_url' => $variety->getImageUrl('thumb'),
|
||||
'edit_url' => route('seller.business.products.edit', [$business->slug, $variety->hashid]),
|
||||
@@ -123,7 +128,7 @@ class ProductController extends Controller
|
||||
'sku' => $product->sku ?? 'N/A',
|
||||
'brand' => $product->brand->name ?? 'N/A',
|
||||
'channel' => 'Marketplace', // TODO: Add channel field to products
|
||||
'price' => $product->wholesale_price ?? 0,
|
||||
'price' => $product->effective_price ?? $product->wholesale_price ?? 0,
|
||||
'views' => rand(500, 3000), // TODO: Replace with real view tracking
|
||||
'orders' => rand(10, 200), // TODO: Replace with real order count
|
||||
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation
|
||||
@@ -150,7 +155,20 @@ class ProductController extends Controller
|
||||
'to' => $paginator->lastItem(),
|
||||
];
|
||||
|
||||
return view('seller.products.index', compact('business', 'products', 'missingBomCount', 'paginator', 'pagination'));
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $products->map(fn ($p) => [
|
||||
'hashid' => $p['hashid'],
|
||||
'name' => $p['product'],
|
||||
'sku' => $p['sku'],
|
||||
'brand' => $p['brand'],
|
||||
])->values()->toArray(),
|
||||
'pagination' => $pagination,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.products.index', compact('business', 'brands', 'products', 'missingBomCount', 'paginator', 'pagination'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -475,20 +493,34 @@ class ProductController extends Controller
|
||||
// Set default value for price_unit if not provided
|
||||
$validated['price_unit'] = $validated['price_unit'] ?? 'each';
|
||||
|
||||
// Create product
|
||||
$product = Product::create($validated);
|
||||
// Create product and handle images in a transaction
|
||||
$product = \DB::transaction(function () use ($validated, $request, $business) {
|
||||
$product = Product::create($validated);
|
||||
|
||||
// Handle image uploads if present
|
||||
if ($request->hasFile('images')) {
|
||||
foreach ($request->file('images') as $index => $image) {
|
||||
$path = $image->store('products', 'public');
|
||||
$product->images()->create([
|
||||
'path' => $path,
|
||||
'type' => 'product',
|
||||
'is_primary' => $index === 0,
|
||||
]);
|
||||
// Handle image uploads if present
|
||||
// Note: Uses default disk (minio) per CLAUDE.md rules - never use 'public' disk for media
|
||||
if ($request->hasFile('images')) {
|
||||
$brand = $product->brand;
|
||||
$basePath = "businesses/{$business->slug}/brands/{$brand->slug}/products/{$product->sku}/images";
|
||||
|
||||
foreach ($request->file('images') as $index => $image) {
|
||||
$filename = $image->hashName();
|
||||
$path = $image->storeAs($basePath, $filename);
|
||||
|
||||
if ($path === false) {
|
||||
throw new \RuntimeException('Failed to upload image to storage');
|
||||
}
|
||||
|
||||
$product->images()->create([
|
||||
'path' => $path,
|
||||
'type' => 'product',
|
||||
'is_primary' => $index === 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $product;
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.products.index', $business->slug)
|
||||
@@ -849,9 +881,9 @@ class ProductController extends Controller
|
||||
'content' => [
|
||||
'description' => ['nullable', 'string', 'max:255'],
|
||||
'tagline' => ['nullable', 'string', 'max:100'],
|
||||
'long_description' => ['nullable', 'string', 'max:500'],
|
||||
'consumer_long_description' => ['nullable', 'string', 'max:500'],
|
||||
'buyer_long_description' => ['nullable', 'string', 'max:500'],
|
||||
'long_description' => ['nullable', 'string', 'max:5000'],
|
||||
'consumer_long_description' => ['nullable', 'string', 'max:5000'],
|
||||
'buyer_long_description' => ['nullable', 'string', 'max:5000'],
|
||||
'product_link' => 'nullable|url|max:255',
|
||||
'creatives_json' => 'nullable|json',
|
||||
'seo_title' => ['nullable', 'string', 'max:70'],
|
||||
@@ -891,10 +923,10 @@ class ProductController extends Controller
|
||||
|
||||
// Define checkbox fields per tab
|
||||
$checkboxesByTab = [
|
||||
'overview' => ['is_active', 'is_featured', 'sell_multiples', 'fractional_quantities', 'allow_sample', 'has_varieties'],
|
||||
'overview' => ['is_active', 'is_featured', 'is_sellable', 'sell_multiples', 'fractional_quantities', 'allow_sample', 'has_varieties'],
|
||||
'pricing' => ['is_case', 'is_box'],
|
||||
'inventory' => ['sync_bamboo', 'low_stock_alert_enabled', 'is_assembly', 'show_inventory_to_buyers', 'has_varieties'],
|
||||
'advanced' => ['is_sellable', 'is_fpr', 'is_raw_material'],
|
||||
'advanced' => ['is_fpr', 'is_raw_material'],
|
||||
];
|
||||
|
||||
// Convert checkboxes to boolean - only for fields in current validation scope
|
||||
@@ -906,7 +938,7 @@ class ProductController extends Controller
|
||||
if (array_key_exists($checkbox, $rules)) {
|
||||
// Use boolean() for fields that send actual values (hidden inputs with 0/1)
|
||||
// Use has() for traditional checkboxes that are absent when unchecked
|
||||
$useBoolean = in_array($checkbox, ['is_assembly', 'is_raw_material', 'is_active', 'is_featured', 'low_stock_alert_enabled', 'has_varieties']);
|
||||
$useBoolean = in_array($checkbox, ['is_assembly', 'is_raw_material', 'is_active', 'is_featured', 'is_sellable', 'low_stock_alert_enabled', 'has_varieties']);
|
||||
$validated[$checkbox] = $useBoolean
|
||||
? $request->boolean($checkbox)
|
||||
: $request->has($checkbox);
|
||||
@@ -1032,7 +1064,7 @@ class ProductController extends Controller
|
||||
'sku' => $product->sku ?? 'N/A',
|
||||
'brand' => $product->brand->name ?? 'N/A',
|
||||
'channel' => 'Marketplace', // TODO: Add channel field to products
|
||||
'price' => $product->wholesale_price ?? 0,
|
||||
'price' => (float) ($product->wholesale_price ?? 0),
|
||||
'views' => rand(500, 3000), // TODO: Replace with real view tracking
|
||||
'orders' => rand(10, 200), // TODO: Replace with real order count
|
||||
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation
|
||||
|
||||
@@ -29,16 +29,25 @@ class ProductImageController extends Controller
|
||||
'image' => 'required|image|mimes:jpeg,jpg,png|max:2048|dimensions:min_width=750,min_height=384', // 2MB max, 750x384 min
|
||||
]);
|
||||
|
||||
// Check if product already has 8 images
|
||||
if ($product->images()->count() >= 8) {
|
||||
// Check if product already has 6 images
|
||||
if ($product->images()->count() >= 6) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Maximum of 8 images allowed per product',
|
||||
'message' => 'Maximum of 6 images allowed per product',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Store the image using trait method
|
||||
$path = $this->storeFile($request->file('image'), 'products');
|
||||
// Build proper storage path: businesses/{business_slug}/brands/{brand_slug}/products/{sku}/images/
|
||||
$brand = $product->brand;
|
||||
$storagePath = sprintf(
|
||||
'businesses/%s/brands/%s/products/%s/images',
|
||||
$business->slug,
|
||||
$brand->slug,
|
||||
$product->sku
|
||||
);
|
||||
|
||||
// Store the image with proper path
|
||||
$path = $this->storeFile($request->file('image'), $storagePath);
|
||||
|
||||
// Determine if this should be the primary image (first one)
|
||||
$isPrimary = $product->images()->count() === 0;
|
||||
@@ -61,6 +70,8 @@ class ProductImageController extends Controller
|
||||
'id' => $image->id,
|
||||
'path' => $image->path,
|
||||
'is_primary' => $image->is_primary,
|
||||
'url' => route('image.product', ['product' => $product->hashid, 'width' => 400]),
|
||||
'thumb_url' => route('image.product', ['product' => $product->hashid, 'width' => 80]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -16,17 +16,32 @@ class PromotionController extends Controller
|
||||
protected PromoCalculator $promoCalculator
|
||||
) {}
|
||||
|
||||
public function index(Business $business)
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// TODO: Future deprecation - This global promotions page will be replaced by brand-scoped promotions
|
||||
// Once brand-scoped promotions are stable and rolled out, this route should redirect to:
|
||||
// return redirect()->route('seller.business.brands.promotions.index', [$business, $defaultBrand]);
|
||||
// Where $defaultBrand is determined by business context or user preference
|
||||
|
||||
$promotions = Promotion::where('business_id', $business->id)
|
||||
->withCount('products')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
// Get brands for filter dropdown
|
||||
$brands = \App\Models\Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'hashid']);
|
||||
|
||||
$query = Promotion::where('business_id', $business->id)
|
||||
->withCount('products');
|
||||
|
||||
// Filter by brand
|
||||
if ($request->filled('brand')) {
|
||||
$query->where('brand_id', $request->brand);
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$promotions = $query->orderBy('created_at', 'desc')->get();
|
||||
|
||||
// Load pending recommendations with product data
|
||||
// Gracefully handle if promo_recommendations table doesn't exist yet
|
||||
@@ -41,7 +56,7 @@ class PromotionController extends Controller
|
||||
->get();
|
||||
}
|
||||
|
||||
return view('seller.promotions.index', compact('business', 'promotions', 'recommendations'));
|
||||
return view('seller.promotions.index', compact('business', 'promotions', 'recommendations', 'brands'));
|
||||
}
|
||||
|
||||
public function create(Business $business)
|
||||
|
||||
@@ -44,8 +44,8 @@ class RequisitionsController extends Controller
|
||||
|
||||
if ($search = $request->get('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('requisition_number', 'like', "%{$search}%")
|
||||
->orWhere('notes', 'like', "%{$search}%");
|
||||
$q->where('requisition_number', 'ilike', "%{$search}%")
|
||||
->orWhere('notes', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
274
app/Http/Controllers/Seller/Sales/AccountsController.php
Normal file
274
app/Http/Controllers/Seller/Sales/AccountsController.php
Normal file
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AccountNote;
|
||||
use App\Models\Business;
|
||||
use App\Models\Order;
|
||||
use App\Models\SalesRepAssignment;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountsController extends Controller
|
||||
{
|
||||
/**
|
||||
* My Accounts - list of accounts assigned to this sales rep
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Get account assignments with eager loading
|
||||
$assignments = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->accounts()
|
||||
->with(['assignable', 'assignable.locations', 'assignable.contacts'])
|
||||
->get();
|
||||
|
||||
// Get account metrics in batch
|
||||
$accountIds = $assignments->pluck('assignable_id');
|
||||
$metrics = $this->getAccountMetrics($accountIds);
|
||||
|
||||
// Build account list with metrics
|
||||
$accounts = $assignments->map(function ($assignment) use ($metrics) {
|
||||
$accountId = $assignment->assignable_id;
|
||||
$accountMetrics = $metrics[$accountId] ?? [];
|
||||
|
||||
return [
|
||||
'assignment' => $assignment,
|
||||
'account' => $assignment->assignable,
|
||||
'metrics' => $accountMetrics,
|
||||
'health' => $this->calculateHealth($accountMetrics),
|
||||
];
|
||||
});
|
||||
|
||||
// Apply filters
|
||||
$statusFilter = $request->get('status');
|
||||
if ($statusFilter) {
|
||||
$accounts = $accounts->filter(fn ($a) => $a['health']['status'] === $statusFilter);
|
||||
}
|
||||
|
||||
// Sort by health priority (at_risk first)
|
||||
$accounts = $accounts->sortBy(fn ($a) => $a['health']['priority'])->values();
|
||||
|
||||
return view('seller.sales.accounts.index', compact('business', 'accounts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show account detail with full history
|
||||
*/
|
||||
public function show(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Verify user is assigned to this account
|
||||
$assignment = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->where('assignable_type', Business::class)
|
||||
->where('assignable_id', $account->id)
|
||||
->first();
|
||||
|
||||
if (! $assignment) {
|
||||
abort(403, 'You are not assigned to this account.');
|
||||
}
|
||||
|
||||
// Get account details with relationships
|
||||
$account->load(['locations', 'contacts']);
|
||||
|
||||
// Get order history
|
||||
$orders = Order::where('business_id', $account->id)
|
||||
->with(['items.product.brand'])
|
||||
->orderByDesc('created_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Get account notes
|
||||
$notes = AccountNote::forBusiness($business->id)
|
||||
->forAccount($account->id)
|
||||
->with('author')
|
||||
->orderByDesc('is_pinned')
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
// Get account metrics
|
||||
$metrics = $this->getAccountDetailMetrics($account);
|
||||
|
||||
return view('seller.sales.accounts.show', compact(
|
||||
'business',
|
||||
'account',
|
||||
'assignment',
|
||||
'orders',
|
||||
'notes',
|
||||
'metrics'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new account note
|
||||
*/
|
||||
public function storeNote(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'note_type' => 'required|in:general,competitor,pain_point,opportunity,objection',
|
||||
'content' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
$note = AccountNote::create([
|
||||
'business_id' => $business->id,
|
||||
'account_id' => $account->id,
|
||||
'user_id' => $request->user()->id,
|
||||
'note_type' => $validated['note_type'],
|
||||
'content' => $validated['content'],
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Note added successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle note pinned status
|
||||
*/
|
||||
public function toggleNotePin(Request $request, Business $business, AccountNote $note)
|
||||
{
|
||||
// Verify note belongs to this business
|
||||
if ($note->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$note->is_pinned = ! $note->is_pinned;
|
||||
$note->save();
|
||||
|
||||
return back()->with('success', $note->is_pinned ? 'Note pinned.' : 'Note unpinned.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an account note
|
||||
*/
|
||||
public function destroyNote(Request $request, Business $business, AccountNote $note)
|
||||
{
|
||||
// Verify note belongs to this business
|
||||
if ($note->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Only allow deletion by author or admin
|
||||
if ($note->user_id !== $request->user()->id && ! $request->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$note->delete();
|
||||
|
||||
return back()->with('success', 'Note deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch metrics for multiple accounts
|
||||
*/
|
||||
protected function getAccountMetrics($accountIds): array
|
||||
{
|
||||
if ($accountIds->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$metrics = Order::whereIn('business_id', $accountIds)
|
||||
->where('status', 'completed')
|
||||
->groupBy('business_id')
|
||||
->selectRaw('
|
||||
business_id,
|
||||
COUNT(*) as order_count,
|
||||
SUM(total) as total_revenue,
|
||||
MAX(created_at) as last_order_date
|
||||
')
|
||||
->get()
|
||||
->keyBy('business_id');
|
||||
|
||||
// Get 4-week rolling revenue
|
||||
$recentRevenue = Order::whereIn('business_id', $accountIds)
|
||||
->where('status', 'completed')
|
||||
->where('created_at', '>=', now()->subWeeks(4))
|
||||
->groupBy('business_id')
|
||||
->selectRaw('business_id, SUM(total) as four_week_revenue')
|
||||
->pluck('four_week_revenue', 'business_id');
|
||||
|
||||
return $accountIds->mapWithKeys(function ($id) use ($metrics, $recentRevenue) {
|
||||
$m = $metrics[$id] ?? null;
|
||||
|
||||
return [$id => [
|
||||
'order_count' => $m?->order_count ?? 0,
|
||||
'total_revenue' => ($m?->total_revenue ?? 0) / 100,
|
||||
'four_week_revenue' => ($recentRevenue[$id] ?? 0) / 100,
|
||||
'last_order_date' => $m?->last_order_date,
|
||||
'days_since_order' => $m?->last_order_date
|
||||
? now()->diffInDays($m->last_order_date)
|
||||
: null,
|
||||
]];
|
||||
})->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed metrics for a single account
|
||||
*/
|
||||
protected function getAccountDetailMetrics(Business $account): array
|
||||
{
|
||||
$orders = Order::where('business_id', $account->id)
|
||||
->where('status', 'completed')
|
||||
->get();
|
||||
|
||||
if ($orders->isEmpty()) {
|
||||
return [
|
||||
'lifetime_revenue' => 0,
|
||||
'lifetime_orders' => 0,
|
||||
'avg_order_value' => 0,
|
||||
'four_week_revenue' => 0,
|
||||
'last_order_date' => null,
|
||||
'days_since_order' => null,
|
||||
'avg_order_interval' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$lifetime = $orders->sum('total') / 100;
|
||||
$recentOrders = $orders->where('created_at', '>=', now()->subWeeks(4));
|
||||
$fourWeekRevenue = $recentOrders->sum('total') / 100;
|
||||
|
||||
// Calculate average order interval
|
||||
$sortedDates = $orders->pluck('created_at')->sort()->values();
|
||||
$intervals = [];
|
||||
for ($i = 1; $i < $sortedDates->count(); $i++) {
|
||||
$intervals[] = $sortedDates[$i]->diffInDays($sortedDates[$i - 1]);
|
||||
}
|
||||
$avgInterval = count($intervals) > 0 ? array_sum($intervals) / count($intervals) : null;
|
||||
|
||||
return [
|
||||
'lifetime_revenue' => $lifetime,
|
||||
'lifetime_orders' => $orders->count(),
|
||||
'avg_order_value' => $orders->count() > 0 ? $lifetime / $orders->count() : 0,
|
||||
'four_week_revenue' => $fourWeekRevenue,
|
||||
'last_order_date' => $orders->max('created_at'),
|
||||
'days_since_order' => $orders->max('created_at')
|
||||
? now()->diffInDays($orders->max('created_at'))
|
||||
: null,
|
||||
'avg_order_interval' => $avgInterval ? round($avgInterval) : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate health status
|
||||
*/
|
||||
protected function calculateHealth(array $metrics): array
|
||||
{
|
||||
$days = $metrics['days_since_order'] ?? null;
|
||||
|
||||
if ($days === null) {
|
||||
return ['status' => 'new', 'label' => 'New', 'color' => 'info', 'priority' => 2];
|
||||
}
|
||||
|
||||
if ($days >= 60) {
|
||||
return ['status' => 'at_risk', 'label' => 'At Risk', 'color' => 'error', 'priority' => 0];
|
||||
}
|
||||
|
||||
if ($days >= 30) {
|
||||
return ['status' => 'needs_attention', 'label' => 'Needs Attention', 'color' => 'warning', 'priority' => 1];
|
||||
}
|
||||
|
||||
return ['status' => 'healthy', 'label' => 'Healthy', 'color' => 'success', 'priority' => 3];
|
||||
}
|
||||
}
|
||||
261
app/Http/Controllers/Seller/Sales/CommissionController.php
Normal file
261
app/Http/Controllers/Seller/Sales/CommissionController.php
Normal file
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\SalesCommission;
|
||||
use App\Models\SalesCommissionRate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CommissionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Commission dashboard for sales rep - see their earnings
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Get commission summary
|
||||
$summary = $this->getCommissionSummary($business, $user);
|
||||
|
||||
// Get recent commissions
|
||||
$commissions = SalesCommission::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->with(['order', 'order.business'])
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
// Get commission rates for this user
|
||||
$rates = SalesCommissionRate::forBusiness($business->id)
|
||||
->where(function ($q) use ($user) {
|
||||
$q->whereNull('user_id')
|
||||
->orWhere('user_id', $user->id);
|
||||
})
|
||||
->active()
|
||||
->effective()
|
||||
->orderBy('rate_type')
|
||||
->get();
|
||||
|
||||
return view('seller.sales.commissions.index', compact(
|
||||
'business',
|
||||
'summary',
|
||||
'commissions',
|
||||
'rates'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin view - manage all commission rates and approve commissions
|
||||
*/
|
||||
public function manage(Request $request, Business $business)
|
||||
{
|
||||
// Get pending commissions
|
||||
$pendingCommissions = SalesCommission::forBusiness($business->id)
|
||||
->pending()
|
||||
->with(['salesRep', 'order', 'order.business'])
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
// Get approved commissions ready for payment
|
||||
$approvedCommissions = SalesCommission::forBusiness($business->id)
|
||||
->approved()
|
||||
->with(['salesRep', 'order'])
|
||||
->orderByDesc('approved_at')
|
||||
->get();
|
||||
|
||||
// Get commission rates
|
||||
$rates = SalesCommissionRate::forBusiness($business->id)
|
||||
->with('user')
|
||||
->orderBy('rate_type')
|
||||
->orderBy('user_id')
|
||||
->get();
|
||||
|
||||
// Get sales reps for rate assignment
|
||||
$salesReps = $this->getAvailableSalesReps($business);
|
||||
|
||||
// Summary stats
|
||||
$stats = [
|
||||
'pending_count' => $pendingCommissions->count(),
|
||||
'pending_total' => $pendingCommissions->sum('commission_amount') / 100,
|
||||
'approved_count' => $approvedCommissions->count(),
|
||||
'approved_total' => $approvedCommissions->sum('commission_amount') / 100,
|
||||
'paid_this_month' => SalesCommission::forBusiness($business->id)
|
||||
->paid()
|
||||
->whereMonth('paid_at', now()->month)
|
||||
->whereYear('paid_at', now()->year)
|
||||
->sum('commission_amount') / 100,
|
||||
];
|
||||
|
||||
return view('seller.sales.commissions.manage', compact(
|
||||
'business',
|
||||
'pendingCommissions',
|
||||
'approvedCommissions',
|
||||
'rates',
|
||||
'salesReps',
|
||||
'stats'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a commission
|
||||
*/
|
||||
public function approve(Request $request, Business $business, SalesCommission $commission)
|
||||
{
|
||||
if ($commission->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$commission->approve($request->user());
|
||||
|
||||
return back()->with('success', 'Commission approved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk approve commissions
|
||||
*/
|
||||
public function bulkApprove(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'commission_ids' => 'required|array',
|
||||
'commission_ids.*' => 'exists:sales_commissions,id',
|
||||
]);
|
||||
|
||||
$count = SalesCommission::forBusiness($business->id)
|
||||
->whereIn('id', $validated['commission_ids'])
|
||||
->pending()
|
||||
->update([
|
||||
'status' => SalesCommission::STATUS_APPROVED,
|
||||
'approved_at' => now(),
|
||||
'approved_by' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return back()->with('success', "{$count} commissions approved.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark commissions as paid
|
||||
*/
|
||||
public function markPaid(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'commission_ids' => 'required|array',
|
||||
'commission_ids.*' => 'exists:sales_commissions,id',
|
||||
'payment_reference' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$count = SalesCommission::forBusiness($business->id)
|
||||
->whereIn('id', $validated['commission_ids'])
|
||||
->approved()
|
||||
->update([
|
||||
'status' => SalesCommission::STATUS_PAID,
|
||||
'paid_at' => now(),
|
||||
'payment_reference' => $validated['payment_reference'] ?? null,
|
||||
]);
|
||||
|
||||
return back()->with('success', "{$count} commissions marked as paid.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new commission rate
|
||||
*/
|
||||
public function storeRate(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'user_id' => 'nullable|exists:users,id',
|
||||
'rate_type' => 'required|in:default,account,product,brand',
|
||||
'commission_percent' => 'required|numeric|min:0|max:100',
|
||||
'effective_from' => 'required|date',
|
||||
'effective_to' => 'nullable|date|after:effective_from',
|
||||
]);
|
||||
|
||||
SalesCommissionRate::create([
|
||||
'business_id' => $business->id,
|
||||
'user_id' => $validated['user_id'],
|
||||
'rate_type' => $validated['rate_type'],
|
||||
'commission_percent' => $validated['commission_percent'],
|
||||
'effective_from' => $validated['effective_from'],
|
||||
'effective_to' => $validated['effective_to'] ?? null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Commission rate created.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a commission rate
|
||||
*/
|
||||
public function updateRate(Request $request, Business $business, SalesCommissionRate $rate)
|
||||
{
|
||||
if ($rate->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'commission_percent' => 'required|numeric|min:0|max:100',
|
||||
'effective_to' => 'nullable|date',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$rate->update([
|
||||
'commission_percent' => $validated['commission_percent'],
|
||||
'effective_to' => $validated['effective_to'] ?? null,
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Commission rate updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a commission rate
|
||||
*/
|
||||
public function destroyRate(Request $request, Business $business, SalesCommissionRate $rate)
|
||||
{
|
||||
if ($rate->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Don't delete if commissions reference this rate
|
||||
if ($rate->commissions()->exists()) {
|
||||
$rate->update(['is_active' => false]);
|
||||
|
||||
return back()->with('warning', 'Rate deactivated (has existing commissions).');
|
||||
}
|
||||
|
||||
$rate->delete();
|
||||
|
||||
return back()->with('success', 'Commission rate deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commission summary for a user
|
||||
*/
|
||||
protected function getCommissionSummary(Business $business, User $user): array
|
||||
{
|
||||
$baseQuery = SalesCommission::forBusiness($business->id)->forUser($user->id);
|
||||
|
||||
return [
|
||||
'pending' => (clone $baseQuery)->pending()->sum('commission_amount') / 100,
|
||||
'approved' => (clone $baseQuery)->approved()->sum('commission_amount') / 100,
|
||||
'paid_this_month' => (clone $baseQuery)
|
||||
->paid()
|
||||
->whereMonth('paid_at', now()->month)
|
||||
->whereYear('paid_at', now()->year)
|
||||
->sum('commission_amount') / 100,
|
||||
'paid_total' => (clone $baseQuery)->paid()->sum('commission_amount') / 100,
|
||||
'total_orders' => (clone $baseQuery)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available sales reps for this business
|
||||
*/
|
||||
protected function getAvailableSalesReps(Business $business)
|
||||
{
|
||||
return User::whereHas('businesses', function ($q) use ($business) {
|
||||
$q->where('businesses.id', $business->id);
|
||||
})->orderBy('name')->get();
|
||||
}
|
||||
}
|
||||
153
app/Http/Controllers/Seller/Sales/CompetitorController.php
Normal file
153
app/Http/Controllers/Seller/Sales/CompetitorController.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\CompetitorReplacement;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CompetitorController extends Controller
|
||||
{
|
||||
/**
|
||||
* List competitor replacements - when you see competitor X, pitch product Y
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$replacements = CompetitorReplacement::forBusiness($business->id)
|
||||
->with(['product', 'product.brand', 'creator'])
|
||||
->orderBy('competitor_name')
|
||||
->get()
|
||||
->groupBy('competitor_name');
|
||||
|
||||
// Get products for the add form
|
||||
$products = Product::whereHas('brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->with('brand')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get unique competitor names for filtering
|
||||
$competitors = CompetitorReplacement::forBusiness($business->id)
|
||||
->distinct()
|
||||
->pluck('competitor_name')
|
||||
->sort();
|
||||
|
||||
return view('seller.sales.competitors.index', compact(
|
||||
'business',
|
||||
'replacements',
|
||||
'products',
|
||||
'competitors'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new competitor replacement mapping
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'competitor_name' => 'required|string|max:255',
|
||||
'competitor_product_name' => 'nullable|string|max:255',
|
||||
'cannaiq_product_id' => 'nullable|string|max:255',
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'advantage_notes' => 'nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
// Verify product belongs to this business
|
||||
$product = Product::whereHas('brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})->findOrFail($validated['product_id']);
|
||||
|
||||
CompetitorReplacement::create([
|
||||
'business_id' => $business->id,
|
||||
'cannaiq_product_id' => $validated['cannaiq_product_id'] ?? uniqid('manual_'),
|
||||
'competitor_name' => $validated['competitor_name'],
|
||||
'competitor_product_name' => $validated['competitor_product_name'],
|
||||
'product_id' => $product->id,
|
||||
'advantage_notes' => $validated['advantage_notes'],
|
||||
'created_by' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Competitor replacement added.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a competitor replacement
|
||||
*/
|
||||
public function update(Request $request, Business $business, CompetitorReplacement $replacement)
|
||||
{
|
||||
if ($replacement->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'competitor_product_name' => 'nullable|string|max:255',
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'advantage_notes' => 'nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
// Verify product belongs to this business
|
||||
$product = Product::whereHas('brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})->findOrFail($validated['product_id']);
|
||||
|
||||
$replacement->update([
|
||||
'competitor_product_name' => $validated['competitor_product_name'],
|
||||
'product_id' => $product->id,
|
||||
'advantage_notes' => $validated['advantage_notes'],
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Replacement updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a competitor replacement
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, CompetitorReplacement $replacement)
|
||||
{
|
||||
if ($replacement->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$replacement->delete();
|
||||
|
||||
return back()->with('success', 'Replacement deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick lookup - get our replacement for a competitor product
|
||||
*/
|
||||
public function lookup(Request $request, Business $business)
|
||||
{
|
||||
$competitorName = $request->get('competitor');
|
||||
$productName = $request->get('product');
|
||||
|
||||
$query = CompetitorReplacement::forBusiness($business->id)
|
||||
->with(['product', 'product.brand']);
|
||||
|
||||
if ($competitorName) {
|
||||
$query->where('competitor_name', 'ILIKE', "%{$competitorName}%");
|
||||
}
|
||||
|
||||
if ($productName) {
|
||||
$query->where('competitor_product_name', 'ILIKE', "%{$productName}%");
|
||||
}
|
||||
|
||||
$replacements = $query->limit(10)->get();
|
||||
|
||||
return response()->json([
|
||||
'replacements' => $replacements->map(fn ($r) => [
|
||||
'id' => $r->id,
|
||||
'competitor' => $r->competitor_name,
|
||||
'competitor_product' => $r->competitor_product_name,
|
||||
'our_product' => $r->product->name,
|
||||
'our_sku' => $r->product->sku,
|
||||
'advantage' => $r->advantage_notes,
|
||||
'pitch' => $r->getPitchSummary(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
285
app/Http/Controllers/Seller/Sales/DashboardController.php
Normal file
285
app/Http/Controllers/Seller/Sales/DashboardController.php
Normal file
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Order;
|
||||
use App\Models\SalesRepAssignment;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* Sales Rep Dashboard - My Accounts with health status
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Cache dashboard data for 5 minutes
|
||||
$cacheKey = "sales_dashboard_{$business->id}_{$user->id}";
|
||||
|
||||
$data = Cache::remember($cacheKey, 300, function () use ($business, $user) {
|
||||
return $this->getDashboardData($business, $user);
|
||||
});
|
||||
|
||||
$data['business'] = $business;
|
||||
|
||||
return view('seller.sales.dashboard.index', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* My Accounts view - accounts assigned to this sales rep
|
||||
*/
|
||||
public function myAccounts(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Get account assignments for this user
|
||||
$assignments = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->accounts()
|
||||
->with(['assignable', 'assignable.locations'])
|
||||
->get();
|
||||
|
||||
// Get account IDs
|
||||
$accountIds = $assignments->pluck('assignable_id');
|
||||
|
||||
// Calculate account health metrics in a single efficient query
|
||||
$accountMetrics = $this->getAccountMetrics($accountIds);
|
||||
|
||||
// Combine assignments with metrics
|
||||
$accounts = $assignments->map(function ($assignment) use ($accountMetrics) {
|
||||
$accountId = $assignment->assignable_id;
|
||||
$metrics = $accountMetrics[$accountId] ?? [];
|
||||
|
||||
return [
|
||||
'assignment' => $assignment,
|
||||
'account' => $assignment->assignable,
|
||||
'metrics' => $metrics,
|
||||
'health_status' => $this->calculateHealthStatus($metrics),
|
||||
];
|
||||
})->sortBy(fn ($a) => $a['health_status']['priority']);
|
||||
|
||||
return view('seller.sales.accounts.index', compact('business', 'accounts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard data
|
||||
*/
|
||||
protected function getDashboardData(Business $business, $user): array
|
||||
{
|
||||
// Get assigned accounts count
|
||||
$accountAssignments = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->accounts()
|
||||
->count();
|
||||
|
||||
// Get assigned locations count
|
||||
$locationAssignments = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->locations()
|
||||
->count();
|
||||
|
||||
// Get accounts needing attention (no order in 30+ days)
|
||||
$needsAttention = $this->getAccountsNeedingAttention($business, $user);
|
||||
|
||||
// Get accounts at risk (no order in 60+ days)
|
||||
$atRisk = $this->getAccountsAtRisk($business, $user);
|
||||
|
||||
// Get recent orders for assigned accounts
|
||||
$recentOrders = $this->getRecentOrders($business, $user, 10);
|
||||
|
||||
// Get commission summary
|
||||
$commissionSummary = $this->getCommissionSummary($business, $user);
|
||||
|
||||
return [
|
||||
'stats' => [
|
||||
'assigned_accounts' => $accountAssignments,
|
||||
'assigned_locations' => $locationAssignments,
|
||||
'needs_attention' => $needsAttention->count(),
|
||||
'at_risk' => $atRisk->count(),
|
||||
],
|
||||
'needs_attention' => $needsAttention,
|
||||
'at_risk' => $atRisk,
|
||||
'recent_orders' => $recentOrders,
|
||||
'commission_summary' => $commissionSummary,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account metrics for multiple accounts efficiently
|
||||
*/
|
||||
protected function getAccountMetrics($accountIds): array
|
||||
{
|
||||
if ($accountIds->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get order metrics per account
|
||||
$orderMetrics = Order::whereIn('business_id', $accountIds)
|
||||
->where('status', 'completed')
|
||||
->groupBy('business_id')
|
||||
->selectRaw('business_id,
|
||||
COUNT(*) as order_count,
|
||||
SUM(total) as total_revenue,
|
||||
MAX(created_at) as last_order_date,
|
||||
AVG(EXTRACT(EPOCH FROM (created_at - LAG(created_at) OVER (PARTITION BY business_id ORDER BY created_at)))) as avg_order_interval_seconds
|
||||
')
|
||||
->get()
|
||||
->keyBy('business_id');
|
||||
|
||||
return $orderMetrics->mapWithKeys(function ($metrics, $accountId) {
|
||||
return [$accountId => [
|
||||
'order_count' => $metrics->order_count,
|
||||
'total_revenue' => $metrics->total_revenue ?? 0,
|
||||
'last_order_date' => $metrics->last_order_date,
|
||||
'days_since_last_order' => $metrics->last_order_date
|
||||
? now()->diffInDays($metrics->last_order_date)
|
||||
: null,
|
||||
'avg_order_interval_days' => $metrics->avg_order_interval_seconds
|
||||
? round($metrics->avg_order_interval_seconds / 86400)
|
||||
: null,
|
||||
]];
|
||||
})->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate health status based on metrics
|
||||
*/
|
||||
protected function calculateHealthStatus(array $metrics): array
|
||||
{
|
||||
$daysSinceOrder = $metrics['days_since_last_order'] ?? null;
|
||||
$avgInterval = $metrics['avg_order_interval_days'] ?? 30;
|
||||
|
||||
if ($daysSinceOrder === null) {
|
||||
return [
|
||||
'status' => 'new',
|
||||
'label' => 'New Account',
|
||||
'color' => 'info',
|
||||
'priority' => 2,
|
||||
];
|
||||
}
|
||||
|
||||
// At risk: More than 2x their average order interval, or 60+ days
|
||||
if ($daysSinceOrder >= max($avgInterval * 2, 60)) {
|
||||
return [
|
||||
'status' => 'at_risk',
|
||||
'label' => 'At Risk',
|
||||
'color' => 'error',
|
||||
'priority' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Needs attention: More than 1.5x their average order interval, or 30+ days
|
||||
if ($daysSinceOrder >= max($avgInterval * 1.5, 30)) {
|
||||
return [
|
||||
'status' => 'needs_attention',
|
||||
'label' => 'Needs Attention',
|
||||
'color' => 'warning',
|
||||
'priority' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'healthy',
|
||||
'label' => 'Healthy',
|
||||
'color' => 'success',
|
||||
'priority' => 3,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounts needing attention (no order in 30-59 days)
|
||||
*/
|
||||
protected function getAccountsNeedingAttention(Business $business, $user)
|
||||
{
|
||||
$assignedAccountIds = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->accounts()
|
||||
->pluck('assignable_id');
|
||||
|
||||
return Business::whereIn('id', $assignedAccountIds)
|
||||
->whereHas('orders', function ($q) {
|
||||
$q->where('status', 'completed')
|
||||
->where('created_at', '<', now()->subDays(30))
|
||||
->where('created_at', '>=', now()->subDays(60));
|
||||
})
|
||||
->orWhereDoesntHave('orders')
|
||||
->with('locations')
|
||||
->limit(10)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounts at risk (no order in 60+ days)
|
||||
*/
|
||||
protected function getAccountsAtRisk(Business $business, $user)
|
||||
{
|
||||
$assignedAccountIds = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->accounts()
|
||||
->pluck('assignable_id');
|
||||
|
||||
return Business::whereIn('id', $assignedAccountIds)
|
||||
->whereHas('orders', function ($q) {
|
||||
$q->where('status', 'completed')
|
||||
->where('created_at', '<', now()->subDays(60));
|
||||
})
|
||||
->with('locations')
|
||||
->limit(10)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent orders for assigned accounts
|
||||
*/
|
||||
protected function getRecentOrders(Business $business, $user, int $limit)
|
||||
{
|
||||
$assignedAccountIds = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->accounts()
|
||||
->pluck('assignable_id');
|
||||
|
||||
return Order::whereIn('business_id', $assignedAccountIds)
|
||||
->with(['business', 'items.product'])
|
||||
->orderByDesc('created_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commission summary for the current user
|
||||
*/
|
||||
protected function getCommissionSummary(Business $business, $user): array
|
||||
{
|
||||
$pendingCommission = DB::table('sales_commissions')
|
||||
->where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'pending')
|
||||
->sum('commission_amount');
|
||||
|
||||
$approvedCommission = DB::table('sales_commissions')
|
||||
->where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'approved')
|
||||
->sum('commission_amount');
|
||||
|
||||
$paidThisMonth = DB::table('sales_commissions')
|
||||
->where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'paid')
|
||||
->whereMonth('paid_at', now()->month)
|
||||
->whereYear('paid_at', now()->year)
|
||||
->sum('commission_amount');
|
||||
|
||||
return [
|
||||
'pending' => $pendingCommission / 100,
|
||||
'approved' => $approvedCommission / 100,
|
||||
'paid_this_month' => $paidThisMonth / 100,
|
||||
];
|
||||
}
|
||||
}
|
||||
334
app/Http/Controllers/Seller/Sales/ExportController.php
Normal file
334
app/Http/Controllers/Seller/Sales/ExportController.php
Normal file
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmLead;
|
||||
use App\Models\Order;
|
||||
use App\Models\SalesRepAssignment;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ExportController extends Controller
|
||||
{
|
||||
/**
|
||||
* Export accounts assigned to the current sales rep as CSV.
|
||||
*/
|
||||
public function accounts(Business $business): StreamedResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Get assigned account IDs
|
||||
$assignedAccountIds = SalesRepAssignment::where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('assignable_type', Business::class)
|
||||
->pluck('assignable_id');
|
||||
|
||||
$accounts = Business::whereIn('id', $assignedAccountIds)
|
||||
->with(['locations', 'contacts'])
|
||||
->get();
|
||||
|
||||
$filename = 'my-accounts-'.now()->format('Y-m-d').'.csv';
|
||||
|
||||
return $this->streamCsv($filename, function () use ($accounts, $business) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
// Header row
|
||||
fputcsv($handle, [
|
||||
'Account Name',
|
||||
'Status',
|
||||
'Primary Location',
|
||||
'City',
|
||||
'State',
|
||||
'ZIP',
|
||||
'Primary Contact',
|
||||
'Email',
|
||||
'Phone',
|
||||
'Last Order Date',
|
||||
'Last Order Total',
|
||||
'Days Since Order',
|
||||
]);
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$location = $account->locations->first();
|
||||
$contact = $account->contacts->first();
|
||||
|
||||
// Get last order
|
||||
$lastOrder = Order::where('business_id', $account->id)
|
||||
->whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
|
||||
fputcsv($handle, [
|
||||
$account->name,
|
||||
$account->status ?? 'active',
|
||||
$location?->name ?? '',
|
||||
$location?->city ?? '',
|
||||
$location?->state ?? '',
|
||||
$location?->zipcode ?? '',
|
||||
$contact?->name ?? '',
|
||||
$contact?->email ?? '',
|
||||
$contact?->phone ?? '',
|
||||
$lastOrder?->created_at?->format('Y-m-d') ?? '',
|
||||
$lastOrder ? '$'.number_format($lastOrder->total / 100, 2) : '',
|
||||
$lastOrder ? now()->diffInDays($lastOrder->created_at) : '',
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a single account's order history for meeting prep.
|
||||
*/
|
||||
public function accountHistory(Business $business, Business $account): StreamedResponse
|
||||
{
|
||||
// Verify assignment
|
||||
$isAssigned = SalesRepAssignment::where('business_id', $business->id)
|
||||
->where('user_id', Auth::id())
|
||||
->where('assignable_type', Business::class)
|
||||
->where('assignable_id', $account->id)
|
||||
->exists();
|
||||
|
||||
if (! $isAssigned) {
|
||||
abort(403, 'Account not assigned to you');
|
||||
}
|
||||
|
||||
$orders = Order::where('business_id', $account->id)
|
||||
->whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->with(['items.product'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
$filename = Str::slug($account->name).'-history-'.now()->format('Y-m-d').'.csv';
|
||||
|
||||
return $this->streamCsv($filename, function () use ($orders, $account) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
// Account summary header
|
||||
fputcsv($handle, ['Account Summary: '.$account->name]);
|
||||
fputcsv($handle, ['Generated: '.now()->format('F j, Y')]);
|
||||
fputcsv($handle, ['Total Orders: '.$orders->count()]);
|
||||
fputcsv($handle, ['']);
|
||||
|
||||
// Order details header
|
||||
fputcsv($handle, [
|
||||
'Order Number',
|
||||
'Date',
|
||||
'Status',
|
||||
'Product',
|
||||
'SKU',
|
||||
'Quantity',
|
||||
'Unit Price',
|
||||
'Line Total',
|
||||
'Order Total',
|
||||
]);
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$firstItem = true;
|
||||
foreach ($order->items as $item) {
|
||||
fputcsv($handle, [
|
||||
$firstItem ? $order->order_number : '',
|
||||
$firstItem ? $order->created_at->format('Y-m-d') : '',
|
||||
$firstItem ? ucfirst($order->status) : '',
|
||||
$item->product?->name ?? 'Unknown',
|
||||
$item->product?->sku ?? '',
|
||||
$item->quantity,
|
||||
'$'.number_format($item->price / 100, 2),
|
||||
'$'.number_format(($item->price * $item->quantity) / 100, 2),
|
||||
$firstItem ? '$'.number_format($order->total / 100, 2) : '',
|
||||
]);
|
||||
$firstItem = false;
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Export prospect data with insights for pitch preparation.
|
||||
*/
|
||||
public function prospects(Business $business): StreamedResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$leads = CrmLead::where('business_id', $business->id)
|
||||
->where('assigned_to', $user->id)
|
||||
->with(['insights'])
|
||||
->get();
|
||||
|
||||
$filename = 'prospects-'.now()->format('Y-m-d').'.csv';
|
||||
|
||||
return $this->streamCsv($filename, function () use ($leads) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
// Header row
|
||||
fputcsv($handle, [
|
||||
'Company Name',
|
||||
'Contact Name',
|
||||
'Email',
|
||||
'Phone',
|
||||
'City',
|
||||
'State',
|
||||
'Status',
|
||||
'Source',
|
||||
'License Number',
|
||||
'Gaps',
|
||||
'Pain Points',
|
||||
'Opportunities',
|
||||
'Notes',
|
||||
]);
|
||||
|
||||
foreach ($leads as $lead) {
|
||||
$gaps = $lead->insights->where('insight_type', 'gap')->pluck('description')->implode('; ');
|
||||
$painPoints = $lead->insights->where('insight_type', 'pain_point')->pluck('description')->implode('; ');
|
||||
$opportunities = $lead->insights->where('insight_type', 'opportunity')->pluck('description')->implode('; ');
|
||||
|
||||
fputcsv($handle, [
|
||||
$lead->company_name,
|
||||
$lead->contact_name ?? '',
|
||||
$lead->email ?? '',
|
||||
$lead->phone ?? '',
|
||||
$lead->city ?? '',
|
||||
$lead->state ?? '',
|
||||
ucfirst($lead->status),
|
||||
$lead->source ?? '',
|
||||
$lead->license_number ?? '',
|
||||
$gaps,
|
||||
$painPoints,
|
||||
$opportunities,
|
||||
$lead->notes ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Export competitor replacement data for sales training.
|
||||
*/
|
||||
public function competitors(Business $business): StreamedResponse
|
||||
{
|
||||
$replacements = \App\Models\CompetitorReplacement::where('business_id', $business->id)
|
||||
->with(['product.brand'])
|
||||
->orderBy('competitor_name')
|
||||
->get();
|
||||
|
||||
$filename = 'competitor-replacements-'.now()->format('Y-m-d').'.csv';
|
||||
|
||||
return $this->streamCsv($filename, function () use ($replacements) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
fputcsv($handle, [
|
||||
'Competitor Brand',
|
||||
'Competitor Product',
|
||||
'Our Product',
|
||||
'Our SKU',
|
||||
'Our Brand',
|
||||
'Why Ours is Better',
|
||||
]);
|
||||
|
||||
foreach ($replacements as $replacement) {
|
||||
fputcsv($handle, [
|
||||
$replacement->competitor_name,
|
||||
$replacement->competitor_product_name ?? 'Any product',
|
||||
$replacement->product->name,
|
||||
$replacement->product->sku,
|
||||
$replacement->product->brand?->name ?? '',
|
||||
$replacement->advantage_notes ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a pitch builder export for a specific prospect.
|
||||
*/
|
||||
public function pitchBuilder(Business $business, CrmLead $lead): StreamedResponse
|
||||
{
|
||||
// Verify assignment
|
||||
if ($lead->assigned_to !== Auth::id()) {
|
||||
abort(403, 'Lead not assigned to you');
|
||||
}
|
||||
|
||||
// Get similar successful accounts for reference
|
||||
$successStories = Business::where('type', 'buyer')
|
||||
->whereHas('orders', function ($query) use ($business) {
|
||||
$query->whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->where('created_at', '>=', now()->subMonths(3));
|
||||
})
|
||||
->when($lead->city, fn ($q) => $q->whereHas('locations', fn ($l) => $l->where('city', $lead->city)))
|
||||
->with(['locations'])
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
$filename = 'pitch-'.Str::slug($lead->company_name).'-'.now()->format('Y-m-d').'.csv';
|
||||
|
||||
return $this->streamCsv($filename, function () use ($lead, $successStories) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
// Prospect info
|
||||
fputcsv($handle, ['PITCH PREPARATION: '.$lead->company_name]);
|
||||
fputcsv($handle, ['Generated: '.now()->format('F j, Y')]);
|
||||
fputcsv($handle, ['']);
|
||||
|
||||
// Contact info
|
||||
fputcsv($handle, ['CONTACT INFORMATION']);
|
||||
fputcsv($handle, ['Contact Name', $lead->contact_name ?? 'N/A']);
|
||||
fputcsv($handle, ['Email', $lead->email ?? 'N/A']);
|
||||
fputcsv($handle, ['Phone', $lead->phone ?? 'N/A']);
|
||||
fputcsv($handle, ['Location', ($lead->city ?? '').($lead->city && $lead->state ? ', ' : '').($lead->state ?? '')]);
|
||||
fputcsv($handle, ['License', $lead->license_number ?? 'N/A']);
|
||||
fputcsv($handle, ['']);
|
||||
|
||||
// Insights
|
||||
fputcsv($handle, ['IDENTIFIED GAPS & OPPORTUNITIES']);
|
||||
foreach ($lead->insights as $insight) {
|
||||
fputcsv($handle, [
|
||||
ucfirst(str_replace('_', ' ', $insight->insight_type)),
|
||||
$insight->description,
|
||||
]);
|
||||
}
|
||||
fputcsv($handle, ['']);
|
||||
|
||||
// Success stories
|
||||
fputcsv($handle, ['SIMILAR SUCCESSFUL ACCOUNTS (Reference for pitch)']);
|
||||
fputcsv($handle, ['Account Name', 'Location']);
|
||||
foreach ($successStories as $account) {
|
||||
$location = $account->locations->first();
|
||||
fputcsv($handle, [
|
||||
$account->name,
|
||||
($location?->city ?? '').($location?->city && $location?->state ? ', ' : '').($location?->state ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
fputcsv($handle, ['']);
|
||||
fputcsv($handle, ['NOTES']);
|
||||
fputcsv($handle, [$lead->notes ?? 'No additional notes']);
|
||||
|
||||
fclose($handle);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to stream a CSV response.
|
||||
*/
|
||||
private function streamCsv(string $filename, callable $callback): StreamedResponse
|
||||
{
|
||||
return response()->stream($callback, 200, [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
|
||||
'Pragma' => 'no-cache',
|
||||
'Cache-Control' => 'must-revalidate, post-check=0, pre-check=0',
|
||||
'Expires' => '0',
|
||||
]);
|
||||
}
|
||||
}
|
||||
314
app/Http/Controllers/Seller/Sales/ProspectController.php
Normal file
314
app/Http/Controllers/Seller/Sales/ProspectController.php
Normal file
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmLead;
|
||||
use App\Models\ProspectImport;
|
||||
use App\Models\ProspectInsight;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ProspectController extends Controller
|
||||
{
|
||||
/**
|
||||
* List prospects (leads) assigned to this sales rep
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Get leads assigned to this user
|
||||
$leads = CrmLead::where('seller_business_id', $business->id)
|
||||
->where('assigned_to', $user->id)
|
||||
->with('insights')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
// Get insight counts by type
|
||||
$insightCounts = ProspectInsight::forBusiness($business->id)
|
||||
->whereNotNull('lead_id')
|
||||
->selectRaw('insight_type, COUNT(*) as count')
|
||||
->groupBy('insight_type')
|
||||
->pluck('count', 'insight_type');
|
||||
|
||||
return view('seller.sales.prospects.index', compact(
|
||||
'business',
|
||||
'leads',
|
||||
'insightCounts'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show prospect detail with insights
|
||||
*/
|
||||
public function show(Request $request, Business $business, CrmLead $lead)
|
||||
{
|
||||
if ($lead->seller_business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$lead->load('insights.creator');
|
||||
|
||||
// Get similar successful accounts for reference
|
||||
$successStories = $this->findSimilarSuccessStories($business, $lead);
|
||||
|
||||
return view('seller.sales.prospects.show', compact(
|
||||
'business',
|
||||
'lead',
|
||||
'successStories'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add insight to a prospect
|
||||
*/
|
||||
public function storeInsight(Request $request, Business $business, CrmLead $lead)
|
||||
{
|
||||
if ($lead->seller_business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'insight_type' => 'required|in:gap,pain_point,opportunity,objection,competitor_weakness',
|
||||
'category' => 'nullable|in:price_point,quality,consistency,service,margin,reliability,selection',
|
||||
'description' => 'required|string|max:2000',
|
||||
]);
|
||||
|
||||
ProspectInsight::create([
|
||||
'business_id' => $business->id,
|
||||
'lead_id' => $lead->id,
|
||||
'insight_type' => $validated['insight_type'],
|
||||
'category' => $validated['category'],
|
||||
'description' => $validated['description'],
|
||||
'created_by' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Insight added.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an insight
|
||||
*/
|
||||
public function destroyInsight(Request $request, Business $business, ProspectInsight $insight)
|
||||
{
|
||||
if ($insight->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$insight->delete();
|
||||
|
||||
return back()->with('success', 'Insight deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show import history and upload form
|
||||
*/
|
||||
public function imports(Request $request, Business $business)
|
||||
{
|
||||
$imports = ProspectImport::forBusiness($business->id)
|
||||
->with('importer')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(10);
|
||||
|
||||
return view('seller.sales.prospects.imports', compact('business', 'imports'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload and process import file
|
||||
*/
|
||||
public function upload(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'file' => 'required|file|mimes:csv,txt|max:5120', // 5MB max
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$filename = $file->getClientOriginalName();
|
||||
$path = $file->store("imports/{$business->id}", 'local');
|
||||
|
||||
// Count rows
|
||||
$content = file_get_contents($file->getRealPath());
|
||||
$lines = explode("\n", trim($content));
|
||||
$totalRows = count($lines) - 1; // Exclude header
|
||||
|
||||
// Create import record
|
||||
$import = ProspectImport::create([
|
||||
'business_id' => $business->id,
|
||||
'user_id' => $request->user()->id,
|
||||
'filename' => $filename,
|
||||
'status' => ProspectImport::STATUS_PENDING,
|
||||
'total_rows' => max(0, $totalRows),
|
||||
'processed_rows' => 0,
|
||||
'created_count' => 0,
|
||||
'updated_count' => 0,
|
||||
'skipped_count' => 0,
|
||||
'error_count' => 0,
|
||||
]);
|
||||
|
||||
// Get headers for mapping
|
||||
$headers = str_getcsv($lines[0]);
|
||||
|
||||
return view('seller.sales.prospects.map-columns', compact(
|
||||
'business',
|
||||
'import',
|
||||
'headers',
|
||||
'path'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process import with column mapping
|
||||
*/
|
||||
public function processImport(Request $request, Business $business, ProspectImport $import)
|
||||
{
|
||||
if ($import->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'mapping' => 'required|array',
|
||||
'mapping.company_name' => 'required|string',
|
||||
'path' => 'required|string',
|
||||
]);
|
||||
|
||||
$import->update([
|
||||
'column_mapping' => $validated['mapping'],
|
||||
'status' => ProspectImport::STATUS_PROCESSING,
|
||||
]);
|
||||
|
||||
// Process synchronously for now (could dispatch to queue for large files)
|
||||
$this->processImportFile($import, $validated['path'], $validated['mapping']);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.sales.prospects.imports', $business)
|
||||
->with('success', "Import completed. {$import->created_count} created, {$import->updated_count} updated, {$import->error_count} errors.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the import file
|
||||
*/
|
||||
protected function processImportFile(ProspectImport $import, string $path, array $mapping): void
|
||||
{
|
||||
$content = Storage::disk('local')->get($path);
|
||||
$lines = explode("\n", trim($content));
|
||||
$headers = str_getcsv(array_shift($lines));
|
||||
|
||||
// Create column index map
|
||||
$columnMap = [];
|
||||
foreach ($mapping as $field => $column) {
|
||||
$index = array_search($column, $headers);
|
||||
if ($index !== false) {
|
||||
$columnMap[$field] = $index;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($lines as $lineNum => $line) {
|
||||
if (empty(trim($line))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = str_getcsv($line);
|
||||
$import->incrementProcessed();
|
||||
|
||||
try {
|
||||
$companyName = $row[$columnMap['company_name']] ?? null;
|
||||
|
||||
if (! $companyName) {
|
||||
$import->addError($lineNum + 2, 'Missing company name');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
$existing = CrmLead::where('seller_business_id', $import->business_id)
|
||||
->where('company_name', $companyName)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
// Update existing
|
||||
$this->updateLeadFromRow($existing, $row, $columnMap);
|
||||
$import->incrementUpdated();
|
||||
} else {
|
||||
// Create new
|
||||
$this->createLeadFromRow($import, $row, $columnMap);
|
||||
$import->incrementCreated();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$import->addError($lineNum + 2, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$import->markCompleted();
|
||||
|
||||
// Clean up file
|
||||
Storage::disk('local')->delete($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new lead from import row
|
||||
*/
|
||||
protected function createLeadFromRow(ProspectImport $import, array $row, array $columnMap): CrmLead
|
||||
{
|
||||
return CrmLead::create([
|
||||
'seller_business_id' => $import->business_id,
|
||||
'company_name' => $row[$columnMap['company_name']] ?? null,
|
||||
'contact_name' => $row[$columnMap['contact_name'] ?? -1] ?? null,
|
||||
'email' => $row[$columnMap['email'] ?? -1] ?? null,
|
||||
'phone' => $row[$columnMap['phone'] ?? -1] ?? null,
|
||||
'address' => $row[$columnMap['address'] ?? -1] ?? null,
|
||||
'city' => $row[$columnMap['city'] ?? -1] ?? null,
|
||||
'state' => $row[$columnMap['state'] ?? -1] ?? null,
|
||||
'zipcode' => $row[$columnMap['zipcode'] ?? -1] ?? null,
|
||||
'license_number' => $row[$columnMap['license_number'] ?? -1] ?? null,
|
||||
'notes' => $row[$columnMap['notes'] ?? -1] ?? null,
|
||||
'source' => 'import',
|
||||
'status' => 'new',
|
||||
'assigned_to' => $import->user_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing lead from import row
|
||||
*/
|
||||
protected function updateLeadFromRow(CrmLead $lead, array $row, array $columnMap): void
|
||||
{
|
||||
$updates = [];
|
||||
|
||||
foreach (['contact_name', 'email', 'phone', 'address', 'city', 'state', 'zipcode', 'license_number'] as $field) {
|
||||
if (isset($columnMap[$field]) && ! empty($row[$columnMap[$field]])) {
|
||||
$updates[$field] = $row[$columnMap[$field]];
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($updates)) {
|
||||
$lead->update($updates);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find similar successful accounts for a prospect
|
||||
*/
|
||||
protected function findSimilarSuccessStories(Business $business, CrmLead $lead): \Illuminate\Support\Collection
|
||||
{
|
||||
// Get successful accounts in same city/state
|
||||
$query = Business::query()
|
||||
->whereHas('orders', function ($q) {
|
||||
$q->where('status', 'completed');
|
||||
})
|
||||
->with('locations');
|
||||
|
||||
if ($lead->city) {
|
||||
$query->whereHas('locations', function ($q) use ($lead) {
|
||||
$q->where('city', 'ILIKE', "%{$lead->city}%");
|
||||
});
|
||||
} elseif ($lead->state) {
|
||||
$query->whereHas('locations', function ($q) use ($lead) {
|
||||
$q->where('state', $lead->state);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->limit(5)->get();
|
||||
}
|
||||
}
|
||||
39
app/Http/Controllers/Seller/Sales/ReorderController.php
Normal file
39
app/Http/Controllers/Seller/Sales/ReorderController.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Services\Sales\ReorderPredictionService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ReorderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ReorderPredictionService $reorderService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Reorder Alerts - accounts approaching their reorder window
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Get accounts approaching reorder with predictions
|
||||
$accounts = $this->reorderService->getReorderAlerts($business->id, $user->id);
|
||||
|
||||
// Separate into categories
|
||||
$overdue = $accounts->filter(fn ($a) => ($a['days_until_predicted_order'] ?? 0) < 0);
|
||||
$dueSoon = $accounts->filter(fn ($a) => ($a['days_until_predicted_order'] ?? 0) >= 0 && ($a['days_until_predicted_order'] ?? 999) <= 7);
|
||||
$upcoming = $accounts->filter(fn ($a) => ($a['days_until_predicted_order'] ?? 999) > 7 && ($a['days_until_predicted_order'] ?? 999) <= 14);
|
||||
|
||||
return view('seller.sales.reorders.index', compact(
|
||||
'business',
|
||||
'accounts',
|
||||
'overdue',
|
||||
'dueSoon',
|
||||
'upcoming'
|
||||
));
|
||||
}
|
||||
}
|
||||
193
app/Http/Controllers/Seller/Sales/TerritoryController.php
Normal file
193
app/Http/Controllers/Seller/Sales/TerritoryController.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\SalesTerritory;
|
||||
use App\Models\SalesTerritoryArea;
|
||||
use App\Models\SalesTerritoryAssignment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TerritoryController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all territories for this business
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$territories = SalesTerritory::forBusiness($business->id)
|
||||
->with(['areas', 'salesReps'])
|
||||
->withCount('assignments')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get available sales reps for assignment
|
||||
$salesReps = $this->getAvailableSalesReps($business);
|
||||
|
||||
return view('seller.sales.territories.index', compact('business', 'territories', 'salesReps'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create territory form
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$salesReps = $this->getAvailableSalesReps($business);
|
||||
|
||||
return view('seller.sales.territories.create', compact('business', 'salesReps'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new territory
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'color' => 'required|string|max:7',
|
||||
'areas' => 'nullable|array',
|
||||
'areas.*.type' => 'required_with:areas|in:zip,city,state,county',
|
||||
'areas.*.value' => 'required_with:areas|string|max:255',
|
||||
'primary_rep_id' => 'nullable|exists:users,id',
|
||||
]);
|
||||
|
||||
$territory = SalesTerritory::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'color' => $validated['color'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// Add areas
|
||||
if (! empty($validated['areas'])) {
|
||||
foreach ($validated['areas'] as $area) {
|
||||
SalesTerritoryArea::create([
|
||||
'territory_id' => $territory->id,
|
||||
'area_type' => $area['type'],
|
||||
'area_value' => $area['value'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Assign primary rep
|
||||
if (! empty($validated['primary_rep_id'])) {
|
||||
SalesTerritoryAssignment::create([
|
||||
'territory_id' => $territory->id,
|
||||
'user_id' => $validated['primary_rep_id'],
|
||||
'assignment_type' => 'primary',
|
||||
'assigned_at' => now(),
|
||||
'assigned_by' => $request->user()->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.sales.territories', $business)
|
||||
->with('success', 'Territory created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit territory form
|
||||
*/
|
||||
public function edit(Request $request, Business $business, SalesTerritory $territory)
|
||||
{
|
||||
// Verify territory belongs to this business
|
||||
if ($territory->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$territory->load(['areas', 'salesReps']);
|
||||
$salesReps = $this->getAvailableSalesReps($business);
|
||||
|
||||
return view('seller.sales.territories.edit', compact('business', 'territory', 'salesReps'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a territory
|
||||
*/
|
||||
public function update(Request $request, Business $business, SalesTerritory $territory)
|
||||
{
|
||||
// Verify territory belongs to this business
|
||||
if ($territory->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'color' => 'required|string|max:7',
|
||||
'is_active' => 'boolean',
|
||||
'areas' => 'nullable|array',
|
||||
'areas.*.type' => 'required_with:areas|in:zip,city,state,county',
|
||||
'areas.*.value' => 'required_with:areas|string|max:255',
|
||||
'primary_rep_id' => 'nullable|exists:users,id',
|
||||
]);
|
||||
|
||||
$territory->update([
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'color' => $validated['color'],
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
// Replace areas
|
||||
$territory->areas()->delete();
|
||||
if (! empty($validated['areas'])) {
|
||||
foreach ($validated['areas'] as $area) {
|
||||
SalesTerritoryArea::create([
|
||||
'territory_id' => $territory->id,
|
||||
'area_type' => $area['type'],
|
||||
'area_value' => $area['value'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Update primary rep
|
||||
$territory->assignments()->where('assignment_type', 'primary')->delete();
|
||||
if (! empty($validated['primary_rep_id'])) {
|
||||
SalesTerritoryAssignment::create([
|
||||
'territory_id' => $territory->id,
|
||||
'user_id' => $validated['primary_rep_id'],
|
||||
'assignment_type' => 'primary',
|
||||
'assigned_at' => now(),
|
||||
'assigned_by' => $request->user()->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.sales.territories', $business)
|
||||
->with('success', 'Territory updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a territory
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, SalesTerritory $territory)
|
||||
{
|
||||
// Verify territory belongs to this business
|
||||
if ($territory->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$territory->areas()->delete();
|
||||
$territory->assignments()->delete();
|
||||
$territory->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.sales.territories', $business)
|
||||
->with('success', 'Territory deleted successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available sales reps for this business
|
||||
*/
|
||||
protected function getAvailableSalesReps(Business $business)
|
||||
{
|
||||
return User::whereHas('businesses', function ($q) use ($business) {
|
||||
$q->where('businesses.id', $business->id);
|
||||
})->orderBy('name')->get();
|
||||
}
|
||||
}
|
||||
@@ -55,12 +55,16 @@ class SearchController extends Controller
|
||||
/**
|
||||
* Search contacts for a specific customer or the seller's own contacts.
|
||||
*
|
||||
* GET /s/{business}/search/contacts?q=...&customer_id=...
|
||||
* GET /s/{business}/search/contacts?q=...&customer_id=...&location_id=...
|
||||
*
|
||||
* If location_id is provided, returns only contacts assigned to that location
|
||||
* via the location_contact pivot table.
|
||||
*/
|
||||
public function contacts(Request $request, Business $business): JsonResponse
|
||||
{
|
||||
$query = $request->input('q', '');
|
||||
$customerId = $request->input('customer_id');
|
||||
$locationId = $request->input('location_id');
|
||||
|
||||
$contactsQuery = Contact::query()
|
||||
->where('is_active', true);
|
||||
@@ -73,6 +77,13 @@ class SearchController extends Controller
|
||||
$contactsQuery->where('business_id', $business->id);
|
||||
}
|
||||
|
||||
// If location_id is provided, filter to contacts assigned to that location
|
||||
if ($locationId) {
|
||||
$contactsQuery->whereHas('locations', function ($q) use ($locationId) {
|
||||
$q->where('locations.id', $locationId);
|
||||
});
|
||||
}
|
||||
|
||||
$contacts = $contactsQuery
|
||||
->when($query, function ($q) use ($query) {
|
||||
$q->where(function ($q2) use ($query) {
|
||||
@@ -87,6 +98,26 @@ class SearchController extends Controller
|
||||
->limit(25)
|
||||
->get(['id', 'first_name', 'last_name', 'email', 'title']);
|
||||
|
||||
// If filtering by location, include pivot data for is_primary
|
||||
if ($locationId) {
|
||||
// Reload contacts with pivot data
|
||||
$contactIds = $contacts->pluck('id')->toArray();
|
||||
$pivotData = \DB::table('location_contact')
|
||||
->whereIn('contact_id', $contactIds)
|
||||
->where('location_id', $locationId)
|
||||
->get()
|
||||
->keyBy('contact_id');
|
||||
|
||||
return response()->json(
|
||||
$contacts->map(fn ($c) => [
|
||||
'value' => $c->id,
|
||||
'label' => $c->getFullName().($c->email ? " ({$c->email})" : ''),
|
||||
'is_primary' => $pivotData[$c->id]->is_primary ?? false,
|
||||
'role' => $pivotData[$c->id]->role ?? null,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json(
|
||||
$contacts->map(fn ($c) => [
|
||||
'value' => $c->id,
|
||||
|
||||
@@ -147,8 +147,8 @@ class SettingsController extends Controller
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
$q->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ class SettingsController extends Controller
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'position' => $validated['position'] ?? null,
|
||||
'user_type' => $business->business_type, // Match business type
|
||||
'user_type' => 'seller', // Users in seller area are sellers
|
||||
'password' => bcrypt(str()->random(32)), // Temporary password
|
||||
]);
|
||||
|
||||
@@ -917,11 +917,11 @@ class SettingsController extends Controller
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('description', 'like', "%{$search}%")
|
||||
->orWhere('event', 'like', "%{$search}%")
|
||||
$q->where('description', 'ilike', "%{$search}%")
|
||||
->orWhere('event', 'ilike', "%{$search}%")
|
||||
->orWhereHas('user', function ($userQuery) use ($search) {
|
||||
$userQuery->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
$userQuery->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1123,11 +1123,11 @@ class SettingsController extends Controller
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('description', 'like', "%{$search}%")
|
||||
->orWhere('event', 'like', "%{$search}%")
|
||||
$q->where('description', 'ilike', "%{$search}%")
|
||||
->orWhere('event', 'ilike', "%{$search}%")
|
||||
->orWhereHas('user', function ($userQuery) use ($search) {
|
||||
$userQuery->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
$userQuery->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@ class StoreBrandRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'tagline' => ['nullable', 'string', 'min:30', 'max:45'],
|
||||
'description' => ['nullable', 'string', 'min:100', 'max:150'],
|
||||
'long_description' => ['nullable', 'string', 'max:500'],
|
||||
'tagline' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'long_description' => ['nullable', 'string', 'max:5000'],
|
||||
'brand_announcement' => ['nullable', 'string', 'max:500'],
|
||||
'website_url' => 'nullable|string|max:255',
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ class UpdateBrandRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'tagline' => ['nullable', 'string', 'min:30', 'max:45'],
|
||||
'description' => ['nullable', 'string', 'min:100', 'max:150'],
|
||||
'long_description' => ['nullable', 'string', 'max:500'],
|
||||
'tagline' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'long_description' => ['nullable', 'string', 'max:5000'],
|
||||
'brand_announcement' => ['nullable', 'string', 'max:500'],
|
||||
'website_url' => 'nullable|string|max:255',
|
||||
|
||||
|
||||
159
app/Jobs/CalculateBrandAnalysisMetrics.php
Normal file
159
app/Jobs/CalculateBrandAnalysisMetrics.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Services\Cannaiq\BrandAnalysisService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Background job to pre-calculate Brand Analysis metrics.
|
||||
*
|
||||
* This job runs in the background to compute expensive engagement and sentiment
|
||||
* metrics for brands, caching the results for 2 hours. This prevents N+1 queries
|
||||
* and expensive aggregations from running on page load.
|
||||
*
|
||||
* Schedule: Every 2 hours via Horizon
|
||||
* Queue: default (or 'analytics' if available)
|
||||
*
|
||||
* Key benefits:
|
||||
* - Aggregates CRM message counts, response rates, and quote/order metrics in batch
|
||||
* - Pre-computes buyer engagement scores
|
||||
* - For CannaiQ-enabled businesses, also pre-computes sentiment scores
|
||||
* - Uses existing BrandAnalysisService caching mechanism (2-hour TTL)
|
||||
*/
|
||||
class CalculateBrandAnalysisMetrics implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The business to calculate metrics for (null = all seller businesses)
|
||||
*/
|
||||
public ?int $businessId;
|
||||
|
||||
/**
|
||||
* The brand to calculate metrics for (null = all brands in business)
|
||||
*/
|
||||
public ?int $brandId;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(?int $businessId = null, ?int $brandId = null)
|
||||
{
|
||||
$this->businessId = $businessId;
|
||||
$this->brandId = $brandId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(BrandAnalysisService $service): void
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$processedCount = 0;
|
||||
|
||||
try {
|
||||
if ($this->businessId && $this->brandId) {
|
||||
// Single brand calculation
|
||||
$this->calculateForBrand($service, $this->businessId, $this->brandId);
|
||||
$processedCount = 1;
|
||||
} elseif ($this->businessId) {
|
||||
// All brands for a single business
|
||||
$processedCount = $this->calculateForBusiness($service, $this->businessId);
|
||||
} else {
|
||||
// All seller businesses with active brands
|
||||
$processedCount = $this->calculateForAllBusinesses($service);
|
||||
}
|
||||
|
||||
$duration = round(microtime(true) - $startTime, 2);
|
||||
Log::info('CalculateBrandAnalysisMetrics completed', [
|
||||
'business_id' => $this->businessId ?? 'all',
|
||||
'brand_id' => $this->brandId ?? 'all',
|
||||
'brands_processed' => $processedCount,
|
||||
'duration_seconds' => $duration,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CalculateBrandAnalysisMetrics failed', [
|
||||
'business_id' => $this->businessId,
|
||||
'brand_id' => $this->brandId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate metrics for all seller businesses
|
||||
*/
|
||||
private function calculateForAllBusinesses(BrandAnalysisService $service): int
|
||||
{
|
||||
$processedCount = 0;
|
||||
|
||||
Business::where('type', 'seller')
|
||||
->where('status', 'approved')
|
||||
->chunk(10, function ($businesses) use ($service, &$processedCount) {
|
||||
foreach ($businesses as $business) {
|
||||
$processedCount += $this->calculateForBusiness($service, $business->id);
|
||||
}
|
||||
});
|
||||
|
||||
return $processedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate metrics for all active brands in a business
|
||||
*/
|
||||
private function calculateForBusiness(BrandAnalysisService $service, int $businessId): int
|
||||
{
|
||||
$business = Business::find($businessId);
|
||||
if (! $business) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$brands = Brand::where('business_id', $businessId)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
$this->calculateForBrand($service, $businessId, $brand->id);
|
||||
}
|
||||
|
||||
return $brands->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate metrics for a single brand
|
||||
*/
|
||||
private function calculateForBrand(BrandAnalysisService $service, int $businessId, int $brandId): void
|
||||
{
|
||||
$business = Business::find($businessId);
|
||||
$brand = Brand::find($brandId);
|
||||
|
||||
if (! $business || ! $brand) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This triggers the full analysis calculation and caches it
|
||||
// The BrandAnalysisService handles caching internally with 2-hour TTL
|
||||
$service->refreshAnalysis($brand, $business);
|
||||
}
|
||||
|
||||
/**
|
||||
* The job failed to process.
|
||||
*/
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error('CalculateBrandAnalysisMetrics job failed', [
|
||||
'business_id' => $this->businessId,
|
||||
'brand_id' => $this->brandId,
|
||||
'exception' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
127
app/Models/AccountNote.php
Normal file
127
app/Models/AccountNote.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Account Note - Sales rep notes on buyer accounts
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property int $account_id
|
||||
* @property int $user_id
|
||||
* @property string $note_type
|
||||
* @property string $content
|
||||
* @property bool $is_pinned
|
||||
*/
|
||||
class AccountNote extends Model
|
||||
{
|
||||
public const TYPE_GENERAL = 'general';
|
||||
|
||||
public const TYPE_COMPETITOR = 'competitor';
|
||||
|
||||
public const TYPE_PAIN_POINT = 'pain_point';
|
||||
|
||||
public const TYPE_OPPORTUNITY = 'opportunity';
|
||||
|
||||
public const TYPE_OBJECTION = 'objection';
|
||||
|
||||
public const TYPES = [
|
||||
self::TYPE_GENERAL => 'General',
|
||||
self::TYPE_COMPETITOR => 'Competitor Intel',
|
||||
self::TYPE_PAIN_POINT => 'Pain Point',
|
||||
self::TYPE_OPPORTUNITY => 'Opportunity',
|
||||
self::TYPE_OBJECTION => 'Objection',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'account_id',
|
||||
'user_id',
|
||||
'note_type',
|
||||
'content',
|
||||
'is_pinned',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_pinned' => 'boolean',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'account_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForAccount($query, int $accountId)
|
||||
{
|
||||
return $query->where('account_id', $accountId);
|
||||
}
|
||||
|
||||
public function scopePinned($query)
|
||||
{
|
||||
return $query->where('is_pinned', true);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('note_type', $type);
|
||||
}
|
||||
|
||||
public function scopeCompetitor($query)
|
||||
{
|
||||
return $query->where('note_type', self::TYPE_COMPETITOR);
|
||||
}
|
||||
|
||||
public function scopePainPoints($query)
|
||||
{
|
||||
return $query->where('note_type', self::TYPE_PAIN_POINT);
|
||||
}
|
||||
|
||||
public function scopeOpportunities($query)
|
||||
{
|
||||
return $query->where('note_type', self::TYPE_OPPORTUNITY);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
public function getTypeLabel(): string
|
||||
{
|
||||
return self::TYPES[$this->note_type] ?? ucfirst($this->note_type);
|
||||
}
|
||||
|
||||
public function pin(): void
|
||||
{
|
||||
$this->update(['is_pinned' => true]);
|
||||
}
|
||||
|
||||
public function unpin(): void
|
||||
{
|
||||
$this->update(['is_pinned' => false]);
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ class InterBusinessSettlement extends Model
|
||||
|
||||
return DB::transaction(function () use ($parentBusinessId, $prefix) {
|
||||
$lastSettlement = static::where('parent_business_id', $parentBusinessId)
|
||||
->where('settlement_number', 'like', "{$prefix}%")
|
||||
->where('settlement_number', 'ilike', "{$prefix}%")
|
||||
->orderByDesc('settlement_number')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
@@ -204,7 +204,7 @@ class JournalEntry extends Model implements AuditableContract
|
||||
// Get the last entry for this business+day, ordered by entry_number descending
|
||||
// Lock the row to serialize concurrent access (PostgreSQL-safe)
|
||||
$lastEntry = static::where('business_id', $businessId)
|
||||
->where('entry_number', 'like', "{$prefix}%")
|
||||
->where('entry_number', 'ilike', "{$prefix}%")
|
||||
->orderByDesc('entry_number')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
@@ -158,7 +158,7 @@ class Activity extends Model
|
||||
*/
|
||||
public function scopeOfTypeGroup($query, string $prefix)
|
||||
{
|
||||
return $query->where('type', 'like', $prefix.'%');
|
||||
return $query->where('type', 'ilike', $prefix.'%');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -151,7 +151,7 @@ class Address extends Model
|
||||
|
||||
public function scopeInCity($query, string $city)
|
||||
{
|
||||
return $query->where('city', 'like', "%{$city}%");
|
||||
return $query->where('city', 'ilike', "%{$city}%");
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
111
app/Models/AgentStatus.php
Normal file
111
app/Models/AgentStatus.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AgentStatus extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'business_id',
|
||||
'status',
|
||||
'status_message',
|
||||
'last_seen_at',
|
||||
'status_changed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_seen_at' => 'datetime',
|
||||
'status_changed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public const STATUS_ONLINE = 'online';
|
||||
|
||||
public const STATUS_AWAY = 'away';
|
||||
|
||||
public const STATUS_BUSY = 'busy';
|
||||
|
||||
public const STATUS_OFFLINE = 'offline';
|
||||
|
||||
public static function statuses(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_ONLINE => 'Online',
|
||||
self::STATUS_AWAY => 'Away',
|
||||
self::STATUS_BUSY => 'Busy',
|
||||
self::STATUS_OFFLINE => 'Offline',
|
||||
];
|
||||
}
|
||||
|
||||
public static function statusColors(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_ONLINE => 'success',
|
||||
self::STATUS_AWAY => 'warning',
|
||||
self::STATUS_BUSY => 'error',
|
||||
self::STATUS_OFFLINE => 'ghost',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public static function getOrCreate(int $userId, int $businessId): self
|
||||
{
|
||||
return self::firstOrCreate(
|
||||
['user_id' => $userId, 'business_id' => $businessId],
|
||||
['status' => self::STATUS_OFFLINE, 'status_changed_at' => now()]
|
||||
);
|
||||
}
|
||||
|
||||
public function setStatus(string $status, ?string $message = null): self
|
||||
{
|
||||
$this->update([
|
||||
'status' => $status,
|
||||
'status_message' => $message,
|
||||
'status_changed_at' => now(),
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isOnline(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ONLINE;
|
||||
}
|
||||
|
||||
public function getStatusColor(): string
|
||||
{
|
||||
return self::statusColors()[$this->status] ?? 'ghost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last_seen_at timestamp (for heartbeat)
|
||||
*/
|
||||
public function updateLastSeen(): self
|
||||
{
|
||||
$this->update(['last_seen_at' => now()]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if agent is active (seen within last 5 minutes)
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->last_seen_at && $this->last_seen_at->gte(now()->subMinutes(5));
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ class AiContentRule extends Model
|
||||
|
||||
public function scopeForContext(Builder $query, string $context): Builder
|
||||
{
|
||||
return $query->where('content_type_key', 'like', $context.'.%');
|
||||
return $query->where('content_type_key', 'ilike', $context.'.%');
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
||||
50
app/Models/ChatQuickReply.php
Normal file
50
app/Models/ChatQuickReply.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ChatQuickReply extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'chat_quick_replies';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'label',
|
||||
'message',
|
||||
'category',
|
||||
'usage_count',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'usage_count' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeByCategory($query, string $category)
|
||||
{
|
||||
return $query->where('category', $category);
|
||||
}
|
||||
|
||||
public function incrementUsage(): void
|
||||
{
|
||||
$this->increment('usage_count');
|
||||
}
|
||||
}
|
||||
97
app/Models/CompetitorReplacement.php
Normal file
97
app/Models/CompetitorReplacement.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Competitor Replacement - Maps CannaiQ competitor products to our products
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property string $cannaiq_product_id
|
||||
* @property string $competitor_name
|
||||
* @property string|null $competitor_product_name
|
||||
* @property int $product_id
|
||||
* @property string|null $advantage_notes
|
||||
* @property int $created_by
|
||||
*/
|
||||
class CompetitorReplacement extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'cannaiq_product_id',
|
||||
'competitor_name',
|
||||
'competitor_product_name',
|
||||
'product_id',
|
||||
'advantage_notes',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForCompetitor($query, string $competitorName)
|
||||
{
|
||||
return $query->where('competitor_name', $competitorName);
|
||||
}
|
||||
|
||||
public function scopeForProduct($query, int $productId)
|
||||
{
|
||||
return $query->where('product_id', $productId);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
/**
|
||||
* Get display label showing competitor → our product
|
||||
*/
|
||||
public function getDisplayLabel(): string
|
||||
{
|
||||
$competitor = $this->competitor_product_name
|
||||
? "{$this->competitor_name} - {$this->competitor_product_name}"
|
||||
: $this->competitor_name;
|
||||
|
||||
return "{$competitor} → {$this->product->name}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short pitch summary
|
||||
*/
|
||||
public function getPitchSummary(): string
|
||||
{
|
||||
if (! $this->advantage_notes) {
|
||||
return "Replace with {$this->product->name}";
|
||||
}
|
||||
|
||||
// Return first sentence or 100 chars
|
||||
$notes = $this->advantage_notes;
|
||||
$firstSentence = strtok($notes, '.');
|
||||
|
||||
return strlen($firstSentence) > 100
|
||||
? substr($notes, 0, 97).'...'
|
||||
: $firstSentence.'.';
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use DateTime;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
@@ -81,6 +82,7 @@ class Contact extends Model
|
||||
'work_hours', // JSON: schedule
|
||||
'availability_notes',
|
||||
'emergency_contact',
|
||||
'best_time_to_contact',
|
||||
|
||||
// Status & Settings
|
||||
'is_primary', // Primary contact for business/location
|
||||
@@ -97,6 +99,7 @@ class Contact extends Model
|
||||
'last_contact_date',
|
||||
'next_followup_date',
|
||||
'relationship_notes',
|
||||
'working_notes', // Sales notes on how they prefer to work
|
||||
|
||||
// Account Management
|
||||
'archived_at',
|
||||
@@ -134,6 +137,17 @@ class Contact extends Model
|
||||
return $this->belongsTo(Location::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locations this contact is assigned to via the location_contact pivot table.
|
||||
* This is the many-to-many relationship for location-specific contact assignments.
|
||||
*/
|
||||
public function locations(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Location::class, 'location_contact')
|
||||
->withPivot(['role', 'is_primary', 'notes'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
|
||||
@@ -87,7 +87,7 @@ class CrmInternalNote extends Model
|
||||
|
||||
foreach (array_unique($matches[1]) as $username) {
|
||||
$user = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $this->business_id))
|
||||
->where('name', 'like', "%{$username}%")
|
||||
->where('name', 'ilike', "%{$username}%")
|
||||
->first();
|
||||
|
||||
if ($user && $user->id !== $this->user_id) {
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Models\Crm;
|
||||
use App\Models\Accounting\ArInvoice;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessLocation;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
@@ -44,6 +45,7 @@ class CrmInvoice extends Model
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'account_id',
|
||||
'location_id',
|
||||
'contact_id',
|
||||
'deal_id',
|
||||
'quote_id',
|
||||
@@ -102,6 +104,11 @@ class CrmInvoice extends Model
|
||||
return $this->belongsTo(Business::class, 'account_id');
|
||||
}
|
||||
|
||||
public function location(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BusinessLocation::class, 'location_id');
|
||||
}
|
||||
|
||||
public function contact(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Contact::class);
|
||||
@@ -331,6 +338,17 @@ class CrmInvoice extends Model
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
public function canBeSent(): bool
|
||||
{
|
||||
return in_array($this->status, [
|
||||
self::STATUS_DRAFT,
|
||||
self::STATUS_SENT,
|
||||
self::STATUS_VIEWED,
|
||||
self::STATUS_PARTIAL,
|
||||
self::STATUS_OVERDUE,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getDaysOverdue(): int
|
||||
{
|
||||
if (! $this->isOverdue()) {
|
||||
|
||||
@@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* CRM Quote - Sales quotation with line items
|
||||
@@ -26,6 +27,17 @@ class CrmQuote extends Model
|
||||
|
||||
protected $table = 'crm_quotes';
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($quote) {
|
||||
if (empty($quote->view_token)) {
|
||||
$quote->view_token = Str::random(32);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_SENT = 'sent';
|
||||
@@ -47,6 +59,7 @@ class CrmQuote extends Model
|
||||
'quote_number',
|
||||
'title',
|
||||
'status',
|
||||
'quote_date',
|
||||
'subtotal',
|
||||
'discount_type',
|
||||
'discount_value',
|
||||
@@ -73,6 +86,7 @@ class CrmQuote extends Model
|
||||
'order_id',
|
||||
'notes_customer',
|
||||
'notes_internal',
|
||||
'view_token',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -83,6 +97,7 @@ class CrmQuote extends Model
|
||||
'tax_amount' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
'signature_requested' => 'boolean',
|
||||
'quote_date' => 'date',
|
||||
'valid_until' => 'date',
|
||||
'sent_at' => 'datetime',
|
||||
'viewed_at' => 'datetime',
|
||||
|
||||
@@ -7,6 +7,9 @@ use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\MarketplaceChatParticipant;
|
||||
use App\Models\Order;
|
||||
use App\Models\SalesRepAssignment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -49,6 +52,10 @@ class CrmThread extends Model
|
||||
|
||||
public const SENTIMENT_NEGATIVE = 'negative';
|
||||
|
||||
public const TYPE_CRM = 'crm';
|
||||
|
||||
public const TYPE_MARKETPLACE = 'marketplace_b2b';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'brand_id',
|
||||
@@ -80,6 +87,11 @@ class CrmThread extends Model
|
||||
'ai_suggested_actions',
|
||||
'currently_viewing_user_id',
|
||||
'currently_viewing_since',
|
||||
// Marketplace B2B fields
|
||||
'buyer_business_id',
|
||||
'seller_business_id',
|
||||
'thread_type',
|
||||
'order_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -183,6 +195,28 @@ class CrmThread extends Model
|
||||
return $this->belongsTo(User::class, 'currently_viewing_user_id');
|
||||
}
|
||||
|
||||
// Marketplace B2B relationships
|
||||
|
||||
public function buyerBusiness(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'buyer_business_id');
|
||||
}
|
||||
|
||||
public function sellerBusiness(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'seller_business_id');
|
||||
}
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function marketplaceParticipants(): HasMany
|
||||
{
|
||||
return $this->hasMany(MarketplaceChatParticipant::class, 'thread_id');
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
@@ -234,6 +268,20 @@ class CrmThread extends Model
|
||||
return $query->where('brand_id', $brandId);
|
||||
}
|
||||
|
||||
public function scopeMarketplace($query)
|
||||
{
|
||||
return $query->where('thread_type', self::TYPE_MARKETPLACE);
|
||||
}
|
||||
|
||||
public function scopeForMarketplaceBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->marketplace()
|
||||
->where(function ($q) use ($businessId) {
|
||||
$q->where('buyer_business_id', $businessId)
|
||||
->orWhere('seller_business_id', $businessId);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeNeedingAttention($query)
|
||||
{
|
||||
return $query->open()
|
||||
@@ -247,6 +295,44 @@ class CrmThread extends Model
|
||||
->where('snoozed_until', '<=', now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter threads for a sales rep.
|
||||
*
|
||||
* Shows threads where:
|
||||
* - The account (buyer business) is assigned to this sales rep, OR
|
||||
* - The thread is directly assigned to this user
|
||||
*
|
||||
* @param int $businessId The seller business ID
|
||||
* @param int $userId The sales rep user ID
|
||||
*/
|
||||
public function scopeForSalesRep($query, int $businessId, int $userId)
|
||||
{
|
||||
// Get account IDs assigned to this sales rep
|
||||
$assignedAccountIds = SalesRepAssignment::where('business_id', $businessId)
|
||||
->where('user_id', $userId)
|
||||
->where('assignable_type', Business::class)
|
||||
->pluck('assignable_id');
|
||||
|
||||
return $query->where(function ($q) use ($assignedAccountIds, $userId) {
|
||||
// Threads for assigned accounts
|
||||
$q->whereIn('account_id', $assignedAccountIds)
|
||||
// OR threads directly assigned to this user
|
||||
->orWhere('assigned_to', $userId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter threads for a brand portal user.
|
||||
*
|
||||
* Shows only threads related to brands the user has access to.
|
||||
*
|
||||
* @param array $brandIds Brand IDs the user can access
|
||||
*/
|
||||
public function scopeForBrandPortal($query, array $brandIds)
|
||||
{
|
||||
return $query->whereIn('brand_id', $brandIds);
|
||||
}
|
||||
|
||||
// Accessors
|
||||
|
||||
public function getIsSnoozedAttribute(): bool
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
@@ -94,6 +95,12 @@ class Location extends Model
|
||||
'transferred_to_business_id', // For ownership transfers
|
||||
'settings', // JSON
|
||||
'notes',
|
||||
|
||||
// CannaiQ Integration
|
||||
'cannaiq_platform',
|
||||
'cannaiq_store_slug',
|
||||
'cannaiq_store_id',
|
||||
'cannaiq_store_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -122,11 +129,57 @@ class Location extends Model
|
||||
return $this->hasMany(License::class);
|
||||
}
|
||||
|
||||
public function contacts(): HasMany
|
||||
/**
|
||||
* Contacts directly associated with this location (location_id on contact)
|
||||
*/
|
||||
public function directContacts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Contact::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Contacts assigned to this location via pivot with roles
|
||||
*/
|
||||
public function contacts(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Contact::class, 'location_contact')
|
||||
->withPivot(['role', 'is_primary', 'notes'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contacts with a specific role for this location
|
||||
*/
|
||||
public function contactsByRole(string $role)
|
||||
{
|
||||
return $this->contacts()->wherePivot('role', $role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary buyer contact for this location
|
||||
*/
|
||||
public function getPrimaryBuyer()
|
||||
{
|
||||
return $this->contacts()
|
||||
->wherePivot('role', 'buyer')
|
||||
->wherePivot('is_primary', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buyer names as a comma-separated label
|
||||
*/
|
||||
public function getBuyersLabelAttribute(): ?string
|
||||
{
|
||||
$buyers = $this->contacts()->wherePivot('role', 'buyer')->get();
|
||||
|
||||
if ($buyers->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $buyers->map(fn ($c) => $c->getFullName())->implode(', ');
|
||||
}
|
||||
|
||||
public function addresses(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Address::class, 'addressable');
|
||||
@@ -250,4 +303,25 @@ class Location extends Model
|
||||
'archived_reason' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this location has CannaiQ store mapping
|
||||
*/
|
||||
public function hasCannaiqMapping(): bool
|
||||
{
|
||||
return ! empty($this->cannaiq_store_slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the CannaiQ store mapping
|
||||
*/
|
||||
public function clearCannaiqMapping(): void
|
||||
{
|
||||
$this->update([
|
||||
'cannaiq_platform' => null,
|
||||
'cannaiq_store_slug' => null,
|
||||
'cannaiq_store_id' => null,
|
||||
'cannaiq_store_name' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
86
app/Models/MarketplaceChatParticipant.php
Normal file
86
app/Models/MarketplaceChatParticipant.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MarketplaceChatParticipant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'thread_id',
|
||||
'user_id',
|
||||
'business_id',
|
||||
'last_read_at',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_read_at' => 'datetime',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function thread(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Crm\CrmThread::class, 'thread_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function markAsRead(): void
|
||||
{
|
||||
$this->update(['last_read_at' => now()]);
|
||||
}
|
||||
|
||||
public function hasUnread(): bool
|
||||
{
|
||||
if (! $this->last_read_at) {
|
||||
return $this->thread->messages()->exists();
|
||||
}
|
||||
|
||||
return $this->thread->messages()
|
||||
->where('created_at', '>', $this->last_read_at)
|
||||
->where('sender_id', '!=', $this->user_id)
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function unreadCount(): int
|
||||
{
|
||||
if (! $this->last_read_at) {
|
||||
return $this->thread->messages()
|
||||
->where('sender_id', '!=', $this->user_id)
|
||||
->count();
|
||||
}
|
||||
|
||||
return $this->thread->messages()
|
||||
->where('created_at', '>', $this->last_read_at)
|
||||
->where('sender_id', '!=', $this->user_id)
|
||||
->count();
|
||||
}
|
||||
}
|
||||
237
app/Models/ProspectImport.php
Normal file
237
app/Models/ProspectImport.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Prospect Import - Track CSV/bulk import jobs
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property int $user_id
|
||||
* @property string $filename
|
||||
* @property string $status
|
||||
* @property int $total_rows
|
||||
* @property int $processed_rows
|
||||
* @property int $created_count
|
||||
* @property int $updated_count
|
||||
* @property int $skipped_count
|
||||
* @property int $error_count
|
||||
* @property array|null $errors
|
||||
* @property array|null $column_mapping
|
||||
* @property \Carbon\Carbon|null $completed_at
|
||||
*/
|
||||
class ProspectImport extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'user_id',
|
||||
'filename',
|
||||
'status',
|
||||
'total_rows',
|
||||
'processed_rows',
|
||||
'created_count',
|
||||
'updated_count',
|
||||
'skipped_count',
|
||||
'error_count',
|
||||
'errors',
|
||||
'column_mapping',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'total_rows' => 'integer',
|
||||
'processed_rows' => 'integer',
|
||||
'created_count' => 'integer',
|
||||
'updated_count' => 'integer',
|
||||
'skipped_count' => 'integer',
|
||||
'error_count' => 'integer',
|
||||
'errors' => 'array',
|
||||
'column_mapping' => 'array',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function importer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
public function scopeProcessing($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PROCESSING);
|
||||
}
|
||||
|
||||
public function scopeCompleted($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_COMPLETED);
|
||||
}
|
||||
|
||||
public function scopeFailed($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_FAILED);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isProcessing(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PROCESSING;
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress percentage
|
||||
*/
|
||||
public function getProgressPercent(): int
|
||||
{
|
||||
if ($this->total_rows === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) round(($this->processed_rows / $this->total_rows) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get success rate percentage
|
||||
*/
|
||||
public function getSuccessRate(): int
|
||||
{
|
||||
$total = $this->created_count + $this->updated_count + $this->skipped_count + $this->error_count;
|
||||
|
||||
if ($total === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) round((($this->created_count + $this->updated_count) / $total) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark import as processing
|
||||
*/
|
||||
public function markProcessing(): void
|
||||
{
|
||||
$this->update(['status' => self::STATUS_PROCESSING]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark import as completed
|
||||
*/
|
||||
public function markCompleted(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_COMPLETED,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark import as failed
|
||||
*/
|
||||
public function markFailed(?string $reason = null): void
|
||||
{
|
||||
$errors = $this->errors ?? [];
|
||||
if ($reason) {
|
||||
$errors[] = ['row' => 0, 'error' => $reason];
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'status' => self::STATUS_FAILED,
|
||||
'errors' => $errors,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add error for a specific row
|
||||
*/
|
||||
public function addError(int $row, string $error): void
|
||||
{
|
||||
$errors = $this->errors ?? [];
|
||||
$errors[] = ['row' => $row, 'error' => $error];
|
||||
|
||||
$this->update([
|
||||
'errors' => $errors,
|
||||
'error_count' => $this->error_count + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment processed count
|
||||
*/
|
||||
public function incrementProcessed(): void
|
||||
{
|
||||
$this->increment('processed_rows');
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment created count
|
||||
*/
|
||||
public function incrementCreated(): void
|
||||
{
|
||||
$this->increment('created_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment updated count
|
||||
*/
|
||||
public function incrementUpdated(): void
|
||||
{
|
||||
$this->increment('updated_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment skipped count
|
||||
*/
|
||||
public function incrementSkipped(): void
|
||||
{
|
||||
$this->increment('skipped_count');
|
||||
}
|
||||
}
|
||||
196
app/Models/ProspectInsight.php
Normal file
196
app/Models/ProspectInsight.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Crm\CrmLead;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Prospect Insight - Gap analysis and opportunity tracking
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property int|null $lead_id
|
||||
* @property int|null $account_id
|
||||
* @property string $insight_type
|
||||
* @property string|null $category
|
||||
* @property string $description
|
||||
* @property array|null $supporting_data
|
||||
* @property int $created_by
|
||||
*/
|
||||
class ProspectInsight extends Model
|
||||
{
|
||||
// Insight types
|
||||
public const TYPE_GAP = 'gap';
|
||||
|
||||
public const TYPE_PAIN_POINT = 'pain_point';
|
||||
|
||||
public const TYPE_OPPORTUNITY = 'opportunity';
|
||||
|
||||
public const TYPE_OBJECTION = 'objection';
|
||||
|
||||
public const TYPE_COMPETITOR_WEAKNESS = 'competitor_weakness';
|
||||
|
||||
public const TYPES = [
|
||||
self::TYPE_GAP => 'Gap',
|
||||
self::TYPE_PAIN_POINT => 'Pain Point',
|
||||
self::TYPE_OPPORTUNITY => 'Opportunity',
|
||||
self::TYPE_OBJECTION => 'Objection',
|
||||
self::TYPE_COMPETITOR_WEAKNESS => 'Competitor Weakness',
|
||||
];
|
||||
|
||||
// Categories
|
||||
public const CATEGORY_PRICE_POINT = 'price_point';
|
||||
|
||||
public const CATEGORY_QUALITY = 'quality';
|
||||
|
||||
public const CATEGORY_CONSISTENCY = 'consistency';
|
||||
|
||||
public const CATEGORY_SERVICE = 'service';
|
||||
|
||||
public const CATEGORY_MARGIN = 'margin';
|
||||
|
||||
public const CATEGORY_RELIABILITY = 'reliability';
|
||||
|
||||
public const CATEGORY_SELECTION = 'selection';
|
||||
|
||||
public const CATEGORIES = [
|
||||
self::CATEGORY_PRICE_POINT => 'Price Point',
|
||||
self::CATEGORY_QUALITY => 'Quality',
|
||||
self::CATEGORY_CONSISTENCY => 'Consistency',
|
||||
self::CATEGORY_SERVICE => 'Service',
|
||||
self::CATEGORY_MARGIN => 'Margin',
|
||||
self::CATEGORY_RELIABILITY => 'Reliability',
|
||||
self::CATEGORY_SELECTION => 'Selection',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'lead_id',
|
||||
'account_id',
|
||||
'insight_type',
|
||||
'category',
|
||||
'description',
|
||||
'supporting_data',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'supporting_data' => 'array',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function lead(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CrmLead::class, 'lead_id');
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'account_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForLead($query, int $leadId)
|
||||
{
|
||||
return $query->where('lead_id', $leadId);
|
||||
}
|
||||
|
||||
public function scopeForAccount($query, int $accountId)
|
||||
{
|
||||
return $query->where('account_id', $accountId);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('insight_type', $type);
|
||||
}
|
||||
|
||||
public function scopeOfCategory($query, string $category)
|
||||
{
|
||||
return $query->where('category', $category);
|
||||
}
|
||||
|
||||
public function scopeGaps($query)
|
||||
{
|
||||
return $query->where('insight_type', self::TYPE_GAP);
|
||||
}
|
||||
|
||||
public function scopePainPoints($query)
|
||||
{
|
||||
return $query->where('insight_type', self::TYPE_PAIN_POINT);
|
||||
}
|
||||
|
||||
public function scopeOpportunities($query)
|
||||
{
|
||||
return $query->where('insight_type', self::TYPE_OPPORTUNITY);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
public function getTypeLabel(): string
|
||||
{
|
||||
return self::TYPES[$this->insight_type] ?? ucfirst($this->insight_type);
|
||||
}
|
||||
|
||||
public function getCategoryLabel(): string
|
||||
{
|
||||
if (! $this->category) {
|
||||
return 'General';
|
||||
}
|
||||
|
||||
return self::CATEGORIES[$this->category] ?? ucfirst($this->category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this insight is for a lead (prospect) vs existing account
|
||||
*/
|
||||
public function isForProspect(): bool
|
||||
{
|
||||
return ! is_null($this->lead_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target entity (lead or account)
|
||||
*/
|
||||
public function getTarget(): CrmLead|Business|null
|
||||
{
|
||||
if ($this->lead_id) {
|
||||
return $this->lead;
|
||||
}
|
||||
|
||||
if ($this->account_id) {
|
||||
return $this->account;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add supporting data reference
|
||||
*/
|
||||
public function addSupportingData(string $key, mixed $value): void
|
||||
{
|
||||
$data = $this->supporting_data ?? [];
|
||||
$data[$key] = $value;
|
||||
$this->update(['supporting_data' => $data]);
|
||||
}
|
||||
}
|
||||
185
app/Models/SalesCommission.php
Normal file
185
app/Models/SalesCommission.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Sales Commission - Actual commission earned by sales rep on an order
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property int $user_id
|
||||
* @property int $order_id
|
||||
* @property int|null $order_item_id
|
||||
* @property int|null $commission_rate_id
|
||||
* @property int $order_total
|
||||
* @property float $commission_percent
|
||||
* @property int $commission_amount
|
||||
* @property string $status
|
||||
* @property \Carbon\Carbon|null $approved_at
|
||||
* @property int|null $approved_by
|
||||
* @property \Carbon\Carbon|null $paid_at
|
||||
* @property string|null $payment_reference
|
||||
* @property string|null $notes
|
||||
*/
|
||||
class SalesCommission extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
|
||||
public const STATUS_PAID = 'paid';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'user_id',
|
||||
'order_id',
|
||||
'order_item_id',
|
||||
'commission_rate_id',
|
||||
'order_total',
|
||||
'commission_percent',
|
||||
'commission_amount',
|
||||
'status',
|
||||
'approved_at',
|
||||
'approved_by',
|
||||
'paid_at',
|
||||
'payment_reference',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'order_total' => 'integer',
|
||||
'commission_percent' => 'decimal:2',
|
||||
'commission_amount' => 'integer',
|
||||
'approved_at' => 'datetime',
|
||||
'paid_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function salesRep(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function orderItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OrderItem::class);
|
||||
}
|
||||
|
||||
public function commissionRate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesCommissionRate::class, 'commission_rate_id');
|
||||
}
|
||||
|
||||
public function approver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
public function scopeApproved($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_APPROVED);
|
||||
}
|
||||
|
||||
public function scopePaid($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PAID);
|
||||
}
|
||||
|
||||
public function scopeUnpaid($query)
|
||||
{
|
||||
return $query->whereIn('status', [self::STATUS_PENDING, self::STATUS_APPROVED]);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_APPROVED;
|
||||
}
|
||||
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PAID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commission amount in dollars
|
||||
*/
|
||||
public function getCommissionDollars(): float
|
||||
{
|
||||
return $this->commission_amount / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order total in dollars
|
||||
*/
|
||||
public function getOrderDollars(): float
|
||||
{
|
||||
return $this->order_total / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve this commission
|
||||
*/
|
||||
public function approve(User $approver): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_APPROVED,
|
||||
'approved_at' => now(),
|
||||
'approved_by' => $approver->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark as paid
|
||||
*/
|
||||
public function markPaid(?string $reference = null): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_PAID,
|
||||
'paid_at' => now(),
|
||||
'payment_reference' => $reference,
|
||||
]);
|
||||
}
|
||||
}
|
||||
152
app/Models/SalesCommissionRate.php
Normal file
152
app/Models/SalesCommissionRate.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* Sales Commission Rate - Defines commission rates for sales reps
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property int|null $user_id
|
||||
* @property string $rate_type
|
||||
* @property string|null $rateable_type
|
||||
* @property int|null $rateable_id
|
||||
* @property float $commission_percent
|
||||
* @property \Carbon\Carbon $effective_from
|
||||
* @property \Carbon\Carbon|null $effective_to
|
||||
* @property bool $is_active
|
||||
*/
|
||||
class SalesCommissionRate extends Model
|
||||
{
|
||||
public const TYPE_DEFAULT = 'default';
|
||||
|
||||
public const TYPE_ACCOUNT = 'account';
|
||||
|
||||
public const TYPE_PRODUCT = 'product';
|
||||
|
||||
public const TYPE_BRAND = 'brand';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'user_id',
|
||||
'rate_type',
|
||||
'rateable_type',
|
||||
'rateable_id',
|
||||
'commission_percent',
|
||||
'effective_from',
|
||||
'effective_to',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'commission_percent' => 'decimal:2',
|
||||
'effective_from' => 'date',
|
||||
'effective_to' => 'date',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function rateable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function commissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesCommission::class, 'commission_rate_id');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, ?int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeEffective($query, ?\Carbon\Carbon $date = null)
|
||||
{
|
||||
$date = $date ?? now();
|
||||
|
||||
return $query
|
||||
->where('effective_from', '<=', $date)
|
||||
->where(function ($q) use ($date) {
|
||||
$q->whereNull('effective_to')
|
||||
->orWhere('effective_to', '>=', $date);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeDefault($query)
|
||||
{
|
||||
return $query->where('rate_type', self::TYPE_DEFAULT);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
/**
|
||||
* Check if this rate is currently effective
|
||||
*/
|
||||
public function isEffective(?\Carbon\Carbon $date = null): bool
|
||||
{
|
||||
$date = $date ?? now();
|
||||
|
||||
if (! $this->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->effective_from > $date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->effective_to && $this->effective_to < $date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display label for this rate
|
||||
*/
|
||||
public function getDisplayLabel(): string
|
||||
{
|
||||
$label = "{$this->commission_percent}%";
|
||||
|
||||
if ($this->user) {
|
||||
$label .= " for {$this->user->name}";
|
||||
}
|
||||
|
||||
return match ($this->rate_type) {
|
||||
self::TYPE_DEFAULT => "Default: {$label}",
|
||||
self::TYPE_ACCOUNT => "Account: {$label}",
|
||||
self::TYPE_PRODUCT => "Product: {$label}",
|
||||
self::TYPE_BRAND => "Brand: {$label}",
|
||||
default => $label,
|
||||
};
|
||||
}
|
||||
}
|
||||
139
app/Models/SalesRepAssignment.php
Normal file
139
app/Models/SalesRepAssignment.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* Sales Rep Assignment - Links sales reps to accounts or stores
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property int $user_id
|
||||
* @property string $assignable_type
|
||||
* @property int $assignable_id
|
||||
* @property string $assignment_type
|
||||
* @property float|null $commission_rate
|
||||
* @property \Carbon\Carbon $assigned_at
|
||||
* @property int|null $assigned_by
|
||||
* @property string|null $notes
|
||||
*/
|
||||
class SalesRepAssignment extends Model
|
||||
{
|
||||
public const TYPE_PRIMARY = 'primary';
|
||||
|
||||
public const TYPE_SECONDARY = 'secondary';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'user_id',
|
||||
'assignable_type',
|
||||
'assignable_id',
|
||||
'assignment_type',
|
||||
'commission_rate',
|
||||
'assigned_at',
|
||||
'assigned_by',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'commission_rate' => 'decimal:2',
|
||||
'assigned_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
/**
|
||||
* The seller business that owns this assignment
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sales rep user
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for user - the sales rep
|
||||
*/
|
||||
public function salesRep(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The assigned entity (Business account or Location store)
|
||||
*/
|
||||
public function assignable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Who made this assignment
|
||||
*/
|
||||
public function assigner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assigned_by');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopePrimary($query)
|
||||
{
|
||||
return $query->where('assignment_type', self::TYPE_PRIMARY);
|
||||
}
|
||||
|
||||
public function scopeSecondary($query)
|
||||
{
|
||||
return $query->where('assignment_type', self::TYPE_SECONDARY);
|
||||
}
|
||||
|
||||
public function scopeAccounts($query)
|
||||
{
|
||||
return $query->where('assignable_type', Business::class);
|
||||
}
|
||||
|
||||
public function scopeLocations($query)
|
||||
{
|
||||
return $query->where('assignable_type', Location::class);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
public function isPrimary(): bool
|
||||
{
|
||||
return $this->assignment_type === self::TYPE_PRIMARY;
|
||||
}
|
||||
|
||||
public function isSecondary(): bool
|
||||
{
|
||||
return $this->assignment_type === self::TYPE_SECONDARY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective commission rate (override or default)
|
||||
*/
|
||||
public function getEffectiveCommissionRate(?float $defaultRate = null): ?float
|
||||
{
|
||||
return $this->commission_rate ?? $defaultRate;
|
||||
}
|
||||
}
|
||||
106
app/Models/SalesTerritory.php
Normal file
106
app/Models/SalesTerritory.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Sales Territory - Geographic region for sales rep assignment
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property string $name
|
||||
* @property string|null $description
|
||||
* @property string $color
|
||||
* @property bool $is_active
|
||||
*/
|
||||
class SalesTerritory extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'name',
|
||||
'description',
|
||||
'color',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function areas(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesTerritoryArea::class, 'territory_id');
|
||||
}
|
||||
|
||||
public function assignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesTerritoryAssignment::class, 'territory_id');
|
||||
}
|
||||
|
||||
public function salesReps(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'sales_territory_assignments', 'territory_id', 'user_id')
|
||||
->withPivot(['assignment_type', 'assigned_at', 'assigned_by'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
/**
|
||||
* Get the primary sales rep for this territory
|
||||
*/
|
||||
public function getPrimaryRep(): ?User
|
||||
{
|
||||
return $this->salesReps()
|
||||
->wherePivot('assignment_type', 'primary')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a location falls within this territory
|
||||
*/
|
||||
public function containsLocation(Location $location): bool
|
||||
{
|
||||
foreach ($this->areas as $area) {
|
||||
if ($area->matchesLocation($location)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all zip codes in this territory
|
||||
*/
|
||||
public function getZipCodes(): array
|
||||
{
|
||||
return $this->areas()
|
||||
->where('area_type', 'zip')
|
||||
->pluck('area_value')
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
72
app/Models/SalesTerritoryArea.php
Normal file
72
app/Models/SalesTerritoryArea.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Sales Territory Area - Geographic area definition (zip, city, state, county)
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $territory_id
|
||||
* @property string $area_type
|
||||
* @property string $area_value
|
||||
*/
|
||||
class SalesTerritoryArea extends Model
|
||||
{
|
||||
public const TYPE_ZIP = 'zip';
|
||||
|
||||
public const TYPE_CITY = 'city';
|
||||
|
||||
public const TYPE_STATE = 'state';
|
||||
|
||||
public const TYPE_COUNTY = 'county';
|
||||
|
||||
protected $fillable = [
|
||||
'territory_id',
|
||||
'area_type',
|
||||
'area_value',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function territory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesTerritory::class, 'territory_id');
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
/**
|
||||
* Check if a location matches this area definition
|
||||
*/
|
||||
public function matchesLocation(Location $location): bool
|
||||
{
|
||||
$value = strtolower(trim($this->area_value));
|
||||
|
||||
return match ($this->area_type) {
|
||||
self::TYPE_ZIP => strtolower(trim($location->zipcode ?? '')) === $value,
|
||||
self::TYPE_CITY => strtolower(trim($location->city ?? '')) === $value,
|
||||
self::TYPE_STATE => strtolower(trim($location->state ?? '')) === $value,
|
||||
self::TYPE_COUNTY => strtolower(trim($location->county ?? '')) === $value,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display label for this area
|
||||
*/
|
||||
public function getDisplayLabel(): string
|
||||
{
|
||||
$typeLabel = match ($this->area_type) {
|
||||
self::TYPE_ZIP => 'ZIP',
|
||||
self::TYPE_CITY => 'City',
|
||||
self::TYPE_STATE => 'State',
|
||||
self::TYPE_COUNTY => 'County',
|
||||
default => ucfirst($this->area_type),
|
||||
};
|
||||
|
||||
return "{$typeLabel}: {$this->area_value}";
|
||||
}
|
||||
}
|
||||
81
app/Models/SalesTerritoryAssignment.php
Normal file
81
app/Models/SalesTerritoryAssignment.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Sales Territory Assignment - Links sales reps to territories
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $territory_id
|
||||
* @property int $user_id
|
||||
* @property string $assignment_type
|
||||
* @property \Carbon\Carbon $assigned_at
|
||||
* @property int|null $assigned_by
|
||||
*/
|
||||
class SalesTerritoryAssignment extends Model
|
||||
{
|
||||
public const TYPE_PRIMARY = 'primary';
|
||||
|
||||
public const TYPE_SECONDARY = 'secondary';
|
||||
|
||||
protected $fillable = [
|
||||
'territory_id',
|
||||
'user_id',
|
||||
'assignment_type',
|
||||
'assigned_at',
|
||||
'assigned_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'assigned_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function territory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesTerritory::class, 'territory_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function salesRep(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function assigner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assigned_by');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopePrimary($query)
|
||||
{
|
||||
return $query->where('assignment_type', self::TYPE_PRIMARY);
|
||||
}
|
||||
|
||||
public function scopeSecondary($query)
|
||||
{
|
||||
return $query->where('assignment_type', self::TYPE_SECONDARY);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
public function isPrimary(): bool
|
||||
{
|
||||
return $this->assignment_type === self::TYPE_PRIMARY;
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,13 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Lab404\Impersonate\Models\Impersonate;
|
||||
use NotificationChannels\WebPush\HasPushSubscriptions;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class User extends Authenticatable implements FilamentUser
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, HasRoles, HasUuids, Impersonate, Notifiable;
|
||||
use HasFactory, HasPushSubscriptions, HasRoles, HasUuids, Impersonate, Notifiable;
|
||||
|
||||
/**
|
||||
* User type constants
|
||||
|
||||
@@ -109,6 +109,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$versionData = cache()->remember('app.version_data', now()->addSeconds(5), function () {
|
||||
$version = 'dev';
|
||||
$commit = 'unknown';
|
||||
$buildDate = null;
|
||||
|
||||
// For Docker: read from version.env (injected at build time)
|
||||
$versionFile = base_path('version.env');
|
||||
@@ -117,6 +118,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$data = parse_ini_file($versionFile);
|
||||
$version = $data['VERSION'] ?? 'dev';
|
||||
$commit = $data['COMMIT'] ?? 'unknown';
|
||||
$buildDate = $data['BUILD_DATE'] ?? null;
|
||||
}
|
||||
// For local dev: read from git directly (but cached for 5 seconds)
|
||||
// Check for .git (directory for regular repos, file for worktrees)
|
||||
@@ -128,6 +130,13 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
// Only proceed if we successfully got a commit SHA
|
||||
if ($commit !== '' && $commit !== 'unknown') {
|
||||
// Get commit date for local dev
|
||||
$dateCommand = sprintf('cd %s && git log -1 --format=%%ci 2>/dev/null', escapeshellarg(base_path()));
|
||||
$commitDate = trim(shell_exec($dateCommand) ?: '');
|
||||
if ($commitDate) {
|
||||
$buildDate = date('M j, g:ia', strtotime($commitDate));
|
||||
}
|
||||
|
||||
// Check for uncommitted changes (dirty working directory)
|
||||
$diffCommand = sprintf('cd %s && git diff --quiet 2>/dev/null; echo $?', escapeshellarg(base_path()));
|
||||
$cachedCommand = sprintf('cd %s && git diff --cached --quiet 2>/dev/null; echo $?', escapeshellarg(base_path()));
|
||||
@@ -147,17 +156,19 @@ class AppServiceProvider extends ServiceProvider
|
||||
return [
|
||||
'version' => $version,
|
||||
'commit' => $commit,
|
||||
'buildDate' => $buildDate,
|
||||
];
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
// If cache fails (e.g., Redis not ready), calculate version without caching
|
||||
$versionData = ['version' => 'dev', 'commit' => 'unknown'];
|
||||
$versionData = ['version' => 'dev', 'commit' => 'unknown', 'buildDate' => null];
|
||||
|
||||
$versionFile = base_path('version.env');
|
||||
if (File::exists($versionFile)) {
|
||||
$data = parse_ini_file($versionFile);
|
||||
$versionData['version'] = $data['VERSION'] ?? 'dev';
|
||||
$versionData['commit'] = $data['COMMIT'] ?? 'unknown';
|
||||
$versionData['buildDate'] = $data['BUILD_DATE'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +176,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$view->with([
|
||||
'appVersion' => $versionData['version'],
|
||||
'appCommit' => $versionData['commit'],
|
||||
'appBuildDate' => $versionData['buildDate'],
|
||||
'appVersionFull' => "{$versionData['version']} (sha-{$versionData['commit']})",
|
||||
]);
|
||||
});
|
||||
@@ -299,5 +311,38 @@ class AppServiceProvider extends ServiceProvider
|
||||
// Department/permission-based access
|
||||
return $user->hasPermission('manage_bom');
|
||||
});
|
||||
|
||||
// Team Management Gate - Manager-only access for team dashboards
|
||||
Gate::define('manage-team', function (User $user, ?Business $business = null) {
|
||||
// Get business from route if not provided
|
||||
$business = $business ?? request()->route('business');
|
||||
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Business Owner always has access
|
||||
if ($user->id === $business->owner_user_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Super admin has access
|
||||
if ($user->hasRole('super-admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user is a manager in any department for this business
|
||||
$userDepartments = $user->departments ?? collect();
|
||||
if ($userDepartments->where('pivot.role', 'manager')->isNotEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check role-based access
|
||||
if (in_array($user->role, ['admin', 'manager'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,10 +260,10 @@ class BrandVoicePrompt
|
||||
* AI generation MUST respect these limits.
|
||||
*/
|
||||
public const CHARACTER_LIMITS = [
|
||||
'tagline' => ['min' => 30, 'max' => 45, 'label' => 'Tagline'],
|
||||
'short_description' => ['min' => 100, 'max' => 150, 'label' => 'Short Description'],
|
||||
'description' => ['min' => 100, 'max' => 150, 'label' => 'Short Description'], // Alias
|
||||
'long_description' => ['min' => 400, 'max' => 500, 'label' => 'Long Description'],
|
||||
'tagline' => ['min' => null, 'max' => 255, 'label' => 'Tagline'],
|
||||
'short_description' => ['min' => null, 'max' => 1000, 'label' => 'Short Description'],
|
||||
'description' => ['min' => null, 'max' => 1000, 'label' => 'Short Description'], // Alias
|
||||
'long_description' => ['min' => null, 'max' => 5000, 'label' => 'Long Description'],
|
||||
'brand_announcement' => ['min' => 400, 'max' => 500, 'label' => 'Brand Announcement'],
|
||||
'seo_title' => ['min' => 60, 'max' => 70, 'label' => 'SEO Title'],
|
||||
'seo_description' => ['min' => 150, 'max' => 160, 'label' => 'SEO Description'],
|
||||
@@ -803,6 +803,11 @@ class BrandVoicePrompt
|
||||
return '';
|
||||
}
|
||||
|
||||
// If no min is set, only enforce max
|
||||
if ($limits['min'] === null) {
|
||||
return "CHARACTER LIMIT: Output should not exceed {$limits['max']} characters.";
|
||||
}
|
||||
|
||||
return "STRICT CHARACTER LIMIT: Output MUST be between {$limits['min']}-{$limits['max']} characters. Do NOT output fewer than {$limits['min']} or more than {$limits['max']} characters.";
|
||||
}
|
||||
|
||||
|
||||
@@ -694,7 +694,7 @@ class AccountingReportingService
|
||||
->active()
|
||||
->where(function ($q) {
|
||||
$q->where('account_subtype', 'cash')
|
||||
->orWhere('name', 'like', '%Cash%');
|
||||
->orWhere('name', 'ilike', '%Cash%');
|
||||
})
|
||||
->get();
|
||||
|
||||
|
||||
@@ -87,10 +87,10 @@ class CashFlowForecastService
|
||||
->where(function ($q) {
|
||||
$q->where('account_subtype', 'cash')
|
||||
->orWhere('account_subtype', 'bank')
|
||||
->orWhere('name', 'like', '%cash%')
|
||||
->orWhere('name', 'like', '%bank%')
|
||||
->orWhere('name', 'like', '%checking%')
|
||||
->orWhere('name', 'like', '%savings%');
|
||||
->orWhere('name', 'ilike', '%cash%')
|
||||
->orWhere('name', 'ilike', '%bank%')
|
||||
->orWhere('name', 'ilike', '%checking%')
|
||||
->orWhere('name', 'ilike', '%savings%');
|
||||
})
|
||||
->active()
|
||||
->postable()
|
||||
|
||||
@@ -294,7 +294,7 @@ class ExpenseService
|
||||
$month = now()->format('m');
|
||||
|
||||
$lastExpense = Expense::where('business_id', $business->id)
|
||||
->where('expense_number', 'like', "{$prefix}-{$year}{$month}-%")
|
||||
->where('expense_number', 'ilike', "{$prefix}-{$year}{$month}-%")
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
|
||||
@@ -411,7 +411,7 @@ class RecurringSchedulerService
|
||||
$month = now()->format('m');
|
||||
|
||||
$last = ArInvoice::where('business_id', $business->id)
|
||||
->where('invoice_number', 'like', "{$prefix}-{$year}{$month}-%")
|
||||
->where('invoice_number', 'ilike', "{$prefix}-{$year}{$month}-%")
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
@@ -433,7 +433,7 @@ class RecurringSchedulerService
|
||||
$month = now()->format('m');
|
||||
|
||||
$last = ApBill::where('business_id', $business->id)
|
||||
->where('bill_number', 'like', "{$prefix}-{$year}{$month}-%")
|
||||
->where('bill_number', 'ilike', "{$prefix}-{$year}{$month}-%")
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
@@ -455,7 +455,7 @@ class RecurringSchedulerService
|
||||
$month = now()->format('m');
|
||||
|
||||
$last = JournalEntry::where('business_id', $business->id)
|
||||
->where('entry_number', 'like', "{$prefix}-{$year}{$month}-%")
|
||||
->where('entry_number', 'ilike', "{$prefix}-{$year}{$month}-%")
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class BuyerContextBuilder extends BaseContextBuilder
|
||||
'name' => $contact->full_name,
|
||||
'email' => $contact->email,
|
||||
'phone' => $contact->phone,
|
||||
'company' => $contact->company_name ?? $contact->business?->name,
|
||||
'company' => $contact->business?->name,
|
||||
'tags' => $contact->tags ?? [],
|
||||
'lifecycle_stage' => $contact->lifecycle_stage ?? 'lead',
|
||||
'created_at' => $contact->created_at->format('Y-m-d'),
|
||||
|
||||
533
app/Services/Cannaiq/AdvancedV3IntelligenceDTO.php
Normal file
533
app/Services/Cannaiq/AdvancedV3IntelligenceDTO.php
Normal file
@@ -0,0 +1,533 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Cannaiq;
|
||||
|
||||
/**
|
||||
* Advanced v4 Intelligence Data Transfer Object
|
||||
*
|
||||
* Contains advanced brand intelligence analytics including:
|
||||
* - Brand positioning and differentiation scoring (v3)
|
||||
* - Trend lead/lag analysis (predictive vs laggy behavior) (v3)
|
||||
* - Cross-state market signals (v3)
|
||||
* - Shelf displacement opportunities (v3)
|
||||
* - Shelf value projections with capture scenarios (v4)
|
||||
*
|
||||
* v4.0 Additions:
|
||||
* - shelfValueProjections: Revenue projections by scope (store/state/multi_state)
|
||||
* - capture_scenarios: 10%, 25%, 50% market capture modeling
|
||||
* - opportunity_label: "Big prize, low effort" etc.
|
||||
* - consumerDemand: Consumer Demand Index + SKU lifecycle stages
|
||||
* - elasticity: Price elasticity metrics per SKU
|
||||
* - competitiveThreat: Competitive pressure scoring
|
||||
* - portfolioBalance: Category mix, redundancy clusters, gaps
|
||||
*
|
||||
* All data is derived from existing CannaiQ + internal data; no new scrapes.
|
||||
*/
|
||||
class AdvancedV3IntelligenceDTO
|
||||
{
|
||||
public function __construct(
|
||||
// v3.0 fields
|
||||
public readonly ?array $brandPositioning = null,
|
||||
public readonly ?array $trendLeadLag = null,
|
||||
public readonly array $marketSignals = [],
|
||||
public readonly array $shelfOpportunities = [],
|
||||
// v4.0: Shelf value projections with capture scenarios
|
||||
public readonly array $shelfValueProjections = [],
|
||||
// v4.0: Consumer Demand Index + SKU lifecycle
|
||||
public readonly ?array $consumerDemand = null,
|
||||
// v4.0: Price elasticity metrics
|
||||
public readonly ?array $elasticity = null,
|
||||
// v4.0: Competitive threat scoring
|
||||
public readonly ?array $competitiveThreat = null,
|
||||
// v4.0: Portfolio balance analysis
|
||||
public readonly ?array $portfolioBalance = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create empty DTO when data is unavailable
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self(
|
||||
brandPositioning: null,
|
||||
trendLeadLag: null,
|
||||
marketSignals: [],
|
||||
shelfOpportunities: [],
|
||||
shelfValueProjections: [],
|
||||
consumerDemand: null,
|
||||
elasticity: null,
|
||||
competitiveThreat: null,
|
||||
portfolioBalance: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty brand positioning structure
|
||||
*
|
||||
* Structure:
|
||||
* - differentiation_score: 0-100 (how unique vs competitors)
|
||||
* - positioning_label: 'more_of_the_same'|'value_disruptor'|'premium_standout'|'potency_leader'|'format_outlier'
|
||||
* - comparables: Array of similar brands with distance scores
|
||||
* - notes: Array of bullet explanations
|
||||
*/
|
||||
public static function emptyBrandPositioning(): array
|
||||
{
|
||||
return [
|
||||
'differentiation_score' => null,
|
||||
'positioning_label' => 'more_of_the_same',
|
||||
'comparables' => [],
|
||||
'notes' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty trend lead/lag structure
|
||||
*
|
||||
* Structure:
|
||||
* - lead_lag_index: -100 (laggy) to +100 (predictive)
|
||||
* - classification: 'strong_leader'|'emerging_leader'|'in_line'|'follower'|'laggy'
|
||||
* - supporting_signals: Array of category-level signals
|
||||
*/
|
||||
public static function emptyTrendLeadLag(): array
|
||||
{
|
||||
return [
|
||||
'lead_lag_index' => 0,
|
||||
'classification' => 'in_line',
|
||||
'supporting_signals' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty market signal structure
|
||||
*
|
||||
* Structure:
|
||||
* - scope: 'multi_state'|'state'|'category'
|
||||
* - state_code: optional state
|
||||
* - category: optional category
|
||||
* - description: human-readable summary
|
||||
* - trend_strength: 0-100
|
||||
* - relevant_to_brand: bool
|
||||
* - brand_fit: 'strong_fit'|'partial_fit'|'gap'
|
||||
* - example_brand: optional example
|
||||
*/
|
||||
public static function emptyMarketSignal(): array
|
||||
{
|
||||
return [
|
||||
'scope' => 'category',
|
||||
'state_code' => null,
|
||||
'category' => null,
|
||||
'description' => '',
|
||||
'trend_strength' => 0,
|
||||
'relevant_to_brand' => false,
|
||||
'brand_fit' => 'gap',
|
||||
'example_brand' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty shelf opportunity structure
|
||||
*
|
||||
* Structure:
|
||||
* - store_id: CannaiQ store external ID
|
||||
* - store_name: Store display name
|
||||
* - state_code: State abbreviation
|
||||
* - opportunity_type: 'whitespace'|'displacement'
|
||||
* - competitor_brand: null for whitespace
|
||||
* - competitor_product_name: null for whitespace
|
||||
* - our_best_sku_id: our matching product ID
|
||||
* - our_best_sku_name: our matching product name
|
||||
* - est_monthly_units_current: competitor's current volume
|
||||
* - est_monthly_units_if_we_win: projected volume if we win
|
||||
* - est_monthly_revenue_if_we_win: projected revenue
|
||||
* - quality_score_delta: -100 to +100 (positive = we're better)
|
||||
* - value_score_delta: -100 to +100 (positive = better value)
|
||||
* - displacement_difficulty: 'low'|'medium'|'high'
|
||||
* - difficulty_score: 0-100 (100 = hardest)
|
||||
* - rationale_tags: Array of reason strings
|
||||
*/
|
||||
public static function emptyShelfOpportunity(): array
|
||||
{
|
||||
return [
|
||||
'store_id' => null,
|
||||
'store_name' => 'Unknown',
|
||||
'state_code' => null,
|
||||
'opportunity_type' => 'whitespace',
|
||||
'competitor_brand' => null,
|
||||
'competitor_product_name' => null,
|
||||
'our_best_sku_id' => null,
|
||||
'our_best_sku_name' => null,
|
||||
'est_monthly_units_current' => 0,
|
||||
'est_monthly_units_if_we_win' => 0,
|
||||
'est_monthly_revenue_if_we_win' => 0,
|
||||
'quality_score_delta' => 0,
|
||||
'value_score_delta' => 0,
|
||||
'displacement_difficulty' => 'medium',
|
||||
'difficulty_score' => 50,
|
||||
'rationale_tags' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty shelf value projection structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - scope: 'store'|'state'|'multi_state' - geographic scope of projection
|
||||
* - store_id: CannaiQ store ID (when scope='store')
|
||||
* - store_name: Store display name (when scope='store')
|
||||
* - state_code: State abbreviation (when scope='store' or 'state')
|
||||
* - current_competitor_sales: Competitor revenue currently on shelf
|
||||
* - category_total_sales: Total category sales at location
|
||||
* - our_current_share: Our % of category sales (0.0-1.0)
|
||||
* - our_current_shelf_value: Our current monthly revenue at location
|
||||
* - avg_displacement_difficulty: 0-100 (aggregated from opportunities)
|
||||
* - opportunity_label: 'Big prize, low effort'|'Low-hanging fruit'|'High potential, high difficulty'|'Grind zone'
|
||||
* - capture_scenarios: Array of capture scenario projections
|
||||
*/
|
||||
public static function emptyShelfValueProjection(): array
|
||||
{
|
||||
return [
|
||||
'scope' => 'store',
|
||||
'store_id' => null,
|
||||
'store_name' => null,
|
||||
'state_code' => null,
|
||||
'current_competitor_sales' => 0,
|
||||
'category_total_sales' => 0,
|
||||
'our_current_share' => 0,
|
||||
'our_current_shelf_value' => 0,
|
||||
'avg_displacement_difficulty' => 50,
|
||||
'opportunity_label' => 'Grind zone',
|
||||
'capture_scenarios' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty capture scenario structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - capture_percent: 10|25|50 - % of competitor shelf to capture
|
||||
* - projected_monthly_revenue: Revenue if we achieve this capture
|
||||
* - projected_units: Units if we achieve this capture
|
||||
* - revenue_lift_from_current: Delta from our current revenue
|
||||
* - effort_level: 'low'|'medium'|'high' - based on difficulty + capture %
|
||||
*/
|
||||
public static function emptyCaptureScenario(): array
|
||||
{
|
||||
return [
|
||||
'capture_percent' => 10,
|
||||
'projected_monthly_revenue' => 0,
|
||||
'projected_units' => 0,
|
||||
'revenue_lift_from_current' => 0,
|
||||
'effort_level' => 'medium',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get opportunity label based on value and difficulty
|
||||
*
|
||||
* @param float $value Estimated monthly revenue opportunity
|
||||
* @param int $difficulty 0-100 difficulty score
|
||||
*/
|
||||
public static function getOpportunityLabel(float $value, int $difficulty): string
|
||||
{
|
||||
// High value threshold: $5,000/mo
|
||||
// Low difficulty threshold: 40
|
||||
$highValue = $value >= 5000;
|
||||
$lowDifficulty = $difficulty <= 40;
|
||||
|
||||
return match (true) {
|
||||
$highValue && $lowDifficulty => 'Big prize, low effort',
|
||||
! $highValue && $lowDifficulty => 'Low-hanging fruit',
|
||||
$highValue && ! $lowDifficulty => 'High potential, high difficulty',
|
||||
default => 'Grind zone',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for views
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'brandPositioning' => $this->brandPositioning,
|
||||
'trendLeadLag' => $this->trendLeadLag,
|
||||
'marketSignals' => $this->marketSignals,
|
||||
'shelfOpportunities' => $this->shelfOpportunities,
|
||||
'shelfValueProjections' => $this->shelfValueProjections,
|
||||
'consumerDemand' => $this->consumerDemand,
|
||||
'elasticity' => $this->elasticity,
|
||||
'competitiveThreat' => $this->competitiveThreat,
|
||||
'portfolioBalance' => $this->portfolioBalance,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any v3/v4 intelligence data is available
|
||||
*/
|
||||
public function hasData(): bool
|
||||
{
|
||||
return $this->brandPositioning !== null
|
||||
|| $this->trendLeadLag !== null
|
||||
|| ! empty($this->marketSignals)
|
||||
|| ! empty($this->shelfOpportunities)
|
||||
|| ! empty($this->shelfValueProjections)
|
||||
|| $this->consumerDemand !== null
|
||||
|| $this->elasticity !== null
|
||||
|| $this->competitiveThreat !== null
|
||||
|| $this->portfolioBalance !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty consumer demand structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - consumer_demand_index: 0-100 overall brand demand score
|
||||
* - sku_scores: Array of per-SKU demand metrics
|
||||
*/
|
||||
public static function emptyConsumerDemand(): array
|
||||
{
|
||||
return [
|
||||
'consumer_demand_index' => null,
|
||||
'sku_scores' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty SKU demand score structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - product_id: Internal product ID
|
||||
* - product_name: Display name
|
||||
* - demand_index: 0-100 demand score
|
||||
* - promo_independence: 0-100 (higher = sells well without promos)
|
||||
* - cross_store_consistency: 0-100 (higher = consistent across stores)
|
||||
* - stage: 'launch'|'growth'|'peak'|'decline'|'terminal'|null
|
||||
*/
|
||||
public static function emptySkuDemandScore(): array
|
||||
{
|
||||
return [
|
||||
'product_id' => null,
|
||||
'product_name' => 'Unknown',
|
||||
'demand_index' => null,
|
||||
'promo_independence' => null,
|
||||
'cross_store_consistency' => null,
|
||||
'stage' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty elasticity structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - sku_elasticity: Array of per-SKU price elasticity metrics
|
||||
*/
|
||||
public static function emptyElasticity(): array
|
||||
{
|
||||
return [
|
||||
'sku_elasticity' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty SKU elasticity structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - product_id: Internal product ID
|
||||
* - product_name: Display name
|
||||
* - current_price: Current average price
|
||||
* - elasticity: Numeric elasticity coefficient (negative = price sensitive)
|
||||
* - price_behavior: 'sensitive'|'stable'|'room_to_raise'|null
|
||||
* - note: Human-readable recommendation
|
||||
*/
|
||||
public static function emptySkuElasticity(): array
|
||||
{
|
||||
return [
|
||||
'product_id' => null,
|
||||
'product_name' => 'Unknown',
|
||||
'current_price' => null,
|
||||
'elasticity' => null,
|
||||
'price_behavior' => null,
|
||||
'note' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty competitive threat structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - overall_threat_score: 0-100 aggregate threat level
|
||||
* - threat_level: 'low'|'medium'|'high'
|
||||
* - threats: Array of competitor threat details
|
||||
*/
|
||||
public static function emptyCompetitiveThreat(): array
|
||||
{
|
||||
return [
|
||||
'overall_threat_score' => null,
|
||||
'threat_level' => null,
|
||||
'threats' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty competitor threat structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - brand_name: Competitor brand name
|
||||
* - threat_score: 0-100 individual threat score
|
||||
* - price_aggression: 0-100 (how aggressively they undercut)
|
||||
* - velocity_trend: -100 to +100 (their growth vs decline)
|
||||
* - overlap_score: 0-100 (category/store overlap)
|
||||
* - notes: Array of threat reasons
|
||||
*/
|
||||
public static function emptyThreatBrand(): array
|
||||
{
|
||||
return [
|
||||
'brand_name' => 'Unknown',
|
||||
'threat_score' => null,
|
||||
'price_aggression' => null,
|
||||
'velocity_trend' => null,
|
||||
'overlap_score' => null,
|
||||
'notes' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty portfolio balance structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - category_mix: Array of category distribution
|
||||
* - redundancy_clusters: Array of similar SKU groupings
|
||||
* - gaps: Array of identified portfolio gaps
|
||||
*/
|
||||
public static function emptyPortfolioBalance(): array
|
||||
{
|
||||
return [
|
||||
'category_mix' => [],
|
||||
'redundancy_clusters' => [],
|
||||
'gaps' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty category mix item structure (v4.0)
|
||||
*/
|
||||
public static function emptyCategoryMix(): array
|
||||
{
|
||||
return [
|
||||
'category' => 'Unknown',
|
||||
'sku_count' => 0,
|
||||
'revenue_share_percent' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty redundancy cluster structure (v4.0)
|
||||
*/
|
||||
public static function emptyRedundancyCluster(): array
|
||||
{
|
||||
return [
|
||||
'cluster_id' => null,
|
||||
'label' => 'Unknown',
|
||||
'product_ids' => [],
|
||||
'note' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty portfolio gap structure (v4.0)
|
||||
*/
|
||||
public static function emptyPortfolioGap(): array
|
||||
{
|
||||
return [
|
||||
'category' => 'Unknown',
|
||||
'description' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get threat level label from score
|
||||
*/
|
||||
public static function getThreatLevel(float $score): string
|
||||
{
|
||||
return match (true) {
|
||||
$score >= 70 => 'high',
|
||||
$score >= 40 => 'medium',
|
||||
default => 'low',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lifecycle stage from velocity metrics
|
||||
*
|
||||
* @param float $velocity Current daily velocity
|
||||
* @param float|null $velocityTrend % change vs prior period (-100 to +100)
|
||||
* @param float $categoryAvgVelocity Category average velocity
|
||||
*/
|
||||
public static function getLifecycleStage(float $velocity, ?float $velocityTrend, float $categoryAvgVelocity): string
|
||||
{
|
||||
$relativeVelocity = $categoryAvgVelocity > 0 ? $velocity / $categoryAvgVelocity : 0;
|
||||
|
||||
// Very low velocity with flat/declining trend = terminal
|
||||
if ($relativeVelocity < 0.2 && ($velocityTrend === null || $velocityTrend <= 0)) {
|
||||
return 'terminal';
|
||||
}
|
||||
|
||||
// Low velocity but growing = launch
|
||||
if ($relativeVelocity < 0.5 && $velocityTrend !== null && $velocityTrend > 20) {
|
||||
return 'launch';
|
||||
}
|
||||
|
||||
// Medium velocity with strong growth = growth
|
||||
if ($velocityTrend !== null && $velocityTrend > 10) {
|
||||
return 'growth';
|
||||
}
|
||||
|
||||
// High velocity, stable = peak
|
||||
if ($relativeVelocity >= 0.8 && ($velocityTrend === null || abs($velocityTrend) <= 10)) {
|
||||
return 'peak';
|
||||
}
|
||||
|
||||
// Declining = decline
|
||||
if ($velocityTrend !== null && $velocityTrend < -10) {
|
||||
return 'decline';
|
||||
}
|
||||
|
||||
// Default to growth for healthy products
|
||||
return 'growth';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get positioning label for display
|
||||
*/
|
||||
public function getPositioningLabelDisplay(): string
|
||||
{
|
||||
if (! $this->brandPositioning) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
return match ($this->brandPositioning['positioning_label'] ?? 'more_of_the_same') {
|
||||
'value_disruptor' => 'Value Disruptor',
|
||||
'premium_standout' => 'Premium Standout',
|
||||
'potency_leader' => 'Potency Leader',
|
||||
'format_outlier' => 'Format Outlier',
|
||||
default => 'More of the Same',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trend classification for display
|
||||
*/
|
||||
public function getTrendClassificationDisplay(): string
|
||||
{
|
||||
if (! $this->trendLeadLag) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
return match ($this->trendLeadLag['classification'] ?? 'in_line') {
|
||||
'strong_leader' => 'Predictive (Leads Market)',
|
||||
'emerging_leader' => 'Early Mover',
|
||||
'follower' => 'Follower',
|
||||
'laggy' => 'Laggy (Follows Late)',
|
||||
default => 'In Line with Market',
|
||||
};
|
||||
}
|
||||
}
|
||||
1895
app/Services/Cannaiq/AdvancedV3IntelligenceService.php
Normal file
1895
app/Services/Cannaiq/AdvancedV3IntelligenceService.php
Normal file
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