Compare commits
208 Commits
fix/ci-git
...
feat/chat-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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"
|
||||
|
||||
|
||||
92
Dockerfile.fast
Normal file
92
Dockerfile.fast
Normal file
@@ -0,0 +1,92 @@
|
||||
# ============================================
|
||||
# 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
|
||||
|
||||
# Fix permissions
|
||||
RUN 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"]
|
||||
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;
|
||||
|
||||
37
app/Http/Controllers/Api/AgentStatusController.php
Normal file
37
app/Http/Controllers/Api/AgentStatusController.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
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']);
|
||||
$agentStatus->setStatus($validated['status'], $validated['status_message'] ?? null);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'status' => $agentStatus->status,
|
||||
'status_label' => AgentStatus::statuses()[$agentStatus->status],
|
||||
]);
|
||||
}
|
||||
}
|
||||
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'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -164,9 +164,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}%"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
93
app/Models/AgentStatus.php
Normal file
93
app/Models/AgentStatus.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
@@ -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,8 @@ 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\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -49,6 +51,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 +86,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 +194,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 +267,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()
|
||||
|
||||
@@ -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
337
app/Services/Cannaiq/BrandAnalysisDTO.php
Normal file
337
app/Services/Cannaiq/BrandAnalysisDTO.php
Normal file
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Cannaiq;
|
||||
|
||||
/**
|
||||
* Brand Analysis Data Transfer Object (v3.0)
|
||||
*
|
||||
* Contains all market intelligence data for a brand, structured for the Analysis page.
|
||||
* When CannaiQ is disabled, contains only internal sales data.
|
||||
* When CannaiQ is enabled, enriched with market intelligence.
|
||||
*
|
||||
* v2.0 Additions:
|
||||
* - engagement: Buyer outreach and response tracking (always available)
|
||||
* - sentiment: Store support and brand positioning (CannaiQ only)
|
||||
*
|
||||
* v3.0 Additions:
|
||||
* - advancedV3: Advanced intelligence analytics (CannaiQ only)
|
||||
* - brandPositioning: Differentiation score and positioning label
|
||||
* - trendLeadLag: Predictive vs laggy behavior analysis
|
||||
* - marketSignals: Cross-state market trends
|
||||
* - shelfOpportunities: Displacement opportunities with difficulty scores
|
||||
*
|
||||
* Structure Reference (v1.5):
|
||||
*
|
||||
* placement: [
|
||||
* 'stores' => [...], // List of stores carrying brand
|
||||
* 'whitespaceStores' => [...], // v1.5: Stores with competitors but not us
|
||||
* 'whitespaceCount' => int, // v1.5: Count of whitespace opportunities
|
||||
* 'penetrationByRegion' => [ // v1.5: Regional breakdown
|
||||
* ['region' => 'CA', 'storeCount' => 10, 'totalStores' => 50, 'penetrationPercent' => 20],
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* competitors: [
|
||||
* 'competitors' => [...], // List of competitor brands
|
||||
* 'pricePosition' => 'value'|'mid'|'premium', // v1.5: Our price position
|
||||
* 'headToHeadSkus' => [ // v1.5: Direct SKU comparisons
|
||||
* ['ourSku' => '...', 'competitorSku' => '...', 'ourVelocity' => 0.5, ...],
|
||||
* ],
|
||||
* 'marketShareTrend' => [ // v1.5: Time series market share
|
||||
* ['period' => '2025-01', 'ourShare' => 12.5, 'competitor1Share' => 15.2, ...],
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* promoPerformance: [
|
||||
* [
|
||||
* 'id' => ..., 'name' => ...,
|
||||
* 'baselineVelocity' => float, // v1.5: Non-promo velocity
|
||||
* 'promoVelocity' => float, // v1.5: During-promo velocity
|
||||
* 'velocityLift' => float, // v1.5: Percent lift
|
||||
* 'efficiencyScore' => float, // v1.5: Units gained per discount dollar
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* inventoryProjection: [
|
||||
* 'items' => [ // v1.5: Structured items array
|
||||
* ['sku' => '...', 'daysOfStock' => int, 'riskLevel' => 'low'|'medium'|'high', ...],
|
||||
* ],
|
||||
* 'overstockedItems' => [...], // v1.5: Items with >90 days supply
|
||||
* 'rollup' => [ // v1.5: Brand-level summary
|
||||
* 'criticalCount' => int,
|
||||
* 'warningCount' => int,
|
||||
* 'overstockedSkuCount' => int,
|
||||
* 'riskLevel' => 'healthy'|'moderate'|'elevated'|'critical',
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* slippage: [
|
||||
* 'alerts' => [...], // Basic alerts (existing)
|
||||
* 'summary' => [ // v1.5: Summary metrics
|
||||
* 'lostStores30dCount' => int,
|
||||
* 'lostStores60dCount' => int,
|
||||
* 'lostSkus30dCount' => int,
|
||||
* 'competitorTakeoverCount' => int,
|
||||
* ],
|
||||
* 'lostStores30d' => [...], // v1.5: List of lost stores
|
||||
* 'lostStores60d' => [...],
|
||||
* 'lostSkus30d' => [...], // v1.5: List of lost SKUs
|
||||
* 'competitorTakeovers' => [...], // v1.5: SKU replacement events
|
||||
* 'oosMetrics' => [ // v1.5: Out-of-stock metrics
|
||||
* 'avgOOSDuration' => float,
|
||||
* 'avgReorderLag' => float,
|
||||
* 'chronicOOSStores' => [...],
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* engagement: [ // v2.0: Buyer outreach & response (ALWAYS available)
|
||||
* 'reach' => [
|
||||
* 'storesContacted30d' => int, // Unique stores contacted
|
||||
* 'messagesSent30d' => int, // Total outbound messages
|
||||
* 'touchesPerStore' => float, // Avg touches per store
|
||||
* 'repActivityLeaders' => [...], // Top reps by activity
|
||||
* ],
|
||||
* 'response' => [
|
||||
* 'responseRate' => float, // 0..1 reply rate
|
||||
* 'avgResponseTimeHours' => float|null, // Median reply time
|
||||
* 'storesNotResponding' => int, // Silent accounts
|
||||
* 'mostEngagedStores' => [...], // Top responding stores
|
||||
* ],
|
||||
* 'actions' => [
|
||||
* 'quotesIssued30d' => int, // Quotes tied to brand
|
||||
* 'ordersPlaced30d' => int, // Orders with brand products
|
||||
* 'conversionRate' => float|null, // Quotes → Orders
|
||||
* 'reorderRate' => float|null, // Repeat buyers
|
||||
* 'atRiskAccounts' => [...], // Accounts needing attention
|
||||
* ],
|
||||
* 'quality' => [
|
||||
* 'touchTypeBreakdown' => [...], // By channel type
|
||||
* 'buyerEngagementScore' => float|null, // 0..100
|
||||
* 'buyerEngagementLabel' => string, // "Strong partner" / "Healthy" / "At risk" / "Needs action"
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* sentiment: [ // v2.0: Store support (CannaiQ ONLY - null when disabled)
|
||||
* 'storeSupport' => [
|
||||
* 'storesPromotingBrand30d' => int, // Stores with active promos
|
||||
* 'promoFrequencyPerStore' => float|null,// Promos per store
|
||||
* 'featuredPlacementCount' => int, // Featured/specials count
|
||||
* 'avgShelfShare' => float|null, // Category share
|
||||
* 'storeSentimentScore' => float|null, // 0..100
|
||||
* 'storeSentimentLabel' => string, // "Advocates" / "Supportive" / "Neutral" / "Unsupportive"
|
||||
* ],
|
||||
* 'pricingBehavior' => [
|
||||
* 'avgDiscountRate' => float|null, // Avg promo discount
|
||||
* 'priceRespectIndex' => float|null, // 0..100 (MSRP adherence)
|
||||
* 'competitorPricePressure' => float|null, // 0..100
|
||||
* ],
|
||||
* 'inventoryBehavior' => [
|
||||
* 'sellThroughAfterRestock' => float|null, // Units/day post-restock
|
||||
* 'restockUrgencyIndex' => float|null, // 0..100 (faster reorders = higher)
|
||||
* 'stockNeglectEvents' => int, // Extended OOS events
|
||||
* 'shelfCommitment' => [
|
||||
* 'singleSkuStores' => int, // Stores with 1 SKU
|
||||
* 'multiSkuStores' => int, // Stores with 3+ SKUs
|
||||
* 'avgSkusPerStore' => float|null, // Avg SKU depth
|
||||
* ],
|
||||
* ],
|
||||
* ]
|
||||
*/
|
||||
class BrandAnalysisDTO
|
||||
{
|
||||
public function __construct(
|
||||
// Core metadata
|
||||
public readonly int $brandId,
|
||||
public readonly string $brandName,
|
||||
public readonly bool $cannaiqEnabled,
|
||||
public readonly ?\DateTimeInterface $dataFreshness = null,
|
||||
|
||||
// Connection error message (when CannaiQ is enabled but API fails)
|
||||
public readonly ?string $connectionError = null,
|
||||
|
||||
// Store placement data (v1.5: enriched with whitespace + regional)
|
||||
public readonly array $placement = [],
|
||||
|
||||
// Competitor analysis (v1.5: enriched with head-to-head + trends)
|
||||
public readonly array $competitors = [],
|
||||
|
||||
// SKU performance data
|
||||
public readonly array $skuPerformance = [],
|
||||
|
||||
// Promo performance data (v1.5: enriched with lift + efficiency)
|
||||
public readonly array $promoPerformance = [],
|
||||
|
||||
// Inventory projections (v1.5: enriched with risk levels + rollup)
|
||||
public readonly array $inventoryProjection = [],
|
||||
|
||||
// Slippage/velocity warnings (v1.5: fully structured)
|
||||
public readonly array $slippage = [],
|
||||
|
||||
// Summary metrics (v1.5: enriched with whitespace count)
|
||||
public readonly array $summary = [],
|
||||
|
||||
// v2.0: Buyer engagement (internal CRM + orders - ALWAYS available)
|
||||
public readonly array $engagement = [],
|
||||
|
||||
// v2.0: Store sentiment (CannaiQ data - ONLY when cannaiq_enabled)
|
||||
public readonly ?array $sentiment = null,
|
||||
|
||||
// v3.0: Advanced intelligence (CannaiQ data - ONLY when cannaiq_enabled)
|
||||
public readonly ?AdvancedV3IntelligenceDTO $advancedV3 = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create empty DTO for when data is unavailable
|
||||
*/
|
||||
public static function empty(int $brandId, string $brandName, bool $cannaiqEnabled): self
|
||||
{
|
||||
return new self(
|
||||
brandId: $brandId,
|
||||
brandName: $brandName,
|
||||
cannaiqEnabled: $cannaiqEnabled,
|
||||
dataFreshness: null,
|
||||
placement: [
|
||||
'stores' => [],
|
||||
'whitespaceStores' => [],
|
||||
'whitespaceCount' => 0,
|
||||
'penetrationByRegion' => [],
|
||||
],
|
||||
competitors: [
|
||||
'competitors' => [],
|
||||
'pricePosition' => null,
|
||||
'headToHeadSkus' => [],
|
||||
'marketShareTrend' => [],
|
||||
],
|
||||
skuPerformance: [],
|
||||
promoPerformance: [],
|
||||
inventoryProjection: [
|
||||
'items' => [],
|
||||
'overstockedItems' => [],
|
||||
'rollup' => [
|
||||
'criticalCount' => 0,
|
||||
'warningCount' => 0,
|
||||
'overstockedSkuCount' => 0,
|
||||
'riskLevel' => 'healthy',
|
||||
],
|
||||
],
|
||||
slippage: [
|
||||
'alerts' => [],
|
||||
'summary' => [
|
||||
'lostStores30dCount' => 0,
|
||||
'lostStores60dCount' => 0,
|
||||
'lostSkus30dCount' => 0,
|
||||
'competitorTakeoverCount' => 0,
|
||||
],
|
||||
'lostStores30d' => [],
|
||||
'lostStores60d' => [],
|
||||
'lostSkus30d' => [],
|
||||
'competitorTakeovers' => [],
|
||||
'oosMetrics' => [
|
||||
'avgOOSDuration' => null,
|
||||
'avgReorderLag' => null,
|
||||
'chronicOOSStores' => [],
|
||||
],
|
||||
],
|
||||
summary: [
|
||||
'totalStores' => 0,
|
||||
'totalSkus' => 0,
|
||||
'avgPrice' => 0,
|
||||
'marketShare' => null,
|
||||
'pricePosition' => null,
|
||||
'whitespaceCount' => 0,
|
||||
],
|
||||
engagement: self::emptyEngagement(),
|
||||
sentiment: null,
|
||||
advancedV3: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get empty engagement structure
|
||||
*/
|
||||
public static function emptyEngagement(): array
|
||||
{
|
||||
return [
|
||||
'reach' => [
|
||||
'storesContacted30d' => 0,
|
||||
'messagesSent30d' => 0,
|
||||
'touchesPerStore' => 0,
|
||||
'repActivityLeaders' => [],
|
||||
],
|
||||
'response' => [
|
||||
'responseRate' => 0,
|
||||
'avgResponseTimeHours' => null,
|
||||
'storesNotResponding' => 0,
|
||||
'mostEngagedStores' => [],
|
||||
],
|
||||
'actions' => [
|
||||
'quotesIssued30d' => 0,
|
||||
'ordersPlaced30d' => 0,
|
||||
'conversionRate' => null,
|
||||
'reorderRate' => null,
|
||||
'atRiskAccounts' => [],
|
||||
],
|
||||
'quality' => [
|
||||
'touchTypeBreakdown' => [],
|
||||
'buyerEngagementScore' => null,
|
||||
'buyerEngagementLabel' => 'Needs action',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get empty sentiment structure
|
||||
*/
|
||||
public static function emptySentiment(): array
|
||||
{
|
||||
return [
|
||||
'storeSupport' => [
|
||||
'storesPromotingBrand30d' => 0,
|
||||
'promoFrequencyPerStore' => null,
|
||||
'featuredPlacementCount' => 0,
|
||||
'avgShelfShare' => null,
|
||||
'storeSentimentScore' => null,
|
||||
'storeSentimentLabel' => 'Neutral',
|
||||
],
|
||||
'pricingBehavior' => [
|
||||
'avgDiscountRate' => null,
|
||||
'priceRespectIndex' => null,
|
||||
'competitorPricePressure' => null,
|
||||
],
|
||||
'inventoryBehavior' => [
|
||||
'sellThroughAfterRestock' => null,
|
||||
'restockUrgencyIndex' => null,
|
||||
'stockNeglectEvents' => 0,
|
||||
'shelfCommitment' => [
|
||||
'singleSkuStores' => 0,
|
||||
'multiSkuStores' => 0,
|
||||
'avgSkusPerStore' => null,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for views
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'brandId' => $this->brandId,
|
||||
'brandName' => $this->brandName,
|
||||
'cannaiqEnabled' => $this->cannaiqEnabled,
|
||||
'connectionError' => $this->connectionError,
|
||||
'dataFreshness' => $this->dataFreshness?->format('Y-m-d H:i:s'),
|
||||
'placement' => $this->placement,
|
||||
'competitors' => $this->competitors,
|
||||
'skuPerformance' => $this->skuPerformance,
|
||||
'promoPerformance' => $this->promoPerformance,
|
||||
'inventoryProjection' => $this->inventoryProjection,
|
||||
'slippage' => $this->slippage,
|
||||
'summary' => $this->summary,
|
||||
'engagement' => $this->engagement,
|
||||
'sentiment' => $this->sentiment,
|
||||
'advancedV3' => $this->advancedV3?->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
1827
app/Services/Cannaiq/BrandAnalysisService.php
Normal file
1827
app/Services/Cannaiq/BrandAnalysisService.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -88,6 +88,46 @@ class CannaiqClient
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search stores by name/query
|
||||
*
|
||||
* @param string $platform Platform to filter by (dutchie, jane, etc)
|
||||
* @param string $query Search query for store name
|
||||
* @param int $limit Max results
|
||||
*/
|
||||
public function searchStores(string $platform, string $query, int $limit = 20): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get('/stores', [
|
||||
'platform' => $platform,
|
||||
'q' => $query,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
|
||||
return $data['stores'] ?? $data;
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to search stores', [
|
||||
'platform' => $platform,
|
||||
'query' => $query,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception searching stores', [
|
||||
'platform' => $platform,
|
||||
'query' => $query,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store details
|
||||
*
|
||||
@@ -437,4 +477,167 @@ class CannaiqClient
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Brand Analytics API Endpoints (v1.5)
|
||||
// These endpoints provide brand-level intelligence
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get brand-level metrics including whitespace and regional penetration
|
||||
*
|
||||
* @param string $brandName Brand name/slug
|
||||
*/
|
||||
public function getBrandMetrics(string $brandName): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/brands/{$brandName}/metrics");
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch brand metrics', [
|
||||
'brand' => $brandName,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch brand metrics'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching brand metrics', [
|
||||
'brand' => $brandName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get competitor analysis for a brand
|
||||
* Returns: head-to-head comparisons, market share trends, price position
|
||||
*
|
||||
* @param string $brandName Brand name/slug
|
||||
* @param array $options Optional parameters (top_n, etc)
|
||||
*/
|
||||
public function getBrandCompetitors(string $brandName, array $options = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/brands/{$brandName}/competitors", $options);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch brand competitors', [
|
||||
'brand' => $brandName,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch brand competitors'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching brand competitors', [
|
||||
'brand' => $brandName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get promotion performance metrics for a brand
|
||||
* Returns: velocity lift, baseline vs promo velocity, efficiency scores
|
||||
*
|
||||
* @param string $brandName Brand name/slug
|
||||
* @param array $options Optional parameters (from, to date range)
|
||||
*/
|
||||
public function getBrandPromoMetrics(string $brandName, array $options = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/brands/{$brandName}/promo-metrics", $options);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch brand promo metrics', [
|
||||
'brand' => $brandName,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch brand promo metrics'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching brand promo metrics', [
|
||||
'brand' => $brandName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slippage/churn metrics for a brand
|
||||
* Returns: lost stores, lost SKUs, competitor takeovers, OOS metrics
|
||||
*
|
||||
* @param string $brandName Brand name/slug
|
||||
* @param array $options Optional parameters (days_back, etc)
|
||||
*/
|
||||
public function getBrandSlippage(string $brandName, array $options = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/brands/{$brandName}/slippage", $options);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch brand slippage', [
|
||||
'brand' => $brandName,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch brand slippage'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching brand slippage', [
|
||||
'brand' => $brandName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store-level metrics for a brand's products
|
||||
*
|
||||
* Returns aggregated metrics per store including:
|
||||
* - OOS SKUs and percentage
|
||||
* - Average days on hand
|
||||
* - Average margin
|
||||
* - Lost opportunity
|
||||
* - Category breakdown
|
||||
* - Tags (must_win, at_risk, etc.)
|
||||
*
|
||||
* @param string $brandName The brand name/slug to fetch metrics for
|
||||
* @param array $options Optional parameters (margin_pct, days, at_risk_oos_pct, must_win_max_skus)
|
||||
* @return array Store metrics indexed by normalized store name
|
||||
*/
|
||||
public function getBrandStoreMetrics(string $brandName, array $options = []): array
|
||||
{
|
||||
// TODO: Implement actual CannaiQ API call when endpoint is available
|
||||
// For now, return empty array - the controller will use internal data only
|
||||
Log::debug('CannaiQ: getBrandStoreMetrics called (stub)', [
|
||||
'brand' => $brandName,
|
||||
'options' => $options,
|
||||
]);
|
||||
|
||||
return [
|
||||
'stores' => [],
|
||||
'tag_thresholds' => null,
|
||||
'margin_pct_assumed' => $options['margin_pct'] ?? 50,
|
||||
'summary' => ['total_stores' => 0],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,10 +164,10 @@ class ContactService
|
||||
{
|
||||
return $company->contacts()
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('first_name', 'like', "%{$query}%")
|
||||
->orWhere('last_name', 'like', "%{$query}%")
|
||||
->orWhere('email', 'like', "%{$query}%")
|
||||
->orWhere('phone', 'like', "%{$query}%");
|
||||
$q->where('first_name', 'ilike', "%{$query}%")
|
||||
->orWhere('last_name', 'ilike', "%{$query}%")
|
||||
->orWhere('email', 'ilike', "%{$query}%")
|
||||
->orWhere('phone', 'ilike', "%{$query}%");
|
||||
})
|
||||
->with('user.roles')
|
||||
->get();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user