Compare commits
149 Commits
fix/asset-
...
ci/trigger
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27c8395d5a | ||
|
|
dbee401f61 | ||
|
|
b17bc590bb | ||
|
|
6ce5ca14e2 | ||
|
|
454b85ffb1 | ||
|
|
e13d7cd7ad | ||
|
|
f3436d35ec | ||
|
|
a46b44055e | ||
|
|
a3dda1520e | ||
|
|
4068bfc0b2 | ||
|
|
497523ee0c | ||
|
|
94d68f80e4 | ||
|
|
c091c3c168 | ||
|
|
7c54ece253 | ||
|
|
f7294fcf83 | ||
|
|
6d64d9527a | ||
|
|
08df003b20 | ||
|
|
59cd09eb5b | ||
|
|
3a6ab1c207 | ||
|
|
404a731bd9 | ||
|
|
2b30deed11 | ||
|
|
109d9cd39d | ||
|
|
aadd7a500a | ||
|
|
111ef20684 | ||
|
|
85fdb71f92 | ||
|
|
08e2eb3ac6 | ||
|
|
87e8384aca | ||
|
|
e56ad20568 | ||
|
|
fafb05e29b | ||
|
|
a322d7609b | ||
|
|
2aefba3619 | ||
|
|
b47fc35857 | ||
|
|
e5e1dea055 | ||
|
|
e5e485d636 | ||
|
|
3d383e0490 | ||
|
|
df188e21ce | ||
|
|
55016f7009 | ||
|
|
9cf89c7b1a | ||
|
|
0d810dff27 | ||
|
|
624a36d2c5 | ||
|
|
92e3e171e1 | ||
|
|
58ca83c8c2 | ||
|
|
7f175709a5 | ||
|
|
26a903bdd9 | ||
|
|
e871426817 | ||
|
|
c99511d696 | ||
|
|
963f00cd39 | ||
|
|
0db70220c7 | ||
|
|
4bcd0cca8a | ||
|
|
6c7d7016c9 | ||
|
|
6d92f37ea7 | ||
|
|
318d6b4fe8 | ||
|
|
9ea69447ec | ||
|
|
a24fbaac9a | ||
|
|
412a3beeed | ||
|
|
4e7f344941 | ||
|
|
d0e9369795 | ||
|
|
8f56f32e62 | ||
|
|
b8d307200b | ||
|
|
4e979c3158 | ||
|
|
085ca6c415 | ||
|
|
1d363d7157 | ||
|
|
71effd6f4c | ||
|
|
2198008b4c | ||
|
|
2320511cd3 | ||
|
|
6124e8fa07 | ||
|
|
23195d1887 | ||
|
|
d9e99b3091 | ||
|
|
e774093e94 | ||
|
|
697ba5f0f4 | ||
|
|
ef043bda0c | ||
|
|
0f419075cd | ||
|
|
9b3bb1d93b | ||
|
|
8b4f6a48ad | ||
|
|
f5d537cb67 | ||
|
|
fad91c5d7d | ||
|
|
7e2b3d4ce6 | ||
|
|
918d2a3a95 | ||
|
|
bff2199cb6 | ||
|
|
8b32be2c19 | ||
|
|
9ee02b6115 | ||
|
|
7c1ff57eb1 | ||
|
|
67c663faf4 | ||
|
|
691aeda2c2 | ||
|
|
0e4e7784d3 | ||
|
|
315a206542 | ||
|
|
d1ff2e8221 | ||
|
|
a2184e2de2 | ||
|
|
cf4a77c72a | ||
|
|
85d0ca2369 | ||
|
|
61fd09f6a8 | ||
|
|
ed20135cbe | ||
|
|
e6f33d4fa9 | ||
|
|
66da7b5a7a | ||
|
|
5dfef28a20 | ||
|
|
2e1eda8c5d | ||
|
|
58e35dc78e | ||
|
|
43b49aafd7 | ||
|
|
b265b407b1 | ||
|
|
4b71bbea6a | ||
|
|
398cd41361 | ||
|
|
17b0f65680 | ||
|
|
a4514f4985 | ||
|
|
3ba9ae86b4 | ||
|
|
261f00043e | ||
|
|
656ebd023b | ||
|
|
55ab18ee53 | ||
|
|
391bd6546b | ||
|
|
ef5af08609 | ||
|
|
8f171c0784 | ||
|
|
d8d2bc5fb1 | ||
|
|
11c67f491c | ||
|
|
f3b8281cf7 | ||
|
|
8ec47836d7 | ||
|
|
e4205cbc77 | ||
|
|
8f6701fb9c | ||
|
|
648d9d56ab | ||
|
|
577dd6c369 | ||
|
|
6015195885 | ||
|
|
7522cadce5 | ||
|
|
af899f39ca | ||
|
|
90b752cb8f | ||
|
|
3f049b505b | ||
|
|
daf9ec9134 | ||
|
|
ee757761e3 | ||
|
|
010e1f9259 | ||
|
|
154ecfb507 | ||
|
|
97a41afed1 | ||
|
|
3088d05825 | ||
|
|
93648ed001 | ||
|
|
88b201222f | ||
|
|
de402c03d5 | ||
|
|
b92ba4b86d | ||
|
|
f8f219f00b | ||
|
|
f16dac012d | ||
|
|
f566b83cc6 | ||
|
|
418da7a39e | ||
|
|
3c6fe92811 | ||
|
|
7d3243b67e | ||
|
|
8f6597f428 | ||
|
|
64d38b8b2f | ||
|
|
7aa366eda9 | ||
|
|
d7adaf0cba | ||
|
|
4adc611e83 | ||
|
|
3c88bbfb4d | ||
|
|
3496421264 | ||
|
|
91f1ae217a | ||
|
|
5b7a2dd7bf | ||
|
|
c991d3f141 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -37,6 +37,7 @@ yarn-error.log
|
||||
*.gz
|
||||
*.sql.gz
|
||||
*.sql
|
||||
!database/dumps/*.sql
|
||||
|
||||
# Version files (generated at build time or locally)
|
||||
version.txt
|
||||
@@ -81,3 +82,4 @@ SESSION_*
|
||||
# AI workflow personal context files
|
||||
CLAUDE.local.md
|
||||
claude.*.md
|
||||
cannabrands_dev_backup.dump
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# Woodpecker CI/CD Pipeline for Cannabrands Hub
|
||||
# Documentation: https://woodpecker-ci.org/docs/intro
|
||||
#
|
||||
# 3-Environment Workflow:
|
||||
# - develop branch → dev.cannabrands.app (unstable, daily integration)
|
||||
# - master branch → staging.cannabrands.app (stable, pre-production)
|
||||
# - tags (2025.X) → cannabrands.app (production releases)
|
||||
# 2-Environment Workflow (Optimized for small team):
|
||||
# - develop branch → dev.cannabrands.app (integration/testing)
|
||||
# - master branch → cannabrands.app (production)
|
||||
# - tags (2025.X) → cannabrands.app (versioned production releases)
|
||||
#
|
||||
# Pipeline Strategy:
|
||||
# - PRs: Run tests (lint, style, phpunit)
|
||||
# - Push to develop/master: Skip tests (already passed on PR), build + deploy
|
||||
# - Tags: Build versioned release
|
||||
|
||||
when:
|
||||
- branch: [develop, master]
|
||||
@@ -26,18 +31,10 @@ steps:
|
||||
volumes:
|
||||
- /tmp/woodpecker-cache:/tmp/cache
|
||||
|
||||
# Install dependencies
|
||||
# Install dependencies (uses pre-built Laravel image with all extensions)
|
||||
composer-install:
|
||||
image: php:8.3-cli
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
commands:
|
||||
- echo "Installing system dependencies..."
|
||||
- apt-get update -qq
|
||||
- apt-get install -y -qq git zip unzip libicu-dev libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libpq-dev
|
||||
- echo "Installing PHP extensions..."
|
||||
- docker-php-ext-configure gd --with-freetype --with-jpeg
|
||||
- docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip gd pcntl
|
||||
- echo "Installing Composer..."
|
||||
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
|
||||
- echo "Creating minimal .env for package discovery..."
|
||||
- |
|
||||
cat > .env << 'EOF'
|
||||
@@ -59,13 +56,12 @@ steps:
|
||||
- |
|
||||
if [ -d "vendor" ] && [ -f "vendor/autoload.php" ]; then
|
||||
echo "✅ Restored vendor from cache"
|
||||
echo "Verifying cached dependencies are up to date..."
|
||||
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
||||
else
|
||||
echo "📦 Installing fresh dependencies (cache miss)"
|
||||
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
||||
fi
|
||||
- echo "Composer dependencies ready!"
|
||||
- echo "✅ Composer dependencies ready!"
|
||||
|
||||
# Rebuild Composer cache
|
||||
rebuild-composer-cache:
|
||||
@@ -80,29 +76,35 @@ steps:
|
||||
volumes:
|
||||
- /tmp/woodpecker-cache:/tmp/cache
|
||||
|
||||
# PHP Syntax Check (runs after composer install so traits/classes are available)
|
||||
# PHP Syntax Check (PRs only - skipped on merge since tests already passed)
|
||||
php-lint:
|
||||
image: php:8.3-cli
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
commands:
|
||||
- echo "Checking PHP syntax..."
|
||||
- find app -name "*.php" -exec php -l {} \;
|
||||
- find routes -name "*.php" -exec php -l {} \;
|
||||
- find database -name "*.php" -exec php -l {} \;
|
||||
- echo "PHP syntax check complete!"
|
||||
- find app -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
|
||||
- find routes -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
|
||||
- find database -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
|
||||
- echo "✅ PHP syntax check complete!"
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# Run Laravel Pint (Code Style)
|
||||
# Run Laravel Pint (PRs only - skipped on merge since tests already passed)
|
||||
code-style:
|
||||
image: php:8.3-cli
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
commands:
|
||||
- echo "Checking code style with Laravel Pint..."
|
||||
- ./vendor/bin/pint --test
|
||||
- echo "Code style check complete!"
|
||||
- echo "✅ Code style check complete!"
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# Run PHPUnit Tests
|
||||
# Run PHPUnit Tests (PRs only - skipped on merge since tests already passed)
|
||||
# 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
|
||||
when:
|
||||
event: pull_request
|
||||
environment:
|
||||
APP_ENV: testing
|
||||
BROADCAST_CONNECTION: reverb
|
||||
@@ -183,9 +185,12 @@ steps:
|
||||
VITE_REVERB_HOST: "dev.cannabrands.app"
|
||||
VITE_REVERB_PORT: "443"
|
||||
VITE_REVERB_SCHEME: "https"
|
||||
cache_images:
|
||||
cache_from:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-dev
|
||||
cache_to: code.cannabrands.app/cannabrands/hub:buildcache-dev
|
||||
platforms: linux/amd64
|
||||
# Disable provenance attestations - can cause Gitea registry 500 errors
|
||||
provenance: false
|
||||
when:
|
||||
branch: develop
|
||||
event: push
|
||||
@@ -228,8 +233,8 @@ steps:
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for STAGING environment (master branch)
|
||||
build-image-staging:
|
||||
# Build and push Docker image for PRODUCTION (master branch)
|
||||
build-image-production:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
@@ -239,21 +244,53 @@ steps:
|
||||
password:
|
||||
from_secret: gitea_token
|
||||
tags:
|
||||
- staging # Latest staging build → staging.cannabrands.app
|
||||
- latest # Latest production build
|
||||
- prod-${CI_COMMIT_SHA:0:7} # Unique prod tag with SHA
|
||||
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
|
||||
- ${CI_COMMIT_BRANCH} # Branch name (master)
|
||||
build_args:
|
||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
||||
APP_VERSION: "staging"
|
||||
cache_images:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-staging
|
||||
APP_VERSION: "production"
|
||||
cache_from:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-prod
|
||||
cache_to: code.cannabrands.app/cannabrands/hub:buildcache-prod
|
||||
platforms: linux/amd64
|
||||
# Disable provenance attestations - can cause Gitea registry 500 errors
|
||||
provenance: false
|
||||
when:
|
||||
branch: master
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for PRODUCTION (tagged releases)
|
||||
# Deploy to production (master branch)
|
||||
deploy-production:
|
||||
image: bitnami/kubectl:latest
|
||||
environment:
|
||||
KUBECONFIG_CONTENT:
|
||||
from_secret: kubeconfig_prod
|
||||
commands:
|
||||
- echo "🚀 Deploying to PRODUCTION (cannabrands.app)..."
|
||||
- echo "Commit SHA ${CI_COMMIT_SHA:0:7}"
|
||||
- mkdir -p ~/.kube
|
||||
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
|
||||
- chmod 600 ~/.kube/config
|
||||
- |
|
||||
kubectl set image deployment/cannabrands-hub \
|
||||
app=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
migrate=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
-n cannabrands-prod
|
||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-prod --timeout=300s
|
||||
- |
|
||||
echo ""
|
||||
echo "✅ PRODUCTION deployment successful!"
|
||||
echo "Pod status:"
|
||||
kubectl get pods -n cannabrands-prod -l app=cannabrands-hub
|
||||
when:
|
||||
branch: master
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for tagged releases (optional versioned releases)
|
||||
build-image-release:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
@@ -272,6 +309,8 @@ steps:
|
||||
cache_images:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-prod
|
||||
platforms: linux/amd64
|
||||
# Disable provenance attestations - can cause Gitea registry 500 errors
|
||||
provenance: false
|
||||
when:
|
||||
event: tag
|
||||
status: success
|
||||
@@ -306,25 +345,10 @@ steps:
|
||||
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "master" ]; then
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "🧪 STAGING BUILD COMPLETE"
|
||||
echo "🚀 PRODUCTION DEPLOYED"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Branch: master"
|
||||
echo "Registry: code.cannabrands.app/cannabrands/hub"
|
||||
echo "Tags:"
|
||||
echo " - staging"
|
||||
echo " - sha-${CI_COMMIT_SHA:0:7}"
|
||||
echo " - ${CI_COMMIT_BRANCH}"
|
||||
echo ""
|
||||
echo "📦 Deploy to STAGING (staging.cannabrands.app):"
|
||||
echo " docker pull code.cannabrands.app/cannabrands/hub:staging"
|
||||
echo " docker-compose -f docker-compose.staging.yml up -d"
|
||||
echo ""
|
||||
echo "👥 Next steps:"
|
||||
echo " 1. Super-admin tests on staging.cannabrands.app"
|
||||
echo " 2. Validate all features work"
|
||||
echo " 3. When ready, create production tag:"
|
||||
echo " git tag -a 2025.10.1 -m 'Release 2025.10.1'"
|
||||
echo " git push origin 2025.10.1"
|
||||
echo "Site: https://cannabrands.app"
|
||||
echo "Image: prod-${CI_COMMIT_SHA:0:7}"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "develop" ]; then
|
||||
echo ""
|
||||
@@ -349,10 +373,14 @@ steps:
|
||||
echo " - Login: admin@example.com / password"
|
||||
echo " - Check: https://dev.cannabrands.app/telescope"
|
||||
echo ""
|
||||
echo "👥 Next steps:"
|
||||
echo " 1. Verify feature works on dev.cannabrands.app"
|
||||
echo " 2. When stable, merge to master for staging:"
|
||||
echo " git checkout master && git merge develop && git push"
|
||||
echo "Ready for production? Open PR: develop → master"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
elif [ "${CI_PIPELINE_EVENT}" = "pull_request" ]; then
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "✅ PR CHECKS PASSED"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Ready to merge to master for production deployment."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
fi
|
||||
|
||||
|
||||
55
CLAUDE.md
55
CLAUDE.md
@@ -54,7 +54,36 @@ ALL routes need auth + user type middleware except public pages
|
||||
❌ No IF/ELSE logic in migrations (not supported)
|
||||
✅ Use Laravel Schema builder or conditional PHP code
|
||||
|
||||
### 7. Styling - DaisyUI/Tailwind Only
|
||||
### 7. Git Workflow - ALWAYS Use PRs
|
||||
❌ **NEVER** push directly to `develop` or `master`
|
||||
❌ **NEVER** bypass pull requests
|
||||
❌ **NEVER** use GitHub CLI (`gh`) - we use Gitea
|
||||
✅ **ALWAYS** create a feature branch and PR for review
|
||||
✅ **ALWAYS** use Gitea API for PR creation (see below)
|
||||
**Why:** PRs are required for code review, CI checks, and audit trail
|
||||
|
||||
**Creating PRs via Gitea API:**
|
||||
```bash
|
||||
# Requires GITEA_TOKEN environment variable
|
||||
curl -X POST "https://code.cannabrands.app/api/v1/repos/Cannabrands/hub/pulls" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title": "PR title", "body": "Description", "head": "feature-branch", "base": "develop"}'
|
||||
```
|
||||
|
||||
**Gitea Services:**
|
||||
- **Gitea:** `https://code.cannabrands.app`
|
||||
- **Woodpecker CI:** `https://ci.cannabrands.app`
|
||||
|
||||
### 8. User-Business Relationship (Pivot Table)
|
||||
Users connect to businesses via `business_user` pivot table (many-to-many).
|
||||
❌ **Wrong:** `User::where('business_id', $id)` — users table has NO business_id column
|
||||
✅ **Right:** `User::whereHas('businesses', fn($q) => $q->where('businesses.id', $id))`
|
||||
|
||||
**Pivot table columns:** `business_id`, `user_id`, `role`, `role_template`, `is_primary`, `permissions`
|
||||
**Why:** Allows users to belong to multiple businesses with different roles per business
|
||||
|
||||
### 9. Styling - DaisyUI/Tailwind Only
|
||||
❌ **NEVER use inline `style=""` attributes** in Blade templates
|
||||
✅ **ALWAYS use DaisyUI/Tailwind utility classes**
|
||||
**Why:** Consistency, maintainability, theme switching, and better performance
|
||||
@@ -67,7 +96,29 @@ ALL routes need auth + user type middleware except public pages
|
||||
|
||||
**Exception:** Only use inline styles for truly dynamic values from database (e.g., user-uploaded brand colors)
|
||||
|
||||
### 8. Media Storage - MinIO Architecture (CRITICAL!)
|
||||
### 10. Suites Architecture - NOT Modules (CRITICAL!)
|
||||
❌ **NEVER use** `has_crm`, `has_marketing`, or other legacy module flags
|
||||
❌ **NEVER create** routes like `seller.crm.*` (without `.business.`)
|
||||
❌ **NEVER extend** `seller.crm.layouts.crm` layout (outdated CRM module layout)
|
||||
✅ **ALWAYS use** Suites system (Sales Suite, Processing Suite, etc.)
|
||||
✅ **ALWAYS use** route pattern `seller.business.crm.*` (includes `{business}` segment)
|
||||
✅ **ALWAYS extend** `layouts.seller` for seller views
|
||||
**Why:** We migrated from individual modules to a Suites architecture. CRM features are now part of the **Sales Suite**.
|
||||
|
||||
**See:** `docs/SUITES_AND_PRICING_MODEL.md` for full architecture
|
||||
|
||||
**The 7 Suites:**
|
||||
1. **Sales Suite** - Products, Orders, Buyers, CRM, Marketing, Analytics, Orchestrator
|
||||
2. **Processing Suite** - Extraction, Wash Reports, Yields (internal)
|
||||
3. **Manufacturing Suite** - Work Orders, BOM, Packaging (internal)
|
||||
4. **Delivery Suite** - Pick/Pack, Drivers, Manifests (internal)
|
||||
5. **Management Suite** - Finance, AP/AR, Budgets (Canopy only)
|
||||
6. **Brand Manager Suite** - Read-only brand portal (external partners)
|
||||
7. **Dispensary Suite** - Buyer marketplace (dispensaries)
|
||||
|
||||
**Legacy module flags still exist** in database but are deprecated. Suite permissions control access now.
|
||||
|
||||
### 11. Media Storage - MinIO Architecture (CRITICAL!)
|
||||
❌ **NEVER use** `Storage::disk('public')` for brand/product media
|
||||
✅ **ALWAYS use** `Storage` (respects .env FILESYSTEM_DISK=minio)
|
||||
**Why:** All media lives on MinIO (S3-compatible storage), not local disk. Using wrong disk breaks production images.
|
||||
|
||||
@@ -102,7 +102,7 @@ class GenerateBriefingsCommand extends Command
|
||||
}
|
||||
|
||||
// Get users who should receive briefings (sellers/admins)
|
||||
$users = User::where('business_id', $businessId)
|
||||
$users = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $businessId))
|
||||
->whereIn('user_type', ['seller', 'both'])
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
@@ -150,7 +150,7 @@ class GenerateBriefingsCommand extends Command
|
||||
$totalUsers = 0;
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$userCount = User::where('business_id', $business->id)
|
||||
$userCount = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->whereIn('user_type', ['seller', 'both'])
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
@@ -164,7 +164,7 @@ class GenerateBriefingsCommand extends Command
|
||||
$bar->start();
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$users = User::where('business_id', $business->id)
|
||||
$users = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->whereIn('user_type', ['seller', 'both'])
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
@@ -7,7 +7,7 @@ use Illuminate\Console\Command;
|
||||
class DevSetup extends Command
|
||||
{
|
||||
protected $signature = 'dev:setup
|
||||
{--fresh : Drop all tables and re-run migrations}
|
||||
{--fresh : Drop all tables and re-run migrations (DESTRUCTIVE - requires confirmation)}
|
||||
{--skip-seed : Skip seeding dev fixtures}';
|
||||
|
||||
protected $description = 'Set up local development environment with migrations and dev fixtures';
|
||||
@@ -25,8 +25,18 @@ class DevSetup extends Command
|
||||
|
||||
// Run migrations
|
||||
if ($this->option('fresh')) {
|
||||
$this->warn('Dropping all tables and re-running migrations...');
|
||||
$this->call('migrate:fresh');
|
||||
$this->newLine();
|
||||
$this->error('WARNING: --fresh will DELETE ALL DATA in the database!');
|
||||
$this->warn('This includes development data being preserved for production release.');
|
||||
$this->newLine();
|
||||
|
||||
if (! $this->confirm('Are you SURE you want to drop all tables and lose all data?', false)) {
|
||||
$this->info('Aborted. Running normal migrations instead...');
|
||||
$this->call('migrate');
|
||||
} else {
|
||||
$this->warn('Dropping all tables and re-running migrations...');
|
||||
$this->call('migrate:fresh');
|
||||
}
|
||||
} else {
|
||||
$this->info('Running migrations...');
|
||||
$this->call('migrate');
|
||||
|
||||
176
app/Console/Commands/ExportCannabrandsData.php
Normal file
176
app/Console/Commands/ExportCannabrandsData.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
/**
|
||||
* Export Cannabrands data to PostgreSQL SQL dumps.
|
||||
*
|
||||
* This command exports current database data to SQL files in database/dumps/
|
||||
* for later restoration without requiring a MySQL connection.
|
||||
*
|
||||
* Usage:
|
||||
* - Configure your local database with the desired settings
|
||||
* - Run: php artisan db:export-cannabrands
|
||||
* - Commit the updated dump files (if they should be in git)
|
||||
*/
|
||||
class ExportCannabrandsData extends Command
|
||||
{
|
||||
protected $signature = 'db:export-cannabrands
|
||||
{--tables= : Comma-separated list of specific tables to export}';
|
||||
|
||||
protected $description = 'Export Cannabrands data to PostgreSQL SQL dumps';
|
||||
|
||||
// Tables to export (same as restore command)
|
||||
protected array $tables = [
|
||||
'strains',
|
||||
'product_categories',
|
||||
'businesses',
|
||||
'users',
|
||||
'brands',
|
||||
'locations',
|
||||
'contacts',
|
||||
'products',
|
||||
'orders',
|
||||
'order_items',
|
||||
'invoices',
|
||||
'business_user',
|
||||
'brand_user',
|
||||
'model_has_roles',
|
||||
'ai_settings',
|
||||
'orchestrator_sales_configs',
|
||||
'orchestrator_marketing_configs',
|
||||
];
|
||||
|
||||
protected string $dumpsPath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->dumpsPath = database_path('dumps');
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Exporting Cannabrands data to SQL dumps...');
|
||||
|
||||
// Create dumps directory if it doesn't exist
|
||||
if (! is_dir($this->dumpsPath)) {
|
||||
mkdir($this->dumpsPath, 0755, true);
|
||||
$this->info("Created dumps directory: {$this->dumpsPath}");
|
||||
}
|
||||
|
||||
// Determine which tables to export
|
||||
$tablesToExport = $this->tables;
|
||||
if ($this->option('tables')) {
|
||||
$requestedTables = array_map('trim', explode(',', $this->option('tables')));
|
||||
$tablesToExport = array_intersect($this->tables, $requestedTables);
|
||||
|
||||
if (empty($tablesToExport)) {
|
||||
$this->error('No valid tables specified. Available tables: '.implode(', ', $this->tables));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
// Get database connection info
|
||||
$database = config('database.connections.pgsql.database');
|
||||
$username = config('database.connections.pgsql.username');
|
||||
$host = config('database.connections.pgsql.host');
|
||||
$port = config('database.connections.pgsql.port');
|
||||
|
||||
$exported = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($tablesToExport as $table) {
|
||||
$dumpFile = "{$this->dumpsPath}/{$table}.sql";
|
||||
$this->line("Exporting {$table}...");
|
||||
|
||||
// Build pg_dump command
|
||||
// Using --column-inserts for portable SQL
|
||||
// Using --on-conflict-do-nothing for idempotent inserts
|
||||
$pgDumpArgs = sprintf(
|
||||
'--data-only --column-inserts --on-conflict-do-nothing --table=%s %s',
|
||||
escapeshellarg($table),
|
||||
escapeshellarg($database)
|
||||
);
|
||||
|
||||
// pg_dump with connection info
|
||||
// Works both inside Sail container (pgsql hostname) and natively
|
||||
$command = sprintf(
|
||||
'PGPASSWORD=%s pg_dump -h %s -p %s -U %s %s',
|
||||
escapeshellarg(config('database.connections.pgsql.password')),
|
||||
escapeshellarg($host),
|
||||
escapeshellarg($port),
|
||||
escapeshellarg($username),
|
||||
$pgDumpArgs
|
||||
);
|
||||
|
||||
$result = Process::run($command);
|
||||
|
||||
if ($result->successful()) {
|
||||
// Extract only INSERT statements (remove pg_dump headers and SET commands)
|
||||
// Handle multi-line INSERTs by looking for the ending pattern
|
||||
$output = $result->output();
|
||||
$lines = explode("\n", $output);
|
||||
$inserts = [];
|
||||
$currentInsert = '';
|
||||
$inInsert = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (str_starts_with(trim($line), 'INSERT INTO')) {
|
||||
// Start of new INSERT
|
||||
$inInsert = true;
|
||||
$currentInsert = $line;
|
||||
|
||||
// Check if this INSERT ends on same line
|
||||
if (str_ends_with(trim($line), 'ON CONFLICT DO NOTHING;')) {
|
||||
$inserts[] = $currentInsert;
|
||||
$currentInsert = '';
|
||||
$inInsert = false;
|
||||
}
|
||||
} elseif ($inInsert) {
|
||||
// Continuation of current INSERT (multi-line due to embedded newlines in data)
|
||||
// We need to escape the actual newline in the SQL string value
|
||||
// Since we're inside a string value, replace with \n escape sequence
|
||||
$currentInsert .= "\n".$line;
|
||||
|
||||
// Check if this line ends the INSERT
|
||||
if (str_ends_with(trim($line), 'ON CONFLICT DO NOTHING;')) {
|
||||
$inserts[] = $currentInsert;
|
||||
$currentInsert = '';
|
||||
$inInsert = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last one if it didn't end properly
|
||||
if (! empty($currentInsert)) {
|
||||
$inserts[] = $currentInsert;
|
||||
}
|
||||
|
||||
$cleanOutput = implode("\n", $inserts);
|
||||
file_put_contents($dumpFile, $cleanOutput);
|
||||
|
||||
$this->info(' -> Exported '.count($inserts)." rows to {$table}.sql");
|
||||
$exported++;
|
||||
} else {
|
||||
$this->error("Failed to export {$table}: ".$result->errorOutput());
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Exported {$exported} tables. Errors: {$errors}");
|
||||
|
||||
if ($exported > 0) {
|
||||
$this->newLine();
|
||||
$this->info('To restore this data on another machine:');
|
||||
$this->line(' php artisan db:restore-cannabrands');
|
||||
}
|
||||
|
||||
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
186
app/Console/Commands/RestoreCannabrandsData.php
Normal file
186
app/Console/Commands/RestoreCannabrandsData.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Restore Cannabrands data from PostgreSQL SQL dumps.
|
||||
*
|
||||
* This command loads data from pre-exported SQL files in database/dumps/
|
||||
* without requiring a MySQL connection. Data was originally imported from
|
||||
* the MySQL hub_cannabrands database.
|
||||
*
|
||||
* Order of restoration matters due to foreign key constraints:
|
||||
* 1. strains (no dependencies)
|
||||
* 2. product_categories (self-referential via parent_id)
|
||||
* 3. businesses (no dependencies)
|
||||
* 4. users (no dependencies)
|
||||
* 5. brands (depends on businesses)
|
||||
* 6. locations (depends on businesses)
|
||||
* 7. contacts (depends on businesses, locations)
|
||||
* 8. products (depends on brands, strains, product_categories)
|
||||
* 9. orders (depends on businesses)
|
||||
* 10. order_items (depends on orders, products)
|
||||
* 11. invoices (depends on orders, businesses)
|
||||
* 12. business_user (depends on businesses, users)
|
||||
* 13. brand_user (depends on brands, users)
|
||||
* 14. model_has_roles (depends on users, roles)
|
||||
* 15. ai_settings (depends on businesses)
|
||||
* 16. orchestrator_sales_configs (depends on businesses)
|
||||
* 17. orchestrator_marketing_configs (depends on businesses)
|
||||
*/
|
||||
class RestoreCannabrandsData extends Command
|
||||
{
|
||||
protected $signature = 'db:restore-cannabrands
|
||||
{--fresh : Truncate tables before restoring}
|
||||
{--tables= : Comma-separated list of specific tables to restore}';
|
||||
|
||||
protected $description = 'Restore Cannabrands data from PostgreSQL SQL dumps';
|
||||
|
||||
// Tables in dependency order
|
||||
protected array $tables = [
|
||||
'strains',
|
||||
'product_categories',
|
||||
'businesses',
|
||||
'users',
|
||||
'brands',
|
||||
'locations',
|
||||
'contacts',
|
||||
'products',
|
||||
'orders',
|
||||
'order_items',
|
||||
'invoices',
|
||||
'business_user',
|
||||
'brand_user',
|
||||
'model_has_roles',
|
||||
'ai_settings',
|
||||
'orchestrator_sales_configs',
|
||||
'orchestrator_marketing_configs',
|
||||
];
|
||||
|
||||
protected string $dumpsPath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->dumpsPath = database_path('dumps');
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Restoring Cannabrands data from SQL dumps...');
|
||||
|
||||
// Check if dumps directory exists
|
||||
if (! is_dir($this->dumpsPath)) {
|
||||
$this->error("Dumps directory not found: {$this->dumpsPath}");
|
||||
$this->error('Run the MySQL import seeders first to create the dumps.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Determine which tables to restore
|
||||
$tablesToRestore = $this->tables;
|
||||
if ($this->option('tables')) {
|
||||
$requestedTables = array_map('trim', explode(',', $this->option('tables')));
|
||||
$tablesToRestore = array_intersect($this->tables, $requestedTables);
|
||||
|
||||
if (empty($tablesToRestore)) {
|
||||
$this->error('No valid tables specified. Available tables: '.implode(', ', $this->tables));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh option - truncate tables in reverse order
|
||||
if ($this->option('fresh')) {
|
||||
$this->warn('Truncating tables before restore...');
|
||||
DB::statement('SET session_replication_role = replica;'); // Disable FK checks
|
||||
|
||||
foreach (array_reverse($tablesToRestore) as $table) {
|
||||
$this->line("Truncating {$table}...");
|
||||
DB::table($table)->truncate();
|
||||
}
|
||||
|
||||
DB::statement('SET session_replication_role = DEFAULT;'); // Re-enable FK checks
|
||||
}
|
||||
|
||||
// Restore each table
|
||||
$restored = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($tablesToRestore as $table) {
|
||||
$dumpFile = "{$this->dumpsPath}/{$table}.sql";
|
||||
|
||||
if (! file_exists($dumpFile)) {
|
||||
$this->warn("Dump file not found for {$table}: {$dumpFile}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line("Restoring {$table}...");
|
||||
|
||||
try {
|
||||
$sql = file_get_contents($dumpFile);
|
||||
|
||||
if (empty(trim($sql))) {
|
||||
$this->info(' -> 0 rows (empty file)');
|
||||
$restored++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Disable FK checks for this session to allow loading in any order
|
||||
DB::statement('SET session_replication_role = replica;');
|
||||
|
||||
// Execute all statements at once
|
||||
DB::unprepared($sql);
|
||||
|
||||
// Re-enable FK checks
|
||||
DB::statement('SET session_replication_role = DEFAULT;');
|
||||
|
||||
// Count rows
|
||||
$count = DB::table($table)->count();
|
||||
$this->info(" -> {$count} rows in {$table}");
|
||||
$restored++;
|
||||
} catch (\Exception $e) {
|
||||
// Re-enable FK checks even on error
|
||||
try {
|
||||
DB::statement('SET session_replication_role = DEFAULT;');
|
||||
} catch (\Exception $ignored) {
|
||||
}
|
||||
|
||||
$this->error("Failed to restore {$table}: ".$e->getMessage());
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset sequences to max ID + 1 for each table
|
||||
$this->info('Resetting sequence counters...');
|
||||
foreach ($tablesToRestore as $table) {
|
||||
$this->resetSequence($table);
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Restored {$restored} tables. Errors: {$errors}");
|
||||
|
||||
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the sequence for a table to max ID + 1.
|
||||
*/
|
||||
protected function resetSequence(string $table): void
|
||||
{
|
||||
try {
|
||||
$maxId = DB::table($table)->max('id');
|
||||
if ($maxId) {
|
||||
$sequence = "{$table}_id_seq";
|
||||
DB::statement("SELECT setval('{$sequence}', ?)", [$maxId]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Sequence might not exist for this table
|
||||
}
|
||||
}
|
||||
}
|
||||
175
app/Console/Commands/RunFixedAssetDepreciation.php
Normal file
175
app/Console/Commands/RunFixedAssetDepreciation.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\FixedAssetService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Run monthly depreciation for fixed assets.
|
||||
*
|
||||
* This command calculates and posts depreciation entries for all
|
||||
* eligible fixed assets. Can be run for a specific business or all
|
||||
* businesses with Management Suite enabled.
|
||||
*
|
||||
* Safe to run multiple times in the same month - assets that have
|
||||
* already been depreciated for the period will be skipped.
|
||||
*/
|
||||
class RunFixedAssetDepreciation extends Command
|
||||
{
|
||||
protected $signature = 'fixed-assets:run-depreciation
|
||||
{business_id? : Specific business ID to run for}
|
||||
{--period= : Period date (Y-m-d format, defaults to end of current month)}
|
||||
{--dry-run : Show what would be depreciated without making changes}';
|
||||
|
||||
protected $description = 'Run monthly depreciation for fixed assets';
|
||||
|
||||
public function __construct(
|
||||
protected FixedAssetService $assetService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$businessId = $this->argument('business_id');
|
||||
$periodOption = $this->option('period');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
// Parse period date
|
||||
$periodDate = $periodOption
|
||||
? Carbon::parse($periodOption)->endOfMonth()
|
||||
: Carbon::now()->endOfMonth();
|
||||
|
||||
$this->info("Running depreciation for period: {$periodDate->format('Y-m')}");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No changes will be made');
|
||||
}
|
||||
|
||||
// Get businesses to process
|
||||
$businesses = $this->getBusinesses($businessId);
|
||||
|
||||
if ($businesses->isEmpty()) {
|
||||
$this->warn('No businesses found to process.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$totalRuns = 0;
|
||||
$totalAmount = 0;
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$this->line('');
|
||||
$this->info("Processing: {$business->name}");
|
||||
|
||||
if ($dryRun) {
|
||||
$results = $this->previewDepreciation($business, $periodDate);
|
||||
} else {
|
||||
$results = $this->assetService->runBatchDepreciation($business, $periodDate);
|
||||
}
|
||||
|
||||
$count = $results->count();
|
||||
$amount = $results->sum('depreciation_amount');
|
||||
|
||||
if ($count > 0) {
|
||||
$this->line(" - Depreciated {$count} assets");
|
||||
$this->line(" - Total amount: \${$amount}");
|
||||
$totalRuns += $count;
|
||||
$totalAmount += $amount;
|
||||
} else {
|
||||
$this->line(' - No assets to depreciate');
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->info('=== Summary ===');
|
||||
$this->info("Total assets depreciated: {$totalRuns}");
|
||||
$this->info("Total depreciation amount: \${$totalAmount}");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('This was a dry run. Run without --dry-run to apply changes.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get businesses to process.
|
||||
*/
|
||||
protected function getBusinesses(?string $businessId): \Illuminate\Support\Collection
|
||||
{
|
||||
if ($businessId) {
|
||||
$business = Business::find($businessId);
|
||||
|
||||
if (! $business) {
|
||||
$this->error("Business with ID {$businessId} not found.");
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
if (! $business->hasManagementSuite()) {
|
||||
$this->warn("Business {$business->name} does not have Management Suite enabled.");
|
||||
}
|
||||
|
||||
return collect([$business]);
|
||||
}
|
||||
|
||||
// Get all businesses with Management Suite
|
||||
return Business::whereHas('suites', function ($query) {
|
||||
$query->where('key', 'management');
|
||||
})->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview depreciation without making changes.
|
||||
*/
|
||||
protected function previewDepreciation(Business $business, Carbon $periodDate): \Illuminate\Support\Collection
|
||||
{
|
||||
$period = $periodDate->format('Y-m');
|
||||
|
||||
$assets = \App\Models\Accounting\FixedAsset::where('business_id', $business->id)
|
||||
->where('status', \App\Models\Accounting\FixedAsset::STATUS_ACTIVE)
|
||||
->where('category', '!=', \App\Models\Accounting\FixedAsset::CATEGORY_LAND)
|
||||
->get();
|
||||
|
||||
$results = collect();
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
// Skip if already depreciated for this period
|
||||
$existing = \App\Models\Accounting\FixedAssetDepreciationRun::where('fixed_asset_id', $asset->id)
|
||||
->where('period', $period)
|
||||
->where('is_reversed', false)
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if fully depreciated
|
||||
if ($asset->book_value <= $asset->salvage_value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$depreciationAmount = $asset->monthly_depreciation;
|
||||
$maxDepreciation = $asset->book_value - $asset->salvage_value;
|
||||
$depreciationAmount = min($depreciationAmount, $maxDepreciation);
|
||||
|
||||
if ($depreciationAmount > 0) {
|
||||
$results->push((object) [
|
||||
'fixed_asset_id' => $asset->id,
|
||||
'asset_name' => $asset->name,
|
||||
'depreciation_amount' => $depreciationAmount,
|
||||
]);
|
||||
|
||||
$this->line(" - {$asset->name}: \${$depreciationAmount}");
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
103
app/Console/Commands/RunRecurringSchedules.php
Normal file
103
app/Console/Commands/RunRecurringSchedules.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Accounting\RecurringSchedulerService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RunRecurringSchedules extends Command
|
||||
{
|
||||
protected $signature = 'recurring:run
|
||||
{--date= : The date to run schedules for (YYYY-MM-DD, default: today)}
|
||||
{--business= : Specific business ID to run schedules for}
|
||||
{--dry-run : Preview what would be generated without actually creating transactions}';
|
||||
|
||||
protected $description = 'Run due recurring schedules to generate AR invoices, AP bills, and journal entries';
|
||||
|
||||
public function __construct(
|
||||
protected RecurringSchedulerService $schedulerService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dateString = $this->option('date');
|
||||
$businessId = $this->option('business') ? (int) $this->option('business') : null;
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$date = $dateString ? Carbon::parse($dateString) : now();
|
||||
|
||||
$this->info("Running recurring schedules for {$date->toDateString()}...");
|
||||
|
||||
if ($businessId) {
|
||||
$this->info("Filtering to business ID: {$businessId}");
|
||||
}
|
||||
|
||||
// Get due schedules
|
||||
$dueSchedules = $this->schedulerService->getDueSchedules($date, $businessId);
|
||||
|
||||
if ($dueSchedules->isEmpty()) {
|
||||
$this->info('No schedules are due for execution.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$dueSchedules->count()} schedule(s) due for execution.");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No transactions will be created.');
|
||||
$this->table(
|
||||
['ID', 'Name', 'Type', 'Business', 'Next Run Date', 'Auto Post'],
|
||||
$dueSchedules->map(fn ($s) => [
|
||||
$s->id,
|
||||
$s->name,
|
||||
$s->type_label,
|
||||
$s->business->name ?? 'N/A',
|
||||
$s->next_run_date->toDateString(),
|
||||
$s->auto_post ? 'Yes' : 'No',
|
||||
])
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Run all due schedules
|
||||
$results = $this->schedulerService->runAllDue($date, $businessId);
|
||||
|
||||
// Output results
|
||||
$this->newLine();
|
||||
$this->info('Execution Summary:');
|
||||
$this->line(" Processed: {$results['processed']}");
|
||||
$this->line(" Successful: {$results['success']}");
|
||||
$this->line(" Failed: {$results['failed']}");
|
||||
|
||||
if (! empty($results['generated'])) {
|
||||
$this->newLine();
|
||||
$this->info('Generated Transactions:');
|
||||
$this->table(
|
||||
['Schedule', 'Type', 'Result ID'],
|
||||
collect($results['generated'])->map(fn ($g) => [
|
||||
$g['schedule_name'],
|
||||
$g['type'],
|
||||
$g['result_id'],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if (! empty($results['errors'])) {
|
||||
$this->newLine();
|
||||
$this->error('Errors:');
|
||||
foreach ($results['errors'] as $error) {
|
||||
$this->line(" [{$error['schedule_id']}] {$error['schedule_name']}: {$error['error']}");
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
43
app/Console/Commands/SafeFreshCommand.php
Normal file
43
app/Console/Commands/SafeFreshCommand.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Database\Console\Migrations\FreshCommand;
|
||||
|
||||
/**
|
||||
* Override migrate:fresh to prevent accidental data loss.
|
||||
*
|
||||
* This command blocks migrate:fresh in all environments except when
|
||||
* explicitly targeting a test database (DB_DATABASE=testing or *_test_*).
|
||||
*/
|
||||
class SafeFreshCommand extends FreshCommand
|
||||
{
|
||||
public function handle()
|
||||
{
|
||||
// Check both config and direct env (env var may not be in config yet)
|
||||
$database = env('DB_DATABASE', config('database.connections.pgsql.database'));
|
||||
|
||||
// Allow migrate:fresh ONLY for test databases
|
||||
$isTestDatabase = $database === 'testing'
|
||||
|| str_contains($database, '_test_')
|
||||
|| str_contains($database, 'testing_');
|
||||
|
||||
if (! $isTestDatabase) {
|
||||
$this->components->error('migrate:fresh is BLOCKED to prevent data loss!');
|
||||
$this->components->warn("Database: {$database}");
|
||||
$this->newLine();
|
||||
$this->components->bulletList([
|
||||
'This command drops ALL tables and destroys ALL data.',
|
||||
'It is blocked in local, dev, staging, and production.',
|
||||
'For testing: DB_DATABASE=testing ./vendor/bin/sail artisan migrate:fresh',
|
||||
'To seed existing data: php artisan db:seed --class=ProductionSyncSeeder',
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->components->info("Running migrate:fresh on TEST database: {$database}");
|
||||
|
||||
return parent::handle();
|
||||
}
|
||||
}
|
||||
@@ -229,13 +229,13 @@ class SendCrmDailyDigest extends Command
|
||||
if ($business->crm_notification_emails) {
|
||||
$emails = array_map('trim', explode(',', $business->crm_notification_emails));
|
||||
|
||||
return User::where('business_id', $business->id)
|
||||
return User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->whereIn('email', $emails)
|
||||
->get();
|
||||
}
|
||||
|
||||
// Otherwise, send to the business owner or first admin
|
||||
return User::where('business_id', $business->id)
|
||||
return User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->where(function ($q) {
|
||||
$q->where('is_business_owner', true)
|
||||
->orWhere('user_type', 'admin');
|
||||
|
||||
113
app/Console/Commands/SyncBrandMediaPaths.php
Normal file
113
app/Console/Commands/SyncBrandMediaPaths.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Brand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class SyncBrandMediaPaths extends Command
|
||||
{
|
||||
protected $signature = 'brands:sync-media-paths
|
||||
{--dry-run : Preview changes without applying}
|
||||
{--business= : Limit to specific business slug}';
|
||||
|
||||
protected $description = 'Sync brand logo_path and banner_path from MinIO storage';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$businessFilter = $this->option('business');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN - No changes will be made');
|
||||
}
|
||||
|
||||
$this->info('Scanning MinIO for brand media...');
|
||||
|
||||
$businessDirs = Storage::directories('businesses');
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($businessDirs as $businessDir) {
|
||||
$businessSlug = basename($businessDir);
|
||||
|
||||
if ($businessFilter && $businessSlug !== $businessFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$brandsDir = $businessDir.'/brands';
|
||||
if (! Storage::exists($brandsDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$brandDirs = Storage::directories($brandsDir);
|
||||
|
||||
foreach ($brandDirs as $brandDir) {
|
||||
$brandSlug = basename($brandDir);
|
||||
$brandingDir = $brandDir.'/branding';
|
||||
|
||||
if (! Storage::exists($brandingDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$brand = Brand::where('slug', $brandSlug)->first();
|
||||
if (! $brand) {
|
||||
$this->line(" <fg=yellow>?</> {$brandSlug} - not found in database");
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$files = Storage::files($brandingDir);
|
||||
$logoPath = null;
|
||||
$bannerPath = null;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filename = strtolower(basename($file));
|
||||
if (str_starts_with($filename, 'logo.')) {
|
||||
$logoPath = $file;
|
||||
} elseif (str_starts_with($filename, 'banner.')) {
|
||||
$bannerPath = $file;
|
||||
}
|
||||
}
|
||||
|
||||
$changes = [];
|
||||
if ($logoPath && $brand->logo_path !== $logoPath) {
|
||||
$changes[] = "logo: {$logoPath}";
|
||||
}
|
||||
if ($bannerPath && $brand->banner_path !== $bannerPath) {
|
||||
$changes[] = "banner: {$bannerPath}";
|
||||
}
|
||||
|
||||
if (empty($changes)) {
|
||||
$this->line(" <fg=green>✓</> {$brandSlug} - already synced");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
if ($logoPath) {
|
||||
$brand->logo_path = $logoPath;
|
||||
}
|
||||
if ($bannerPath) {
|
||||
$brand->banner_path = $bannerPath;
|
||||
}
|
||||
$brand->save();
|
||||
}
|
||||
|
||||
$this->line(" <fg=blue>↻</> {$brandSlug} - ".implode(', ', $changes));
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Updated: {$updated} | Skipped: {$skipped}");
|
||||
|
||||
if ($dryRun && $updated > 0) {
|
||||
$this->warn('Run without --dry-run to apply changes');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
24
app/Exceptions/PeriodLockedException.php
Normal file
24
app/Exceptions/PeriodLockedException.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use App\Models\Accounting\AccountingPeriod;
|
||||
|
||||
class PeriodLockedException extends \Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly ?AccountingPeriod $period = null,
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public function getPeriod(): ?AccountingPeriod
|
||||
{
|
||||
return $this->period;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class CreateBatch extends CreateRecord
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data['business_id'] = auth()->user()->business_id;
|
||||
$data['business_id'] = auth()->user()->primaryBusiness()?->id;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use Filament\Forms;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
@@ -45,6 +46,13 @@ class BusinessResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Business::class;
|
||||
|
||||
/**
|
||||
* Force Filament to use 'id' for record route binding in admin panel.
|
||||
* This is necessary because Business model uses 'slug' as getRouteKeyName()
|
||||
* for public routes, but admin panel needs 'id' for reliable record binding.
|
||||
*/
|
||||
protected static ?string $recordRouteKeyName = 'id';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'Accounts';
|
||||
@@ -147,80 +155,191 @@ class BusinessResource extends Resource
|
||||
]),
|
||||
]),
|
||||
|
||||
Tab::make('Addresses')
|
||||
Tab::make('Locations')
|
||||
->label(fn ($livewire) => self::isDispensaryBusiness($livewire->getRecord()) ? 'Locations' : 'Address')
|
||||
->schema([
|
||||
Section::make('Physical Address')
|
||||
Repeater::make('locations')
|
||||
->relationship('locations')
|
||||
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
|
||||
$data['slug'] = $data['slug'] ?? \Illuminate\Support\Str::slug($data['name'] ?? 'location');
|
||||
|
||||
return $data;
|
||||
})
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextInput::make('physical_address')
|
||||
TextInput::make('name')
|
||||
->label(fn ($livewire) => self::isDispensaryBusiness($livewire->getRecord()) ? 'Location Name' : 'Address Name')
|
||||
->maxLength(255),
|
||||
Select::make('location_type')
|
||||
->label('Type')
|
||||
->options([
|
||||
'physical' => 'Physical',
|
||||
'billing' => 'Billing',
|
||||
'delivery' => 'Delivery',
|
||||
])
|
||||
->default('physical'),
|
||||
TextInput::make('license_number')
|
||||
->label('License #')
|
||||
->maxLength(255),
|
||||
]),
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextInput::make('address')
|
||||
->label('Street Address')
|
||||
->maxLength(255)
|
||||
->columnSpan(2),
|
||||
TextInput::make('physical_city')
|
||||
TextInput::make('unit')
|
||||
->label('Unit/Suite')
|
||||
->maxLength(255),
|
||||
TextInput::make('city')
|
||||
->label('City')
|
||||
->maxLength(255),
|
||||
TextInput::make('physical_state')
|
||||
]),
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextInput::make('state')
|
||||
->label('State')
|
||||
->maxLength(255),
|
||||
TextInput::make('physical_zipcode')
|
||||
->label('ZIP Code')
|
||||
->maxLength(2),
|
||||
TextInput::make('zipcode')
|
||||
->label('ZIP')
|
||||
->maxLength(10),
|
||||
TextInput::make('phone')
|
||||
->label('Phone')
|
||||
->tel()
|
||||
->maxLength(20),
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->maxLength(255),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Billing Address')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('billing_address')
|
||||
->label('Billing Street Address')
|
||||
->maxLength(255)
|
||||
->columnSpan(2),
|
||||
TextInput::make('billing_city')
|
||||
->label('Billing City')
|
||||
->maxLength(255),
|
||||
TextInput::make('billing_state')
|
||||
->label('Billing State')
|
||||
->maxLength(255),
|
||||
TextInput::make('billing_zipcode')
|
||||
->label('Billing ZIP Code')
|
||||
->maxLength(255),
|
||||
Toggle::make('is_primary')
|
||||
->label(fn ($livewire) => self::isDispensaryBusiness($livewire->getRecord()) ? 'Primary Location' : 'Primary Address'),
|
||||
Toggle::make('is_billing')
|
||||
->label('Billing Address'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->itemLabel(fn (array $state, $livewire): ?string => $state['name'] ?? (self::isDispensaryBusiness($livewire->getRecord()) ? 'New Location' : 'New Address'))
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->addActionLabel(fn ($livewire) => self::isDispensaryBusiness($livewire->getRecord()) ? 'Add Location' : 'Add Address')
|
||||
->defaultItems(0),
|
||||
]),
|
||||
|
||||
Tab::make('Users & Access')
|
||||
->schema([
|
||||
// Quick add from business contacts section
|
||||
Forms\Components\Placeholder::make('contacts_without_users')
|
||||
->label('Contacts Without Platform Access')
|
||||
->content(function ($livewire) {
|
||||
$business = $livewire->getRecord();
|
||||
if (! $business) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$existingUserEmails = \App\Models\User::pluck('email')->map(fn ($e) => strtolower($e))->toArray();
|
||||
|
||||
$contacts = $business->contacts()
|
||||
->whereNotNull('email')
|
||||
->where('email', '!=', '')
|
||||
->get()
|
||||
->filter(fn ($c) => ! in_array(strtolower($c->email), $existingUserEmails));
|
||||
|
||||
if ($contacts->isEmpty()) {
|
||||
return new \Illuminate\Support\HtmlString(
|
||||
'<span class="text-gray-500 text-sm">All business contacts with emails already have platform access.</span>'
|
||||
);
|
||||
}
|
||||
|
||||
$html = '<div class="text-sm text-gray-600 mb-2">These business contacts have emails but no platform login. Click "Add Platform User" below and use "Link Existing User" or manually add them:</div>';
|
||||
$html .= '<div class="flex flex-wrap gap-2">';
|
||||
foreach ($contacts as $contact) {
|
||||
$name = trim($contact->first_name.' '.$contact->last_name) ?: 'Unknown';
|
||||
$type = $contact->contact_type ? ucfirst($contact->contact_type) : '';
|
||||
$html .= '<span class="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-amber-50 text-amber-800 border border-amber-200 text-xs">';
|
||||
$html .= '<strong>'.e($name).'</strong>';
|
||||
if ($type) {
|
||||
$html .= ' <span class="text-amber-600">('.$type.')</span>';
|
||||
}
|
||||
$html .= ' - '.e($contact->email);
|
||||
$html .= '</span>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return new \Illuminate\Support\HtmlString($html);
|
||||
})
|
||||
->visible(function ($livewire) {
|
||||
$business = $livewire->getRecord();
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$existingUserEmails = \App\Models\User::pluck('email')->map(fn ($e) => strtolower($e))->toArray();
|
||||
|
||||
return $business->contacts()
|
||||
->whereNotNull('email')
|
||||
->where('email', '!=', '')
|
||||
->get()
|
||||
->filter(fn ($c) => ! in_array(strtolower($c->email), $existingUserEmails))
|
||||
->isNotEmpty();
|
||||
})
|
||||
->columnSpanFull(),
|
||||
|
||||
Repeater::make('users')
|
||||
->relationship('users')
|
||||
->helperText('Users with login credentials and access to manage this business')
|
||||
->schema([
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextInput::make('first_name')
|
||||
->label('First Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('last_name')
|
||||
->label('Last Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('phone')
|
||||
->label('Phone')
|
||||
->tel()
|
||||
->maxLength(255),
|
||||
Select::make('contact_type')
|
||||
->label('Role/Type')
|
||||
->required()
|
||||
->options(Contact::CONTACT_TYPES)
|
||||
->default('staff')
|
||||
->searchable(),
|
||||
Hidden::make('id'),
|
||||
Select::make('user_id')
|
||||
->label('Link Existing User')
|
||||
->options(function ($get, $livewire) {
|
||||
$business = $livewire->getRecord();
|
||||
$currentUserIds = $business ? $business->users()->pluck('users.id')->toArray() : [];
|
||||
$currentId = $get('id');
|
||||
|
||||
return \App\Models\User::query()
|
||||
->with('businesses')
|
||||
->where(function ($query) use ($currentUserIds, $currentId) {
|
||||
$query->whereNotIn('id', $currentUserIds);
|
||||
if ($currentId) {
|
||||
$query->orWhere('id', $currentId);
|
||||
}
|
||||
})
|
||||
->where('user_type', '!=', 'admin')
|
||||
->orderBy('first_name')
|
||||
->get()
|
||||
->mapWithKeys(function ($user) {
|
||||
$businesses = $user->businesses->pluck('name')->join(', ');
|
||||
$label = $user->full_name.' ('.$user->email.')';
|
||||
if ($businesses) {
|
||||
$label .= ' - '.$businesses;
|
||||
}
|
||||
|
||||
return [$user->id => $label];
|
||||
});
|
||||
})
|
||||
->searchable()
|
||||
->preload()
|
||||
->live()
|
||||
->dehydrated(false)
|
||||
->afterStateUpdated(function ($state, callable $set) {
|
||||
if ($state) {
|
||||
$user = \App\Models\User::find($state);
|
||||
if ($user) {
|
||||
$set('id', $user->id);
|
||||
$set('first_name', $user->first_name);
|
||||
$set('last_name', $user->last_name);
|
||||
$set('email', $user->email);
|
||||
$set('phone', $user->phone);
|
||||
}
|
||||
}
|
||||
})
|
||||
->helperText('Search and select an existing user, or leave empty to create new')
|
||||
->columnSpan(2),
|
||||
Toggle::make('is_primary')
|
||||
->label(new \Illuminate\Support\HtmlString(
|
||||
'<span style="text-decoration: underline dotted; cursor: help;" title="Only one primary user allowed - clicking will immediately switch the primary user">Primary</span>'
|
||||
@@ -259,6 +378,31 @@ class BusinessResource extends Resource
|
||||
return false;
|
||||
})
|
||||
->inline(false),
|
||||
TextInput::make('first_name')
|
||||
->label('First Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('last_name')
|
||||
->label('Last Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->disabled(fn ($get) => ! empty($get('id')))
|
||||
->helperText(fn ($get) => ! empty($get('id')) ? 'Email cannot be changed for existing users' : 'New user will be created with this email'),
|
||||
TextInput::make('phone')
|
||||
->label('Phone')
|
||||
->tel()
|
||||
->maxLength(255),
|
||||
Select::make('contact_type')
|
||||
->label('Role/Type')
|
||||
->required()
|
||||
->options(Contact::CONTACT_TYPES)
|
||||
->default('staff')
|
||||
->searchable(),
|
||||
]),
|
||||
])
|
||||
->saveRelationshipsUsing(function ($component, $state, $record) {
|
||||
@@ -267,22 +411,54 @@ class BusinessResource extends Resource
|
||||
}
|
||||
$syncData = [];
|
||||
foreach ($state as $item) {
|
||||
$email = $item['email'] ?? null;
|
||||
if (! $email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if user exists by ID or email
|
||||
$user = null;
|
||||
if (isset($item['id'])) {
|
||||
$user = \App\Models\User::find($item['id']);
|
||||
if ($user) {
|
||||
$user->update([
|
||||
'first_name' => $item['first_name'] ?? null,
|
||||
'last_name' => $item['last_name'] ?? null,
|
||||
'email' => $item['email'] ?? null,
|
||||
'phone' => $item['phone'] ?? null,
|
||||
]);
|
||||
}
|
||||
$syncData[$item['id']] = [
|
||||
'contact_type' => $item['contact_type'] ?? 'staff',
|
||||
'is_primary' => $item['is_primary'] ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
// If no user found by ID, try to find by email
|
||||
if (! $user) {
|
||||
$user = \App\Models\User::where('email', $email)->first();
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
// Update existing user
|
||||
$user->update([
|
||||
'first_name' => $item['first_name'] ?? $user->first_name,
|
||||
'last_name' => $item['last_name'] ?? $user->last_name,
|
||||
'phone' => $item['phone'] ?? $user->phone,
|
||||
]);
|
||||
} else {
|
||||
// Create new user
|
||||
$user = \App\Models\User::create([
|
||||
'first_name' => $item['first_name'] ?? '',
|
||||
'last_name' => $item['last_name'] ?? '',
|
||||
'email' => $email,
|
||||
'phone' => $item['phone'] ?? null,
|
||||
'password' => bcrypt(\Illuminate\Support\Str::random(16)),
|
||||
'user_type' => $record->business_type === 'retailer' ? 'buyer' : 'seller',
|
||||
]);
|
||||
}
|
||||
|
||||
$syncData[$user->id] = [
|
||||
'contact_type' => $item['contact_type'] ?? 'staff',
|
||||
'is_primary' => $item['is_primary'] ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
// Auto-set first user as primary if no primary is set
|
||||
$hasPrimary = collect($syncData)->contains(fn ($data) => $data['is_primary']);
|
||||
if (! $hasPrimary && ! empty($syncData)) {
|
||||
$firstUserId = array_key_first($syncData);
|
||||
$syncData[$firstUserId]['is_primary'] = true;
|
||||
}
|
||||
|
||||
$record->users()->sync($syncData);
|
||||
})
|
||||
->itemLabel(fn (array $state): ?string => trim(($state['first_name'] ?? '').' '.($state['last_name'] ?? '')) ?:
|
||||
@@ -546,53 +722,83 @@ class BusinessResource extends Resource
|
||||
->bulkToggleable()
|
||||
->helperText('Select the suites this business should have access to. Each suite enables specific features and menu items.'),
|
||||
|
||||
Forms\Components\Placeholder::make('suite_info')
|
||||
->label('')
|
||||
->content(function () {
|
||||
// Show available suites (excluding deprecated and internal)
|
||||
$suites = \App\Models\Suite::available()->orderBy('sort_order')->get();
|
||||
$html = '<div class="grid grid-cols-2 gap-4 text-sm mt-4">';
|
||||
foreach ($suites as $suite) {
|
||||
$colorClass = match ($suite->color) {
|
||||
'emerald' => 'border-emerald-300 bg-emerald-50 dark:border-emerald-700 dark:bg-emerald-950', // Sales
|
||||
'pink' => 'border-pink-300 bg-pink-50 dark:border-pink-700 dark:bg-pink-950', // Marketing
|
||||
'cyan' => 'border-cyan-300 bg-cyan-50 dark:border-cyan-700 dark:bg-cyan-950', // Inventory
|
||||
'blue' => 'border-blue-300 bg-blue-50 dark:border-blue-700 dark:bg-blue-950', // Processing
|
||||
'orange' => 'border-orange-300 bg-orange-50 dark:border-orange-700 dark:bg-orange-950', // Manufacturing
|
||||
'indigo' => 'border-indigo-300 bg-indigo-50 dark:border-indigo-700 dark:bg-indigo-950', // Procurement
|
||||
'violet' => 'border-violet-300 bg-violet-50 dark:border-violet-700 dark:bg-violet-950', // Distribution
|
||||
'green' => 'border-green-300 bg-green-50 dark:border-green-700 dark:bg-green-950', // Finance
|
||||
'amber' => 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950', // Compliance
|
||||
'sky' => 'border-sky-300 bg-sky-50 dark:border-sky-700 dark:bg-sky-950', // Inbox
|
||||
'slate' => 'border-slate-300 bg-slate-50 dark:border-slate-700 dark:bg-slate-950', // Tools
|
||||
'gray' => 'border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-950', // Management
|
||||
'lime' => 'border-lime-300 bg-lime-50 dark:border-lime-700 dark:bg-lime-950', // Dispensary
|
||||
'gold' => 'border-yellow-300 bg-yellow-50 dark:border-yellow-700 dark:bg-yellow-950', // Enterprise
|
||||
'teal' => 'border-teal-300 bg-teal-50 dark:border-teal-700 dark:bg-teal-950', // Brand Manager
|
||||
'red' => 'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-950',
|
||||
'rose' => 'border-rose-300 bg-rose-50 dark:border-rose-700 dark:bg-rose-950',
|
||||
'fuchsia' => 'border-fuchsia-300 bg-fuchsia-50 dark:border-fuchsia-700 dark:bg-fuchsia-950',
|
||||
default => 'border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-950',
|
||||
};
|
||||
$features = is_array($suite->included_features) ? implode(', ', $suite->included_features) : '';
|
||||
$html .= '<div class="border rounded-lg p-3 '.$colorClass.'">';
|
||||
$html .= '<div class="font-medium">'.e($suite->name).'</div>';
|
||||
$html .= '<div class="text-xs text-gray-600 dark:text-gray-400 mt-1">'.e($features).'</div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return new \Illuminate\Support\HtmlString($html);
|
||||
}),
|
||||
]),
|
||||
|
||||
Section::make('Navigation Settings')
|
||||
->description('Control how this business experiences the seller sidebar navigation.')
|
||||
// ===== SUITE SHARES SECTION =====
|
||||
// Allows this business to share parts of their suite TO other businesses
|
||||
Section::make('Suite Shares')
|
||||
->description('Share parts of THIS business\'s suite with other businesses. The recipient will see these menu items with a "Shared" badge.')
|
||||
->collapsed()
|
||||
->schema([
|
||||
Toggle::make('use_suite_navigation')
|
||||
->label('Use Suite Navigation (beta)')
|
||||
->helperText('When enabled, this business uses the new suite-based sidebar instead of the legacy menu.')
|
||||
->default(false),
|
||||
Forms\Components\Repeater::make('suiteShares')
|
||||
->relationship('suiteShares')
|
||||
->label('')
|
||||
->schema([
|
||||
Select::make('target_business_id')
|
||||
->label('Share TO Business')
|
||||
->options(function (callable $get) {
|
||||
$currentBusinessId = $get('../../id');
|
||||
|
||||
return \App\Models\Business::query()
|
||||
->when($currentBusinessId, fn ($q) => $q->where('id', '!=', $currentBusinessId))
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id');
|
||||
})
|
||||
->searchable()
|
||||
->required()
|
||||
->helperText('Select the business that will RECEIVE these shared menu items'),
|
||||
Select::make('shared_suite_key')
|
||||
->label('Suite to Share From')
|
||||
->options(function ($livewire) {
|
||||
// Get suites assigned to THIS business (source)
|
||||
$business = $livewire->record;
|
||||
if (! $business) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $business->suites()
|
||||
->orderBy('sort_order')
|
||||
->pluck('name', 'key')
|
||||
->toArray();
|
||||
})
|
||||
->required()
|
||||
->reactive()
|
||||
->helperText('Select which of THIS business\'s suites to share items from'),
|
||||
CheckboxList::make('shared_menu_keys')
|
||||
->label('Menu Items to Share')
|
||||
->options(function (callable $get) {
|
||||
$suiteKey = $get('shared_suite_key');
|
||||
if (! $suiteKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get menu keys for this suite from config
|
||||
$menuKeys = config("suites.menus.{$suiteKey}", []);
|
||||
$resolver = app(\App\Services\SuiteMenuResolver::class);
|
||||
|
||||
$options = [];
|
||||
foreach ($menuKeys as $key) {
|
||||
$def = $resolver->getMenuDefinition($key);
|
||||
if ($def) {
|
||||
$options[$key] = $def['label'].' ('.$def['section'].')';
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
})
|
||||
->columns(2)
|
||||
->required()
|
||||
->visible(fn (callable $get) => ! empty($get('shared_suite_key'))),
|
||||
])
|
||||
->columns(1)
|
||||
->defaultItems(0)
|
||||
->addActionLabel('Add Suite Share')
|
||||
->reorderable(false)
|
||||
->collapsible()
|
||||
->itemLabel(fn (array $state): ?string => isset($state['target_business_id'])
|
||||
? 'Share to: '.(\App\Models\Business::find($state['target_business_id'])?->name ?? 'New Share')
|
||||
: 'New Share'
|
||||
),
|
||||
]),
|
||||
|
||||
Section::make('Sales Suite Usage Limits')
|
||||
@@ -1653,23 +1859,27 @@ class BusinessResource extends Resource
|
||||
default => 'gray',
|
||||
})
|
||||
->sortable(),
|
||||
TextColumn::make('owner.full_name')
|
||||
TextColumn::make('primary_user')
|
||||
->label('Account Owner')
|
||||
->getStateUsing(function (Business $record): ?string {
|
||||
$owner = $record->owner;
|
||||
if ($owner) {
|
||||
$name = trim($owner->first_name.' '.$owner->last_name);
|
||||
// Use the primary user from the pivot table
|
||||
$primaryUser = $record->users->first();
|
||||
if ($primaryUser) {
|
||||
$name = trim($primaryUser->first_name.' '.$primaryUser->last_name);
|
||||
|
||||
return $name.' ('.$owner->email.')';
|
||||
return $name.' ('.$primaryUser->email.')';
|
||||
}
|
||||
|
||||
return 'N/A';
|
||||
})
|
||||
->searchable(query: function ($query, $search) {
|
||||
return $query->whereHas('owner', function ($q) use ($search) {
|
||||
$q->where('first_name', 'like', "%{$search}%")
|
||||
->orWhere('last_name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
return $query->whereHas('users', function ($q) use ($search) {
|
||||
$q->wherePivot('is_primary', true)
|
||||
->where(function ($q2) use ($search) {
|
||||
$q2->where('first_name', 'like', "%{$search}%")
|
||||
->orWhere('last_name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
})
|
||||
->sortable(),
|
||||
@@ -1850,4 +2060,19 @@ class BusinessResource extends Resource
|
||||
'edit' => EditBusiness::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if business is a dispensary/retailer type.
|
||||
* Used to determine whether to show "Locations" (multi-location dispensaries)
|
||||
* or "Address" (single address for sellers/manufacturers).
|
||||
*/
|
||||
protected static function isDispensaryBusiness(?\App\Models\Business $business): bool
|
||||
{
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if business has the "dispensary" type key assigned
|
||||
return $business->types()->where('business_types.key', 'dispensary')->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,15 @@ use Filament\Resources\Pages\CreateRecord;
|
||||
class CreateBusiness extends CreateRecord
|
||||
{
|
||||
protected static string $resource = BusinessResource::class;
|
||||
|
||||
/**
|
||||
* Override redirect URL to use record ID instead of slug.
|
||||
*
|
||||
* This ensures proper routing after business creation since
|
||||
* Business model uses 'slug' as getRouteKeyName() but admin uses 'id'.
|
||||
*/
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return static::getResource()::getUrl('edit', ['record' => $this->record->getKey()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ class EditBusiness extends EditRecord
|
||||
{
|
||||
protected static string $resource = BusinessResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'Edit '.$this->record->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Livewire listeners for audit trail integration.
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Filament\Resources\BusinessResource\Pages;
|
||||
use App\Filament\Resources\BusinessResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ListBusinesses extends ListRecords
|
||||
{
|
||||
@@ -24,4 +25,22 @@ class ListBusinesses extends ListRecords
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Override URL generation to use business ID instead of slug.
|
||||
*
|
||||
* The Business model uses 'slug' as route key for public routes,
|
||||
* but admin panel needs the primary key for reliable routing.
|
||||
*
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
public function getResourceUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = true): string
|
||||
{
|
||||
// Convert Model to ID for the 'record' parameter
|
||||
if (isset($parameters['record']) && $parameters['record'] instanceof Model) {
|
||||
$parameters['record'] = $parameters['record']->getKey();
|
||||
}
|
||||
|
||||
return parent::getResourceUrl($name, $parameters, $isAbsolute, $panel, $tenant, $shouldGuessMissingParameters);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,20 +43,25 @@ class LabResource extends Resource
|
||||
|
||||
// Scope to user's business products and batches unless they're a super admin
|
||||
if (auth()->check() && ! auth()->user()->hasRole('Super Admin')) {
|
||||
$businessId = auth()->user()->business_id;
|
||||
$businessId = auth()->user()->primaryBusiness()?->id;
|
||||
|
||||
$query->where(function ($q) use ($businessId) {
|
||||
// Include labs for products owned by this business
|
||||
$q->whereHas('product', function ($productQuery) use ($businessId) {
|
||||
$productQuery->whereHas('brand', function ($brandQuery) use ($businessId) {
|
||||
$brandQuery->where('business_id', $businessId);
|
||||
});
|
||||
})
|
||||
// OR labs for batches owned by this business
|
||||
->orWhereHas('batch', function ($batchQuery) use ($businessId) {
|
||||
$batchQuery->where('business_id', $businessId);
|
||||
});
|
||||
});
|
||||
if ($businessId) {
|
||||
$query->where(function ($q) use ($businessId) {
|
||||
// Include labs for products owned by this business
|
||||
$q->whereHas('product', function ($productQuery) use ($businessId) {
|
||||
$productQuery->whereHas('brand', function ($brandQuery) use ($businessId) {
|
||||
$brandQuery->where('business_id', $businessId);
|
||||
});
|
||||
})
|
||||
// OR labs for batches owned by this business
|
||||
->orWhereHas('batch', function ($batchQuery) use ($businessId) {
|
||||
$batchQuery->where('business_id', $businessId);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// No business association - show nothing
|
||||
$query->whereRaw('1 = 0');
|
||||
}
|
||||
}
|
||||
|
||||
return $query;
|
||||
|
||||
@@ -55,6 +55,22 @@ class OrchestratorOutcomesChart extends ChartWidget
|
||||
->pending()
|
||||
->count();
|
||||
|
||||
// If all values are zero, show a placeholder to prevent empty doughnut rendering
|
||||
$total = $completed + $dismissed + $snoozed + $pending;
|
||||
if ($total === 0) {
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => 'No Data',
|
||||
'data' => [1],
|
||||
'backgroundColor' => ['rgba(209, 213, 219, 0.5)'], // gray placeholder
|
||||
'borderWidth' => 0,
|
||||
],
|
||||
],
|
||||
'labels' => ['No tasks yet'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
|
||||
207
app/Http/Controllers/Api/Accounting/ApVendorController.php
Normal file
207
app/Http/Controllers/Api/Accounting/ApVendorController.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Accounting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ApVendor;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ApVendorController extends Controller
|
||||
{
|
||||
/**
|
||||
* List vendors for a business.
|
||||
*
|
||||
* GET /api/{business}/ap/vendors
|
||||
*/
|
||||
public function index(Request $request, Business $business): JsonResponse
|
||||
{
|
||||
$query = ApVendor::where('business_id', $business->id);
|
||||
|
||||
// Search
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Active filter
|
||||
if ($request->has('active')) {
|
||||
$query->where('is_active', $request->boolean('active'));
|
||||
}
|
||||
|
||||
$vendors = $query->orderBy('name')->paginate($request->get('per_page', 50));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $vendors->items(),
|
||||
'meta' => [
|
||||
'current_page' => $vendors->currentPage(),
|
||||
'last_page' => $vendors->lastPage(),
|
||||
'per_page' => $vendors->perPage(),
|
||||
'total' => $vendors->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single vendor.
|
||||
*
|
||||
* GET /api/{business}/ap/vendors/{vendor}
|
||||
*/
|
||||
public function show(Business $business, ApVendor $vendor): JsonResponse
|
||||
{
|
||||
if ($vendor->business_id !== $business->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Vendor does not belong to this business.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $vendor,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new vendor.
|
||||
*
|
||||
* POST /api/{business}/ap/vendors
|
||||
*/
|
||||
public function store(Request $request, Business $business): JsonResponse
|
||||
{
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'code' => 'nullable|string|max:50',
|
||||
'name' => 'required|string|max:255',
|
||||
'legal_name' => 'nullable|string|max:255',
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'default_payment_terms' => 'nullable|integer|min:0',
|
||||
'default_gl_account_id' => 'nullable|integer|exists:gl_accounts,id',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'address_line1' => 'nullable|string|max:255',
|
||||
'address_line2' => 'nullable|string|max:255',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:100',
|
||||
'postal_code' => 'nullable|string|max:20',
|
||||
'country' => 'nullable|string|max:100',
|
||||
'is_1099' => 'boolean',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Generate code if not provided
|
||||
if (empty($validated['code'])) {
|
||||
$validated['code'] = $this->generateVendorCode($business->id, $validated['name']);
|
||||
}
|
||||
|
||||
$vendor = ApVendor::create([
|
||||
'business_id' => $business->id,
|
||||
...$validated,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "Vendor {$vendor->name} created.",
|
||||
'data' => $vendor,
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Vendor creation failed', [
|
||||
'business_id' => $business->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to create vendor: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a vendor.
|
||||
*
|
||||
* PUT /api/{business}/ap/vendors/{vendor}
|
||||
*/
|
||||
public function update(Request $request, Business $business, ApVendor $vendor): JsonResponse
|
||||
{
|
||||
if ($vendor->business_id !== $business->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Vendor does not belong to this business.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'code' => 'nullable|string|max:50',
|
||||
'name' => 'required|string|max:255',
|
||||
'legal_name' => 'nullable|string|max:255',
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'default_payment_terms' => 'nullable|integer|min:0',
|
||||
'default_gl_account_id' => 'nullable|integer|exists:gl_accounts,id',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'address_line1' => 'nullable|string|max:255',
|
||||
'address_line2' => 'nullable|string|max:255',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:100',
|
||||
'postal_code' => 'nullable|string|max:20',
|
||||
'country' => 'nullable|string|max:100',
|
||||
'is_1099' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$vendor->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "Vendor {$vendor->name} updated.",
|
||||
'data' => $vendor->fresh(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Vendor update failed', [
|
||||
'vendor_id' => $vendor->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to update vendor: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate vendor code from name.
|
||||
*/
|
||||
protected function generateVendorCode(int $businessId, string $name): string
|
||||
{
|
||||
$words = preg_split('/\s+/', strtoupper($name));
|
||||
$prefix = '';
|
||||
foreach ($words as $word) {
|
||||
$prefix .= substr(preg_replace('/[^A-Z0-9]/', '', $word), 0, 3);
|
||||
if (strlen($prefix) >= 6) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$prefix = substr($prefix, 0, 6);
|
||||
|
||||
$count = ApVendor::where('business_id', $businessId)
|
||||
->where('code', 'like', "{$prefix}%")
|
||||
->count();
|
||||
|
||||
return $count > 0 ? "{$prefix}-{$count}" : $prefix;
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,10 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Buyer\BuyerBrandFollow;
|
||||
use App\Models\Buyer\BuyerProductBookmark;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use App\Models\Seller\BrandAnnouncement;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Crm\Entities\CrmThread;
|
||||
|
||||
class BrandHubController extends Controller
|
||||
{
|
||||
|
||||
@@ -7,12 +7,12 @@ use App\Models\Buyer\BuyerAnalyticsCache;
|
||||
use App\Models\Buyer\BuyerBrandFollow;
|
||||
use App\Models\Buyer\BuyerQuoteApproval;
|
||||
use App\Models\Buyer\BuyerTask;
|
||||
use App\Models\Crm\CrmInvoice;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use App\Models\Order;
|
||||
use App\Models\Seller\BrandAnnouncement;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Crm\Entities\CrmInvoice;
|
||||
use Modules\Crm\Entities\CrmQuote;
|
||||
use Modules\Crm\Entities\CrmThread;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
|
||||
@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Buyer\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Buyer\BuyerMessageSettings;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Crm\Entities\CrmThread;
|
||||
|
||||
class InboxController extends Controller
|
||||
{
|
||||
|
||||
@@ -5,9 +5,10 @@ namespace App\Http\Controllers\Buyer\Crm;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Buyer\BuyerInvoiceRecord;
|
||||
use App\Models\Buyer\BuyerSavedFilter;
|
||||
use App\Models\Crm\CrmInvoice;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Crm\Entities\CrmInvoice;
|
||||
|
||||
class InvoiceController extends Controller
|
||||
{
|
||||
@@ -115,7 +116,7 @@ class InvoiceController extends Controller
|
||||
}
|
||||
|
||||
// Get related thread if exists
|
||||
$thread = \Modules\Crm\Entities\CrmThread::where('buyer_business_id', $business->id)
|
||||
$thread = CrmThread::where('buyer_business_id', $business->id)
|
||||
->where(function ($q) use ($invoice) {
|
||||
$q->where('order_id', $invoice->order_id)
|
||||
->orWhere('subject', 'ilike', "%{$invoice->invoice_number}%");
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
namespace App\Http\Controllers\Buyer\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Crm\CrmChannelMessage;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Crm\Entities\CrmMessage;
|
||||
use Modules\Crm\Entities\CrmThread;
|
||||
|
||||
class MessageController extends Controller
|
||||
{
|
||||
@@ -64,7 +64,7 @@ class MessageController extends Controller
|
||||
return back()->with('success', 'Message sent.');
|
||||
}
|
||||
|
||||
public function destroy(CrmThread $thread, CrmMessage $message)
|
||||
public function destroy(CrmThread $thread, CrmChannelMessage $message)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
@@ -88,7 +88,7 @@ class MessageController extends Controller
|
||||
return back()->with('success', 'Message deleted.');
|
||||
}
|
||||
|
||||
public function react(Request $request, CrmThread $thread, CrmMessage $message)
|
||||
public function react(Request $request, CrmThread $thread, CrmChannelMessage $message)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
@@ -108,7 +108,7 @@ class OrderController extends Controller
|
||||
$deliveryEvents = BuyerDeliveryEvent::getTimelineForOrder($order->id);
|
||||
|
||||
// Get related thread if exists
|
||||
$thread = \Modules\Crm\Entities\CrmThread::where('order_id', $order->id)
|
||||
$thread = \App\Models\Crm\CrmThread::where('order_id', $order->id)
|
||||
->where('buyer_business_id', $business->id)
|
||||
->first();
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Buyer\BuyerQuoteApproval;
|
||||
use App\Models\Buyer\BuyerSavedFilter;
|
||||
use App\Models\Buyer\BuyerTeamMember;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Crm\Entities\CrmQuote;
|
||||
|
||||
class QuoteController extends Controller
|
||||
{
|
||||
|
||||
@@ -9,12 +9,13 @@ class CustomerController extends Controller
|
||||
/**
|
||||
* Customers entry point - smart gateway to CRM Accounts.
|
||||
*
|
||||
* If CRM is enabled: redirect to /s/{business}/crm/accounts
|
||||
* If CRM is enabled (via Sales Suite or CRM feature): redirect to /s/{business}/crm/accounts
|
||||
* If CRM is disabled: show feature-disabled view
|
||||
*/
|
||||
public function index(Business $business)
|
||||
{
|
||||
if ($business->has_crm) {
|
||||
// CRM is included in Sales Suite or can be enabled as standalone feature
|
||||
if ($business->hasCrmAccess()) {
|
||||
return redirect()->route('seller.business.crm.accounts.index', $business);
|
||||
}
|
||||
|
||||
@@ -22,18 +23,25 @@ class CustomerController extends Controller
|
||||
'business' => $business,
|
||||
'feature' => 'Customers',
|
||||
'description' => 'The Customers feature requires CRM to be enabled for your business.',
|
||||
'benefits' => [
|
||||
'Manage all your customer accounts in one place',
|
||||
'Track contact information and order history',
|
||||
'Build stronger customer relationships',
|
||||
'Access customer insights and analytics',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual customer view - redirect to CRM Account detail.
|
||||
*
|
||||
* If CRM is enabled: redirect to the account detail page
|
||||
* If CRM is enabled (via Sales Suite or CRM feature): redirect to the account detail page
|
||||
* If CRM is disabled: show feature-disabled view
|
||||
*/
|
||||
public function show(Business $business, $customer)
|
||||
{
|
||||
if ($business->has_crm) {
|
||||
// CRM is included in Sales Suite or can be enabled as standalone feature
|
||||
if ($business->hasCrmAccess()) {
|
||||
// Redirect to CRM Account detail - $customer is the account ID
|
||||
return redirect()->route('seller.business.crm.accounts.show', [$business, $customer]);
|
||||
}
|
||||
@@ -42,6 +50,12 @@ class CustomerController extends Controller
|
||||
'business' => $business,
|
||||
'feature' => 'Customers',
|
||||
'description' => 'The Customers feature requires CRM to be enabled for your business.',
|
||||
'benefits' => [
|
||||
'Manage all your customer accounts in one place',
|
||||
'Track contact information and order history',
|
||||
'Build stronger customer relationships',
|
||||
'Access customer insights and analytics',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,15 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* Cache TTL for dashboard metrics (5 minutes)
|
||||
*/
|
||||
private const DASHBOARD_CACHE_TTL = 300;
|
||||
|
||||
/**
|
||||
* Main dashboard redirect - automatically routes to business context
|
||||
* Redirects to /s/{business}/dashboard based on user's primary business
|
||||
@@ -37,103 +43,120 @@ class DashboardController extends Controller
|
||||
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
|
||||
$brandNames = \App\Models\Brand::whereIn('id', $brandIds)->pluck('name')->toArray();
|
||||
|
||||
// Time periods
|
||||
$currentStart = now()->subDays(30);
|
||||
$currentEnd = now();
|
||||
$previousStart = now()->subDays(60);
|
||||
$previousEnd = now()->subDays(30);
|
||||
// Generate cache key based on business and brand selection
|
||||
$brandKey = md5(implode(',', $brandIds));
|
||||
$cacheKey = "dashboard.overview.{$business->id}.{$brandKey}";
|
||||
|
||||
// Cache expensive KPI calculations for 5 minutes
|
||||
$kpiData = Cache::remember($cacheKey, self::DASHBOARD_CACHE_TTL, function () use ($business, $brandNames, $brandIds) {
|
||||
// Time periods
|
||||
$currentStart = now()->subDays(30);
|
||||
$currentEnd = now();
|
||||
$previousStart = now()->subDays(60);
|
||||
$previousEnd = now()->subDays(30);
|
||||
|
||||
// Get core metrics for current and previous periods
|
||||
$currentStats = $this->getOrderStats($brandNames, $currentStart, $currentEnd);
|
||||
$previousStats = $this->getOrderStats($brandNames, $previousStart, $previousEnd);
|
||||
|
||||
// Calculate KPIs
|
||||
$revenueLast30 = ($currentStats->revenue ?? 0) / 100;
|
||||
$ordersLast30 = $currentStats->order_count ?? 0;
|
||||
$unitsSoldLast30 = $currentStats->total_units ?? 0;
|
||||
$averageOrderValueLast30 = $ordersLast30 > 0 ? $revenueLast30 / $ordersLast30 : 0;
|
||||
|
||||
// Previous period metrics for growth calculation
|
||||
$previousRevenue = ($previousStats->revenue ?? 0) / 100;
|
||||
$previousOrders = $previousStats->order_count ?? 0;
|
||||
$previousUnits = $previousStats->total_units ?? 0;
|
||||
$previousAOV = $previousOrders > 0 ? $previousRevenue / $previousOrders : 0;
|
||||
|
||||
// Growth percentages
|
||||
$revenueGrowth = $previousRevenue > 0 ? (($revenueLast30 - $previousRevenue) / $previousRevenue) * 100 : 0;
|
||||
$ordersGrowth = $previousOrders > 0 ? (($ordersLast30 - $previousOrders) / $previousOrders) * 100 : 0;
|
||||
$unitsGrowth = $previousUnits > 0 ? (($unitsSoldLast30 - $previousUnits) / $previousUnits) * 100 : 0;
|
||||
$aovGrowth = $previousAOV > 0 ? (($averageOrderValueLast30 - $previousAOV) / $previousAOV) * 100 : 0;
|
||||
|
||||
// Count active brands
|
||||
$activeBrandCount = \App\Models\Brand::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
|
||||
// Count active buyers (distinct buyer businesses that ordered in last 30 days)
|
||||
$activeBuyerCount = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->where('orders.created_at', '>=', $currentStart)
|
||||
->distinct('orders.business_id')
|
||||
->count('orders.business_id');
|
||||
|
||||
// Get inventory alerts count
|
||||
$activeInventoryAlertsCount = \App\Models\InventoryAlert::where('business_id', $business->id)
|
||||
->whereIn('alert_type', ['low_stock', 'out_of_stock'])
|
||||
->active()
|
||||
->count();
|
||||
|
||||
// Get active promotions count
|
||||
$activePromotionCount = \App\Models\Broadcast::where('business_id', $business->id)
|
||||
->whereIn('status', ['scheduled', 'sending'])
|
||||
->count();
|
||||
|
||||
// Top Products (last 30 days by revenue)
|
||||
$topProducts = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->where('orders.created_at', '>=', $currentStart)
|
||||
->select('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
|
||||
->selectRaw('SUM(order_items.quantity) as total_units')
|
||||
->selectRaw('SUM(order_items.line_total) as total_revenue')
|
||||
->groupBy('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
|
||||
->orderByDesc('total_revenue')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
$item->total_revenue_dollars = $item->total_revenue / 100;
|
||||
|
||||
return $item;
|
||||
});
|
||||
|
||||
// Top Brands (last 30 days) - fetch previous period stats in single query to avoid N+1
|
||||
$previousBrandStats = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->whereBetween('orders.created_at', [$previousStart, $previousEnd])
|
||||
->select('order_items.brand_name')
|
||||
->selectRaw('SUM(order_items.line_total) as revenue')
|
||||
->groupBy('order_items.brand_name')
|
||||
->pluck('revenue', 'brand_name');
|
||||
|
||||
$topBrands = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->where('orders.created_at', '>=', $currentStart)
|
||||
->select('order_items.brand_name')
|
||||
->selectRaw('SUM(order_items.quantity) as total_units')
|
||||
->selectRaw('SUM(order_items.line_total) as total_revenue')
|
||||
->selectRaw('COUNT(DISTINCT orders.id) as order_count')
|
||||
->groupBy('order_items.brand_name')
|
||||
->orderByDesc('total_revenue')
|
||||
->get()
|
||||
->map(function ($item) use ($previousBrandStats) {
|
||||
$item->total_revenue_dollars = $item->total_revenue / 100;
|
||||
$prevRevenue = ($previousBrandStats[$item->brand_name] ?? 0) / 100;
|
||||
$item->growth_pct = $prevRevenue > 0 ? (($item->total_revenue_dollars - $prevRevenue) / $prevRevenue) * 100 : null;
|
||||
|
||||
return $item;
|
||||
});
|
||||
|
||||
return compact(
|
||||
'revenueLast30', 'ordersLast30', 'unitsSoldLast30', 'averageOrderValueLast30',
|
||||
'revenueGrowth', 'ordersGrowth', 'unitsGrowth', 'aovGrowth',
|
||||
'activeBrandCount', 'activeBuyerCount', 'activeInventoryAlertsCount', 'activePromotionCount',
|
||||
'topProducts', 'topBrands', 'currentStart'
|
||||
);
|
||||
});
|
||||
|
||||
// Extract cached values
|
||||
extract($kpiData);
|
||||
$start7 = now()->subDays(7);
|
||||
|
||||
// Get core metrics for current and previous periods
|
||||
$currentStats = $this->getOrderStats($brandNames, $currentStart, $currentEnd);
|
||||
$previousStats = $this->getOrderStats($brandNames, $previousStart, $previousEnd);
|
||||
|
||||
// Calculate KPIs
|
||||
$revenueLast30 = ($currentStats->revenue ?? 0) / 100;
|
||||
$ordersLast30 = $currentStats->order_count ?? 0;
|
||||
$unitsSoldLast30 = $currentStats->total_units ?? 0;
|
||||
$averageOrderValueLast30 = $ordersLast30 > 0 ? $revenueLast30 / $ordersLast30 : 0;
|
||||
|
||||
// Previous period metrics for growth calculation
|
||||
$previousRevenue = ($previousStats->revenue ?? 0) / 100;
|
||||
$previousOrders = $previousStats->order_count ?? 0;
|
||||
$previousUnits = $previousStats->total_units ?? 0;
|
||||
$previousAOV = $previousOrders > 0 ? $previousRevenue / $previousOrders : 0;
|
||||
|
||||
// Growth percentages
|
||||
$revenueGrowth = $previousRevenue > 0 ? (($revenueLast30 - $previousRevenue) / $previousRevenue) * 100 : 0;
|
||||
$ordersGrowth = $previousOrders > 0 ? (($ordersLast30 - $previousOrders) / $previousOrders) * 100 : 0;
|
||||
$unitsGrowth = $previousUnits > 0 ? (($unitsSoldLast30 - $previousUnits) / $previousUnits) * 100 : 0;
|
||||
$aovGrowth = $previousAOV > 0 ? (($averageOrderValueLast30 - $previousAOV) / $previousAOV) * 100 : 0;
|
||||
|
||||
// Count active brands
|
||||
$activeBrandCount = \App\Models\Brand::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
|
||||
// Count active buyers (distinct buyer businesses that ordered in last 30 days)
|
||||
$activeBuyerCount = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->where('orders.created_at', '>=', $currentStart)
|
||||
->distinct('orders.business_id')
|
||||
->count('orders.business_id');
|
||||
|
||||
// Get inventory alerts count
|
||||
$activeInventoryAlertsCount = \App\Models\InventoryAlert::where('business_id', $business->id)
|
||||
->whereIn('alert_type', ['low_stock', 'out_of_stock'])
|
||||
->active()
|
||||
->count();
|
||||
|
||||
// Get active promotions count
|
||||
$activePromotionCount = \App\Models\Broadcast::where('business_id', $business->id)
|
||||
->whereIn('status', ['scheduled', 'sending'])
|
||||
->count();
|
||||
|
||||
// Top Products (last 30 days by revenue)
|
||||
$topProducts = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->where('orders.created_at', '>=', $currentStart)
|
||||
->select('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
|
||||
->selectRaw('SUM(order_items.quantity) as total_units')
|
||||
->selectRaw('SUM(order_items.line_total) as total_revenue')
|
||||
->groupBy('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
|
||||
->orderByDesc('total_revenue')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
$item->total_revenue_dollars = $item->total_revenue / 100;
|
||||
|
||||
return $item;
|
||||
});
|
||||
|
||||
// Top Brands (last 30 days)
|
||||
$topBrands = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->where('orders.created_at', '>=', $currentStart)
|
||||
->select('order_items.brand_name')
|
||||
->selectRaw('SUM(order_items.quantity) as total_units')
|
||||
->selectRaw('SUM(order_items.line_total) as total_revenue')
|
||||
->selectRaw('COUNT(DISTINCT orders.id) as order_count')
|
||||
->groupBy('order_items.brand_name')
|
||||
->orderByDesc('total_revenue')
|
||||
->get()
|
||||
->map(function ($item) use ($previousStart, $previousEnd) {
|
||||
$item->total_revenue_dollars = $item->total_revenue / 100;
|
||||
|
||||
// Calculate growth vs previous period
|
||||
$prevBrandStats = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->where('order_items.brand_name', $item->brand_name)
|
||||
->whereBetween('orders.created_at', [$previousStart, $previousEnd])
|
||||
->selectRaw('SUM(order_items.line_total) as revenue')
|
||||
->first();
|
||||
|
||||
$prevRevenue = ($prevBrandStats->revenue ?? 0) / 100;
|
||||
$item->growth_pct = $prevRevenue > 0 ? (($item->total_revenue_dollars - $prevRevenue) / $prevRevenue) * 100 : null;
|
||||
|
||||
return $item;
|
||||
});
|
||||
|
||||
// Needs Attention - Combined collection of items requiring immediate action
|
||||
// Needs Attention - Combined collection of items requiring immediate action (not cached - real-time data)
|
||||
$needsAttention = collect();
|
||||
|
||||
// 1. Low Stock SKUs (inventory items at or below reorder point)
|
||||
@@ -183,22 +206,22 @@ class DashboardController extends Controller
|
||||
}
|
||||
|
||||
// 3. Engaged Buyers with No Recent Orders
|
||||
// Get buyers with high engagement but no orders in last 30 days
|
||||
// Pre-fetch buyer IDs that HAVE ordered recently (single query instead of N+1)
|
||||
$buyersWithRecentOrders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->where('orders.created_at', '>=', $currentStart)
|
||||
->distinct()
|
||||
->pluck('orders.business_id')
|
||||
->toArray();
|
||||
|
||||
// Get hot buyers that are NOT in the recent orders list
|
||||
$engagedNoOrders = \App\Models\Analytics\BuyerEngagementScore::where('seller_business_id', $business->id)
|
||||
->where('engagement_level', 'hot')
|
||||
->whereNotIn('buyer_business_id', $buyersWithRecentOrders)
|
||||
->with('buyerBusiness')
|
||||
->get()
|
||||
->filter(function ($score) use ($brandNames, $currentStart) {
|
||||
// Check if they have NO orders in last 30 days
|
||||
$hasRecentOrder = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->where('orders.business_id', $score->buyer_business_id)
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->where('orders.created_at', '>=', $currentStart)
|
||||
->exists();
|
||||
|
||||
return ! $hasRecentOrder;
|
||||
})
|
||||
->take(3);
|
||||
->orderByDesc('total_score')
|
||||
->limit(3)
|
||||
->get();
|
||||
|
||||
foreach ($engagedNoOrders as $score) {
|
||||
$needsAttention->push([
|
||||
@@ -937,23 +960,21 @@ class DashboardController extends Controller
|
||||
],
|
||||
];
|
||||
|
||||
// Invoice Statistics
|
||||
$invoices = \App\Models\Invoice::with(['order.items.product.brand'])
|
||||
->whereHas('order.items.product.brand', function ($query) use ($brandIds) {
|
||||
$query->whereIn('id', $brandIds);
|
||||
})
|
||||
->get();
|
||||
// Invoice Statistics - optimized with aggregate queries instead of loading all records
|
||||
$invoiceBaseQuery = \App\Models\Invoice::whereHas('order.items.product.brand', function ($query) use ($brandIds) {
|
||||
$query->whereIn('id', $brandIds);
|
||||
});
|
||||
|
||||
$stats = [
|
||||
'total_invoices' => $invoices->count(),
|
||||
'pending_invoices' => $invoices->where('payment_status', 'unpaid')->count(),
|
||||
'paid_invoices' => $invoices->where('payment_status', 'paid')->count(),
|
||||
'overdue_invoices' => $invoices->filter(fn ($inv) => $inv->isOverdue())->count(),
|
||||
'total_outstanding' => $invoices->where('payment_status', 'unpaid')->sum('amount_due'),
|
||||
'total_invoices' => (clone $invoiceBaseQuery)->count(),
|
||||
'pending_invoices' => (clone $invoiceBaseQuery)->where('payment_status', 'unpaid')->count(),
|
||||
'paid_invoices' => (clone $invoiceBaseQuery)->where('payment_status', 'paid')->count(),
|
||||
'overdue_invoices' => (clone $invoiceBaseQuery)->where('payment_status', 'unpaid')->where('due_date', '<', now())->count(),
|
||||
'total_outstanding' => (clone $invoiceBaseQuery)->where('payment_status', 'unpaid')->sum('amount_due'),
|
||||
];
|
||||
|
||||
// Recent Invoices (last 5)
|
||||
$recentInvoices = \App\Models\Invoice::with(['order.items.product.brand', 'business'])
|
||||
// Recent Invoices (last 5) - only load what we need
|
||||
$recentInvoices = \App\Models\Invoice::with(['order:id,business_id', 'business:id,name'])
|
||||
->whereHas('order.items.product.brand', function ($query) use ($brandIds) {
|
||||
$query->whereIn('id', $brandIds);
|
||||
})
|
||||
@@ -980,24 +1001,21 @@ class DashboardController extends Controller
|
||||
$fleetData = $this->getFleetMetrics($business);
|
||||
}
|
||||
|
||||
// Get low-stock alerts for sales metrics
|
||||
// Get low-stock alerts for sales metrics - optimized to single query
|
||||
$lowStockAlerts = collect([]);
|
||||
$lowStockCount = 0;
|
||||
if ($showSalesMetrics) {
|
||||
// Get active low-stock alerts for this business's brands
|
||||
$lowStockAlerts = \App\Models\InventoryAlert::where('business_id', $business->id)
|
||||
// Get active low-stock alerts with count in single query
|
||||
$lowStockQuery = \App\Models\InventoryAlert::where('business_id', $business->id)
|
||||
->whereIn('alert_type', ['low_stock', 'out_of_stock'])
|
||||
->active()
|
||||
->with(['product', 'inventoryItem'])
|
||||
->active();
|
||||
|
||||
$lowStockCount = (clone $lowStockQuery)->count();
|
||||
$lowStockAlerts = $lowStockQuery
|
||||
->with(['product:id,name', 'inventoryItem:id,quantity_on_hand'])
|
||||
->latest('triggered_at')
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
// Count total low-stock items
|
||||
$lowStockCount = \App\Models\InventoryAlert::where('business_id', $business->id)
|
||||
->whereIn('alert_type', ['low_stock', 'out_of_stock'])
|
||||
->active()
|
||||
->count();
|
||||
}
|
||||
|
||||
// Get recent notifications for the dashboard widget
|
||||
@@ -1068,24 +1086,33 @@ class DashboardController extends Controller
|
||||
/**
|
||||
* Generate revenue chart data for different time periods
|
||||
* Supports 7 days, 30 days, and 12 months views
|
||||
* Optimized to use single query for all periods
|
||||
*/
|
||||
private function getRevenueChartData(array $brandIds): array
|
||||
{
|
||||
$brandNames = \App\Models\Brand::whereIn('id', $brandIds)->pluck('name')->toArray();
|
||||
|
||||
// 7 Days Data
|
||||
$sevenDaysData = $this->getRevenueByPeriod($brandNames, 7, 'days');
|
||||
// Single query for the full 12-month period (covers all time ranges)
|
||||
$start = now()->subMonths(12)->startOfDay();
|
||||
$allOrders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->where('orders.created_at', '>=', $start)
|
||||
->selectRaw('DATE(orders.created_at) as date, SUM(orders.total) as revenue')
|
||||
->groupBy('date')
|
||||
->orderBy('date', 'asc')
|
||||
->get();
|
||||
|
||||
// 30 Days Data
|
||||
$thirtyDaysData = $this->getRevenueByPeriod($brandNames, 30, 'days');
|
||||
// Filter for different time periods from the same result set
|
||||
$sevenDaysStart = now()->subDays(7)->startOfDay();
|
||||
$thirtyDaysStart = now()->subDays(30)->startOfDay();
|
||||
|
||||
// 12 Months Data
|
||||
$twelveMonthsData = $this->getRevenueByPeriod($brandNames, 12, 'months');
|
||||
$sevenDaysOrders = $allOrders->filter(fn ($o) => \Carbon\Carbon::parse($o->date) >= $sevenDaysStart);
|
||||
$thirtyDaysOrders = $allOrders->filter(fn ($o) => \Carbon\Carbon::parse($o->date) >= $thirtyDaysStart);
|
||||
|
||||
return [
|
||||
'7_days' => $sevenDaysData,
|
||||
'30_days' => $thirtyDaysData,
|
||||
'12_months' => $twelveMonthsData,
|
||||
'7_days' => $this->generateDailyData($sevenDaysOrders, 7),
|
||||
'30_days' => $this->generateDailyData($thirtyDaysOrders, 30),
|
||||
'12_months' => $this->generateMonthlyData($allOrders, 12),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1179,6 +1206,7 @@ class DashboardController extends Controller
|
||||
|
||||
/**
|
||||
* Get processing/manufacturing metrics for solventless departments
|
||||
* Optimized with caching and reduced queries
|
||||
*/
|
||||
private function getProcessingMetrics(Business $business, $userDepartments): array
|
||||
{
|
||||
@@ -1190,59 +1218,52 @@ class DashboardController extends Controller
|
||||
$previousStart = now()->subDays(60);
|
||||
$previousEnd = now()->subDays(30);
|
||||
|
||||
// Get wash reports (hash washes) - using Conversion model
|
||||
$currentWashes = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->where('created_at', '>=', $currentStart)
|
||||
->count();
|
||||
// Cache processing metrics for 5 minutes
|
||||
$cacheKey = "dashboard.processing.{$business->id}";
|
||||
$cachedMetrics = Cache::remember($cacheKey, self::DASHBOARD_CACHE_TTL, function () use ($business, $currentStart, $previousStart, $previousEnd) {
|
||||
// Fetch all completed washes in one query for both periods (60 days)
|
||||
$allWashes = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->where('created_at', '>=', $previousStart)
|
||||
->select('id', 'stage_1_metadata', 'stage_2_metadata', 'created_at')
|
||||
->get();
|
||||
|
||||
$previousWashes = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->whereBetween('created_at', [$previousStart, $previousEnd])
|
||||
->count();
|
||||
// Split into current and previous periods
|
||||
$currentWashesData = $allWashes->filter(fn ($w) => $w->created_at >= $currentStart);
|
||||
$previousWashesData = $allWashes->filter(fn ($w) => $w->created_at < $currentStart);
|
||||
|
||||
$washesChange = $previousWashes > 0 ? (($currentWashes - $previousWashes) / $previousWashes) * 100 : 0;
|
||||
$currentWashes = $currentWashesData->count();
|
||||
$previousWashes = $previousWashesData->count();
|
||||
$washesChange = $previousWashes > 0 ? (($currentWashes - $previousWashes) / $previousWashes) * 100 : 0;
|
||||
|
||||
// Average Yield (calculate from metadata)
|
||||
$currentWashesWithYield = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->where('created_at', '>=', $currentStart)
|
||||
->get();
|
||||
// Calculate yields from already-loaded data (avoid re-fetching)
|
||||
$calculateYield = function ($collection) {
|
||||
if ($collection->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$currentYield = $currentWashesWithYield->avg(function ($conversion) {
|
||||
$stage1 = $conversion->getStage1Data();
|
||||
$stage2 = $conversion->getStage2Data();
|
||||
if (! $stage1 || ! $stage2) {
|
||||
return 0;
|
||||
}
|
||||
$startingWeight = $stage1['starting_weight'] ?? 0;
|
||||
$totalYield = $stage2['total_yield'] ?? 0;
|
||||
return $collection->avg(function ($conversion) {
|
||||
$stage1 = $conversion->getStage1Data();
|
||||
$stage2 = $conversion->getStage2Data();
|
||||
if (! $stage1 || ! $stage2) {
|
||||
return 0;
|
||||
}
|
||||
$startingWeight = $stage1['starting_weight'] ?? 0;
|
||||
$totalYield = $stage2['total_yield'] ?? 0;
|
||||
|
||||
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
|
||||
}) ?? 0;
|
||||
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
|
||||
});
|
||||
};
|
||||
|
||||
$previousWashesWithYield = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->whereBetween('created_at', [$previousStart, $previousEnd])
|
||||
->get();
|
||||
$currentYield = $calculateYield($currentWashesData);
|
||||
$previousYield = $calculateYield($previousWashesData);
|
||||
$yieldChange = $previousYield > 0 ? (($currentYield - $previousYield) / $previousYield) * 100 : 0;
|
||||
|
||||
$previousYield = $previousWashesWithYield->avg(function ($conversion) {
|
||||
$stage1 = $conversion->getStage1Data();
|
||||
$stage2 = $conversion->getStage2Data();
|
||||
if (! $stage1 || ! $stage2) {
|
||||
return 0;
|
||||
}
|
||||
$startingWeight = $stage1['starting_weight'] ?? 0;
|
||||
$totalYield = $stage2['total_yield'] ?? 0;
|
||||
return compact('currentWashes', 'previousWashes', 'washesChange', 'currentYield', 'previousYield', 'yieldChange');
|
||||
});
|
||||
|
||||
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
|
||||
}) ?? 0;
|
||||
|
||||
$yieldChange = $previousYield > 0 ? (($currentYield - $previousYield) / $previousYield) * 100 : 0;
|
||||
extract($cachedMetrics);
|
||||
|
||||
// Active Work Orders
|
||||
$activeWorkOrders = \App\Models\WorkOrder::where('business_id', $business->id)
|
||||
@@ -1347,18 +1368,29 @@ class DashboardController extends Controller
|
||||
->limit(5) // Show top 5 on dashboard
|
||||
->get();
|
||||
|
||||
// Add past performance data for each component
|
||||
$componentsWithPerformance = $components->map(function ($component) use ($business) {
|
||||
$strainName = str_replace(' - Fresh Frozen', '', $component->name);
|
||||
// Extract strain names from components
|
||||
$strainNames = $components->map(fn ($c) => str_replace(' - Fresh Frozen', '', $c->name))->toArray();
|
||||
|
||||
// Get past washes for this strain
|
||||
$pastWashes = \App\Models\Conversion::where('business_id', $business->id)
|
||||
// Fetch ALL past washes for ALL strains in ONE query (eliminates N+1)
|
||||
$allPastWashes = Cache::remember("dashboard.strain_washes.{$business->id}", self::DASHBOARD_CACHE_TTL, function () use ($business) {
|
||||
return \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->whereJsonContains('metadata->stage_1->strain', $strainName)
|
||||
->select('id', 'stage_1_metadata', 'stage_2_metadata', 'completed_at')
|
||||
->orderBy('completed_at', 'desc')
|
||||
->take(10)
|
||||
->get();
|
||||
->limit(100) // Get recent washes
|
||||
->get()
|
||||
->groupBy(function ($wash) {
|
||||
$stage1 = $wash->getStage1Data();
|
||||
|
||||
return $stage1['strain'] ?? 'unknown';
|
||||
});
|
||||
});
|
||||
|
||||
// Add past performance data for each component (no additional queries)
|
||||
$componentsWithPerformance = $components->map(function ($component) use ($allPastWashes) {
|
||||
$strainName = str_replace(' - Fresh Frozen', '', $component->name);
|
||||
$pastWashes = ($allPastWashes[$strainName] ?? collect())->take(10);
|
||||
|
||||
if ($pastWashes->isEmpty()) {
|
||||
$component->past_performance = [
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Accounting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ApBill;
|
||||
use App\Models\Accounting\ApVendor;
|
||||
use App\Models\Accounting\ArInvoice;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Read-only accounting alias controllers for child businesses (divisions).
|
||||
*
|
||||
* Child businesses can view limited accounting data from their parent company.
|
||||
* This provides visibility without granting write access to financial systems.
|
||||
*
|
||||
* Requirements:
|
||||
* - Business must have parent_id (be a division)
|
||||
* - User must have appropriate viewing permissions
|
||||
*/
|
||||
class DivisionAccountingController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display vendor list (read-only from parent company).
|
||||
*
|
||||
* GET /s/{business}/accounting/vendors
|
||||
*/
|
||||
public function vendorsIndex(Request $request, Business $business): View
|
||||
{
|
||||
$this->authorizeChildBusiness($business);
|
||||
|
||||
// Get parent's vendors
|
||||
$parentId = $business->parent_id;
|
||||
|
||||
$query = ApVendor::where('business_id', $parentId)
|
||||
->where('is_active', true);
|
||||
|
||||
// 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}%");
|
||||
});
|
||||
}
|
||||
|
||||
$vendors = $query->orderBy('name')->paginate(30)->withQueryString();
|
||||
|
||||
return view('seller.accounting.vendors.index', [
|
||||
'business' => $business,
|
||||
'vendors' => $vendors,
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display AR snapshot (read-only summary for division).
|
||||
*
|
||||
* GET /s/{business}/accounting/ar-snapshot
|
||||
*/
|
||||
public function arSnapshot(Request $request, Business $business): View
|
||||
{
|
||||
$this->authorizeChildBusiness($business);
|
||||
|
||||
$parentId = $business->parent_id;
|
||||
|
||||
// Get AR summary stats (scoped to this division's invoices if possible,
|
||||
// otherwise show high-level parent metrics)
|
||||
$stats = [
|
||||
'total_outstanding' => ArInvoice::where('business_id', $business->id)
|
||||
->where('status', '!=', 'paid')
|
||||
->sum('balance_due'),
|
||||
'overdue_count' => ArInvoice::where('business_id', $business->id)
|
||||
->where('status', '!=', 'paid')
|
||||
->where('due_date', '<', now())
|
||||
->count(),
|
||||
'overdue_amount' => ArInvoice::where('business_id', $business->id)
|
||||
->where('status', '!=', 'paid')
|
||||
->where('due_date', '<', now())
|
||||
->sum('balance_due'),
|
||||
'current_month_billed' => ArInvoice::where('business_id', $business->id)
|
||||
->whereMonth('invoice_date', now()->month)
|
||||
->whereYear('invoice_date', now()->year)
|
||||
->sum('total_amount'),
|
||||
];
|
||||
|
||||
// Recent invoices for this division
|
||||
$recentInvoices = ArInvoice::where('business_id', $business->id)
|
||||
->with('customer')
|
||||
->orderByDesc('invoice_date')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return view('seller.accounting.ar-snapshot', [
|
||||
'business' => $business,
|
||||
'stats' => $stats,
|
||||
'recentInvoices' => $recentInvoices,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display AP snapshot (read-only summary for division).
|
||||
*
|
||||
* GET /s/{business}/accounting/ap-snapshot
|
||||
*/
|
||||
public function apSnapshot(Request $request, Business $business): View
|
||||
{
|
||||
$this->authorizeChildBusiness($business);
|
||||
|
||||
$parentId = $business->parent_id;
|
||||
|
||||
// Get AP summary stats scoped to this division's bills
|
||||
$stats = [
|
||||
'total_outstanding' => ApBill::where('business_id', $business->id)
|
||||
->whereIn('status', ['approved', 'partial'])
|
||||
->sum('balance_due'),
|
||||
'overdue_count' => ApBill::where('business_id', $business->id)
|
||||
->whereIn('status', ['approved', 'partial'])
|
||||
->where('due_date', '<', now())
|
||||
->count(),
|
||||
'overdue_amount' => ApBill::where('business_id', $business->id)
|
||||
->whereIn('status', ['approved', 'partial'])
|
||||
->where('due_date', '<', now())
|
||||
->sum('balance_due'),
|
||||
'pending_approval' => ApBill::where('business_id', $business->id)
|
||||
->whereIn('status', ['draft', 'pending'])
|
||||
->count(),
|
||||
];
|
||||
|
||||
// Recent bills for this division
|
||||
$recentBills = ApBill::where('business_id', $business->id)
|
||||
->with('vendor')
|
||||
->orderByDesc('bill_date')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return view('seller.accounting.ap-snapshot', [
|
||||
'business' => $business,
|
||||
'stats' => $stats,
|
||||
'recentBills' => $recentBills,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure this is a child business with parent_id.
|
||||
*/
|
||||
protected function authorizeChildBusiness(Business $business): void
|
||||
{
|
||||
if ($business->parent_id === null) {
|
||||
abort(404, 'This feature is only available for division businesses.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,10 +250,14 @@ class BrandController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Load products for this brand (newest first)
|
||||
$products = $brand->products()
|
||||
// Load products for this brand (newest first) with pagination
|
||||
$perPage = $request->get('per_page', 50);
|
||||
$productsPaginator = $brand->products()
|
||||
->with('images')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get()
|
||||
->paginate($perPage);
|
||||
|
||||
$products = $productsPaginator->getCollection()
|
||||
->map(function ($product) use ($business, $brand) {
|
||||
// Set brand relationship so getImageUrl() can fall back to brand logo
|
||||
$product->setRelation('brand', $brand);
|
||||
@@ -273,6 +277,16 @@ class BrandController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
// Pagination info for the view
|
||||
$productsPagination = [
|
||||
'current_page' => $productsPaginator->currentPage(),
|
||||
'last_page' => $productsPaginator->lastPage(),
|
||||
'per_page' => $productsPaginator->perPage(),
|
||||
'total' => $productsPaginator->total(),
|
||||
'from' => $productsPaginator->firstItem(),
|
||||
'to' => $productsPaginator->lastItem(),
|
||||
];
|
||||
|
||||
return view('seller.brands.dashboard', array_merge($stats, [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
@@ -286,6 +300,8 @@ class BrandController extends Controller
|
||||
'recommendations' => $recommendations,
|
||||
'menus' => $menus,
|
||||
'products' => $products,
|
||||
'productsPagination' => $productsPagination,
|
||||
'productsPaginator' => $productsPaginator,
|
||||
'collections' => collect(), // Placeholder for future collections feature
|
||||
]));
|
||||
}
|
||||
@@ -293,31 +309,31 @@ class BrandController extends Controller
|
||||
/**
|
||||
* Preview the brand as it would appear to buyers
|
||||
*/
|
||||
public function preview(Business $business, Brand $brand)
|
||||
public function preview(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// Load relationships including active products with images, strain, unit, and product line
|
||||
// Only load parent products (exclude varieties from top level) and eager load their varieties
|
||||
$brand->load([
|
||||
'business',
|
||||
'products' => function ($query) {
|
||||
$query->where('is_active', true)
|
||||
->whereNull('parent_product_id') // Only parent products
|
||||
->with([
|
||||
'images',
|
||||
'strain',
|
||||
'unit',
|
||||
'productLine',
|
||||
'varieties' => function ($q) {
|
||||
$q->where('is_active', true)
|
||||
->with(['images', 'strain', 'unit'])
|
||||
->orderBy('name');
|
||||
},
|
||||
])
|
||||
->orderBy('name');
|
||||
},
|
||||
]);
|
||||
// Load brand with business relationship
|
||||
$brand->load('business');
|
||||
|
||||
// Paginate products (50 per page) instead of loading all
|
||||
$perPage = $request->get('per_page', 50);
|
||||
$productsPaginator = $brand->products()
|
||||
->where('is_active', true)
|
||||
->whereNull('parent_product_id') // Only parent products
|
||||
->with([
|
||||
'images',
|
||||
'strain',
|
||||
'unit',
|
||||
'productLine',
|
||||
'varieties' => function ($q) {
|
||||
$q->where('is_active', true)
|
||||
->with(['images', 'strain', 'unit'])
|
||||
->orderBy('name');
|
||||
},
|
||||
])
|
||||
->orderBy('name')
|
||||
->paginate($perPage);
|
||||
|
||||
// Get other brands from the same business
|
||||
$otherBrands = Brand::where('business_id', $brand->business_id)
|
||||
@@ -325,15 +341,15 @@ class BrandController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Group products by product line
|
||||
$productsByLine = $brand->products->groupBy(function ($product) {
|
||||
// Group paginated products by product line
|
||||
$productsByLine = $productsPaginator->getCollection()->groupBy(function ($product) {
|
||||
return $product->productLine->name ?? 'Uncategorized';
|
||||
});
|
||||
|
||||
// Allow viewing as buyer with ?as=buyer query parameter (for testing)
|
||||
$isSeller = request()->query('as') !== 'buyer';
|
||||
|
||||
return view('seller.brands.preview', compact('business', 'brand', 'otherBrands', 'productsByLine', 'isSeller'));
|
||||
return view('seller.brands.preview', compact('business', 'brand', 'otherBrands', 'productsByLine', 'productsPaginator', 'isSeller'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,8 +14,12 @@ use Illuminate\Support\Facades\Auth;
|
||||
/**
|
||||
* Brand Portal Controller
|
||||
*
|
||||
* Handles all Brand Portal functionality for external brand partners.
|
||||
* Brand Portal users have read-only access to data scoped to their linked brands.
|
||||
* Handles all Brand Portal functionality for external brand partners and brand managers.
|
||||
* Both user types have read-only access to data scoped to their linked brands.
|
||||
*
|
||||
* Supported access modes:
|
||||
* - Brand Portal users (in "Brand Partner" department with linked brands)
|
||||
* - Brand Manager users (contact_type = 'brand_manager' with linked brands)
|
||||
*
|
||||
* Key constraints:
|
||||
* - All data is scoped to the user's linked brands (via brand_user pivot)
|
||||
@@ -25,6 +29,26 @@ use Illuminate\Support\Facades\Auth;
|
||||
*/
|
||||
class BrandPortalController extends Controller
|
||||
{
|
||||
/**
|
||||
* Check if user has brand access (Portal or Manager) and get their brand IDs.
|
||||
*/
|
||||
protected function validateAccessAndGetBrandIds(Business $business): array
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Check for Brand Portal access
|
||||
if ($user->isBrandPortalUser($business)) {
|
||||
return $user->getBrandIdsForPortal($business);
|
||||
}
|
||||
|
||||
// Check for Brand Manager access
|
||||
if ($user->isBrandManagerUser($business)) {
|
||||
return $user->getBrandIdsForManager($business);
|
||||
}
|
||||
|
||||
abort(403, 'Access denied. Brand Portal or Brand Manager access required.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand Portal Dashboard - Brand Overview.
|
||||
*
|
||||
@@ -36,14 +60,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function dashboard(Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Ensure user is in Brand Portal mode
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// Summary stats
|
||||
@@ -90,13 +107,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function orders(Request $request, Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// Filter by brand if specified
|
||||
@@ -135,13 +146,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function accounts(Request $request, Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// Get businesses that have ordered products from linked brands
|
||||
@@ -177,13 +182,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function inventory(Request $request, Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// Filter by brand if specified
|
||||
@@ -233,13 +232,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function promotions(Request $request, Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// Filter by brand if specified
|
||||
@@ -280,13 +273,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function inbox(Request $request, Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// For inbox, we show conversations but in a limited Brand Portal context
|
||||
@@ -305,13 +292,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function contacts(Request $request, Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// For contacts, show in Brand Portal context
|
||||
@@ -326,13 +307,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function showOrder(Business $business, Order $order)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
|
||||
// Verify order contains products from user's linked brands
|
||||
$hasLinkedBrandProducts = $order->items()
|
||||
@@ -364,13 +339,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function showProduct(Business $business, Product $product)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
|
||||
// Verify product belongs to user's linked brands
|
||||
if (! in_array($product->brand_id, $brandIds)) {
|
||||
|
||||
@@ -37,8 +37,28 @@ class CategoryController extends Controller
|
||||
|
||||
public function index(Business $business)
|
||||
{
|
||||
// Product categories table is not properly set up - skipping for now
|
||||
$productCategories = collect();
|
||||
// Load product categories with nesting and counts (include parent if division)
|
||||
// Use recursive eager loading for nested children
|
||||
$productCategories = ProductCategory::where(function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
if ($business->parent_id) {
|
||||
$query->orWhere('business_id', $business->parent_id);
|
||||
}
|
||||
})
|
||||
->whereNull('parent_id')
|
||||
->with(['children' => function ($query) {
|
||||
$query->orderBy('sort_order')->orderBy('name')
|
||||
->with(['children' => function ($q) {
|
||||
$q->orderBy('sort_order')->orderBy('name')
|
||||
->with(['children' => function ($q2) {
|
||||
$q2->orderBy('sort_order')->orderBy('name');
|
||||
}]);
|
||||
}]);
|
||||
}])
|
||||
->withCount('products')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Load component categories with nesting and counts (include parent if division)
|
||||
$componentCategories = ComponentCategory::where(function ($query) use ($business) {
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmEvent;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\SalesOpportunity;
|
||||
use App\Models\SendMenuLog;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountController extends Controller
|
||||
@@ -27,13 +32,84 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function show(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$account->load(['contacts', 'orders' => function ($q) use ($business) {
|
||||
$q->whereHas('items.product.brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
})->latest()->limit(10);
|
||||
}]);
|
||||
$account->load(['contacts']);
|
||||
|
||||
return view('seller.crm.accounts.show', compact('business', 'account'));
|
||||
// Get orders for this account from this seller
|
||||
$orders = $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Get opportunities for this account from this seller
|
||||
// SalesOpportunity uses business_id for the buyer
|
||||
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->with(['stage', 'brand'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Get tasks related to this account
|
||||
// CrmTask uses business_id for the buyer
|
||||
$tasks = CrmTask::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->whereNull('completed_at')
|
||||
->with('assignee')
|
||||
->orderBy('due_at')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Get conversation events for this account
|
||||
$conversationEvents = CrmEvent::where('seller_business_id', $business->id)
|
||||
->where('buyer_business_id', $account->id)
|
||||
->latest('occurred_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Get menu send history for this account
|
||||
$sendHistory = SendMenuLog::where('business_id', $business->id)
|
||||
->where('customer_id', $account->id)
|
||||
->with(['menu', 'brand'])
|
||||
->latest('sent_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Get activity log for this account
|
||||
$activities = Activity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->with(['causer'])
|
||||
->latest()
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Compute stats for this account (orders from this seller)
|
||||
$ordersQuery = $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
});
|
||||
|
||||
$pipelineValue = $opportunities->where('status', 'open')->sum('value');
|
||||
|
||||
$stats = [
|
||||
'total_orders' => $ordersQuery->count(),
|
||||
'total_revenue' => $ordersQuery->sum('total') ?? 0,
|
||||
'open_opportunities' => $opportunities->where('status', 'open')->count(),
|
||||
'pipeline_value' => $pipelineValue ?? 0,
|
||||
];
|
||||
|
||||
return view('seller.crm.accounts.show', compact(
|
||||
'business',
|
||||
'account',
|
||||
'stats',
|
||||
'orders',
|
||||
'opportunities',
|
||||
'tasks',
|
||||
'conversationEvents',
|
||||
'sendHistory',
|
||||
'activities'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,4 +153,27 @@ class AccountController extends Controller
|
||||
{
|
||||
return view('seller.crm.accounts.tasks', compact('business', 'account'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a note for an account
|
||||
*/
|
||||
public function storeNote(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$request->validate([
|
||||
'note' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
CrmEvent::log(
|
||||
sellerBusinessId: $business->id,
|
||||
eventType: 'note_added',
|
||||
summary: $request->input('note'),
|
||||
buyerBusinessId: $account->id,
|
||||
userId: auth()->id(),
|
||||
channel: 'system'
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
|
||||
->with('success', 'Note added successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmAutomation;
|
||||
use App\Models\Crm\CrmAutomationAction;
|
||||
use App\Models\Crm\CrmAutomationCondition;
|
||||
@@ -13,10 +14,8 @@ class AutomationController extends Controller
|
||||
/**
|
||||
* List automations
|
||||
*/
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$automations = CrmAutomation::forBusiness($business->id)
|
||||
->with('creator')
|
||||
->withCount(['logs as successful_runs' => fn ($q) => $q->completed()])
|
||||
@@ -41,10 +40,8 @@ class AutomationController extends Controller
|
||||
/**
|
||||
* Store automation
|
||||
*/
|
||||
public function store(Request $request)
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
@@ -107,10 +104,8 @@ class AutomationController extends Controller
|
||||
/**
|
||||
* Show automation details
|
||||
*/
|
||||
public function show(Request $request, CrmAutomation $automation)
|
||||
public function show(Request $request, Business $business, CrmAutomation $automation)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($automation->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -123,10 +118,8 @@ class AutomationController extends Controller
|
||||
/**
|
||||
* Edit automation
|
||||
*/
|
||||
public function edit(Request $request, CrmAutomation $automation)
|
||||
public function edit(Request $request, Business $business, CrmAutomation $automation)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($automation->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -143,10 +136,8 @@ class AutomationController extends Controller
|
||||
/**
|
||||
* Update automation
|
||||
*/
|
||||
public function update(Request $request, CrmAutomation $automation)
|
||||
public function update(Request $request, Business $business, CrmAutomation $automation)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($automation->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -218,10 +209,8 @@ class AutomationController extends Controller
|
||||
/**
|
||||
* Toggle automation active status
|
||||
*/
|
||||
public function toggle(Request $request, CrmAutomation $automation)
|
||||
public function toggle(Request $request, Business $business, CrmAutomation $automation)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($automation->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -238,10 +227,8 @@ class AutomationController extends Controller
|
||||
/**
|
||||
* Duplicate automation
|
||||
*/
|
||||
public function duplicate(Request $request, CrmAutomation $automation)
|
||||
public function duplicate(Request $request, Business $business, CrmAutomation $automation)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($automation->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -255,10 +242,8 @@ class AutomationController extends Controller
|
||||
/**
|
||||
* Delete automation
|
||||
*/
|
||||
public function destroy(Request $request, CrmAutomation $automation)
|
||||
public function destroy(Request $request, Business $business, CrmAutomation $automation)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($automation->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\Crm\SyncCalendarJob;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmCalendarConnection;
|
||||
use App\Models\Crm\CrmSyncedEvent;
|
||||
use App\Services\Crm\CrmCalendarService;
|
||||
@@ -18,9 +19,8 @@ class CrmCalendarController extends Controller
|
||||
/**
|
||||
* Calendar view
|
||||
*/
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
$user = $request->user();
|
||||
|
||||
// Get calendar connections
|
||||
@@ -32,15 +32,15 @@ class CrmCalendarController extends Controller
|
||||
$startDate = $request->input('start', now()->startOfMonth());
|
||||
$endDate = $request->input('end', now()->endOfMonth());
|
||||
|
||||
$events = CrmSyncedEvent::whereIn('connection_id', $connections->pluck('id'))
|
||||
->whereBetween('start_time', [$startDate, $endDate])
|
||||
$events = CrmSyncedEvent::whereIn('calendar_connection_id', $connections->pluck('id'))
|
||||
->whereBetween('start_at', [$startDate, $endDate])
|
||||
->get()
|
||||
->map(fn ($e) => [
|
||||
'id' => $e->id,
|
||||
'title' => $e->title,
|
||||
'start' => $e->start_time->toIso8601String(),
|
||||
'end' => $e->end_time->toIso8601String(),
|
||||
'allDay' => $e->is_all_day,
|
||||
'start' => $e->start_at->toIso8601String(),
|
||||
'end' => $e->end_at?->toIso8601String(),
|
||||
'allDay' => $e->all_day,
|
||||
'color' => $e->connection->provider === 'google' ? '#4285f4' : '#0078d4',
|
||||
'extendedProps' => [
|
||||
'location' => $e->location,
|
||||
@@ -50,54 +50,55 @@ class CrmCalendarController extends Controller
|
||||
]);
|
||||
|
||||
// Get meeting bookings
|
||||
$bookings = \Modules\Crm\Entities\CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
|
||||
$bookings = \App\Models\Crm\CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
|
||||
$q->where('business_id', $business->id)
|
||||
->where('user_id', $user->id);
|
||||
})
|
||||
->whereBetween('start_time', [$startDate, $endDate])
|
||||
->whereBetween('start_at', [$startDate, $endDate])
|
||||
->with(['meetingLink', 'contact'])
|
||||
->get()
|
||||
->map(fn ($b) => [
|
||||
'id' => 'booking_'.$b->id,
|
||||
'title' => $b->meetingLink->name.' - '.$b->guest_name,
|
||||
'start' => $b->start_time->toIso8601String(),
|
||||
'end' => $b->end_time->toIso8601String(),
|
||||
'title' => $b->meetingLink->name.' - '.$b->booker_name,
|
||||
'start' => $b->start_at->toIso8601String(),
|
||||
'end' => $b->end_at->toIso8601String(),
|
||||
'color' => '#10b981',
|
||||
'extendedProps' => [
|
||||
'type' => 'booking',
|
||||
'contact_id' => $b->contact_id,
|
||||
'guest_email' => $b->guest_email,
|
||||
'booker_email' => $b->booker_email,
|
||||
],
|
||||
]);
|
||||
|
||||
$allEvents = $events->merge($bookings);
|
||||
|
||||
return view('seller.crm.calendar.index', compact('connections', 'allEvents'));
|
||||
// Pass $business to view for route generation (Premium CRM uses seller.business.crm.* routes)
|
||||
return view('seller.crm.calendar.index', compact('business', 'connections', 'allEvents'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar connections settings
|
||||
*/
|
||||
public function connections(Request $request)
|
||||
public function connections(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
$user = $request->user();
|
||||
|
||||
$connections = CrmCalendarConnection::where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->get();
|
||||
|
||||
return view('seller.crm.calendar.connections', compact('connections'));
|
||||
// Pass $business to view for route generation (Premium CRM uses seller.business.crm.* routes)
|
||||
return view('seller.crm.calendar.connections', compact('business', 'connections'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth flow for Google Calendar
|
||||
*/
|
||||
public function connectGoogle(Request $request)
|
||||
public function connectGoogle(Request $request, Business $business)
|
||||
{
|
||||
$state = encrypt([
|
||||
'user_id' => $request->user()->id,
|
||||
'business_id' => $request->user()->business_id,
|
||||
'business_id' => $business->id,
|
||||
'provider' => 'google',
|
||||
]);
|
||||
|
||||
@@ -117,11 +118,11 @@ class CrmCalendarController extends Controller
|
||||
/**
|
||||
* Start OAuth flow for Outlook Calendar
|
||||
*/
|
||||
public function connectOutlook(Request $request)
|
||||
public function connectOutlook(Request $request, Business $business)
|
||||
{
|
||||
$state = encrypt([
|
||||
'user_id' => $request->user()->id,
|
||||
'business_id' => $request->user()->business_id,
|
||||
'business_id' => $business->id,
|
||||
'provider' => 'outlook',
|
||||
]);
|
||||
|
||||
@@ -195,10 +196,8 @@ class CrmCalendarController extends Controller
|
||||
/**
|
||||
* Disconnect a calendar
|
||||
*/
|
||||
public function disconnect(Request $request, CrmCalendarConnection $connection)
|
||||
public function disconnect(Request $request, Business $business, CrmCalendarConnection $connection)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($connection->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -215,10 +214,8 @@ class CrmCalendarController extends Controller
|
||||
/**
|
||||
* Toggle sync for a connection
|
||||
*/
|
||||
public function toggleSync(Request $request, CrmCalendarConnection $connection)
|
||||
public function toggleSync(Request $request, Business $business, CrmCalendarConnection $connection)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($connection->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -231,10 +228,8 @@ class CrmCalendarController extends Controller
|
||||
/**
|
||||
* Force sync a calendar
|
||||
*/
|
||||
public function sync(Request $request, CrmCalendarConnection $connection)
|
||||
public function sync(Request $request, Business $business, CrmCalendarConnection $connection)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($connection->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -247,9 +242,8 @@ class CrmCalendarController extends Controller
|
||||
/**
|
||||
* API: Get events for date range (for calendar JS)
|
||||
*/
|
||||
public function events(Request $request)
|
||||
public function events(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -261,15 +255,15 @@ class CrmCalendarController extends Controller
|
||||
->where('user_id', $user->id)
|
||||
->pluck('id');
|
||||
|
||||
$events = CrmSyncedEvent::whereIn('connection_id', $connections)
|
||||
->whereBetween('start_time', [$validated['start'], $validated['end']])
|
||||
$events = CrmSyncedEvent::whereIn('calendar_connection_id', $connections)
|
||||
->whereBetween('start_at', [$validated['start'], $validated['end']])
|
||||
->get()
|
||||
->map(fn ($e) => [
|
||||
'id' => $e->id,
|
||||
'title' => $e->title,
|
||||
'start' => $e->start_time->toIso8601String(),
|
||||
'end' => $e->end_time->toIso8601String(),
|
||||
'allDay' => $e->is_all_day,
|
||||
'start' => $e->start_at->toIso8601String(),
|
||||
'end' => $e->end_at?->toIso8601String(),
|
||||
'allDay' => $e->all_day,
|
||||
]);
|
||||
|
||||
return response()->json($events);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmDeal;
|
||||
use App\Models\Crm\CrmRepMetric;
|
||||
use App\Models\Crm\CrmSlaTimer;
|
||||
@@ -20,9 +21,8 @@ class CrmDashboardController extends Controller
|
||||
/**
|
||||
* Main CRM dashboard
|
||||
*/
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
$user = $request->user();
|
||||
|
||||
// Cache dashboard data for 1 minute
|
||||
@@ -32,15 +32,17 @@ class CrmDashboardController extends Controller
|
||||
return $this->getDashboardData($business, $user);
|
||||
});
|
||||
|
||||
// Ensure $business is always passed to view (not cached)
|
||||
$data['business'] = $business;
|
||||
|
||||
return view('seller.crm.dashboard.index', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sales performance dashboard
|
||||
*/
|
||||
public function sales(Request $request)
|
||||
public function sales(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
// Pipeline summary
|
||||
$pipelineSummary = CrmDeal::forBusiness($business->id)
|
||||
@@ -91,9 +93,8 @@ class CrmDashboardController extends Controller
|
||||
/**
|
||||
* Team performance dashboard
|
||||
*/
|
||||
public function team(Request $request)
|
||||
public function team(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
// SLA metrics
|
||||
$slaMetrics = $this->slaService->getMetrics($business->id, 30);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmChannel;
|
||||
use App\Models\Crm\CrmMessageTemplate;
|
||||
use App\Models\Crm\CrmPipeline;
|
||||
@@ -16,9 +17,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Settings overview
|
||||
*/
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$stats = [
|
||||
'channels' => CrmChannel::where('business_id', $business->id)->count(),
|
||||
@@ -36,9 +36,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* List channels
|
||||
*/
|
||||
public function channels(Request $request)
|
||||
public function channels(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$channels = CrmChannel::where('business_id', $business->id)
|
||||
->orderBy('type')
|
||||
@@ -61,9 +60,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Store channel
|
||||
*/
|
||||
public function storeChannel(Request $request)
|
||||
public function storeChannel(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
@@ -95,9 +93,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Edit channel
|
||||
*/
|
||||
public function editChannel(Request $request, CrmChannel $channel)
|
||||
public function editChannel(Request $request, Business $business, CrmChannel $channel)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($channel->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -111,9 +108,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Update channel
|
||||
*/
|
||||
public function updateChannel(Request $request, CrmChannel $channel)
|
||||
public function updateChannel(Request $request, Business $business, CrmChannel $channel)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($channel->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -143,9 +139,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Delete channel
|
||||
*/
|
||||
public function destroyChannel(Request $request, CrmChannel $channel)
|
||||
public function destroyChannel(Request $request, Business $business, CrmChannel $channel)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($channel->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -161,9 +156,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* List pipelines
|
||||
*/
|
||||
public function pipelines(Request $request)
|
||||
public function pipelines(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$pipelines = CrmPipeline::where('business_id', $business->id)
|
||||
->withCount('deals')
|
||||
@@ -184,9 +178,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Store pipeline
|
||||
*/
|
||||
public function storePipeline(Request $request)
|
||||
public function storePipeline(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
@@ -220,9 +213,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Edit pipeline
|
||||
*/
|
||||
public function editPipeline(Request $request, CrmPipeline $pipeline)
|
||||
public function editPipeline(Request $request, Business $business, CrmPipeline $pipeline)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($pipeline->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -234,9 +226,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Update pipeline
|
||||
*/
|
||||
public function updatePipeline(Request $request, CrmPipeline $pipeline)
|
||||
public function updatePipeline(Request $request, Business $business, CrmPipeline $pipeline)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($pipeline->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -269,9 +260,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Delete pipeline
|
||||
*/
|
||||
public function destroyPipeline(Request $request, CrmPipeline $pipeline)
|
||||
public function destroyPipeline(Request $request, Business $business, CrmPipeline $pipeline)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($pipeline->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -291,9 +281,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* List SLA policies
|
||||
*/
|
||||
public function slaPolicies(Request $request)
|
||||
public function slaPolicies(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$policies = CrmSlaPolicy::where('business_id', $business->id)
|
||||
->orderBy('priority')
|
||||
@@ -313,9 +302,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Store SLA policy
|
||||
*/
|
||||
public function storeSlaPolicy(Request $request)
|
||||
public function storeSlaPolicy(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
@@ -349,9 +337,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Edit SLA policy
|
||||
*/
|
||||
public function editSlaPolicy(Request $request, CrmSlaPolicy $policy)
|
||||
public function editSlaPolicy(Request $request, Business $business, CrmSlaPolicy $policy)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($policy->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -363,9 +350,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Update SLA policy
|
||||
*/
|
||||
public function updateSlaPolicy(Request $request, CrmSlaPolicy $policy)
|
||||
public function updateSlaPolicy(Request $request, Business $business, CrmSlaPolicy $policy)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($policy->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -392,9 +378,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Delete SLA policy
|
||||
*/
|
||||
public function destroySlaPolicy(Request $request, CrmSlaPolicy $policy)
|
||||
public function destroySlaPolicy(Request $request, Business $business, CrmSlaPolicy $policy)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($policy->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -410,9 +395,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* List tags
|
||||
*/
|
||||
public function tags(Request $request)
|
||||
public function tags(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$tags = CrmTag::where('business_id', $business->id)
|
||||
->withCount('taggables')
|
||||
@@ -425,9 +409,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Store tag
|
||||
*/
|
||||
public function storeTag(Request $request)
|
||||
public function storeTag(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
@@ -448,9 +431,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Update tag
|
||||
*/
|
||||
public function updateTag(Request $request, CrmTag $tag)
|
||||
public function updateTag(Request $request, Business $business, CrmTag $tag)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($tag->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -470,9 +452,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Delete tag
|
||||
*/
|
||||
public function destroyTag(Request $request, CrmTag $tag)
|
||||
public function destroyTag(Request $request, Business $business, CrmTag $tag)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($tag->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -488,9 +469,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* List templates
|
||||
*/
|
||||
public function templates(Request $request)
|
||||
public function templates(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$templates = CrmMessageTemplate::where('business_id', $business->id)
|
||||
->orderBy('category')
|
||||
@@ -515,9 +495,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Store template
|
||||
*/
|
||||
public function storeTemplate(Request $request)
|
||||
public function storeTemplate(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
@@ -547,9 +526,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Edit template
|
||||
*/
|
||||
public function editTemplate(Request $request, CrmMessageTemplate $template)
|
||||
public function editTemplate(Request $request, Business $business, CrmMessageTemplate $template)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($template->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -564,9 +542,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Update template
|
||||
*/
|
||||
public function updateTemplate(Request $request, CrmMessageTemplate $template)
|
||||
public function updateTemplate(Request $request, Business $business, CrmMessageTemplate $template)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($template->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -590,9 +567,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Delete template
|
||||
*/
|
||||
public function destroyTemplate(Request $request, CrmMessageTemplate $template)
|
||||
public function destroyTemplate(Request $request, Business $business, CrmMessageTemplate $template)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($template->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -608,9 +584,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* List team roles
|
||||
*/
|
||||
public function teamRoles(Request $request)
|
||||
public function teamRoles(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$roles = CrmTeamRole::where('business_id', $business->id)
|
||||
->withCount('users')
|
||||
@@ -623,9 +598,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Store team role
|
||||
*/
|
||||
public function storeTeamRole(Request $request)
|
||||
public function storeTeamRole(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
@@ -644,9 +618,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Update team role
|
||||
*/
|
||||
public function updateTeamRole(Request $request, CrmTeamRole $role)
|
||||
public function updateTeamRole(Request $request, Business $business, CrmTeamRole $role)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($role->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -665,9 +638,8 @@ class CrmSettingsController extends Controller
|
||||
/**
|
||||
* Delete team role
|
||||
*/
|
||||
public function destroyTeamRole(Request $request, CrmTeamRole $role)
|
||||
public function destroyTeamRole(Request $request, Business $business, CrmTeamRole $role)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($role->business_id !== $business->id) {
|
||||
abort(404);
|
||||
|
||||
@@ -22,10 +22,8 @@ class DealController extends Controller
|
||||
/**
|
||||
* Display pipeline board view
|
||||
*/
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
// Get active pipeline
|
||||
$pipeline = CrmPipeline::forBusiness($business->id)
|
||||
->where('id', $request->input('pipeline_id'))
|
||||
@@ -60,7 +58,7 @@ class DealController extends Controller
|
||||
$pipelines = CrmPipeline::forBusiness($business->id)->active()->get();
|
||||
|
||||
// Get team members
|
||||
$teamMembers = User::where('business_id', $business->id)->get();
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
|
||||
// Calculate stats
|
||||
$stats = [
|
||||
@@ -73,22 +71,20 @@ class DealController extends Controller
|
||||
->sum('value'),
|
||||
];
|
||||
|
||||
return view('seller.crm.deals.index', compact('pipeline', 'deals', 'pipelines', 'teamMembers', 'stats'));
|
||||
return view('seller.crm.deals.index', compact('business', 'pipeline', 'deals', 'pipelines', 'teamMembers', 'stats'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create deal form
|
||||
*/
|
||||
public function create(Request $request)
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$pipelines = CrmPipeline::forBusiness($business->id)->active()->get();
|
||||
$contacts = Contact::where('business_id', $business->id)->get();
|
||||
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
|
||||
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
|
||||
})->get();
|
||||
$teamMembers = User::where('business_id', $business->id)->get();
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
|
||||
return view('seller.crm.deals.create', compact('pipelines', 'contacts', 'accounts', 'teamMembers', 'brands'));
|
||||
@@ -97,10 +93,8 @@ class DealController extends Controller
|
||||
/**
|
||||
* Store new deal
|
||||
*/
|
||||
public function store(Request $request)
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'pipeline_id' => 'required|exists:crm_pipelines,id',
|
||||
@@ -132,7 +126,7 @@ class DealController extends Controller
|
||||
// SECURITY: Verify owner belongs to business
|
||||
if (! empty($validated['owner_id'])) {
|
||||
User::where('id', $validated['owner_id'])
|
||||
->where('business_id', $business->id)
|
||||
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
@@ -168,10 +162,8 @@ class DealController extends Controller
|
||||
/**
|
||||
* Show deal details
|
||||
*/
|
||||
public function show(Request $request, CrmDeal $deal)
|
||||
public function show(Request $request, Business $business, CrmDeal $deal)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($deal->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -205,10 +197,8 @@ class DealController extends Controller
|
||||
/**
|
||||
* Update deal stage (drag & drop)
|
||||
*/
|
||||
public function updateStage(Request $request, CrmDeal $deal)
|
||||
public function updateStage(Request $request, Business $business, CrmDeal $deal)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($deal->business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
@@ -232,10 +222,8 @@ class DealController extends Controller
|
||||
/**
|
||||
* Mark deal as won
|
||||
*/
|
||||
public function markWon(Request $request, CrmDeal $deal)
|
||||
public function markWon(Request $request, Business $business, CrmDeal $deal)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($deal->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -253,10 +241,8 @@ class DealController extends Controller
|
||||
/**
|
||||
* Mark deal as lost
|
||||
*/
|
||||
public function markLost(Request $request, CrmDeal $deal)
|
||||
public function markLost(Request $request, Business $business, CrmDeal $deal)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($deal->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -280,10 +266,8 @@ class DealController extends Controller
|
||||
/**
|
||||
* Reopen a closed deal
|
||||
*/
|
||||
public function reopen(Request $request, CrmDeal $deal)
|
||||
public function reopen(Request $request, Business $business, CrmDeal $deal)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($deal->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -296,10 +280,8 @@ class DealController extends Controller
|
||||
/**
|
||||
* Update deal details
|
||||
*/
|
||||
public function update(Request $request, CrmDeal $deal)
|
||||
public function update(Request $request, Business $business, CrmDeal $deal)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($deal->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -326,7 +308,7 @@ class DealController extends Controller
|
||||
|
||||
if (! empty($validated['owner_id'])) {
|
||||
User::where('id', $validated['owner_id'])
|
||||
->where('business_id', $business->id)
|
||||
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
@@ -338,10 +320,8 @@ class DealController extends Controller
|
||||
/**
|
||||
* Delete deal
|
||||
*/
|
||||
public function destroy(Request $request, CrmDeal $deal)
|
||||
public function destroy(Request $request, Business $business, CrmDeal $deal)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($deal->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmInvoice;
|
||||
use App\Models\Crm\CrmInvoiceItem;
|
||||
use App\Models\Crm\CrmInvoicePayment;
|
||||
@@ -14,10 +15,8 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* List invoices
|
||||
*/
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$query = CrmInvoice::forBusiness($business->id)
|
||||
->with(['contact', 'account', 'creator'])
|
||||
->withCount('items');
|
||||
@@ -56,10 +55,8 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Show invoice details
|
||||
*/
|
||||
public function show(Request $request, CrmInvoice $invoice)
|
||||
public function show(Request $request, Business $business, CrmInvoice $invoice)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($invoice->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -72,10 +69,8 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Create invoice form
|
||||
*/
|
||||
public function create(Request $request)
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$contacts = \App\Models\Contact::where('business_id', $business->id)->get();
|
||||
$quotes = CrmQuote::forBusiness($business->id)
|
||||
->where('status', CrmQuote::STATUS_ACCEPTED)
|
||||
@@ -88,10 +83,8 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Store new invoice
|
||||
*/
|
||||
public function store(Request $request)
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'contact_id' => 'required|exists:contacts,id',
|
||||
@@ -158,10 +151,8 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Send invoice to contact
|
||||
*/
|
||||
public function send(Request $request, CrmInvoice $invoice)
|
||||
public function send(Request $request, Business $business, CrmInvoice $invoice)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($invoice->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -180,10 +171,8 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Record a payment
|
||||
*/
|
||||
public function recordPayment(Request $request, CrmInvoice $invoice)
|
||||
public function recordPayment(Request $request, Business $business, CrmInvoice $invoice)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($invoice->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -223,10 +212,8 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Mark invoice as void
|
||||
*/
|
||||
public function void(Request $request, CrmInvoice $invoice)
|
||||
public function void(Request $request, Business $business, CrmInvoice $invoice)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($invoice->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -247,10 +234,8 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Download invoice PDF
|
||||
*/
|
||||
public function download(Request $request, CrmInvoice $invoice)
|
||||
public function download(Request $request, Business $business, CrmInvoice $invoice)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($invoice->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -262,10 +247,8 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Delete invoice
|
||||
*/
|
||||
public function destroy(Request $request, CrmInvoice $invoice)
|
||||
public function destroy(Request $request, Business $business, CrmInvoice $invoice)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($invoice->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmMeetingBooking;
|
||||
use App\Models\Crm\CrmMeetingLink;
|
||||
use App\Services\Crm\CrmCalendarService;
|
||||
@@ -17,9 +18,8 @@ class MeetingLinkController extends Controller
|
||||
/**
|
||||
* List meeting links
|
||||
*/
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
$user = $request->user();
|
||||
|
||||
$meetingLinks = CrmMeetingLink::where('business_id', $business->id)
|
||||
@@ -42,9 +42,8 @@ class MeetingLinkController extends Controller
|
||||
/**
|
||||
* Store new meeting link
|
||||
*/
|
||||
public function store(Request $request)
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -89,10 +88,8 @@ class MeetingLinkController extends Controller
|
||||
/**
|
||||
* Show meeting link details
|
||||
*/
|
||||
public function show(Request $request, CrmMeetingLink $meetingLink)
|
||||
public function show(Request $request, Business $business, CrmMeetingLink $meetingLink)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($meetingLink->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -105,10 +102,8 @@ class MeetingLinkController extends Controller
|
||||
/**
|
||||
* Edit meeting link
|
||||
*/
|
||||
public function edit(Request $request, CrmMeetingLink $meetingLink)
|
||||
public function edit(Request $request, Business $business, CrmMeetingLink $meetingLink)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($meetingLink->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -119,10 +114,8 @@ class MeetingLinkController extends Controller
|
||||
/**
|
||||
* Update meeting link
|
||||
*/
|
||||
public function update(Request $request, CrmMeetingLink $meetingLink)
|
||||
public function update(Request $request, Business $business, CrmMeetingLink $meetingLink)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($meetingLink->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -150,10 +143,8 @@ class MeetingLinkController extends Controller
|
||||
/**
|
||||
* Toggle active status
|
||||
*/
|
||||
public function toggle(Request $request, CrmMeetingLink $meetingLink)
|
||||
public function toggle(Request $request, Business $business, CrmMeetingLink $meetingLink)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($meetingLink->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -166,10 +157,8 @@ class MeetingLinkController extends Controller
|
||||
/**
|
||||
* Delete meeting link
|
||||
*/
|
||||
public function destroy(Request $request, CrmMeetingLink $meetingLink)
|
||||
public function destroy(Request $request, Business $business, CrmMeetingLink $meetingLink)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($meetingLink->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -250,9 +239,8 @@ class MeetingLinkController extends Controller
|
||||
/**
|
||||
* List upcoming bookings
|
||||
*/
|
||||
public function bookings(Request $request)
|
||||
public function bookings(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
$user = $request->user();
|
||||
|
||||
$bookings = CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
|
||||
@@ -270,10 +258,8 @@ class MeetingLinkController extends Controller
|
||||
/**
|
||||
* Cancel a booking
|
||||
*/
|
||||
public function cancelBooking(Request $request, CrmMeetingBooking $booking)
|
||||
public function cancelBooking(Request $request, Business $business, CrmMeetingBooking $booking)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($booking->meetingLink->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Crm\CrmDeal;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use App\Models\Crm\CrmQuoteItem;
|
||||
use App\Models\Product;
|
||||
use App\Services\Accounting\ArService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class QuoteController extends Controller
|
||||
@@ -16,10 +17,8 @@ class QuoteController extends Controller
|
||||
/**
|
||||
* List quotes
|
||||
*/
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$query = CrmQuote::forBusiness($business->id)
|
||||
->with(['contact', 'account', 'deal', 'creator'])
|
||||
->withCount('items');
|
||||
@@ -44,10 +43,8 @@ class QuoteController extends Controller
|
||||
/**
|
||||
* Show create quote form
|
||||
*/
|
||||
public function create(Request $request)
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$contacts = Contact::where('business_id', $business->id)->get();
|
||||
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
|
||||
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
|
||||
@@ -68,10 +65,8 @@ class QuoteController extends Controller
|
||||
/**
|
||||
* Store new quote
|
||||
*/
|
||||
public function store(Request $request)
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'contact_id' => 'required|exists:contacts,id',
|
||||
@@ -148,10 +143,8 @@ class QuoteController extends Controller
|
||||
/**
|
||||
* Show quote details
|
||||
*/
|
||||
public function show(Request $request, CrmQuote $quote)
|
||||
public function show(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -164,10 +157,8 @@ class QuoteController extends Controller
|
||||
/**
|
||||
* Edit quote
|
||||
*/
|
||||
public function edit(Request $request, CrmQuote $quote)
|
||||
public function edit(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -191,10 +182,8 @@ class QuoteController extends Controller
|
||||
/**
|
||||
* Update quote
|
||||
*/
|
||||
public function update(Request $request, CrmQuote $quote)
|
||||
public function update(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -253,10 +242,8 @@ class QuoteController extends Controller
|
||||
/**
|
||||
* Send quote to contact
|
||||
*/
|
||||
public function send(Request $request, CrmQuote $quote)
|
||||
public function send(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -275,10 +262,8 @@ class QuoteController extends Controller
|
||||
/**
|
||||
* Convert quote to invoice
|
||||
*/
|
||||
public function convertToInvoice(Request $request, CrmQuote $quote)
|
||||
public function convertToInvoice(Request $request, Business $business, CrmQuote $quote, ArService $arService)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -291,6 +276,30 @@ class QuoteController extends Controller
|
||||
return back()->withErrors(['error' => 'This quote already has an invoice.']);
|
||||
}
|
||||
|
||||
// Credit check enforcement - only if there's an account (buyer business)
|
||||
if ($quote->account_id) {
|
||||
$buyerBusiness = Business::find($quote->account_id);
|
||||
|
||||
if ($buyerBusiness) {
|
||||
$creditCheck = $arService->checkCreditForAccount(
|
||||
$business,
|
||||
$buyerBusiness,
|
||||
(float) $quote->total
|
||||
);
|
||||
|
||||
if (! $creditCheck['can_extend']) {
|
||||
return back()->withErrors([
|
||||
'error' => 'Cannot create invoice: '.$creditCheck['reason'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Store warning in session if present
|
||||
if (! empty($creditCheck['details']['warning'])) {
|
||||
session()->flash('warning', $creditCheck['details']['warning']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$invoice = $quote->convertToInvoice();
|
||||
|
||||
return redirect()->route('seller.crm.invoices.show', $invoice)
|
||||
@@ -300,10 +309,8 @@ class QuoteController extends Controller
|
||||
/**
|
||||
* Download quote PDF
|
||||
*/
|
||||
public function download(Request $request, CrmQuote $quote)
|
||||
public function download(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -315,10 +322,8 @@ class QuoteController extends Controller
|
||||
/**
|
||||
* Delete quote
|
||||
*/
|
||||
public function destroy(Request $request, CrmQuote $quote)
|
||||
public function destroy(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Seller\Crm;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TaskController extends Controller
|
||||
@@ -16,13 +17,17 @@ class TaskController extends Controller
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$tasksQuery = CrmTask::where('business_id', $business->id)
|
||||
->with(['assignee', 'creator', 'related'])
|
||||
->orderBy('due_date');
|
||||
$tasksQuery = CrmTask::where('seller_business_id', $business->id)
|
||||
->with(['assignee', 'creator', 'contact', 'business'])
|
||||
->orderBy('due_at');
|
||||
|
||||
// Filter by status
|
||||
// Filter by status (completed vs incomplete)
|
||||
if ($request->filled('status')) {
|
||||
$tasksQuery->where('status', $request->status);
|
||||
if ($request->status === 'completed') {
|
||||
$tasksQuery->whereNotNull('completed_at');
|
||||
} elseif ($request->status === 'pending') {
|
||||
$tasksQuery->whereNull('completed_at');
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by assignee
|
||||
@@ -39,21 +44,26 @@ class TaskController extends Controller
|
||||
|
||||
// Get stats
|
||||
$stats = [
|
||||
'my_tasks' => CrmTask::where('business_id', $business->id)
|
||||
'my_tasks' => CrmTask::where('seller_business_id', $business->id)
|
||||
->where('assigned_to', $user->id)
|
||||
->where('status', '!=', 'completed')
|
||||
->whereNull('completed_at')
|
||||
->count(),
|
||||
'overdue' => CrmTask::where('business_id', $business->id)
|
||||
->where('status', '!=', 'completed')
|
||||
->where('due_date', '<', now())
|
||||
'overdue' => CrmTask::where('seller_business_id', $business->id)
|
||||
->whereNull('completed_at')
|
||||
->where('due_at', '<', now())
|
||||
->count(),
|
||||
'due_today' => CrmTask::where('business_id', $business->id)
|
||||
->where('status', '!=', 'completed')
|
||||
->whereDate('due_date', today())
|
||||
'due_today' => CrmTask::where('seller_business_id', $business->id)
|
||||
->whereNull('completed_at')
|
||||
->whereDate('due_at', today())
|
||||
->count(),
|
||||
];
|
||||
|
||||
return view('seller.crm.tasks.index', compact('business', 'tasks', 'stats'));
|
||||
$counts = $stats; // View expects $counts
|
||||
|
||||
// Get team members for assignment filter
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
|
||||
return view('seller.crm.tasks.index', compact('business', 'tasks', 'counts', 'teamMembers'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,19 +81,26 @@ class TaskController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|in:call,email,meeting,task,follow_up',
|
||||
'priority' => 'required|in:low,medium,high,urgent',
|
||||
'due_date' => 'required|date',
|
||||
'details' => 'nullable|string',
|
||||
'type' => 'required|in:call,email,meeting,follow_up,demo,other',
|
||||
'priority' => 'required|in:low,normal,high,urgent',
|
||||
'due_at' => 'required|date',
|
||||
'assigned_to' => 'nullable|exists:users,id',
|
||||
'contact_id' => 'nullable|exists:contacts,id',
|
||||
'business_id' => 'nullable|exists:businesses,id',
|
||||
]);
|
||||
|
||||
$task = CrmTask::create([
|
||||
...$validated,
|
||||
'business_id' => $business->id,
|
||||
'title' => $validated['title'],
|
||||
'details' => $validated['details'] ?? null,
|
||||
'type' => $validated['type'],
|
||||
'priority' => $validated['priority'],
|
||||
'due_at' => $validated['due_at'],
|
||||
'contact_id' => $validated['contact_id'] ?? null,
|
||||
'business_id' => $validated['business_id'] ?? null,
|
||||
'seller_business_id' => $business->id,
|
||||
'created_by' => $request->user()->id,
|
||||
'assigned_to' => $validated['assigned_to'] ?? $request->user()->id,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
@@ -96,7 +113,7 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function show(Request $request, Business $business, CrmTask $task)
|
||||
{
|
||||
$task->load(['assignee', 'creator', 'related']);
|
||||
$task->load(['assignee', 'creator', 'contact', 'business', 'opportunity', 'order']);
|
||||
|
||||
return view('seller.crm.tasks.show', compact('business', 'task'));
|
||||
}
|
||||
@@ -108,11 +125,10 @@ class TaskController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'sometimes|in:call,email,meeting,task,follow_up',
|
||||
'priority' => 'sometimes|in:low,medium,high,urgent',
|
||||
'status' => 'sometimes|in:pending,in_progress,completed,cancelled',
|
||||
'due_date' => 'sometimes|date',
|
||||
'details' => 'nullable|string',
|
||||
'type' => 'sometimes|in:call,email,meeting,follow_up,demo,other',
|
||||
'priority' => 'sometimes|in:low,normal,high,urgent',
|
||||
'due_at' => 'sometimes|date',
|
||||
'assigned_to' => 'nullable|exists:users,id',
|
||||
]);
|
||||
|
||||
@@ -140,10 +156,7 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function complete(Request $request, Business $business, CrmTask $task)
|
||||
{
|
||||
$task->update([
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$task->markComplete($request->user());
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmActiveView;
|
||||
use App\Models\Crm\CrmChannel;
|
||||
use App\Models\Crm\CrmInternalNote;
|
||||
@@ -24,10 +25,8 @@ class ThreadController extends Controller
|
||||
/**
|
||||
* Display unified inbox
|
||||
*/
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
$query = CrmThread::forBusiness($business->id)
|
||||
->with(['contact', 'assignee', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||
->withCount('messages');
|
||||
@@ -66,21 +65,19 @@ class ThreadController extends Controller
|
||||
->paginate(25);
|
||||
|
||||
// Get team members for assignment dropdown
|
||||
$teamMembers = User::where('business_id', $business->id)->get();
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
|
||||
// Get available channels
|
||||
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||
|
||||
return view('seller.crm.threads.index', compact('threads', 'teamMembers', 'channels'));
|
||||
return view('seller.crm.threads.index', compact('business', 'threads', 'teamMembers', 'channels'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a single thread
|
||||
*/
|
||||
public function show(Request $request, CrmThread $thread)
|
||||
public function show(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
// SECURITY: Verify business ownership
|
||||
if ($thread->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -116,22 +113,25 @@ class ThreadController extends Controller
|
||||
// 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 view('seller.crm.threads.show', compact(
|
||||
'business',
|
||||
'thread',
|
||||
'otherViewers',
|
||||
'slaStatus',
|
||||
'suggestions',
|
||||
'channels'
|
||||
'channels',
|
||||
'teamMembers'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reply in thread
|
||||
*/
|
||||
public function reply(Request $request, CrmThread $thread)
|
||||
public function reply(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($thread->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -177,10 +177,8 @@ class ThreadController extends Controller
|
||||
/**
|
||||
* Assign thread to user
|
||||
*/
|
||||
public function assign(Request $request, CrmThread $thread)
|
||||
public function assign(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($thread->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -191,7 +189,7 @@ class ThreadController extends Controller
|
||||
|
||||
// SECURITY: Verify user belongs to business
|
||||
$assignee = User::where('id', $validated['assigned_to'])
|
||||
->where('business_id', $business->id)
|
||||
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->first();
|
||||
|
||||
if (! $assignee) {
|
||||
@@ -206,10 +204,8 @@ class ThreadController extends Controller
|
||||
/**
|
||||
* Close thread
|
||||
*/
|
||||
public function close(Request $request, CrmThread $thread)
|
||||
public function close(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($thread->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -222,10 +218,8 @@ class ThreadController extends Controller
|
||||
/**
|
||||
* Reopen thread
|
||||
*/
|
||||
public function reopen(Request $request, CrmThread $thread)
|
||||
public function reopen(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($thread->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -241,10 +235,8 @@ class ThreadController extends Controller
|
||||
/**
|
||||
* Snooze thread
|
||||
*/
|
||||
public function snooze(Request $request, CrmThread $thread)
|
||||
public function snooze(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($thread->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -264,10 +256,8 @@ class ThreadController extends Controller
|
||||
/**
|
||||
* Add internal note
|
||||
*/
|
||||
public function addNote(Request $request, CrmThread $thread)
|
||||
public function addNote(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($thread->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -290,10 +280,8 @@ class ThreadController extends Controller
|
||||
/**
|
||||
* Generate AI reply draft
|
||||
*/
|
||||
public function generateAiReply(Request $request, CrmThread $thread)
|
||||
public function generateAiReply(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($thread->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
@@ -313,10 +301,8 @@ class ThreadController extends Controller
|
||||
/**
|
||||
* Heartbeat for active viewing
|
||||
*/
|
||||
public function heartbeat(Request $request, CrmThread $thread)
|
||||
public function heartbeat(Request $request, Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
if ($thread->business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
335
app/Http/Controllers/Seller/ExpensesController.php
Normal file
335
app/Http/Controllers/Seller/ExpensesController.php
Normal file
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\Expense;
|
||||
use App\Models\Accounting\GlAccount;
|
||||
use App\Models\Business;
|
||||
use App\Models\Department;
|
||||
use App\Services\Accounting\ExpenseService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Staff/Child Business Expense Controller.
|
||||
*
|
||||
* Handles expense creation and submission by employees.
|
||||
* Approval and payment are handled by Management controller.
|
||||
*/
|
||||
class ExpensesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ExpenseService $expenseService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List expenses for the current business.
|
||||
*
|
||||
* GET /s/{business}/expenses
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$query = Expense::where('business_id', $business->id)
|
||||
->with(['department', 'createdBy', 'items']);
|
||||
|
||||
// Non-admins only see their own expenses
|
||||
if (! $this->canViewAllExpenses($user, $business)) {
|
||||
$query->where('created_by_user_id', $user->id);
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// Department filter
|
||||
if ($request->filled('department_id')) {
|
||||
$query->where('department_id', $request->department_id);
|
||||
}
|
||||
|
||||
$expenses = $query->orderByDesc('expense_date')->paginate(20)->withQueryString();
|
||||
|
||||
// Get departments for filter
|
||||
$departments = Department::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Stats for current user
|
||||
$myStats = [
|
||||
'draft' => Expense::where('business_id', $business->id)
|
||||
->where('created_by_user_id', $user->id)
|
||||
->status(Expense::STATUS_DRAFT)
|
||||
->count(),
|
||||
'submitted' => Expense::where('business_id', $business->id)
|
||||
->where('created_by_user_id', $user->id)
|
||||
->status(Expense::STATUS_SUBMITTED)
|
||||
->count(),
|
||||
'approved' => Expense::where('business_id', $business->id)
|
||||
->where('created_by_user_id', $user->id)
|
||||
->status(Expense::STATUS_APPROVED)
|
||||
->count(),
|
||||
'total_pending' => Expense::where('business_id', $business->id)
|
||||
->where('created_by_user_id', $user->id)
|
||||
->whereIn('status', [Expense::STATUS_SUBMITTED, Expense::STATUS_APPROVED])
|
||||
->sum('total_amount'),
|
||||
];
|
||||
|
||||
return view('seller.expenses.index', compact(
|
||||
'business',
|
||||
'expenses',
|
||||
'departments',
|
||||
'myStats'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create expense form.
|
||||
*
|
||||
* GET /s/{business}/expenses/create
|
||||
*/
|
||||
public function create(Request $request, Business $business): View
|
||||
{
|
||||
$departments = Department::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$glAccounts = GlAccount::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->where('is_header', false)
|
||||
->where('account_type', 'expense')
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
$paymentMethods = Expense::getPaymentMethods();
|
||||
|
||||
return view('seller.expenses.create', compact(
|
||||
'business',
|
||||
'departments',
|
||||
'glAccounts',
|
||||
'paymentMethods'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new expense.
|
||||
*
|
||||
* POST /s/{business}/expenses
|
||||
*/
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'expense_date' => 'required|date',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'payment_method' => 'required|string|in:'.implode(',', array_keys(Expense::getPaymentMethods())),
|
||||
'reference' => 'nullable|string|max:255',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.description' => 'required|string|max:255',
|
||||
'items.*.amount' => 'required|numeric|min:0.01',
|
||||
'items.*.gl_expense_account_id' => 'required|integer|exists:gl_accounts,id',
|
||||
'items.*.department_id' => 'nullable|integer|exists:departments,id',
|
||||
'items.*.tax_amount' => 'nullable|numeric|min:0',
|
||||
'submit' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
$items = $validated['items'];
|
||||
unset($validated['items'], $validated['submit']);
|
||||
|
||||
// Set default status
|
||||
$validated['status'] = $request->boolean('submit')
|
||||
? Expense::STATUS_SUBMITTED
|
||||
: Expense::STATUS_DRAFT;
|
||||
|
||||
$expense = $this->expenseService->createExpense($business, $user, $validated, $items);
|
||||
|
||||
$message = $expense->isSubmitted()
|
||||
? "Expense {$expense->expense_number} submitted for approval."
|
||||
: "Expense {$expense->expense_number} saved as draft.";
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.expenses.show', [$business, $expense])
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show expense details.
|
||||
*
|
||||
* GET /s/{business}/expenses/{expense}
|
||||
*/
|
||||
public function show(Request $request, Business $business, Expense $expense): View
|
||||
{
|
||||
$this->authorizeExpenseAccess($expense, $business);
|
||||
|
||||
$expense->load(['items.glAccount', 'items.department', 'department', 'createdBy', 'approvedBy', 'paidBy']);
|
||||
|
||||
return view('seller.expenses.show', compact('business', 'expense'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit expense form (draft only).
|
||||
*
|
||||
* GET /s/{business}/expenses/{expense}/edit
|
||||
*/
|
||||
public function edit(Request $request, Business $business, Expense $expense): View
|
||||
{
|
||||
$this->authorizeExpenseAccess($expense, $business);
|
||||
|
||||
if (! $expense->canEdit()) {
|
||||
abort(403, 'Only draft expenses can be edited.');
|
||||
}
|
||||
|
||||
$expense->load(['items']);
|
||||
|
||||
$departments = Department::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$glAccounts = GlAccount::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->where('is_header', false)
|
||||
->where('account_type', 'expense')
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
$paymentMethods = Expense::getPaymentMethods();
|
||||
|
||||
return view('seller.expenses.edit', compact(
|
||||
'business',
|
||||
'expense',
|
||||
'departments',
|
||||
'glAccounts',
|
||||
'paymentMethods'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an expense (draft only).
|
||||
*
|
||||
* PUT /s/{business}/expenses/{expense}
|
||||
*/
|
||||
public function update(Request $request, Business $business, Expense $expense): RedirectResponse
|
||||
{
|
||||
$this->authorizeExpenseAccess($expense, $business);
|
||||
|
||||
if (! $expense->canEdit()) {
|
||||
return back()->with('error', 'Only draft expenses can be edited.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'expense_date' => 'required|date',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'payment_method' => 'required|string|in:'.implode(',', array_keys(Expense::getPaymentMethods())),
|
||||
'reference' => 'nullable|string|max:255',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.description' => 'required|string|max:255',
|
||||
'items.*.amount' => 'required|numeric|min:0.01',
|
||||
'items.*.gl_expense_account_id' => 'required|integer|exists:gl_accounts,id',
|
||||
'items.*.department_id' => 'nullable|integer|exists:departments,id',
|
||||
'items.*.tax_amount' => 'nullable|numeric|min:0',
|
||||
'submit' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$items = $validated['items'];
|
||||
unset($validated['items'], $validated['submit']);
|
||||
|
||||
// Update status if submitting
|
||||
if ($request->boolean('submit')) {
|
||||
$validated['status'] = Expense::STATUS_SUBMITTED;
|
||||
}
|
||||
|
||||
$expense = $this->expenseService->updateExpense($expense, $validated, $items);
|
||||
|
||||
$message = $expense->isSubmitted()
|
||||
? "Expense {$expense->expense_number} submitted for approval."
|
||||
: "Expense {$expense->expense_number} updated.";
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.expenses.show', [$business, $expense])
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an expense for approval.
|
||||
*
|
||||
* POST /s/{business}/expenses/{expense}/submit
|
||||
*/
|
||||
public function submit(Request $request, Business $business, Expense $expense): RedirectResponse
|
||||
{
|
||||
$this->authorizeExpenseAccess($expense, $business);
|
||||
|
||||
try {
|
||||
$this->expenseService->submitExpense($expense, auth()->user());
|
||||
|
||||
return back()->with('success', "Expense {$expense->expense_number} submitted for approval.");
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a draft expense.
|
||||
*
|
||||
* DELETE /s/{business}/expenses/{expense}
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, Expense $expense): RedirectResponse
|
||||
{
|
||||
$this->authorizeExpenseAccess($expense, $business);
|
||||
|
||||
try {
|
||||
$this->expenseService->deleteExpense($expense);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.expenses.index', $business)
|
||||
->with('success', 'Expense deleted.');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can view all expenses (not just their own).
|
||||
*/
|
||||
protected function canViewAllExpenses($user, Business $business): bool
|
||||
{
|
||||
// Business owners and admins can view all
|
||||
$pivot = $user->businesses()
|
||||
->where('businesses.id', $business->id)
|
||||
->first()
|
||||
?->pivot;
|
||||
|
||||
if ($pivot && in_array($pivot->role ?? $pivot->contact_type ?? '', ['owner', 'primary', 'admin'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->user_type === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize access to a specific expense.
|
||||
*/
|
||||
protected function authorizeExpenseAccess(Expense $expense, Business $business): void
|
||||
{
|
||||
// Must belong to this business
|
||||
if ($expense->business_id !== $business->id) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
// Must be creator or have view-all permission
|
||||
if ($expense->created_by_user_id !== $user->id && ! $this->canViewAllExpenses($user, $business)) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class InvoiceController extends Controller
|
||||
'quantity_on_hand', 'quantity_allocated', 'type', 'image_path')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function ($product) {
|
||||
->map(function ($product) use ($business) {
|
||||
// Map batches with their COA data
|
||||
$batches = $product->availableBatches->map(function ($batch) {
|
||||
$latestLab = $batch->getLatestLab();
|
||||
|
||||
171
app/Http/Controllers/Seller/Management/AccountingController.php
Normal file
171
app/Http/Controllers/Seller/Management/AccountingController.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\AccountingReportingService;
|
||||
use App\Services\Accounting\ReportExportService;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AccountingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AccountingReportingService $reportingService,
|
||||
protected ReportExportService $exportService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* General Ledger Account Detail.
|
||||
*
|
||||
* GET /s/{business}/management/accounting/gl
|
||||
*/
|
||||
public function gl(Request $request, Business $business)
|
||||
{
|
||||
$fromDate = $request->get('from_date', now()->startOfMonth()->format('Y-m-d'));
|
||||
$toDate = $request->get('to_date', now()->format('Y-m-d'));
|
||||
$accountId = $request->get('account_id');
|
||||
|
||||
$accounts = $this->reportingService->getAccountsForSelect($business);
|
||||
$isParent = $this->reportingService->isParentCompany($business);
|
||||
|
||||
$ledgerData = null;
|
||||
if ($accountId) {
|
||||
$ledgerData = $this->reportingService->getGeneralLedger(
|
||||
$business,
|
||||
(int) $accountId,
|
||||
$fromDate,
|
||||
$toDate
|
||||
);
|
||||
}
|
||||
|
||||
return view('seller.management.accounting.gl', compact(
|
||||
'business',
|
||||
'accounts',
|
||||
'ledgerData',
|
||||
'fromDate',
|
||||
'toDate',
|
||||
'accountId',
|
||||
'isParent'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Journal Entry Browser.
|
||||
*
|
||||
* GET /s/{business}/management/accounting/journals
|
||||
*/
|
||||
public function journals(Request $request, Business $business)
|
||||
{
|
||||
$filters = [
|
||||
'from_date' => $request->get('from_date', now()->startOfMonth()->format('Y-m-d')),
|
||||
'to_date' => $request->get('to_date', now()->format('Y-m-d')),
|
||||
'source_type' => $request->get('source_type'),
|
||||
'status' => $request->get('status'),
|
||||
'division_id' => $request->get('division_id'),
|
||||
'include_children' => true,
|
||||
];
|
||||
|
||||
$entries = $this->reportingService->getJournalEntries($business, $filters);
|
||||
$isParent = $this->reportingService->isParentCompany($business);
|
||||
$divisions = $isParent ? $this->reportingService->getDivisions($business) : collect();
|
||||
|
||||
$sourceTypes = [
|
||||
'manual' => 'Manual Entry',
|
||||
'ap_bill' => 'AP Bill',
|
||||
'ap_payment' => 'AP Payment',
|
||||
'inter_company' => 'Inter-Company',
|
||||
];
|
||||
|
||||
$statuses = [
|
||||
'draft' => 'Draft',
|
||||
'posted' => 'Posted',
|
||||
'reversed' => 'Reversed',
|
||||
];
|
||||
|
||||
return view('seller.management.accounting.journals', compact(
|
||||
'business',
|
||||
'entries',
|
||||
'filters',
|
||||
'isParent',
|
||||
'divisions',
|
||||
'sourceTypes',
|
||||
'statuses'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trial Balance Report.
|
||||
*
|
||||
* GET /s/{business}/management/accounting/trial-balance
|
||||
*/
|
||||
public function trialBalance(Request $request, Business $business)
|
||||
{
|
||||
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
|
||||
$toDate = $request->get('to_date', now()->format('Y-m-d'));
|
||||
$includeChildren = $request->boolean('include_children', true);
|
||||
|
||||
$isParent = $this->reportingService->isParentCompany($business);
|
||||
|
||||
$filters = [
|
||||
'include_children' => $isParent && $includeChildren,
|
||||
];
|
||||
|
||||
$trialBalance = $this->reportingService->getTrialBalance(
|
||||
$business,
|
||||
$fromDate,
|
||||
$toDate,
|
||||
$filters
|
||||
);
|
||||
|
||||
// Calculate totals
|
||||
$totals = [
|
||||
'debits' => $trialBalance->sum('debits'),
|
||||
'credits' => $trialBalance->sum('credits'),
|
||||
'net_debit' => $trialBalance->where('closing_balance', '>', 0)->sum('closing_balance'),
|
||||
'net_credit' => abs($trialBalance->where('closing_balance', '<', 0)->sum('closing_balance')),
|
||||
];
|
||||
|
||||
return view('seller.management.accounting.trial-balance', compact(
|
||||
'business',
|
||||
'trialBalance',
|
||||
'totals',
|
||||
'fromDate',
|
||||
'toDate',
|
||||
'includeChildren',
|
||||
'isParent'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Trial Balance as CSV.
|
||||
*
|
||||
* GET /s/{business}/management/accounting/trial-balance/export
|
||||
*/
|
||||
public function exportTrialBalance(Request $request, Business $business): StreamedResponse
|
||||
{
|
||||
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
|
||||
$toDate = $request->get('to_date', now()->format('Y-m-d'));
|
||||
$includeChildren = $request->boolean('include_children', true);
|
||||
|
||||
$isParent = $this->reportingService->isParentCompany($business);
|
||||
|
||||
$filters = [
|
||||
'include_children' => $isParent && $includeChildren,
|
||||
];
|
||||
|
||||
$trialBalance = $this->reportingService->getTrialBalance(
|
||||
$business,
|
||||
$fromDate,
|
||||
$toDate,
|
||||
$filters
|
||||
);
|
||||
|
||||
$filename = 'trial_balance_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
|
||||
|
||||
return $this->exportService->exportTrialBalance($trialBalance, $filename);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\AccountingPeriod;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\PeriodLockService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AccountingPeriodsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PeriodLockService $periodLockService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display accounting periods.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$year = $request->input('year', now()->year);
|
||||
$periods = $this->periodLockService->getPeriodsForBusiness($business, (int) $year);
|
||||
|
||||
// Get available years
|
||||
$yearsWithPeriods = AccountingPeriod::forBusiness($business->id)
|
||||
->selectRaw('EXTRACT(YEAR FROM period_start) as year')
|
||||
->distinct()
|
||||
->pluck('year')
|
||||
->map(fn ($y) => (int) $y)
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
// Always include current and next year
|
||||
$availableYears = $yearsWithPeriods
|
||||
->push(now()->year)
|
||||
->push(now()->year + 1)
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
$canClosePeriods = $this->periodLockService->userHasPermission($business, $request->user(), 'can_close_periods');
|
||||
$canReopenPeriods = $this->periodLockService->userHasPermission($business, $request->user(), 'can_reopen_periods');
|
||||
|
||||
return view('seller.management.accounting.periods.index', [
|
||||
'business' => $business,
|
||||
'periods' => $periods,
|
||||
'year' => (int) $year,
|
||||
'availableYears' => $availableYears,
|
||||
'canClosePeriods' => $canClosePeriods,
|
||||
'canReopenPeriods' => $canReopenPeriods,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate periods for a year.
|
||||
*/
|
||||
public function generate(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requirePermission($business, $request->user(), 'can_close_periods');
|
||||
|
||||
$validated = $request->validate([
|
||||
'year' => 'required|integer|min:2000|max:2100',
|
||||
]);
|
||||
|
||||
$periods = $this->periodLockService->ensurePeriodsExist($business, (int) $validated['year']);
|
||||
|
||||
return back()->with('success', 'Generated '.count($periods).' periods for '.$validated['year'].'.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a period.
|
||||
*/
|
||||
public function close(Request $request, Business $business, AccountingPeriod $period): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requirePermission($business, $request->user(), 'can_close_periods');
|
||||
|
||||
// Ensure period belongs to this business
|
||||
if ($period->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|in:soft_closed,hard_closed',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$this->periodLockService->closePeriod(
|
||||
$period,
|
||||
$validated['status'],
|
||||
$request->user(),
|
||||
$validated['notes'] ?? null
|
||||
);
|
||||
|
||||
$statusLabel = $validated['status'] === 'soft_closed' ? 'soft closed' : 'hard closed';
|
||||
|
||||
return back()->with('success', "Period {$period->period_label} has been {$statusLabel}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen a period.
|
||||
*/
|
||||
public function reopen(Request $request, Business $business, AccountingPeriod $period): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requirePermission($business, $request->user(), 'can_reopen_periods');
|
||||
|
||||
// Ensure period belongs to this business
|
||||
if ($period->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$this->periodLockService->reopenPeriod(
|
||||
$period,
|
||||
$request->user(),
|
||||
$validated['notes'] ?? null
|
||||
);
|
||||
|
||||
return back()->with('success', "Period {$period->period_label} has been reopened.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Require Management Suite access.
|
||||
*/
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a specific finance permission.
|
||||
*/
|
||||
private function requirePermission(Business $business, $user, string $permission): void
|
||||
{
|
||||
// Business owners always have access
|
||||
if ($business->owner_user_id === $user->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check bypass mode
|
||||
if (config('finance_roles.bypass_permissions', false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->periodLockService->userHasPermission($business, $user, $permission)) {
|
||||
abort(403, 'You do not have permission for this action.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ApBill;
|
||||
use App\Models\Accounting\ArInvoice;
|
||||
use App\Models\Accounting\Expense;
|
||||
use App\Models\Accounting\RecurringTransaction;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\BillService;
|
||||
use App\Services\Accounting\ExpenseService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Action Center - Centralized hub for pending approvals and exceptions.
|
||||
*
|
||||
* Management Suite only - provides quick access to items needing attention:
|
||||
* - Bills pending approval
|
||||
* - Expenses pending approval
|
||||
* - Recurring drafts needing review
|
||||
* - AR exceptions (credit limits, holds, past due)
|
||||
* - Budget exceptions
|
||||
*/
|
||||
class ActionCenterController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected BillService $billService,
|
||||
protected ExpenseService $expenseService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display the Action Center dashboard.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
|
||||
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
|
||||
|
||||
// 1. Bills Pending Approval
|
||||
$pendingBills = ApBill::whereIn('business_id', $allBusinessIds)
|
||||
->where('status', ApBill::STATUS_PENDING)
|
||||
->with(['vendor', 'business'])
|
||||
->orderBy('due_date')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
$pendingBillsCount = ApBill::whereIn('business_id', $allBusinessIds)
|
||||
->where('status', ApBill::STATUS_PENDING)
|
||||
->count();
|
||||
|
||||
// 2. Expenses Pending Approval
|
||||
$pendingExpenses = Expense::whereIn('business_id', $allBusinessIds)
|
||||
->where('status', Expense::STATUS_SUBMITTED)
|
||||
->with(['user', 'business', 'glAccount'])
|
||||
->orderBy('expense_date')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
$pendingExpensesCount = Expense::whereIn('business_id', $allBusinessIds)
|
||||
->where('status', Expense::STATUS_SUBMITTED)
|
||||
->count();
|
||||
|
||||
// 3. Recurring Drafts Needing Review
|
||||
$recurringDrafts = collect();
|
||||
$recurringDraftsCount = 0;
|
||||
if (class_exists(RecurringTransaction::class)) {
|
||||
$recurringDrafts = RecurringTransaction::whereIn('business_id', $allBusinessIds)
|
||||
->where('status', 'draft')
|
||||
->with('business')
|
||||
->limit(20)
|
||||
->get();
|
||||
$recurringDraftsCount = RecurringTransaction::whereIn('business_id', $allBusinessIds)
|
||||
->where('status', 'draft')
|
||||
->count();
|
||||
}
|
||||
|
||||
// 4. AR Exceptions
|
||||
$arExceptions = $this->getArExceptions($allBusinessIds);
|
||||
|
||||
// 5. Budget Exceptions (placeholder - will expand when budget variance tracking exists)
|
||||
$budgetExceptions = $this->getBudgetExceptions($parentBusiness);
|
||||
|
||||
// Summary counts
|
||||
$totalActionItems = $pendingBillsCount + $pendingExpensesCount + $recurringDraftsCount
|
||||
+ $arExceptions['count'] + $budgetExceptions['count'];
|
||||
|
||||
return view('seller.management.action-center.index', compact(
|
||||
'business',
|
||||
'parentBusiness',
|
||||
'pendingBills',
|
||||
'pendingBillsCount',
|
||||
'pendingExpenses',
|
||||
'pendingExpensesCount',
|
||||
'recurringDrafts',
|
||||
'recurringDraftsCount',
|
||||
'arExceptions',
|
||||
'budgetExceptions',
|
||||
'totalActionItems'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk approve pending bills.
|
||||
*/
|
||||
public function bulkApproveBills(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'bill_ids' => 'required|array',
|
||||
'bill_ids.*' => 'exists:ap_bills,id',
|
||||
]);
|
||||
|
||||
$approved = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($validated['bill_ids'] as $billId) {
|
||||
try {
|
||||
$bill = ApBill::findOrFail($billId);
|
||||
$this->billService->approveBill($bill, auth()->id());
|
||||
$approved++;
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "Bill #{$billId}: {$e->getMessage()}";
|
||||
}
|
||||
}
|
||||
|
||||
$message = "{$approved} bill(s) approved successfully.";
|
||||
if (! empty($errors)) {
|
||||
$message .= ' Errors: '.implode(', ', $errors);
|
||||
}
|
||||
|
||||
return back()->with($errors ? 'warning' : 'success', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk approve pending expenses.
|
||||
*/
|
||||
public function bulkApproveExpenses(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'expense_ids' => 'required|array',
|
||||
'expense_ids.*' => 'exists:expenses,id',
|
||||
]);
|
||||
|
||||
$approved = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($validated['expense_ids'] as $expenseId) {
|
||||
try {
|
||||
$expense = Expense::findOrFail($expenseId);
|
||||
$this->expenseService->approve($expense, auth()->id());
|
||||
$approved++;
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "Expense #{$expenseId}: {$e->getMessage()}";
|
||||
}
|
||||
}
|
||||
|
||||
$message = "{$approved} expense(s) approved successfully.";
|
||||
if (! empty($errors)) {
|
||||
$message .= ' Errors: '.implode(', ', $errors);
|
||||
}
|
||||
|
||||
return back()->with($errors ? 'warning' : 'success', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk reject pending expenses.
|
||||
*/
|
||||
public function bulkRejectExpenses(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'expense_ids' => 'required|array',
|
||||
'expense_ids.*' => 'exists:expenses,id',
|
||||
'rejection_reason' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
$rejected = 0;
|
||||
|
||||
foreach ($validated['expense_ids'] as $expenseId) {
|
||||
$expense = Expense::findOrFail($expenseId);
|
||||
$this->expenseService->reject($expense, auth()->id(), $validated['rejection_reason']);
|
||||
$rejected++;
|
||||
}
|
||||
|
||||
return back()->with('success', "{$rejected} expense(s) rejected.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AR exceptions (credit limits, holds, past due).
|
||||
*/
|
||||
protected function getArExceptions(array $businessIds): array
|
||||
{
|
||||
$exceptions = [
|
||||
'over_credit_limit' => collect(),
|
||||
'credit_hold' => collect(),
|
||||
'past_due_60' => collect(),
|
||||
'past_due_90' => collect(),
|
||||
'count' => 0,
|
||||
];
|
||||
|
||||
// Past due > 60 days
|
||||
$pastDue60 = ArInvoice::whereIn('business_id', $businessIds)
|
||||
->where('status', ArInvoice::STATUS_OVERDUE)
|
||||
->where('due_date', '<', now()->subDays(60))
|
||||
->where('balance_due', '>', 0)
|
||||
->with(['customer', 'business'])
|
||||
->get();
|
||||
$exceptions['past_due_60'] = $pastDue60->filter(fn ($inv) => $inv->due_date >= now()->subDays(90));
|
||||
|
||||
// Past due > 90 days
|
||||
$exceptions['past_due_90'] = ArInvoice::whereIn('business_id', $businessIds)
|
||||
->where('status', ArInvoice::STATUS_OVERDUE)
|
||||
->where('due_date', '<', now()->subDays(90))
|
||||
->where('balance_due', '>', 0)
|
||||
->with(['customer', 'business'])
|
||||
->get();
|
||||
|
||||
$exceptions['count'] = $exceptions['past_due_60']->count() + $exceptions['past_due_90']->count();
|
||||
|
||||
return $exceptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get budget exceptions (over budget items).
|
||||
*/
|
||||
protected function getBudgetExceptions(Business $business): array
|
||||
{
|
||||
// Placeholder - will expand when budget variance tracking is implemented
|
||||
return [
|
||||
'items' => collect(),
|
||||
'count' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ApBill;
|
||||
use App\Models\Accounting\ApPayment;
|
||||
use App\Models\Accounting\ArInvoice;
|
||||
use App\Models\Accounting\Expense;
|
||||
use App\Models\Accounting\JournalEntry;
|
||||
use App\Models\Accounting\JournalEntryLine;
|
||||
use App\Models\Business;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Advanced Analytics - Deep dive dashboards for financial analysis.
|
||||
*
|
||||
* Provides:
|
||||
* - AR Analytics (aging, DSO, collection rate)
|
||||
* - AP Analytics (payment timing, vendor analysis)
|
||||
* - Cash Analytics (position, forecast, runway)
|
||||
* - Expense Analytics (category breakdown, trends)
|
||||
*/
|
||||
class AdvancedAnalyticsController extends Controller
|
||||
{
|
||||
/**
|
||||
* AR Analytics Dashboard.
|
||||
*/
|
||||
public function arAnalytics(Request $request, Business $business): View
|
||||
{
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
|
||||
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
|
||||
|
||||
$endDate = Carbon::parse($request->get('end_date', now()));
|
||||
$startDate = Carbon::parse($request->get('start_date', now()->subMonths(12)));
|
||||
|
||||
// Aging buckets
|
||||
$aging = $this->calculateArAging($allBusinessIds);
|
||||
|
||||
// DSO (Days Sales Outstanding)
|
||||
$dso = $this->calculateDSO($allBusinessIds, $startDate, $endDate);
|
||||
|
||||
// Collection rate (last 12 months)
|
||||
$collectionRate = $this->calculateCollectionRate($allBusinessIds, $startDate, $endDate);
|
||||
|
||||
// Monthly AR trend
|
||||
$monthlyTrend = $this->getArMonthlyTrend($allBusinessIds, 12);
|
||||
|
||||
// Top customers by AR balance
|
||||
$topCustomers = ArInvoice::whereIn('business_id', $allBusinessIds)
|
||||
->where('balance_due', '>', 0)
|
||||
->selectRaw('customer_id, SUM(balance_due) as total_balance')
|
||||
->groupBy('customer_id')
|
||||
->with('customer')
|
||||
->orderByDesc('total_balance')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return view('seller.management.analytics.ar', compact(
|
||||
'business',
|
||||
'parentBusiness',
|
||||
'aging',
|
||||
'dso',
|
||||
'collectionRate',
|
||||
'monthlyTrend',
|
||||
'topCustomers',
|
||||
'startDate',
|
||||
'endDate'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* AP Analytics Dashboard.
|
||||
*/
|
||||
public function apAnalytics(Request $request, Business $business): View
|
||||
{
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
|
||||
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
|
||||
|
||||
$endDate = Carbon::parse($request->get('end_date', now()));
|
||||
$startDate = Carbon::parse($request->get('start_date', now()->subMonths(12)));
|
||||
|
||||
// Aging buckets
|
||||
$aging = $this->calculateApAging($allBusinessIds);
|
||||
|
||||
// DPO (Days Payable Outstanding)
|
||||
$dpo = $this->calculateDPO($allBusinessIds, $startDate, $endDate);
|
||||
|
||||
// Payment timing analysis
|
||||
$paymentTiming = $this->analyzePaymentTiming($allBusinessIds, $startDate, $endDate);
|
||||
|
||||
// Top vendors by AP balance
|
||||
$topVendors = ApBill::whereIn('business_id', $allBusinessIds)
|
||||
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
|
||||
->selectRaw('vendor_id, SUM(total - paid_amount) as total_balance')
|
||||
->groupBy('vendor_id')
|
||||
->with('vendor')
|
||||
->orderByDesc('total_balance')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Monthly AP trend
|
||||
$monthlyTrend = $this->getApMonthlyTrend($allBusinessIds, 12);
|
||||
|
||||
return view('seller.management.analytics.ap', compact(
|
||||
'business',
|
||||
'parentBusiness',
|
||||
'aging',
|
||||
'dpo',
|
||||
'paymentTiming',
|
||||
'topVendors',
|
||||
'monthlyTrend',
|
||||
'startDate',
|
||||
'endDate'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash Analytics Dashboard.
|
||||
*/
|
||||
public function cashAnalytics(Request $request, Business $business): View
|
||||
{
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
|
||||
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
|
||||
|
||||
// Current cash position from GL
|
||||
$cashPosition = $this->calculateCashPosition($parentBusiness);
|
||||
|
||||
// Cash flow by month (last 12 months)
|
||||
$monthlyCashFlow = $this->getMonthlyCashFlow($parentBusiness, 12);
|
||||
|
||||
// Expected collections (upcoming AR)
|
||||
$expectedCollections = ArInvoice::whereIn('business_id', $allBusinessIds)
|
||||
->where('balance_due', '>', 0)
|
||||
->where('due_date', '>=', now())
|
||||
->where('due_date', '<=', now()->addDays(90))
|
||||
->selectRaw("
|
||||
CASE
|
||||
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
|
||||
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
|
||||
ELSE '61-90 days'
|
||||
END as period,
|
||||
SUM(balance_due) as total
|
||||
")
|
||||
->groupBy(DB::raw("
|
||||
CASE
|
||||
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
|
||||
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
|
||||
ELSE '61-90 days'
|
||||
END
|
||||
"))
|
||||
->get()
|
||||
->pluck('total', 'period');
|
||||
|
||||
// Expected payments (upcoming AP)
|
||||
$expectedPayments = ApBill::whereIn('business_id', $allBusinessIds)
|
||||
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
|
||||
->where('due_date', '>=', now())
|
||||
->where('due_date', '<=', now()->addDays(90))
|
||||
->selectRaw("
|
||||
CASE
|
||||
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
|
||||
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
|
||||
ELSE '61-90 days'
|
||||
END as period,
|
||||
SUM(total - paid_amount) as total
|
||||
")
|
||||
->groupBy(DB::raw("
|
||||
CASE
|
||||
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
|
||||
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
|
||||
ELSE '61-90 days'
|
||||
END
|
||||
"))
|
||||
->get()
|
||||
->pluck('total', 'period');
|
||||
|
||||
// Cash runway (months of runway based on avg monthly expenses)
|
||||
$avgMonthlyExpenses = $this->getAverageMonthlyExpenses($allBusinessIds, 6);
|
||||
$cashRunway = $avgMonthlyExpenses > 0 ? round($cashPosition / $avgMonthlyExpenses, 1) : null;
|
||||
|
||||
return view('seller.management.analytics.cash', compact(
|
||||
'business',
|
||||
'parentBusiness',
|
||||
'cashPosition',
|
||||
'monthlyCashFlow',
|
||||
'expectedCollections',
|
||||
'expectedPayments',
|
||||
'avgMonthlyExpenses',
|
||||
'cashRunway'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Expense Analytics Dashboard.
|
||||
*/
|
||||
public function expenseAnalytics(Request $request, Business $business): View
|
||||
{
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
|
||||
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
|
||||
|
||||
$endDate = Carbon::parse($request->get('end_date', now()));
|
||||
$startDate = Carbon::parse($request->get('start_date', now()->subMonths(12)));
|
||||
|
||||
// Expenses by category
|
||||
$byCategory = Expense::whereIn('business_id', $allBusinessIds)
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->where('status', Expense::STATUS_APPROVED)
|
||||
->selectRaw('gl_account_id, SUM(amount) as total')
|
||||
->groupBy('gl_account_id')
|
||||
->with('glAccount')
|
||||
->orderByDesc('total')
|
||||
->get();
|
||||
|
||||
// Expenses by division
|
||||
$byDivision = Expense::whereIn('business_id', $allBusinessIds)
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->where('status', Expense::STATUS_APPROVED)
|
||||
->selectRaw('business_id, SUM(amount) as total')
|
||||
->groupBy('business_id')
|
||||
->with('business')
|
||||
->orderByDesc('total')
|
||||
->get();
|
||||
|
||||
// Monthly expense trend
|
||||
$monthlyTrend = Expense::whereIn('business_id', $allBusinessIds)
|
||||
->where('status', Expense::STATUS_APPROVED)
|
||||
->where('expense_date', '>=', now()->subMonths(12))
|
||||
->selectRaw("DATE_TRUNC('month', expense_date) as month, SUM(amount) as total")
|
||||
->groupBy(DB::raw("DATE_TRUNC('month', expense_date)"))
|
||||
->orderBy('month')
|
||||
->get();
|
||||
|
||||
// Top expense categories (from GL)
|
||||
$topCategories = JournalEntryLine::whereHas('journalEntry', function ($q) use ($parentBusiness, $startDate, $endDate) {
|
||||
$q->where('business_id', $parentBusiness->id)
|
||||
->where('status', JournalEntry::STATUS_POSTED)
|
||||
->whereBetween('entry_date', [$startDate, $endDate]);
|
||||
})
|
||||
->whereHas('glAccount', function ($q) {
|
||||
$q->where('account_type', 'expense');
|
||||
})
|
||||
->selectRaw('gl_account_id, SUM(debit_amount) as total_debit')
|
||||
->groupBy('gl_account_id')
|
||||
->with('glAccount')
|
||||
->orderByDesc('total_debit')
|
||||
->limit(15)
|
||||
->get();
|
||||
|
||||
return view('seller.management.analytics.expense', compact(
|
||||
'business',
|
||||
'parentBusiness',
|
||||
'byCategory',
|
||||
'byDivision',
|
||||
'monthlyTrend',
|
||||
'topCategories',
|
||||
'startDate',
|
||||
'endDate'
|
||||
));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPER METHODS
|
||||
// =========================================================================
|
||||
|
||||
protected function calculateArAging(array $businessIds): array
|
||||
{
|
||||
$buckets = [
|
||||
'current' => 0,
|
||||
'1_30' => 0,
|
||||
'31_60' => 0,
|
||||
'61_90' => 0,
|
||||
'over_90' => 0,
|
||||
];
|
||||
|
||||
$invoices = ArInvoice::whereIn('business_id', $businessIds)
|
||||
->where('balance_due', '>', 0)
|
||||
->get();
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
$daysOverdue = $invoice->due_date ? now()->diffInDays($invoice->due_date, false) : 0;
|
||||
|
||||
if ($daysOverdue <= 0) {
|
||||
$buckets['current'] += $invoice->balance_due;
|
||||
} elseif ($daysOverdue <= 30) {
|
||||
$buckets['1_30'] += $invoice->balance_due;
|
||||
} elseif ($daysOverdue <= 60) {
|
||||
$buckets['31_60'] += $invoice->balance_due;
|
||||
} elseif ($daysOverdue <= 90) {
|
||||
$buckets['61_90'] += $invoice->balance_due;
|
||||
} else {
|
||||
$buckets['over_90'] += $invoice->balance_due;
|
||||
}
|
||||
}
|
||||
|
||||
$buckets['total'] = array_sum($buckets);
|
||||
|
||||
return $buckets;
|
||||
}
|
||||
|
||||
protected function calculateApAging(array $businessIds): array
|
||||
{
|
||||
$buckets = [
|
||||
'current' => 0,
|
||||
'1_30' => 0,
|
||||
'31_60' => 0,
|
||||
'61_90' => 0,
|
||||
'over_90' => 0,
|
||||
];
|
||||
|
||||
$bills = ApBill::whereIn('business_id', $businessIds)
|
||||
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
|
||||
->get();
|
||||
|
||||
foreach ($bills as $bill) {
|
||||
$balance = $bill->total - $bill->paid_amount;
|
||||
$daysOverdue = $bill->due_date ? now()->diffInDays($bill->due_date, false) : 0;
|
||||
|
||||
if ($daysOverdue <= 0) {
|
||||
$buckets['current'] += $balance;
|
||||
} elseif ($daysOverdue <= 30) {
|
||||
$buckets['1_30'] += $balance;
|
||||
} elseif ($daysOverdue <= 60) {
|
||||
$buckets['31_60'] += $balance;
|
||||
} elseif ($daysOverdue <= 90) {
|
||||
$buckets['61_90'] += $balance;
|
||||
} else {
|
||||
$buckets['over_90'] += $balance;
|
||||
}
|
||||
}
|
||||
|
||||
$buckets['total'] = array_sum($buckets);
|
||||
|
||||
return $buckets;
|
||||
}
|
||||
|
||||
protected function calculateDSO(array $businessIds, Carbon $startDate, Carbon $endDate): float
|
||||
{
|
||||
$totalAR = ArInvoice::whereIn('business_id', $businessIds)
|
||||
->where('balance_due', '>', 0)
|
||||
->sum('balance_due');
|
||||
|
||||
$totalRevenue = ArInvoice::whereIn('business_id', $businessIds)
|
||||
->whereBetween('invoice_date', [$startDate, $endDate])
|
||||
->sum('total');
|
||||
|
||||
$days = $startDate->diffInDays($endDate);
|
||||
|
||||
if ($totalRevenue > 0 && $days > 0) {
|
||||
$avgDailyRevenue = $totalRevenue / $days;
|
||||
|
||||
return round($totalAR / $avgDailyRevenue, 1);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function calculateDPO(array $businessIds, Carbon $startDate, Carbon $endDate): float
|
||||
{
|
||||
$totalAP = ApBill::whereIn('business_id', $businessIds)
|
||||
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
|
||||
->selectRaw('SUM(total - paid_amount) as balance')
|
||||
->value('balance') ?? 0;
|
||||
|
||||
$totalPurchases = ApBill::whereIn('business_id', $businessIds)
|
||||
->whereBetween('bill_date', [$startDate, $endDate])
|
||||
->sum('total');
|
||||
|
||||
$days = $startDate->diffInDays($endDate);
|
||||
|
||||
if ($totalPurchases > 0 && $days > 0) {
|
||||
$avgDailyPurchases = $totalPurchases / $days;
|
||||
|
||||
return round($totalAP / $avgDailyPurchases, 1);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function calculateCollectionRate(array $businessIds, Carbon $startDate, Carbon $endDate): float
|
||||
{
|
||||
$totalInvoiced = ArInvoice::whereIn('business_id', $businessIds)
|
||||
->whereBetween('invoice_date', [$startDate, $endDate])
|
||||
->sum('total');
|
||||
|
||||
$totalCollected = ArInvoice::whereIn('business_id', $businessIds)
|
||||
->whereBetween('invoice_date', [$startDate, $endDate])
|
||||
->selectRaw('SUM(total - balance_due) as collected')
|
||||
->value('collected') ?? 0;
|
||||
|
||||
if ($totalInvoiced > 0) {
|
||||
return round(($totalCollected / $totalInvoiced) * 100, 1);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function analyzePaymentTiming(array $businessIds, Carbon $startDate, Carbon $endDate): array
|
||||
{
|
||||
$payments = ApPayment::whereIn('business_id', $businessIds)
|
||||
->whereBetween('payment_date', [$startDate, $endDate])
|
||||
->with('bill')
|
||||
->get();
|
||||
|
||||
$early = 0;
|
||||
$onTime = 0;
|
||||
$late = 0;
|
||||
|
||||
foreach ($payments as $payment) {
|
||||
if (! $payment->bill?->due_date) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$daysDiff = $payment->payment_date->diffInDays($payment->bill->due_date, false);
|
||||
|
||||
if ($daysDiff > 5) {
|
||||
$early++;
|
||||
} elseif ($daysDiff >= -5) {
|
||||
$onTime++;
|
||||
} else {
|
||||
$late++;
|
||||
}
|
||||
}
|
||||
|
||||
$total = $early + $onTime + $late;
|
||||
|
||||
return [
|
||||
'early' => $early,
|
||||
'on_time' => $onTime,
|
||||
'late' => $late,
|
||||
'early_pct' => $total > 0 ? round(($early / $total) * 100, 1) : 0,
|
||||
'on_time_pct' => $total > 0 ? round(($onTime / $total) * 100, 1) : 0,
|
||||
'late_pct' => $total > 0 ? round(($late / $total) * 100, 1) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getArMonthlyTrend(array $businessIds, int $months): \Illuminate\Support\Collection
|
||||
{
|
||||
return ArInvoice::whereIn('business_id', $businessIds)
|
||||
->where('invoice_date', '>=', now()->subMonths($months))
|
||||
->selectRaw("DATE_TRUNC('month', invoice_date) as month, SUM(total) as invoiced, SUM(total - balance_due) as collected")
|
||||
->groupBy(DB::raw("DATE_TRUNC('month', invoice_date)"))
|
||||
->orderBy('month')
|
||||
->get();
|
||||
}
|
||||
|
||||
protected function getApMonthlyTrend(array $businessIds, int $months): \Illuminate\Support\Collection
|
||||
{
|
||||
return ApBill::whereIn('business_id', $businessIds)
|
||||
->where('bill_date', '>=', now()->subMonths($months))
|
||||
->selectRaw("DATE_TRUNC('month', bill_date) as month, SUM(total) as billed, SUM(paid_amount) as paid")
|
||||
->groupBy(DB::raw("DATE_TRUNC('month', bill_date)"))
|
||||
->orderBy('month')
|
||||
->get();
|
||||
}
|
||||
|
||||
protected function calculateCashPosition(Business $parentBusiness): float
|
||||
{
|
||||
// Sum of all cash accounts (1000-1099 range)
|
||||
return JournalEntryLine::whereHas('journalEntry', function ($q) use ($parentBusiness) {
|
||||
$q->where('business_id', $parentBusiness->id)
|
||||
->where('status', JournalEntry::STATUS_POSTED);
|
||||
})
|
||||
->whereHas('glAccount', function ($q) {
|
||||
$q->where('account_number', '>=', '1000')
|
||||
->where('account_number', '<', '1100');
|
||||
})
|
||||
->selectRaw('SUM(debit_amount - credit_amount) as balance')
|
||||
->value('balance') ?? 0;
|
||||
}
|
||||
|
||||
protected function getMonthlyCashFlow(Business $parentBusiness, int $months): \Illuminate\Support\Collection
|
||||
{
|
||||
return JournalEntryLine::whereHas('journalEntry', function ($q) use ($parentBusiness) {
|
||||
$q->where('business_id', $parentBusiness->id)
|
||||
->where('status', JournalEntry::STATUS_POSTED)
|
||||
->where('entry_date', '>=', now()->subMonths($months));
|
||||
})
|
||||
->whereHas('glAccount', function ($q) {
|
||||
$q->where('account_number', '>=', '1000')
|
||||
->where('account_number', '<', '1100');
|
||||
})
|
||||
->join('journal_entries', 'journal_entry_lines.journal_entry_id', '=', 'journal_entries.id')
|
||||
->selectRaw("DATE_TRUNC('month', journal_entries.entry_date) as month, SUM(debit_amount) as inflows, SUM(credit_amount) as outflows")
|
||||
->groupBy(DB::raw("DATE_TRUNC('month', journal_entries.entry_date)"))
|
||||
->orderBy('month')
|
||||
->get();
|
||||
}
|
||||
|
||||
protected function getAverageMonthlyExpenses(array $businessIds, int $months): float
|
||||
{
|
||||
$total = Expense::whereIn('business_id', $businessIds)
|
||||
->where('status', Expense::STATUS_APPROVED)
|
||||
->where('expense_date', '>=', now()->subMonths($months))
|
||||
->sum('amount');
|
||||
|
||||
return $total / max($months, 1);
|
||||
}
|
||||
}
|
||||
102
app/Http/Controllers/Seller/Management/AnalyticsController.php
Normal file
102
app/Http/Controllers/Seller/Management/AnalyticsController.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AnalyticsController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
$businessIds = $filterData['business_ids'];
|
||||
|
||||
// Collect analytics data across all businesses
|
||||
$analytics = $this->collectAnalytics($businessIds);
|
||||
|
||||
return view('seller.management.analytics.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'analytics' => $analytics,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Require Management Suite access.
|
||||
*/
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function collectAnalytics(array $businessIds): array
|
||||
{
|
||||
// Revenue by division
|
||||
$revenueByDivision = DB::table('orders')
|
||||
->join('businesses', 'orders.business_id', '=', 'businesses.id')
|
||||
->whereIn('orders.business_id', $businessIds)
|
||||
->where('orders.status', 'completed')
|
||||
->select(
|
||||
'businesses.name as division_name',
|
||||
DB::raw('SUM(orders.total) as total_revenue'),
|
||||
DB::raw('COUNT(orders.id) as order_count')
|
||||
)
|
||||
->groupBy('businesses.id', 'businesses.name')
|
||||
->orderByDesc('total_revenue')
|
||||
->get();
|
||||
|
||||
// Expenses by division
|
||||
$expensesByDivision = DB::table('ap_bills')
|
||||
->join('businesses', 'ap_bills.business_id', '=', 'businesses.id')
|
||||
->whereIn('ap_bills.business_id', $businessIds)
|
||||
->whereIn('ap_bills.status', ['approved', 'paid'])
|
||||
->select(
|
||||
'businesses.name as division_name',
|
||||
DB::raw('SUM(ap_bills.total) as total_expenses'),
|
||||
DB::raw('COUNT(ap_bills.id) as bill_count')
|
||||
)
|
||||
->groupBy('businesses.id', 'businesses.name')
|
||||
->orderByDesc('total_expenses')
|
||||
->get();
|
||||
|
||||
// AR totals by division
|
||||
$arByDivision = DB::table('invoices')
|
||||
->join('businesses', 'invoices.business_id', '=', 'businesses.id')
|
||||
->whereIn('invoices.business_id', $businessIds)
|
||||
->whereIn('invoices.payment_status', ['sent', 'partial', 'overdue'])
|
||||
->select(
|
||||
'businesses.name as division_name',
|
||||
DB::raw('SUM(invoices.total) as total_ar'),
|
||||
DB::raw('SUM(invoices.amount_due) as outstanding_ar')
|
||||
)
|
||||
->groupBy('businesses.id', 'businesses.name')
|
||||
->orderByDesc('outstanding_ar')
|
||||
->get();
|
||||
|
||||
// Calculate totals
|
||||
$totalRevenue = $revenueByDivision->sum('total_revenue');
|
||||
$totalExpenses = $expensesByDivision->sum('total_expenses');
|
||||
$totalAr = $arByDivision->sum('outstanding_ar');
|
||||
|
||||
return [
|
||||
'revenue_by_division' => $revenueByDivision,
|
||||
'expenses_by_division' => $expensesByDivision,
|
||||
'ar_by_division' => $arByDivision,
|
||||
'totals' => [
|
||||
'revenue' => $totalRevenue,
|
||||
'expenses' => $totalExpenses,
|
||||
'net_income' => $totalRevenue - $totalExpenses,
|
||||
'outstanding_ar' => $totalAr,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
340
app/Http/Controllers/Seller/Management/ApBillsController.php
Normal file
340
app/Http/Controllers/Seller/Management/ApBillsController.php
Normal file
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ApBill;
|
||||
use App\Models\Accounting\ApVendor;
|
||||
use App\Models\Accounting\GlAccount;
|
||||
use App\Models\Business;
|
||||
use App\Models\Department;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Services\Accounting\BillService;
|
||||
use App\Services\Accounting\PaymentService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ApBillsController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected BillService $billService,
|
||||
protected PaymentService $paymentService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Bills list page.
|
||||
*
|
||||
* GET /s/{business}/management/ap/bills
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
// Get bills with filters - use division filter
|
||||
$query = ApBill::forBusinesses($filterData['business_ids'])
|
||||
->with(['vendor', 'purchaseOrder', 'business']);
|
||||
|
||||
// Status filter
|
||||
if ($request->filled('status')) {
|
||||
$query->status($request->status);
|
||||
}
|
||||
|
||||
// Vendor filter
|
||||
if ($request->filled('vendor_id')) {
|
||||
$query->where('vendor_id', $request->vendor_id);
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if ($request->filled('from_date')) {
|
||||
$query->whereDate('bill_date', '>=', $request->from_date);
|
||||
}
|
||||
if ($request->filled('to_date')) {
|
||||
$query->whereDate('bill_date', '<=', $request->to_date);
|
||||
}
|
||||
|
||||
// Unpaid filter
|
||||
if ($request->boolean('unpaid')) {
|
||||
$query->unpaid();
|
||||
}
|
||||
|
||||
// Overdue filter
|
||||
if ($request->boolean('overdue')) {
|
||||
$query->overdue();
|
||||
}
|
||||
|
||||
// Sort
|
||||
$sortField = $request->get('sort', 'due_date');
|
||||
$sortDir = $request->get('dir', 'asc');
|
||||
$query->orderBy($sortField, $sortDir);
|
||||
|
||||
$bills = $query->paginate(20)->withQueryString();
|
||||
|
||||
// Get vendors for filter dropdown (from all filtered businesses)
|
||||
$vendors = ApVendor::whereIn('business_id', $filterData['business_ids'])
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Stats (scoped to filtered businesses)
|
||||
$stats = [
|
||||
'total_outstanding' => ApBill::forBusinesses($filterData['business_ids'])->unpaid()->sum('balance_due'),
|
||||
'overdue_count' => ApBill::forBusinesses($filterData['business_ids'])->overdue()->count(),
|
||||
'overdue_amount' => ApBill::forBusinesses($filterData['business_ids'])->overdue()->sum('balance_due'),
|
||||
'pending_approval' => ApBill::forBusinesses($filterData['business_ids'])->whereIn('status', ['draft', 'pending'])->count(),
|
||||
];
|
||||
|
||||
// Check if user can pay (parent company only)
|
||||
$canPay = $business->parent_id === null;
|
||||
|
||||
return view('seller.management.ap.bills.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'bills' => $bills,
|
||||
'vendors' => $vendors,
|
||||
'stats' => $stats,
|
||||
'canPay' => $canPay,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill detail page.
|
||||
*
|
||||
* GET /s/{business}/management/ap/bills/{bill}
|
||||
*/
|
||||
public function show(Request $request, Business $business, ApBill $bill)
|
||||
{
|
||||
// Verify bill belongs to this business or its divisions
|
||||
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
|
||||
if (! in_array($bill->business_id, $allowedBusinessIds)) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
$bill->load([
|
||||
'vendor',
|
||||
'items.glAccount',
|
||||
'items.department',
|
||||
'purchaseOrder.items',
|
||||
'paymentApplications.payment',
|
||||
'approvedBy',
|
||||
'createdBy',
|
||||
]);
|
||||
|
||||
// Check if user can pay (parent company only)
|
||||
$canPay = $business->parent_id === null;
|
||||
|
||||
// Check if user can approve
|
||||
$canApprove = in_array($bill->status, [ApBill::STATUS_DRAFT, ApBill::STATUS_PENDING]);
|
||||
|
||||
return view('seller.management.ap.bills.show', compact(
|
||||
'business',
|
||||
'bill',
|
||||
'canPay',
|
||||
'canApprove'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create bill page (manual entry).
|
||||
*
|
||||
* GET /s/{business}/management/ap/bills/create
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
// Get vendors
|
||||
$vendors = ApVendor::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get GL accounts for line items
|
||||
$glAccounts = GlAccount::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->where('is_header', false)
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
// Get departments
|
||||
$departments = Department::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// If creating from PO, get the PO
|
||||
$purchaseOrder = null;
|
||||
if ($request->filled('po_id')) {
|
||||
$purchaseOrder = PurchaseOrder::where('business_id', $business->id)
|
||||
->with('items')
|
||||
->findOrFail($request->po_id);
|
||||
}
|
||||
|
||||
return view('seller.management.ap.bills.create', compact(
|
||||
'business',
|
||||
'vendors',
|
||||
'glAccounts',
|
||||
'departments',
|
||||
'purchaseOrder'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new bill (web form submission).
|
||||
*
|
||||
* POST /s/{business}/management/ap/bills
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'vendor_id' => ['required', 'integer', Rule::exists('ap_vendors', 'id')->where('business_id', $business->id)],
|
||||
'vendor_invoice_number' => 'required|string|max:100',
|
||||
'bill_date' => 'required|date',
|
||||
'due_date' => 'required|date|after_or_equal:bill_date',
|
||||
'payment_terms' => 'nullable|integer|min:0',
|
||||
'department_id' => ['nullable', 'integer', Rule::exists('departments', 'id')->where('business_id', $business->id)],
|
||||
'tax_amount' => 'nullable|numeric|min:0',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.description' => 'required|string|max:255',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.unit_price' => 'required|numeric|min:0',
|
||||
'items.*.gl_account_id' => ['required', 'integer', Rule::exists('gl_accounts', 'id')->where('business_id', $business->id)],
|
||||
'items.*.department_id' => ['nullable', 'integer', Rule::exists('departments', 'id')->where('business_id', $business->id)],
|
||||
'purchase_order_id' => ['nullable', 'integer', Rule::exists('purchase_orders', 'id')->where('business_id', $business->id)],
|
||||
]);
|
||||
|
||||
try {
|
||||
// Check if creating from PO
|
||||
if (! empty($validated['purchase_order_id'])) {
|
||||
$po = PurchaseOrder::where('business_id', $business->id)
|
||||
->findOrFail($validated['purchase_order_id']);
|
||||
|
||||
$bill = $this->billService->createFromPurchaseOrder(
|
||||
$po,
|
||||
$validated['vendor_invoice_number'],
|
||||
$validated
|
||||
);
|
||||
} else {
|
||||
$bill = $this->billService->createManualBill(
|
||||
$business->id,
|
||||
$validated['vendor_id'],
|
||||
$validated['vendor_invoice_number'],
|
||||
$validated['items'],
|
||||
$validated
|
||||
);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.ap.bills.show', [$business, $bill])
|
||||
->with('success', "Bill {$bill->bill_number} created successfully.");
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a bill.
|
||||
*
|
||||
* POST /s/{business}/management/ap/bills/{bill}/approve
|
||||
*/
|
||||
public function approve(Business $business, ApBill $bill)
|
||||
{
|
||||
if ($bill->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->billService->approveBill($bill, auth()->id());
|
||||
|
||||
return back()->with('success', "Bill {$bill->bill_number} approved.");
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay a bill (parent company only).
|
||||
*
|
||||
* POST /s/{business}/management/ap/bills/{bill}/pay
|
||||
*/
|
||||
public function pay(Request $request, Business $business, ApBill $bill)
|
||||
{
|
||||
// Only parent company can pay
|
||||
if ($business->parent_id !== null) {
|
||||
abort(403, 'Only parent company can make payments.');
|
||||
}
|
||||
|
||||
// Bill must be from this business or a child
|
||||
$canPay = $bill->business_id === $business->id
|
||||
|| $bill->business->parent_id === $business->id;
|
||||
|
||||
if (! $canPay) {
|
||||
abort(403, 'Cannot pay this bill.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'payment_method' => 'required|in:check,ach,wire,card,cash',
|
||||
'amount' => 'nullable|numeric|min:0.01',
|
||||
'discount' => 'nullable|numeric|min:0',
|
||||
'reference_number' => 'nullable|string|max:100',
|
||||
'memo' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
try {
|
||||
$discount = $validated['discount'] ?? 0;
|
||||
$amount = $validated['amount'] ?? bcsub((string) $bill->balance_due, (string) $discount, 2);
|
||||
|
||||
$payment = $this->paymentService->createPayment(
|
||||
$business,
|
||||
$bill->vendor_id,
|
||||
(float) $amount,
|
||||
$validated['payment_method'],
|
||||
[
|
||||
[
|
||||
'bill_id' => $bill->id,
|
||||
'amount' => $amount,
|
||||
'discount' => $discount,
|
||||
],
|
||||
],
|
||||
[
|
||||
'reference_number' => $validated['reference_number'] ?? null,
|
||||
'memo' => $validated['memo'] ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
$this->paymentService->completePayment($payment);
|
||||
|
||||
return back()->with('success', "Payment {$payment->payment_number} applied to bill {$bill->bill_number}.");
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Void a bill.
|
||||
*
|
||||
* POST /s/{business}/management/ap/bills/{bill}/void
|
||||
*/
|
||||
public function void(Request $request, Business $business, ApBill $bill)
|
||||
{
|
||||
if ($bill->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'reason' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->billService->voidBill($bill, $validated['reason'] ?? null);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.ap.bills.index', $business)
|
||||
->with('success', "Bill {$bill->bill_number} voided.");
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
328
app/Http/Controllers/Seller/Management/ApVendorsController.php
Normal file
328
app/Http/Controllers/Seller/Management/ApVendorsController.php
Normal file
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ApVendor;
|
||||
use App\Models\Accounting\GlAccount;
|
||||
use App\Models\Business;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ApVendorsController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
/**
|
||||
* Vendors list page.
|
||||
*
|
||||
* GET /s/{business}/management/ap/vendors
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
$isParent = $business->parent_id === null && Business::where('parent_id', $business->id)->exists();
|
||||
|
||||
$query = ApVendor::whereIn('business_id', $filterData['business_ids'])
|
||||
->with('business')
|
||||
->withCount('bills');
|
||||
|
||||
// Search filter
|
||||
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}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Active filter
|
||||
if ($request->has('active')) {
|
||||
$query->where('is_active', $request->boolean('active'));
|
||||
}
|
||||
|
||||
$vendors = $query->orderBy('name')->paginate(20)->withQueryString();
|
||||
|
||||
// For parent business, compute which child divisions use each vendor
|
||||
if ($isParent) {
|
||||
$childBusinessIds = Business::where('parent_id', $business->id)->pluck('id')->toArray();
|
||||
$childBusinesses = Business::whereIn('id', $childBusinessIds)->get()->keyBy('id');
|
||||
|
||||
$vendors->getCollection()->transform(function ($vendor) use ($childBusinessIds, $childBusinesses) {
|
||||
// Get divisions that have bills or POs with this vendor
|
||||
$divisionsUsingVendor = collect();
|
||||
|
||||
// Check if vendor belongs to a child directly
|
||||
if (in_array($vendor->business_id, $childBusinessIds)) {
|
||||
$divisionsUsingVendor->push($childBusinesses[$vendor->business_id] ?? null);
|
||||
}
|
||||
|
||||
// Check for bills from other children using this vendor
|
||||
$billBusinessIds = $vendor->bills()
|
||||
->whereIn('business_id', $childBusinessIds)
|
||||
->distinct()
|
||||
->pluck('business_id')
|
||||
->toArray();
|
||||
|
||||
foreach ($billBusinessIds as $bizId) {
|
||||
if (! $divisionsUsingVendor->contains('id', $bizId) && isset($childBusinesses[$bizId])) {
|
||||
$divisionsUsingVendor->push($childBusinesses[$bizId]);
|
||||
}
|
||||
}
|
||||
|
||||
$vendor->divisions_using = $divisionsUsingVendor->filter()->unique('id')->values();
|
||||
|
||||
return $vendor;
|
||||
});
|
||||
}
|
||||
|
||||
// Get GL accounts for default expense account dropdown
|
||||
$glAccounts = GlAccount::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->where('is_header', false)
|
||||
->whereIn('account_type', ['expense', 'asset'])
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
return view('seller.management.ap.vendors.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'vendors' => $vendors,
|
||||
'glAccounts' => $glAccounts,
|
||||
'isParent' => $isParent,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new vendor.
|
||||
*
|
||||
* POST /s/{business}/management/ap/vendors
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'nullable|string|max:50',
|
||||
'name' => 'required|string|max:255',
|
||||
'legal_name' => 'nullable|string|max:255',
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'default_payment_terms' => 'nullable|integer|min:0',
|
||||
'default_gl_account_id' => ['nullable', 'integer', Rule::exists('gl_accounts', 'id')->where('business_id', $business->id)],
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'address_line1' => 'nullable|string|max:255',
|
||||
'address_line2' => 'nullable|string|max:255',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:100',
|
||||
'postal_code' => 'nullable|string|max:20',
|
||||
'country' => 'nullable|string|max:100',
|
||||
'is_1099' => 'boolean',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Generate code if not provided
|
||||
if (empty($validated['code'])) {
|
||||
$validated['code'] = $this->generateVendorCode($business->id, $validated['name']);
|
||||
}
|
||||
|
||||
$vendor = ApVendor::create([
|
||||
'business_id' => $business->id,
|
||||
...$validated,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'vendor' => $vendor,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', "Vendor {$vendor->name} created successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create vendor form.
|
||||
*
|
||||
* GET /s/{business}/management/ap/vendors/create
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$glAccounts = GlAccount::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->where('is_header', false)
|
||||
->whereIn('account_type', ['expense', 'asset'])
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
return view('seller.management.ap.vendors.create', compact(
|
||||
'business',
|
||||
'glAccounts'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show vendor details.
|
||||
*
|
||||
* GET /s/{business}/management/ap/vendors/{vendor}
|
||||
*/
|
||||
public function show(Request $request, Business $business, ApVendor $vendor)
|
||||
{
|
||||
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
|
||||
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
$vendor->load(['defaultGlAccount']);
|
||||
|
||||
// Get recent bills
|
||||
$recentBills = $vendor->bills()
|
||||
->with(['glAccount'])
|
||||
->orderByDesc('bill_date')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Get recent payments
|
||||
$recentPayments = $vendor->payments()
|
||||
->with(['bills'])
|
||||
->orderByDesc('payment_date')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Calculate metrics
|
||||
$metrics = [
|
||||
'total_bills' => $vendor->bills()->count(),
|
||||
'unpaid_balance' => $vendor->bills()->unpaid()->sum('balance_due'),
|
||||
'overdue_balance' => $vendor->bills()->overdue()->sum('balance_due'),
|
||||
'ytd_payments' => $vendor->payments()
|
||||
->whereYear('payment_date', now()->year)
|
||||
->completed()
|
||||
->sum('amount'),
|
||||
];
|
||||
|
||||
return view('seller.management.ap.vendors.show', compact(
|
||||
'business',
|
||||
'vendor',
|
||||
'recentBills',
|
||||
'recentPayments',
|
||||
'metrics'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit vendor form.
|
||||
*
|
||||
* GET /s/{business}/management/ap/vendors/{vendor}/edit
|
||||
*/
|
||||
public function edit(Request $request, Business $business, ApVendor $vendor)
|
||||
{
|
||||
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
|
||||
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
$glAccounts = GlAccount::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->where('is_header', false)
|
||||
->whereIn('account_type', ['expense', 'asset'])
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
return view('seller.management.ap.vendors.edit', compact(
|
||||
'business',
|
||||
'vendor',
|
||||
'glAccounts'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a vendor.
|
||||
*
|
||||
* PUT /s/{business}/management/ap/vendors/{vendor}
|
||||
*/
|
||||
public function update(Request $request, Business $business, ApVendor $vendor)
|
||||
{
|
||||
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
|
||||
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'code' => 'nullable|string|max:50',
|
||||
'name' => 'required|string|max:255',
|
||||
'legal_name' => 'nullable|string|max:255',
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'default_payment_terms' => 'nullable|integer|min:0',
|
||||
'default_gl_account_id' => ['nullable', 'integer', Rule::exists('gl_accounts', 'id')->where('business_id', $business->id)],
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'address_line1' => 'nullable|string|max:255',
|
||||
'address_line2' => 'nullable|string|max:255',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:100',
|
||||
'postal_code' => 'nullable|string|max:20',
|
||||
'country' => 'nullable|string|max:100',
|
||||
'is_1099' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$vendor->update($validated);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'vendor' => $vendor->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', "Vendor {$vendor->name} updated successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle vendor active status.
|
||||
*
|
||||
* POST /s/{business}/management/ap/vendors/{vendor}/toggle-active
|
||||
*/
|
||||
public function toggleActive(Business $business, ApVendor $vendor)
|
||||
{
|
||||
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
|
||||
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
$vendor->update(['is_active' => ! $vendor->is_active]);
|
||||
|
||||
$status = $vendor->is_active ? 'activated' : 'deactivated';
|
||||
|
||||
return back()->with('success', "Vendor {$vendor->name} {$status}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate vendor code from name.
|
||||
*/
|
||||
protected function generateVendorCode(int $businessId, string $name): string
|
||||
{
|
||||
// Take first 3 chars of each word, uppercase
|
||||
$words = preg_split('/\s+/', strtoupper($name));
|
||||
$prefix = '';
|
||||
foreach ($words as $word) {
|
||||
$prefix .= substr(preg_replace('/[^A-Z0-9]/', '', $word), 0, 3);
|
||||
if (strlen($prefix) >= 6) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$prefix = substr($prefix, 0, 6);
|
||||
|
||||
// Check for uniqueness
|
||||
$count = ApVendor::where('business_id', $businessId)
|
||||
->where('code', 'like', "{$prefix}%")
|
||||
->count();
|
||||
|
||||
return $count > 0 ? "{$prefix}-{$count}" : $prefix;
|
||||
}
|
||||
}
|
||||
207
app/Http/Controllers/Seller/Management/ArController.php
Normal file
207
app/Http/Controllers/Seller/Management/ArController.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ArCustomer;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\ArAnalyticsService;
|
||||
use App\Services\Accounting\ArService;
|
||||
use App\Services\Accounting\CustomerFinancialService;
|
||||
use App\Services\Accounting\ReportExportService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ArController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected ArAnalyticsService $analyticsService,
|
||||
protected ArService $arService,
|
||||
protected CustomerFinancialService $customerService,
|
||||
protected ReportExportService $exportService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* AR Overview dashboard.
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
$metrics = $this->analyticsService->getARMetrics($business, $filterData['business_ids']);
|
||||
$aging = $this->analyticsService->getARAging($business, $filterData['business_ids']);
|
||||
$topCustomers = $this->analyticsService->getARBreakdownByCustomer($business, $filterData['business_ids'], 5);
|
||||
|
||||
return view('seller.management.ar.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'metrics' => $metrics,
|
||||
'aging' => $aging,
|
||||
'topCustomers' => $topCustomers,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* AR Aging detail page.
|
||||
*/
|
||||
public function aging(Request $request, Business $business)
|
||||
{
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
$aging = $this->analyticsService->getARAging($business, $filterData['business_ids']);
|
||||
$byDivision = $this->analyticsService->getARBreakdownByDivision($business, $filterData['business_ids']);
|
||||
$byCustomer = $this->analyticsService->getARBreakdownByCustomer($business, $filterData['business_ids'], 10);
|
||||
|
||||
// Check for bucket filter from drill-down
|
||||
$bucket = $request->get('bucket');
|
||||
|
||||
return view('seller.management.ar.aging', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'aging' => $aging,
|
||||
'byDivision' => $byDivision,
|
||||
'byCustomer' => $byCustomer,
|
||||
'activeBucket' => $bucket,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* AR Accounts list page.
|
||||
*/
|
||||
public function accounts(Request $request, Business $business)
|
||||
{
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
$filters = [
|
||||
'on_hold' => $request->boolean('on_hold'),
|
||||
'at_risk' => $request->boolean('at_risk'),
|
||||
'search' => $request->get('search'),
|
||||
];
|
||||
|
||||
$accounts = $this->arService->getAccountsWithBalances(
|
||||
$business,
|
||||
$filterData['business_ids'],
|
||||
$filters
|
||||
);
|
||||
|
||||
$metrics = $this->analyticsService->getARMetrics($business, $filterData['business_ids']);
|
||||
|
||||
return view('seller.management.ar.accounts', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'accounts' => $accounts,
|
||||
'metrics' => $metrics,
|
||||
'filters' => $filters,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Single account detail page.
|
||||
*/
|
||||
public function showAccount(Request $request, Business $business, ArCustomer $customer)
|
||||
{
|
||||
// Verify customer belongs to this business or a child
|
||||
$isParent = $this->arService->isParentCompany($business);
|
||||
$allowedBusinessIds = $isParent
|
||||
? $this->arService->getBusinessIdsWithChildren($business)
|
||||
: [$business->id];
|
||||
|
||||
if (! in_array($customer->business_id, $allowedBusinessIds)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$summary = $this->customerService->getFinancialSummary($customer, $business, $isParent);
|
||||
$invoices = $this->customerService->getInvoices($customer, $business, $isParent);
|
||||
$payments = $this->customerService->getPayments($customer, $business, $isParent);
|
||||
$activities = $this->customerService->getRecentActivity($customer, $business);
|
||||
|
||||
return view('seller.management.ar.account-detail', [
|
||||
'business' => $business,
|
||||
'customer' => $customer,
|
||||
'summary' => $summary,
|
||||
'invoices' => $invoices,
|
||||
'payments' => $payments,
|
||||
'activities' => $activities,
|
||||
'isParent' => $isParent,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update credit limit (Management only).
|
||||
*/
|
||||
public function updateCreditLimit(Request $request, Business $business, ArCustomer $customer)
|
||||
{
|
||||
$request->validate([
|
||||
'credit_limit' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$this->arService->updateCreditLimit(
|
||||
$customer,
|
||||
(float) $request->input('credit_limit'),
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return back()->with('success', 'Credit limit updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update payment terms (Management only).
|
||||
*/
|
||||
public function updateTerms(Request $request, Business $business, ArCustomer $customer)
|
||||
{
|
||||
$request->validate([
|
||||
'payment_terms' => 'required|string',
|
||||
]);
|
||||
|
||||
$this->arService->updatePaymentTerms(
|
||||
$customer,
|
||||
$request->input('payment_terms'),
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return back()->with('success', 'Payment terms updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Place credit hold (Management only).
|
||||
*/
|
||||
public function placeHold(Request $request, Business $business, ArCustomer $customer)
|
||||
{
|
||||
$request->validate([
|
||||
'reason' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
$this->arService->placeCreditHold(
|
||||
$customer,
|
||||
$request->input('reason'),
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return back()->with('success', 'Credit hold placed successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove credit hold (Management only).
|
||||
*/
|
||||
public function removeHold(Request $request, Business $business, ArCustomer $customer)
|
||||
{
|
||||
$this->arService->removeCreditHold($customer, auth()->id());
|
||||
|
||||
return back()->with('success', 'Credit hold removed successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export AR Aging report as CSV.
|
||||
*/
|
||||
public function exportAging(Request $request, Business $business): StreamedResponse
|
||||
{
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
$byCustomer = $this->analyticsService->getARBreakdownByCustomer($business, $filterData['business_ids'], 1000);
|
||||
|
||||
$filename = 'ar_aging_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
|
||||
|
||||
return $this->exportService->exportArAging($byCustomer, $filename);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\BankAccount;
|
||||
use App\Models\Accounting\GlAccount;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\BankAccountService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BankAccountsController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected BankAccountService $bankAccountService
|
||||
) {}
|
||||
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the list of bank accounts.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
// Determine which business to show accounts for
|
||||
$targetBusiness = $filterData['selected_division'] ?? $business;
|
||||
$includeChildren = $filterData['selected_division'] === null && $business->hasChildBusinesses();
|
||||
|
||||
$accounts = $this->bankAccountService->getAccountsForBusiness($targetBusiness, $includeChildren);
|
||||
$totalBalance = $this->bankAccountService->getTotalCashBalance($targetBusiness, $includeChildren);
|
||||
|
||||
return view('seller.management.bank-accounts.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'accounts' => $accounts,
|
||||
'totalBalance' => $totalBalance,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new bank account.
|
||||
*/
|
||||
public function create(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$glAccounts = GlAccount::where('business_id', $business->id)
|
||||
->where('account_type', 'asset')
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
return view('seller.management.bank-accounts.create', [
|
||||
'business' => $business,
|
||||
'glAccounts' => $glAccounts,
|
||||
'accountTypes' => [
|
||||
BankAccount::TYPE_CHECKING => 'Checking',
|
||||
BankAccount::TYPE_SAVINGS => 'Savings',
|
||||
BankAccount::TYPE_MONEY_MARKET => 'Money Market',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created bank account.
|
||||
*/
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'account_type' => 'required|string|in:checking,savings,money_market',
|
||||
'bank_name' => 'nullable|string|max:255',
|
||||
'account_number_last4' => 'nullable|string|max:4',
|
||||
'routing_number' => 'nullable|string|max:9',
|
||||
'current_balance' => 'nullable|numeric|min:0',
|
||||
'gl_account_id' => 'nullable|exists:gl_accounts,id',
|
||||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$this->bankAccountService->createAccount($business, $validated, auth()->user());
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.bank-accounts.index', $business)
|
||||
->with('success', 'Bank account created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified bank account.
|
||||
*/
|
||||
public function show(Request $request, Business $business, BankAccount $bankAccount): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($bankAccount->business_id !== $business->id) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
$recentTransfers = $bankAccount->outgoingTransfers()
|
||||
->orWhere('to_bank_account_id', $bankAccount->id)
|
||||
->with(['fromAccount', 'toAccount'])
|
||||
->orderBy('transfer_date', 'desc')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return view('seller.management.bank-accounts.show', [
|
||||
'business' => $business,
|
||||
'account' => $bankAccount,
|
||||
'recentTransfers' => $recentTransfers,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the bank account.
|
||||
*/
|
||||
public function edit(Request $request, Business $business, BankAccount $bankAccount): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($bankAccount->business_id !== $business->id) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
$glAccounts = GlAccount::where('business_id', $business->id)
|
||||
->where('account_type', 'asset')
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
return view('seller.management.bank-accounts.edit', [
|
||||
'business' => $business,
|
||||
'account' => $bankAccount,
|
||||
'glAccounts' => $glAccounts,
|
||||
'accountTypes' => [
|
||||
BankAccount::TYPE_CHECKING => 'Checking',
|
||||
BankAccount::TYPE_SAVINGS => 'Savings',
|
||||
BankAccount::TYPE_MONEY_MARKET => 'Money Market',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified bank account.
|
||||
*/
|
||||
public function update(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($bankAccount->business_id !== $business->id) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'account_type' => 'required|string|in:checking,savings,money_market',
|
||||
'bank_name' => 'nullable|string|max:255',
|
||||
'account_number_last4' => 'nullable|string|max:4',
|
||||
'routing_number' => 'nullable|string|max:9',
|
||||
'gl_account_id' => 'nullable|exists:gl_accounts,id',
|
||||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$this->bankAccountService->updateAccount($bankAccount, $validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.bank-accounts.index', $business)
|
||||
->with('success', 'Bank account updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the active status of a bank account.
|
||||
*/
|
||||
public function toggleActive(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($bankAccount->business_id !== $business->id) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
$bankAccount->update(['is_active' => ! $bankAccount->is_active]);
|
||||
|
||||
$status = $bankAccount->is_active ? 'activated' : 'deactivated';
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->with('success', "Bank account {$status} successfully.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ApPayment;
|
||||
use App\Models\Accounting\BankAccount;
|
||||
use App\Models\Accounting\BankMatchRule;
|
||||
use App\Models\Accounting\JournalEntry;
|
||||
use App\Models\Accounting\PlaidAccount;
|
||||
use App\Models\Accounting\PlaidTransaction;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\BankReconciliationService;
|
||||
use App\Services\Accounting\PlaidIntegrationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BankReconciliationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected BankReconciliationService $reconciliationService,
|
||||
protected PlaidIntegrationService $plaidService
|
||||
) {}
|
||||
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the reconciliation dashboard for a bank account.
|
||||
*/
|
||||
public function show(Request $request, Business $business, BankAccount $bankAccount): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeAccountAccess($business, $bankAccount);
|
||||
|
||||
$summary = $this->reconciliationService->getReconciliationSummary($bankAccount);
|
||||
$unmatchedTransactions = $this->reconciliationService->getUnmatchedTransactions($bankAccount);
|
||||
$proposedMatches = $this->reconciliationService->getProposedAutoMatches($bankAccount);
|
||||
|
||||
return view('seller.management.bank-accounts.reconciliation', [
|
||||
'business' => $business,
|
||||
'account' => $bankAccount,
|
||||
'summary' => $summary,
|
||||
'unmatchedTransactions' => $unmatchedTransactions,
|
||||
'proposedMatches' => $proposedMatches,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync transactions from Plaid.
|
||||
*/
|
||||
public function syncTransactions(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeAccountAccess($business, $bankAccount);
|
||||
|
||||
$sinceDate = $request->input('since_date')
|
||||
? new \DateTime($request->input('since_date'))
|
||||
: now()->subDays(30);
|
||||
|
||||
$synced = $this->plaidService->syncTransactions($business, $sinceDate);
|
||||
|
||||
// Run auto-matching
|
||||
$matched = $this->reconciliationService->runAutoMatching($bankAccount);
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->with('success', "Synced {$synced} transactions. {$matched} proposed auto-matches found.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find potential matches for a transaction (AJAX).
|
||||
*/
|
||||
public function findMatches(Request $request, Business $business, BankAccount $bankAccount, PlaidTransaction $transaction): JsonResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeAccountAccess($business, $bankAccount);
|
||||
|
||||
$matches = $this->reconciliationService->findPotentialMatches($transaction, $business);
|
||||
|
||||
return response()->json([
|
||||
'ap_payments' => $matches['ap_payments']->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'type' => 'ap_payment',
|
||||
'label' => "AP Payment #{$p->id} - ".($p->bill?->vendor?->name ?? 'Unknown'),
|
||||
'amount' => $p->amount,
|
||||
'date' => $p->payment_date->format('Y-m-d'),
|
||||
]),
|
||||
'journal_entries' => $matches['journal_entries']->map(fn ($je) => [
|
||||
'id' => $je->id,
|
||||
'type' => 'journal_entry',
|
||||
'label' => "JE #{$je->entry_number} - {$je->memo}",
|
||||
'date' => $je->entry_date->format('Y-m-d'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a transaction to an AP payment.
|
||||
*/
|
||||
public function matchToApPayment(
|
||||
Request $request,
|
||||
Business $business,
|
||||
BankAccount $bankAccount,
|
||||
PlaidTransaction $transaction
|
||||
): RedirectResponse {
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeAccountAccess($business, $bankAccount);
|
||||
|
||||
$validated = $request->validate([
|
||||
'ap_payment_id' => 'required|exists:ap_payments,id',
|
||||
]);
|
||||
|
||||
$payment = ApPayment::findOrFail($validated['ap_payment_id']);
|
||||
$this->reconciliationService->matchToApPayment($transaction, $payment, auth()->user());
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->with('success', 'Transaction matched to AP payment successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a transaction to a journal entry.
|
||||
*/
|
||||
public function matchToJournalEntry(
|
||||
Request $request,
|
||||
Business $business,
|
||||
BankAccount $bankAccount,
|
||||
PlaidTransaction $transaction
|
||||
): RedirectResponse {
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeAccountAccess($business, $bankAccount);
|
||||
|
||||
$validated = $request->validate([
|
||||
'journal_entry_id' => 'required|exists:journal_entries,id',
|
||||
]);
|
||||
|
||||
$entry = JournalEntry::findOrFail($validated['journal_entry_id']);
|
||||
$this->reconciliationService->matchToJournalEntry($transaction, $entry, auth()->user());
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->with('success', 'Transaction matched to journal entry successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm selected auto-matches.
|
||||
*/
|
||||
public function confirmAutoMatches(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeAccountAccess($business, $bankAccount);
|
||||
|
||||
$validated = $request->validate([
|
||||
'transaction_ids' => 'required|array',
|
||||
'transaction_ids.*' => 'exists:plaid_transactions,id',
|
||||
]);
|
||||
|
||||
$confirmed = $this->reconciliationService->confirmAutoMatches(
|
||||
$validated['transaction_ids'],
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->with('success', "Confirmed {$confirmed} auto-matched transactions.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject selected auto-matches.
|
||||
*/
|
||||
public function rejectAutoMatches(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeAccountAccess($business, $bankAccount);
|
||||
|
||||
$validated = $request->validate([
|
||||
'transaction_ids' => 'required|array',
|
||||
'transaction_ids.*' => 'exists:plaid_transactions,id',
|
||||
]);
|
||||
|
||||
$rejected = $this->reconciliationService->rejectAutoMatches(
|
||||
$validated['transaction_ids'],
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->with('success', "Rejected {$rejected} auto-matched transactions.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore selected transactions.
|
||||
*/
|
||||
public function ignoreTransactions(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeAccountAccess($business, $bankAccount);
|
||||
|
||||
$validated = $request->validate([
|
||||
'transaction_ids' => 'required|array',
|
||||
'transaction_ids.*' => 'exists:plaid_transactions,id',
|
||||
]);
|
||||
|
||||
$ignored = $this->reconciliationService->ignoreTransactions($validated['transaction_ids']);
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->with('success', "Ignored {$ignored} transactions.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Display match rules for a bank account.
|
||||
*/
|
||||
public function matchRules(Request $request, Business $business, BankAccount $bankAccount): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeAccountAccess($business, $bankAccount);
|
||||
|
||||
$rules = $this->reconciliationService->getMatchRules($bankAccount);
|
||||
$eligibleRules = $this->reconciliationService->getEligibleRules($bankAccount);
|
||||
|
||||
return view('seller.management.bank-accounts.match-rules', [
|
||||
'business' => $business,
|
||||
'account' => $bankAccount,
|
||||
'rules' => $rules,
|
||||
'eligibleRules' => $eligibleRules,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle auto-enable for a match rule.
|
||||
*/
|
||||
public function toggleRuleAutoEnable(
|
||||
Request $request,
|
||||
Business $business,
|
||||
BankAccount $bankAccount,
|
||||
BankMatchRule $rule
|
||||
): RedirectResponse {
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeAccountAccess($business, $bankAccount);
|
||||
|
||||
if ($rule->bank_account_id !== $bankAccount->id) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
$enabled = $request->boolean('enabled');
|
||||
|
||||
try {
|
||||
$this->reconciliationService->toggleRuleAutoEnable($rule, $enabled);
|
||||
$status = $enabled ? 'enabled' : 'disabled';
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->with('success', "Auto-matching {$status} for rule: {$rule->pattern_name}");
|
||||
} catch (\Exception $e) {
|
||||
return redirect()
|
||||
->back()
|
||||
->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a Plaid account to a bank account.
|
||||
*/
|
||||
public function linkPlaidAccount(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeAccountAccess($business, $bankAccount);
|
||||
|
||||
$validated = $request->validate([
|
||||
'plaid_account_id' => 'required|exists:plaid_accounts,id',
|
||||
]);
|
||||
|
||||
$plaidAccount = PlaidAccount::findOrFail($validated['plaid_account_id']);
|
||||
$this->plaidService->linkPlaidAccountToBankAccount($plaidAccount, $bankAccount);
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->with('success', 'Plaid account linked successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize access to a bank account.
|
||||
*/
|
||||
private function authorizeAccountAccess(Business $business, BankAccount $bankAccount): void
|
||||
{
|
||||
// Allow access if account belongs to this business or a child business
|
||||
if ($bankAccount->business_id === $business->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($business->isParentCompany()) {
|
||||
$childIds = $business->divisions()->pluck('id')->toArray();
|
||||
if (in_array($bankAccount->business_id, $childIds)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\BankAccount;
|
||||
use App\Models\Accounting\BankTransfer;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\BankAccountService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BankTransfersController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected BankAccountService $bankAccountService
|
||||
) {}
|
||||
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the list of bank transfers.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
$filters = [
|
||||
'status' => $request->get('status'),
|
||||
'from_date' => $request->get('from_date'),
|
||||
'to_date' => $request->get('to_date'),
|
||||
];
|
||||
|
||||
$targetBusiness = $filterData['selected_division'] ?? $business;
|
||||
$transfers = $this->bankAccountService->getTransfersForBusiness($targetBusiness, $filters);
|
||||
|
||||
return view('seller.management.bank-transfers.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'transfers' => $transfers,
|
||||
'filters' => $filters,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new bank transfer.
|
||||
*/
|
||||
public function create(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$accounts = BankAccount::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.management.bank-transfers.create', [
|
||||
'business' => $business,
|
||||
'accounts' => $accounts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created bank transfer.
|
||||
*/
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'from_bank_account_id' => 'required|exists:bank_accounts,id',
|
||||
'to_bank_account_id' => 'required|exists:bank_accounts,id|different:from_bank_account_id',
|
||||
'amount' => 'required|numeric|min:0.01',
|
||||
'transfer_date' => 'required|date',
|
||||
'reference' => 'nullable|string|max:255',
|
||||
'memo' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Verify accounts belong to this business
|
||||
$fromAccount = BankAccount::where('id', $validated['from_bank_account_id'])
|
||||
->where('business_id', $business->id)
|
||||
->firstOrFail();
|
||||
|
||||
$toAccount = BankAccount::where('id', $validated['to_bank_account_id'])
|
||||
->where('business_id', $business->id)
|
||||
->firstOrFail();
|
||||
|
||||
$transfer = $this->bankAccountService->createTransfer($business, $validated, auth()->user());
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.bank-transfers.show', [$business, $transfer])
|
||||
->with('success', 'Bank transfer created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified bank transfer.
|
||||
*/
|
||||
public function show(Request $request, Business $business, BankTransfer $bankTransfer): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($bankTransfer->business_id !== $business->id) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
$bankTransfer->load(['fromAccount', 'toAccount', 'createdBy', 'approvedBy', 'journalEntry']);
|
||||
|
||||
return view('seller.management.bank-transfers.show', [
|
||||
'business' => $business,
|
||||
'transfer' => $bankTransfer,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete/approve a pending bank transfer.
|
||||
*/
|
||||
public function complete(Request $request, Business $business, BankTransfer $bankTransfer): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($bankTransfer->business_id !== $business->id) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
if (! $bankTransfer->isPending()) {
|
||||
return redirect()
|
||||
->back()
|
||||
->with('error', 'Only pending transfers can be completed.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->bankAccountService->completeTransfer($bankTransfer, auth()->user());
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.bank-transfers.show', [$business, $bankTransfer])
|
||||
->with('success', 'Bank transfer completed successfully.');
|
||||
} catch (\Exception $e) {
|
||||
return redirect()
|
||||
->back()
|
||||
->with('error', 'Failed to complete transfer: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending bank transfer.
|
||||
*/
|
||||
public function cancel(Request $request, Business $business, BankTransfer $bankTransfer): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($bankTransfer->business_id !== $business->id) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
if (! $bankTransfer->isPending()) {
|
||||
return redirect()
|
||||
->back()
|
||||
->with('error', 'Only pending transfers can be cancelled.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->bankAccountService->cancelTransfer($bankTransfer);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.bank-transfers.index', $business)
|
||||
->with('success', 'Bank transfer cancelled.');
|
||||
} catch (\Exception $e) {
|
||||
return redirect()
|
||||
->back()
|
||||
->with('error', 'Failed to cancel transfer: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\Budget;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\BudgetService;
|
||||
use App\Services\Accounting\ReportExportService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class BudgetReportingController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected BudgetService $budgetService,
|
||||
protected ReportExportService $exportService
|
||||
) {}
|
||||
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List budgets with variance summary for reporting.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
// Get all budgets with quick variance summary
|
||||
$budgets = Budget::whereIn('business_id', $filterData['business_ids'])
|
||||
->active()
|
||||
->with(['business'])
|
||||
->withCount('lines')
|
||||
->orderByDesc('fiscal_year')
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(function ($budget) {
|
||||
$summary = $this->budgetService->getBudgetSummary($budget);
|
||||
|
||||
return [
|
||||
'budget' => $budget,
|
||||
'total_budget' => $summary['total_budget'],
|
||||
'total_actual' => $summary['total_actual'],
|
||||
'variance_amount' => $summary['variance_amount'],
|
||||
'variance_percent' => $summary['variance_percent'],
|
||||
];
|
||||
});
|
||||
|
||||
return view('seller.management.financials.budget-vs-actual.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'budgets' => $budgets,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show detailed Budget vs Actual report for a specific budget.
|
||||
*/
|
||||
public function show(Request $request, Business $business, Budget $budget): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeForBusiness($business, $budget);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
// Get grouping preference
|
||||
$groupBy = $request->get('group_by', 'department');
|
||||
|
||||
// Build filters for the report - use snake_case keys from getDivisionFilterData()
|
||||
$filters = [
|
||||
'include_children' => ($filterData['selected_division'] === null && $business->hasChildBusinesses()),
|
||||
];
|
||||
|
||||
if ($filterData['selected_division']) {
|
||||
$filters['division_id'] = $filterData['selected_division']->id;
|
||||
}
|
||||
|
||||
$report = $this->budgetService->getBudgetVsActual($budget, $groupBy, $filters);
|
||||
|
||||
// Get all budgets for the selector
|
||||
$allBudgets = Budget::whereIn('business_id', $filterData['business_ids'])
|
||||
->active()
|
||||
->orderByDesc('fiscal_year')
|
||||
->get();
|
||||
|
||||
return view('seller.management.financials.budget-vs-actual.show', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'budget' => $budget,
|
||||
'report' => $report,
|
||||
'groupBy' => $groupBy,
|
||||
'allBudgets' => $allBudgets,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate budget belongs to allowed business.
|
||||
*/
|
||||
private function authorizeForBusiness(Business $business, Budget $budget): void
|
||||
{
|
||||
$allowedIds = $this->getAllowedBusinessIds($business);
|
||||
|
||||
if (! in_array($budget->business_id, $allowedIds)) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Budget vs Actual report as CSV.
|
||||
*/
|
||||
public function export(Request $request, Business $business, Budget $budget): StreamedResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeForBusiness($business, $budget);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
$groupBy = $request->get('group_by', 'department');
|
||||
|
||||
$filters = [
|
||||
'include_children' => ($filterData['selected_division'] === null && $business->hasChildBusinesses()),
|
||||
];
|
||||
|
||||
if ($filterData['selected_division']) {
|
||||
$filters['division_id'] = $filterData['selected_division']->id;
|
||||
}
|
||||
|
||||
$report = $this->budgetService->getBudgetVsActual($budget, $groupBy, $filters);
|
||||
|
||||
$filename = 'budget_vs_actual_'.$budget->name.'_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
|
||||
$filename = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $filename);
|
||||
|
||||
return $this->exportService->exportBudgetVsActual($report, $filename);
|
||||
}
|
||||
}
|
||||
330
app/Http/Controllers/Seller/Management/BudgetsController.php
Normal file
330
app/Http/Controllers/Seller/Management/BudgetsController.php
Normal file
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\Budget;
|
||||
use App\Models\Accounting\BudgetLine;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\BudgetService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BudgetsController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected BudgetService $budgetService
|
||||
) {}
|
||||
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List budgets for the business.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
$budgets = Budget::whereIn('business_id', $filterData['business_ids'])
|
||||
->with(['business', 'createdBy', 'approvedBy'])
|
||||
->withCount('lines')
|
||||
->orderByDesc('fiscal_year')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('seller.management.budgets.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'budgets' => $budgets,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show budget creation form.
|
||||
*/
|
||||
public function create(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$currentYear = now()->year;
|
||||
$years = range($currentYear - 1, $currentYear + 2);
|
||||
|
||||
return view('seller.management.budgets.create', [
|
||||
'business' => $business,
|
||||
'years' => $years,
|
||||
'periodTypes' => Budget::getPeriodTypes(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new budget.
|
||||
*/
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'fiscal_year' => 'nullable|integer|min:2020|max:2100',
|
||||
'currency' => 'nullable|string|size:3',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$budget = Budget::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'fiscal_year' => $validated['fiscal_year'] ?? now()->year,
|
||||
'currency' => $validated['currency'] ?? 'USD',
|
||||
'is_active' => true,
|
||||
'created_by_user_id' => auth()->id(),
|
||||
'notes' => $validated['notes'],
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.budgets.edit', [$business, $budget])
|
||||
->with('success', 'Budget created. Now add budget lines.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show budget details.
|
||||
*/
|
||||
public function show(Request $request, Business $business, Budget $budget): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeForBusiness($business, $budget);
|
||||
|
||||
$summary = $this->budgetService->getBudgetSummary($budget);
|
||||
|
||||
return view('seller.management.budgets.show', [
|
||||
'business' => $business,
|
||||
'budget' => $budget->load(['createdBy', 'approvedBy', 'lines.department', 'lines.glAccount']),
|
||||
'summary' => $summary,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit budget (metadata and lines).
|
||||
*/
|
||||
public function edit(Request $request, Business $business, Budget $budget): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeForBusiness($business, $budget);
|
||||
|
||||
$expenseAccounts = $this->budgetService->getExpenseAccounts($business);
|
||||
$departments = $this->budgetService->getDepartments($business);
|
||||
|
||||
// Group lines by department for the grid view
|
||||
$lines = $budget->lines()
|
||||
->with(['department', 'glAccount'])
|
||||
->orderBy('department_id')
|
||||
->orderBy('gl_account_id')
|
||||
->orderBy('period_start')
|
||||
->get();
|
||||
|
||||
return view('seller.management.budgets.edit', [
|
||||
'business' => $business,
|
||||
'budget' => $budget,
|
||||
'lines' => $lines,
|
||||
'expenseAccounts' => $expenseAccounts,
|
||||
'departments' => $departments,
|
||||
'periodTypes' => Budget::getPeriodTypes(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update budget metadata.
|
||||
*/
|
||||
public function update(Request $request, Business $business, Budget $budget): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeForBusiness($business, $budget);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'fiscal_year' => 'nullable|integer|min:2020|max:2100',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$budget->update([
|
||||
'name' => $validated['name'],
|
||||
'fiscal_year' => $validated['fiscal_year'],
|
||||
'notes' => $validated['notes'],
|
||||
'is_active' => $validated['is_active'] ?? $budget->is_active,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Budget updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a budget line.
|
||||
*/
|
||||
public function addLine(Request $request, Business $business, Budget $budget): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeForBusiness($business, $budget);
|
||||
|
||||
$validated = $request->validate([
|
||||
'gl_account_id' => 'required|exists:gl_accounts,id',
|
||||
'department_id' => 'nullable|exists:departments,id',
|
||||
'period_type' => 'required|in:monthly,quarterly,yearly',
|
||||
'amount' => 'required|numeric|min:0',
|
||||
'year' => 'required|integer|min:2020|max:2100',
|
||||
]);
|
||||
|
||||
$year = (int) $validated['year'];
|
||||
$amount = (float) $validated['amount'];
|
||||
|
||||
// Generate lines based on period type
|
||||
match ($validated['period_type']) {
|
||||
'monthly' => $this->budgetService->generateMonthlyLines(
|
||||
$budget,
|
||||
(int) $validated['gl_account_id'],
|
||||
$validated['department_id'] ? (int) $validated['department_id'] : null,
|
||||
$amount,
|
||||
$year
|
||||
),
|
||||
'quarterly' => $this->budgetService->generateQuarterlyLines(
|
||||
$budget,
|
||||
(int) $validated['gl_account_id'],
|
||||
$validated['department_id'] ? (int) $validated['department_id'] : null,
|
||||
$amount,
|
||||
$year
|
||||
),
|
||||
'yearly' => BudgetLine::create([
|
||||
'budget_id' => $budget->id,
|
||||
'gl_account_id' => (int) $validated['gl_account_id'],
|
||||
'department_id' => $validated['department_id'] ? (int) $validated['department_id'] : null,
|
||||
'period_type' => Budget::PERIOD_YEARLY,
|
||||
'period_start' => "{$year}-01-01",
|
||||
'period_end' => "{$year}-12-31",
|
||||
'amount' => $amount,
|
||||
]),
|
||||
};
|
||||
|
||||
return back()->with('success', 'Budget line(s) added.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update budget line amounts.
|
||||
*/
|
||||
public function updateLines(Request $request, Business $business, Budget $budget): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeForBusiness($business, $budget);
|
||||
|
||||
$validated = $request->validate([
|
||||
'lines' => 'required|array',
|
||||
'lines.*' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$this->budgetService->updateBudgetLines($budget, $validated['lines']);
|
||||
|
||||
return back()->with('success', 'Budget amounts updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a budget line.
|
||||
*/
|
||||
public function deleteLine(Request $request, Business $business, Budget $budget, BudgetLine $line): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeForBusiness($business, $budget);
|
||||
|
||||
if ($line->budget_id !== $budget->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$line->delete();
|
||||
|
||||
return back()->with('success', 'Budget line deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a budget.
|
||||
*/
|
||||
public function approve(Request $request, Business $business, Budget $budget): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeForBusiness($business, $budget);
|
||||
|
||||
$budget->approve(auth()->id());
|
||||
|
||||
return back()->with('success', 'Budget approved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unapprove a budget.
|
||||
*/
|
||||
public function unapprove(Request $request, Business $business, Budget $budget): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeForBusiness($business, $budget);
|
||||
|
||||
$budget->unapprove();
|
||||
|
||||
return back()->with('success', 'Budget approval removed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy budget to new fiscal year.
|
||||
*/
|
||||
public function copy(Request $request, Business $business, Budget $budget): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeForBusiness($business, $budget);
|
||||
|
||||
$validated = $request->validate([
|
||||
'target_year' => 'required|integer|min:2020|max:2100',
|
||||
]);
|
||||
|
||||
$newBudget = $this->budgetService->copyBudget(
|
||||
$budget,
|
||||
(int) $validated['target_year'],
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.budgets.edit', [$business, $newBudget])
|
||||
->with('success', 'Budget copied to '.$validated['target_year'].'.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a budget.
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, Budget $budget): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->authorizeForBusiness($business, $budget);
|
||||
|
||||
$budget->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.budgets.index', $business)
|
||||
->with('success', 'Budget deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate budget belongs to allowed business.
|
||||
*/
|
||||
private function authorizeForBusiness(Business $business, Budget $budget): void
|
||||
{
|
||||
$allowedIds = $this->getAllowedBusinessIds($business);
|
||||
|
||||
if (! in_array($budget->business_id, $allowedIds)) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\CashFlowForecastService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CashFlowForecastController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected CashFlowForecastService $forecastService
|
||||
) {}
|
||||
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the cash flow forecast.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
// Get forecast options from request
|
||||
$horizonDays = (int) $request->get('horizon', 60);
|
||||
$horizonDays = in_array($horizonDays, [30, 60, 90]) ? $horizonDays : 60;
|
||||
|
||||
$granularity = $request->get('granularity', 'weekly');
|
||||
$granularity = in_array($granularity, ['daily', 'weekly']) ? $granularity : 'weekly';
|
||||
|
||||
$includeBudgets = $request->boolean('include_budgets', true);
|
||||
$includeRecurring = $request->boolean('include_recurring', true);
|
||||
|
||||
// Determine which business to forecast
|
||||
$forecastBusiness = $filterData['selected_division'] ?? $business;
|
||||
$includeChildren = $filterData['selected_division'] === null && $business->hasChildBusinesses();
|
||||
|
||||
// Generate forecast
|
||||
$forecast = $this->forecastService->getForecastTimeline($forecastBusiness, [
|
||||
'horizon_days' => $horizonDays,
|
||||
'granularity' => $granularity,
|
||||
'include_children' => $includeChildren,
|
||||
'include_budgets' => $includeBudgets,
|
||||
'include_recurring' => $includeRecurring,
|
||||
]);
|
||||
|
||||
return view('seller.management.financials.cash-flow-forecast', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'forecast' => $forecast,
|
||||
'horizonDays' => $horizonDays,
|
||||
'granularity' => $granularity,
|
||||
'includeBudgets' => $includeBudgets,
|
||||
'includeRecurring' => $includeRecurring,
|
||||
], $filterData));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ApBill;
|
||||
use App\Models\Accounting\ArCustomer;
|
||||
use App\Models\Accounting\ArInvoice;
|
||||
use App\Models\Accounting\Budget;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\ArAnalyticsService;
|
||||
use App\Services\Accounting\BudgetService;
|
||||
use App\Services\Accounting\CashFlowForecastService;
|
||||
use App\Services\Accounting\FinanceAnalyticsService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CfoDashboardController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected CashFlowForecastService $cashFlowService,
|
||||
protected ArAnalyticsService $arService,
|
||||
protected FinanceAnalyticsService $financeService,
|
||||
protected BudgetService $budgetService
|
||||
) {}
|
||||
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the CFO Dashboard.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
// Determine scope - use snake_case keys from getDivisionFilterData()
|
||||
$targetBusiness = $filterData['selected_division'] ?? $business;
|
||||
$includeChildren = $filterData['selected_division'] === null && $business->hasChildBusinesses();
|
||||
$businessIds = $filterData['business_ids'];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CASH POSITION & FORECAST
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$cashData = $this->getCashData($targetBusiness, $includeChildren);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AR (RECEIVABLES) DATA
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$arData = $this->getArData($business, $businessIds);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AP (PAYABLES) DATA
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$apData = $this->getApData($business, $businessIds);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// BUDGET VS ACTUAL
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$budgetData = $this->getBudgetData($business, $businessIds);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// TOP CUSTOMERS & VENDORS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$topCustomers = $this->getTopCustomers($businessIds);
|
||||
$topVendors = $this->getTopVendors($businessIds);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// RISK INDICATORS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$riskData = $this->getRiskIndicators($businessIds);
|
||||
|
||||
return view('seller.management.cfo.dashboard', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'cashData' => $cashData,
|
||||
'arData' => $arData,
|
||||
'apData' => $apData,
|
||||
'budgetData' => $budgetData,
|
||||
'topCustomers' => $topCustomers,
|
||||
'topVendors' => $topVendors,
|
||||
'riskData' => $riskData,
|
||||
'isParent' => $business->hasChildBusinesses(),
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cash position and forecast data.
|
||||
*/
|
||||
protected function getCashData(Business $business, bool $includeChildren): array
|
||||
{
|
||||
$startingCash = $this->cashFlowService->getStartingCashBalance($business, now(), $includeChildren);
|
||||
|
||||
$forecast = $this->cashFlowService->getForecastTimeline($business, [
|
||||
'horizon_days' => 30,
|
||||
'granularity' => 'daily',
|
||||
'include_children' => $includeChildren,
|
||||
'include_budgets' => true,
|
||||
'include_recurring' => true,
|
||||
]);
|
||||
|
||||
return [
|
||||
'current_cash' => $startingCash['gl_balance'],
|
||||
'plaid_balance' => $startingCash['plaid_balance'],
|
||||
'plaid_difference' => $startingCash['difference'],
|
||||
'projected_30d_ending' => $forecast['summary']['ending_cash'],
|
||||
'projected_30d_min' => $forecast['summary']['min_cash'],
|
||||
'projected_30d_max' => $forecast['summary']['max_cash'],
|
||||
'min_cash_date' => $forecast['summary']['min_cash_date'],
|
||||
'total_inflows' => $forecast['summary']['total_inflows'],
|
||||
'total_outflows' => $forecast['summary']['total_outflows'],
|
||||
'timeline' => $forecast['timeline'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AR metrics and aging.
|
||||
*/
|
||||
protected function getArData(Business $business, array $businessIds): array
|
||||
{
|
||||
$metrics = $this->arService->getARMetrics($business, $businessIds);
|
||||
$aging = $this->arService->getARAging($business, $businessIds);
|
||||
|
||||
// Count at-risk and on-hold customers
|
||||
$atRiskCount = ArCustomer::whereIn('business_id', $businessIds)
|
||||
->where(function ($q) {
|
||||
$q->where('on_credit_hold', true)
|
||||
->orWhereHas('invoices', function ($q2) {
|
||||
$q2->where('status', ArInvoice::STATUS_OVERDUE)
|
||||
->where('balance_due', '>', 0);
|
||||
});
|
||||
})
|
||||
->count();
|
||||
|
||||
$onHoldCount = ArCustomer::whereIn('business_id', $businessIds)
|
||||
->where('on_credit_hold', true)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total_outstanding' => $metrics['total_outstanding'],
|
||||
'overdue_amount' => $metrics['overdue_amount'],
|
||||
'overdue_count' => $metrics['overdue_count'],
|
||||
'invoice_count' => $metrics['total_invoice_count'],
|
||||
'ytd_collections' => $metrics['ytd_collections'],
|
||||
'avg_days_to_pay' => $metrics['avg_days_to_pay'],
|
||||
'aging' => $aging,
|
||||
'at_risk_count' => $atRiskCount,
|
||||
'on_hold_count' => $onHoldCount,
|
||||
'over_90_amount' => $aging['over_90'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AP metrics and aging.
|
||||
*/
|
||||
protected function getApData(Business $business, array $businessIds): array
|
||||
{
|
||||
$aging = $this->financeService->getAPAging($business);
|
||||
|
||||
// Get 30-day AP due
|
||||
$next30DaysAp = ApBill::whereIn('business_id', $businessIds)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->where('balance_due', '>', 0)
|
||||
->whereBetween('due_date', [now(), now()->addDays(30)])
|
||||
->sum('balance_due');
|
||||
|
||||
$pastDueAmount = ApBill::whereIn('business_id', $businessIds)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->where('balance_due', '>', 0)
|
||||
->where('due_date', '<', now())
|
||||
->sum('balance_due');
|
||||
|
||||
return [
|
||||
'total_outstanding' => $aging['total'],
|
||||
'past_due_amount' => (float) $pastDueAmount,
|
||||
'next_30d_due' => (float) $next30DaysAp,
|
||||
'aging_buckets' => $aging['buckets'],
|
||||
'overdue_bill_count' => $aging['overdue_bills']->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get budget vs actual summary.
|
||||
*/
|
||||
protected function getBudgetData(Business $business, array $businessIds): array
|
||||
{
|
||||
$currentYear = now()->year;
|
||||
|
||||
// Get the first active budget for current year
|
||||
$budget = Budget::whereIn('business_id', $businessIds)
|
||||
->where('fiscal_year', $currentYear)
|
||||
->active()
|
||||
->first();
|
||||
|
||||
if (! $budget) {
|
||||
return [
|
||||
'has_budget' => false,
|
||||
'total_budget' => 0,
|
||||
'total_actual' => 0,
|
||||
'variance_amount' => 0,
|
||||
'variance_percent' => 0,
|
||||
'top_overbudget' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$summary = $this->budgetService->getBudgetSummary($budget);
|
||||
|
||||
// Get top 3 departments over budget
|
||||
$topOverbudget = collect($summary['by_department'])
|
||||
->filter(fn ($dept) => $dept['actual'] > $dept['budget'])
|
||||
->sortByDesc(fn ($dept) => $dept['actual'] - $dept['budget'])
|
||||
->take(3)
|
||||
->values();
|
||||
|
||||
return [
|
||||
'has_budget' => true,
|
||||
'budget_name' => $budget->name,
|
||||
'total_budget' => $summary['total_budget'],
|
||||
'total_actual' => $summary['total_actual'],
|
||||
'variance_amount' => $summary['variance_amount'],
|
||||
'variance_percent' => $summary['variance_percent'],
|
||||
'top_overbudget' => $topOverbudget,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top AR customers by outstanding balance.
|
||||
*/
|
||||
protected function getTopCustomers(array $businessIds): \Illuminate\Support\Collection
|
||||
{
|
||||
return ArInvoice::whereIn('ar_invoices.business_id', $businessIds)
|
||||
->whereNotIn('ar_invoices.status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('ar_invoices.balance_due', '>', 0)
|
||||
->with(['customer', 'business'])
|
||||
->get()
|
||||
->groupBy('customer_id')
|
||||
->map(function ($invoices) {
|
||||
$customer = $invoices->first()->customer;
|
||||
$oldestInvoice = $invoices->sortBy('due_date')->first();
|
||||
$daysOverdue = $oldestInvoice->due_date && $oldestInvoice->due_date->isPast()
|
||||
? $oldestInvoice->due_date->diffInDays(now())
|
||||
: 0;
|
||||
|
||||
return [
|
||||
'customer' => $customer,
|
||||
'division' => $invoices->first()->business,
|
||||
'balance' => (float) $invoices->sum('balance_due'),
|
||||
'invoice_count' => $invoices->count(),
|
||||
'days_overdue' => $daysOverdue,
|
||||
'on_hold' => $customer?->on_credit_hold ?? false,
|
||||
];
|
||||
})
|
||||
->sortByDesc('balance')
|
||||
->take(5)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top AP vendors by outstanding balance.
|
||||
*/
|
||||
protected function getTopVendors(array $businessIds): \Illuminate\Support\Collection
|
||||
{
|
||||
return ApBill::whereIn('ap_bills.business_id', $businessIds)
|
||||
->whereIn('ap_bills.status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->where('ap_bills.balance_due', '>', 0)
|
||||
->with(['vendor', 'business'])
|
||||
->get()
|
||||
->groupBy('vendor_id')
|
||||
->map(function ($bills) {
|
||||
$vendor = $bills->first()->vendor;
|
||||
$oldestBill = $bills->sortBy('due_date')->first();
|
||||
$daysOverdue = $oldestBill->due_date && $oldestBill->due_date->isPast()
|
||||
? $oldestBill->due_date->diffInDays(now())
|
||||
: 0;
|
||||
|
||||
// Get divisions
|
||||
$divisions = $bills->pluck('business')->unique('id');
|
||||
|
||||
return [
|
||||
'vendor' => $vendor,
|
||||
'divisions' => $divisions,
|
||||
'balance' => (float) $bills->sum('balance_due'),
|
||||
'bill_count' => $bills->count(),
|
||||
'days_overdue' => $daysOverdue,
|
||||
];
|
||||
})
|
||||
->sortByDesc('balance')
|
||||
->take(5)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk indicators summary.
|
||||
*/
|
||||
protected function getRiskIndicators(array $businessIds): array
|
||||
{
|
||||
// Credit holds
|
||||
$creditHoldsCount = ArCustomer::whereIn('business_id', $businessIds)
|
||||
->where('on_credit_hold', true)
|
||||
->count();
|
||||
|
||||
// Severely overdue AR (90+ days)
|
||||
$severeArCount = ArInvoice::whereIn('business_id', $businessIds)
|
||||
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('balance_due', '>', 0)
|
||||
->where('due_date', '<', now()->subDays(90))
|
||||
->count();
|
||||
|
||||
// Past due AP
|
||||
$pastDueApCount = ApBill::whereIn('business_id', $businessIds)
|
||||
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
|
||||
->where('balance_due', '>', 0)
|
||||
->where('due_date', '<', now())
|
||||
->count();
|
||||
|
||||
return [
|
||||
'credit_holds' => $creditHoldsCount,
|
||||
'severe_ar_count' => $severeArCount,
|
||||
'past_due_ap_count' => $pastDueApCount,
|
||||
'total_risks' => $creditHoldsCount + $severeArCount + $pastDueApCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\GlAccount;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ChartOfAccountsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the Chart of Accounts.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$parentBusiness = $this->getParentBusiness($business);
|
||||
|
||||
$query = GlAccount::where('business_id', $parentBusiness->id)
|
||||
->orderBy('account_number');
|
||||
|
||||
// Filter by account type
|
||||
if ($request->filled('type')) {
|
||||
$query->where('account_type', $request->type);
|
||||
}
|
||||
|
||||
// Filter by account class
|
||||
if ($request->filled('class')) {
|
||||
$query->where('account_class', $request->class);
|
||||
}
|
||||
|
||||
// Filter by active status
|
||||
if ($request->filled('active')) {
|
||||
$query->where('is_active', $request->active === 'true');
|
||||
}
|
||||
|
||||
// Search by number or name
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('account_number', 'like', "%{$search}%")
|
||||
->orWhere('name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$accounts = $query->get();
|
||||
|
||||
// Group accounts by type for tree view
|
||||
$accountsByType = $accounts->groupBy('account_type');
|
||||
|
||||
// Summary stats
|
||||
$stats = [
|
||||
'total' => $accounts->count(),
|
||||
'active' => $accounts->where('is_active', true)->count(),
|
||||
'inactive' => $accounts->where('is_active', false)->count(),
|
||||
'reconciliation' => $accounts->where('is_reconciliation', true)->count(),
|
||||
'system' => $accounts->where('is_system', true)->count(),
|
||||
];
|
||||
|
||||
return view('seller.management.chart-of-accounts.index', compact(
|
||||
'business',
|
||||
'parentBusiness',
|
||||
'accounts',
|
||||
'accountsByType',
|
||||
'stats'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show form to create a new GL account.
|
||||
*/
|
||||
public function create(Request $request, Business $business): View
|
||||
{
|
||||
$parentBusiness = $this->getParentBusiness($business);
|
||||
|
||||
// Get existing accounts for parent selection
|
||||
$parentAccounts = GlAccount::where('business_id', $parentBusiness->id)
|
||||
->where('is_header', true)
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
return view('seller.management.chart-of-accounts.create', compact(
|
||||
'business',
|
||||
'parentBusiness',
|
||||
'parentAccounts'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new GL account.
|
||||
*/
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$parentBusiness = $this->getParentBusiness($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'account_number' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:20',
|
||||
"unique:gl_accounts,account_number,NULL,id,business_id,{$parentBusiness->id}",
|
||||
],
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'account_type' => 'required|in:asset,liability,equity,revenue,expense',
|
||||
'account_subtype' => 'nullable|string|max:50',
|
||||
'parent_account_id' => 'nullable|exists:gl_accounts,id',
|
||||
'is_header' => 'boolean',
|
||||
'is_reconciliation' => 'boolean',
|
||||
'reconciliation_type' => 'nullable|in:ar,ap,fixed_asset,inventory,bank',
|
||||
'cash_flow_category' => 'nullable|in:operating,investing,financing',
|
||||
]);
|
||||
|
||||
// Set defaults based on account type
|
||||
$validated['business_id'] = $parentBusiness->id;
|
||||
$validated['normal_balance'] = GlAccount::getDefaultNormalBalance($validated['account_type']);
|
||||
$validated['account_class'] = GlAccount::getDefaultAccountClass($validated['account_type']);
|
||||
$validated['is_operating'] = ! in_array($validated['account_subtype'] ?? '', ['interest', 'other_income', 'other_expense']);
|
||||
$validated['is_active'] = true;
|
||||
$validated['is_system'] = false;
|
||||
|
||||
GlAccount::create($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.chart-of-accounts.index', $business)
|
||||
->with('success', 'GL Account created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show form to edit a GL account.
|
||||
*/
|
||||
public function edit(Request $request, Business $business, GlAccount $account): View
|
||||
{
|
||||
$parentBusiness = $this->getParentBusiness($business);
|
||||
|
||||
// Verify account belongs to parent business
|
||||
if ($account->business_id !== $parentBusiness->id) {
|
||||
abort(403, 'Account does not belong to this organization.');
|
||||
}
|
||||
|
||||
// Get existing accounts for parent selection
|
||||
$parentAccounts = GlAccount::where('business_id', $parentBusiness->id)
|
||||
->where('is_header', true)
|
||||
->where('id', '!=', $account->id)
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
return view('seller.management.chart-of-accounts.edit', compact(
|
||||
'business',
|
||||
'parentBusiness',
|
||||
'account',
|
||||
'parentAccounts'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a GL account.
|
||||
*/
|
||||
public function update(Request $request, Business $business, GlAccount $account): RedirectResponse
|
||||
{
|
||||
$parentBusiness = $this->getParentBusiness($business);
|
||||
|
||||
// Verify account belongs to parent business
|
||||
if ($account->business_id !== $parentBusiness->id) {
|
||||
abort(403, 'Account does not belong to this organization.');
|
||||
}
|
||||
|
||||
// System accounts have limited editability
|
||||
$rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'cash_flow_category' => 'nullable|in:operating,investing,financing',
|
||||
];
|
||||
|
||||
if (! $account->is_system) {
|
||||
$rules['account_number'] = [
|
||||
'required',
|
||||
'string',
|
||||
'max:20',
|
||||
"unique:gl_accounts,account_number,{$account->id},id,business_id,{$parentBusiness->id}",
|
||||
];
|
||||
$rules['account_type'] = 'required|in:asset,liability,equity,revenue,expense';
|
||||
$rules['account_subtype'] = 'nullable|string|max:50';
|
||||
$rules['parent_account_id'] = 'nullable|exists:gl_accounts,id';
|
||||
$rules['is_header'] = 'boolean';
|
||||
$rules['is_reconciliation'] = 'boolean';
|
||||
$rules['reconciliation_type'] = 'nullable|in:ar,ap,fixed_asset,inventory,bank';
|
||||
}
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
|
||||
// Update type-dependent fields if type changed
|
||||
if (! $account->is_system && isset($validated['account_type'])) {
|
||||
$validated['normal_balance'] = GlAccount::getDefaultNormalBalance($validated['account_type']);
|
||||
$validated['account_class'] = GlAccount::getDefaultAccountClass($validated['account_type']);
|
||||
}
|
||||
|
||||
$account->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.chart-of-accounts.index', $business)
|
||||
->with('success', 'GL Account updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active status of a GL account.
|
||||
*/
|
||||
public function toggleActive(Request $request, Business $business, GlAccount $account): RedirectResponse
|
||||
{
|
||||
$parentBusiness = $this->getParentBusiness($business);
|
||||
|
||||
if ($account->business_id !== $parentBusiness->id) {
|
||||
abort(403, 'Account does not belong to this organization.');
|
||||
}
|
||||
|
||||
if ($account->is_system) {
|
||||
return back()->with('error', 'System accounts cannot be deactivated.');
|
||||
}
|
||||
|
||||
if ($account->is_active && ! $account->canBeDeactivated()) {
|
||||
return back()->with('error', 'Account has open balance and cannot be deactivated.');
|
||||
}
|
||||
|
||||
$account->update(['is_active' => ! $account->is_active]);
|
||||
|
||||
$status = $account->is_active ? 'activated' : 'deactivated';
|
||||
|
||||
return back()->with('success', "Account {$status} successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a GL account.
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, GlAccount $account): RedirectResponse
|
||||
{
|
||||
$parentBusiness = $this->getParentBusiness($business);
|
||||
|
||||
if ($account->business_id !== $parentBusiness->id) {
|
||||
abort(403, 'Account does not belong to this organization.');
|
||||
}
|
||||
|
||||
if (! $account->canBeDeleted()) {
|
||||
return back()->with('error', 'This account cannot be deleted. It is either a system account or has transactions.');
|
||||
}
|
||||
|
||||
$account->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.chart-of-accounts.index', $business)
|
||||
->with('success', 'GL Account deleted successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent business for GL account management.
|
||||
* GL accounts belong to the parent company, not divisions.
|
||||
*/
|
||||
private function getParentBusiness(Business $business): Business
|
||||
{
|
||||
return $business->parent ?? $business;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ArCustomer;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\CustomerFinancialService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DirectoryCustomersController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected CustomerFinancialService $customerService
|
||||
) {}
|
||||
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List AR customers.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
$customers = ArCustomer::whereIn('business_id', $filterData['business_ids'])
|
||||
->with(['business'])
|
||||
->withCount('invoices')
|
||||
->orderBy('name')
|
||||
->paginate(20);
|
||||
|
||||
return view('seller.management.directory.customers.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'customers' => $customers,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show customer financial summary.
|
||||
*/
|
||||
public function showFinancials(Request $request, Business $business, ArCustomer $customer): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
// Validate ownership
|
||||
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
|
||||
if (! in_array($customer->business_id, $allowedBusinessIds)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$isParent = $business->hasChildBusinesses();
|
||||
$includeChildren = $isParent;
|
||||
|
||||
$summary = $this->customerService->getFinancialSummary($customer, $business, $includeChildren);
|
||||
$invoices = $this->customerService->getInvoices($customer, $business, $includeChildren);
|
||||
$payments = $this->customerService->getPayments($customer, $business, $includeChildren);
|
||||
$activities = $this->customerService->getRecentActivity($customer, $business);
|
||||
|
||||
return view('seller.management.directory.customers.financials', [
|
||||
'business' => $business,
|
||||
'customer' => $customer,
|
||||
'summary' => $summary,
|
||||
'invoices' => $invoices,
|
||||
'payments' => $payments,
|
||||
'activities' => $activities,
|
||||
'isParent' => $isParent,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update customer credit limit.
|
||||
*/
|
||||
public function updateCreditLimit(Request $request, Business $business, ArCustomer $customer): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($customer->business_id !== $business->id) {
|
||||
abort(403, 'Can only modify customers in your own business.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'credit_limit' => 'required|numeric|min:0',
|
||||
'reason' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$this->customerService->updateCreditLimit(
|
||||
$customer,
|
||||
(float) $validated['credit_limit'],
|
||||
auth()->id(),
|
||||
$validated['reason'] ?? null
|
||||
);
|
||||
|
||||
return back()->with('success', 'Credit limit updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update customer terms.
|
||||
*/
|
||||
public function updateTerms(Request $request, Business $business, ArCustomer $customer): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($customer->business_id !== $business->id) {
|
||||
abort(403, 'Can only modify customers in your own business.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'payment_terms' => 'required|string|max:50',
|
||||
]);
|
||||
|
||||
$this->customerService->updateTerms(
|
||||
$customer,
|
||||
$validated['payment_terms'],
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return back()->with('success', 'Payment terms updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Place credit hold.
|
||||
*/
|
||||
public function placeCreditHold(Request $request, Business $business, ArCustomer $customer): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($customer->business_id !== $business->id) {
|
||||
abort(403, 'Can only modify customers in your own business.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'reason' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$this->customerService->placeCreditHold(
|
||||
$customer,
|
||||
auth()->id(),
|
||||
$validated['reason']
|
||||
);
|
||||
|
||||
return back()->with('success', 'Credit hold placed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove credit hold.
|
||||
*/
|
||||
public function removeCreditHold(Request $request, Business $business, ArCustomer $customer): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($customer->business_id !== $business->id) {
|
||||
abort(403, 'Can only modify customers in your own business.');
|
||||
}
|
||||
|
||||
$this->customerService->removeCreditHold(
|
||||
$customer,
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return back()->with('success', 'Credit hold removed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a note.
|
||||
*/
|
||||
public function addNote(Request $request, Business $business, ArCustomer $customer): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
|
||||
if (! in_array($customer->business_id, $allowedBusinessIds)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'note' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
$this->customerService->addNote(
|
||||
$customer,
|
||||
auth()->id(),
|
||||
$validated['note']
|
||||
);
|
||||
|
||||
return back()->with('success', 'Note added.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ApVendor;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\VendorFinancialService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DirectoryVendorsController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected VendorFinancialService $vendorService
|
||||
) {}
|
||||
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List AP vendors.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
$isParent = $business->hasChildBusinesses();
|
||||
|
||||
$vendors = ApVendor::whereIn('business_id', $filterData['business_ids'])
|
||||
->with(['business'])
|
||||
->withCount('bills')
|
||||
->orderBy('name')
|
||||
->paginate(20);
|
||||
|
||||
// For parent companies, load division usage info for vendors
|
||||
$vendorDivisionUsage = [];
|
||||
if ($isParent && $vendors->isNotEmpty()) {
|
||||
// Get all divisions that have bills with each vendor
|
||||
$vendorIds = $vendors->pluck('id');
|
||||
$billsByVendor = \App\Models\Accounting\ApBill::whereIn('vendor_id', $vendorIds)
|
||||
->selectRaw('vendor_id, business_id, COUNT(*) as bill_count')
|
||||
->groupBy('vendor_id', 'business_id')
|
||||
->with('business:id,name,division_name')
|
||||
->get();
|
||||
|
||||
foreach ($billsByVendor as $bill) {
|
||||
if (! isset($vendorDivisionUsage[$bill->vendor_id])) {
|
||||
$vendorDivisionUsage[$bill->vendor_id] = [];
|
||||
}
|
||||
$vendorDivisionUsage[$bill->vendor_id][] = [
|
||||
'business' => $bill->business,
|
||||
'bill_count' => $bill->bill_count,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return view('seller.management.directory.vendors.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'vendors' => $vendors,
|
||||
'vendorDivisionUsage' => $vendorDivisionUsage,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show vendor financial summary.
|
||||
*/
|
||||
public function showFinancials(Request $request, Business $business, ApVendor $vendor): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
// Validate ownership
|
||||
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
|
||||
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$isParent = $business->hasChildBusinesses();
|
||||
$includeChildren = $isParent;
|
||||
|
||||
$summary = $this->vendorService->getFinancialSummary($vendor, $business, $includeChildren);
|
||||
$bills = $this->vendorService->getBills($vendor, $business, $includeChildren);
|
||||
$payments = $this->vendorService->getPayments($vendor, $business, $includeChildren);
|
||||
$activities = $this->vendorService->getRecentActivity($vendor, $business);
|
||||
|
||||
return view('seller.management.directory.vendors.financials', [
|
||||
'business' => $business,
|
||||
'vendor' => $vendor,
|
||||
'summary' => $summary,
|
||||
'bills' => $bills,
|
||||
'payments' => $payments,
|
||||
'activities' => $activities,
|
||||
'isParent' => $isParent,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update vendor terms.
|
||||
*/
|
||||
public function updateTerms(Request $request, Business $business, ApVendor $vendor): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($vendor->business_id !== $business->id) {
|
||||
abort(403, 'Can only modify vendors in your own business.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'payment_terms' => 'required|string|max:50',
|
||||
]);
|
||||
|
||||
$this->vendorService->updateTerms(
|
||||
$vendor,
|
||||
$validated['payment_terms'],
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return back()->with('success', 'Payment terms updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a note.
|
||||
*/
|
||||
public function addNote(Request $request, Business $business, ApVendor $vendor): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
|
||||
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'note' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
$this->vendorService->addNote(
|
||||
$vendor,
|
||||
auth()->id(),
|
||||
$validated['note']
|
||||
);
|
||||
|
||||
return back()->with('success', 'Note added.');
|
||||
}
|
||||
}
|
||||
249
app/Http/Controllers/Seller/Management/ExpensesController.php
Normal file
249
app/Http/Controllers/Seller/Management/ExpensesController.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\Expense;
|
||||
use App\Models\Business;
|
||||
use App\Models\Department;
|
||||
use App\Services\Accounting\ExpenseService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Management Suite Expenses Controller.
|
||||
*
|
||||
* Handles expense approval, rejection, and payment by finance team.
|
||||
* Parent companies can see and manage expenses from all child businesses.
|
||||
*/
|
||||
class ExpensesController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected ExpenseService $expenseService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate that the expense belongs to the current business or its divisions.
|
||||
*/
|
||||
private function validateExpenseOwnership(Business $business, Expense $expense): void
|
||||
{
|
||||
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
|
||||
|
||||
if (! in_array($expense->business_id, $allowedBusinessIds)) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the business has Management Suite access.
|
||||
*/
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List expenses for management review.
|
||||
*
|
||||
* GET /s/{business}/management/expenses
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
$query = Expense::whereIn('business_id', $filterData['business_ids'])
|
||||
->with(['department', 'createdBy', 'approvedBy', 'business', 'items']);
|
||||
|
||||
// Status filter
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// Department filter
|
||||
if ($request->filled('department_id')) {
|
||||
$query->where('department_id', $request->department_id);
|
||||
}
|
||||
|
||||
// Payment method filter
|
||||
if ($request->filled('payment_method')) {
|
||||
$query->where('payment_method', $request->payment_method);
|
||||
}
|
||||
|
||||
// Date range
|
||||
if ($request->filled('from_date')) {
|
||||
$query->whereDate('expense_date', '>=', $request->from_date);
|
||||
}
|
||||
if ($request->filled('to_date')) {
|
||||
$query->whereDate('expense_date', '<=', $request->to_date);
|
||||
}
|
||||
|
||||
$expenses = $query->orderByDesc('expense_date')->paginate(20)->withQueryString();
|
||||
|
||||
// Get departments for filter (from all filtered businesses)
|
||||
$departments = Department::whereIn('business_id', $filterData['business_ids'])
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Metrics
|
||||
$metrics = $this->expenseService->getExpenseMetrics($business, $filterData['business_ids']);
|
||||
|
||||
return view('seller.management.expenses.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'expenses' => $expenses,
|
||||
'departments' => $departments,
|
||||
'metrics' => $metrics,
|
||||
'statuses' => Expense::getStatuses(),
|
||||
'paymentMethods' => Expense::getPaymentMethods(),
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show expense details for management review.
|
||||
*
|
||||
* GET /s/{business}/management/expenses/{expense}
|
||||
*/
|
||||
public function show(Request $request, Business $business, Expense $expense): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->validateExpenseOwnership($business, $expense);
|
||||
|
||||
$expense->load([
|
||||
'items.glAccount',
|
||||
'items.department',
|
||||
'department',
|
||||
'createdBy',
|
||||
'approvedBy',
|
||||
'paidBy',
|
||||
'business',
|
||||
'journalEntry',
|
||||
'apBill',
|
||||
]);
|
||||
|
||||
$isParent = $business->hasChildBusinesses();
|
||||
|
||||
return view('seller.management.expenses.show', compact('business', 'expense', 'isParent'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve an expense.
|
||||
*
|
||||
* POST /s/{business}/management/expenses/{expense}/approve
|
||||
*/
|
||||
public function approve(Request $request, Business $business, Expense $expense): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->validateExpenseOwnership($business, $expense);
|
||||
|
||||
$validated = $request->validate([
|
||||
'payment_method' => 'nullable|string|in:'.implode(',', array_keys(Expense::getPaymentMethods())),
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->expenseService->approveExpense($expense, auth()->user(), $validated);
|
||||
|
||||
return back()->with('success', "Expense {$expense->expense_number} approved.");
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an expense.
|
||||
*
|
||||
* POST /s/{business}/management/expenses/{expense}/reject
|
||||
*/
|
||||
public function reject(Request $request, Business $business, Expense $expense): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->validateExpenseOwnership($business, $expense);
|
||||
|
||||
$validated = $request->validate([
|
||||
'rejection_reason' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->expenseService->rejectExpense($expense, auth()->user(), $validated['rejection_reason'] ?? null);
|
||||
|
||||
return back()->with('success', "Expense {$expense->expense_number} rejected.");
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an expense as paid.
|
||||
*
|
||||
* POST /s/{business}/management/expenses/{expense}/mark-paid
|
||||
*/
|
||||
public function markPaid(Request $request, Business $business, Expense $expense): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->validateExpenseOwnership($business, $expense);
|
||||
|
||||
$validated = $request->validate([
|
||||
'paid_date' => 'nullable|date',
|
||||
'reference' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->expenseService->markExpensePaid($expense, auth()->user(), $validated);
|
||||
|
||||
return back()->with('success', "Expense {$expense->expense_number} marked as paid.");
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk approve expenses.
|
||||
*
|
||||
* POST /s/{business}/management/expenses/bulk-approve
|
||||
*/
|
||||
public function bulkApprove(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'expense_ids' => 'required|array|min:1',
|
||||
'expense_ids.*' => 'integer|exists:expenses,id',
|
||||
]);
|
||||
|
||||
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
|
||||
$approver = auth()->user();
|
||||
$approved = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($validated['expense_ids'] as $expenseId) {
|
||||
$expense = Expense::find($expenseId);
|
||||
|
||||
if (! $expense || ! in_array($expense->business_id, $allowedBusinessIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->expenseService->approveExpense($expense, $approver);
|
||||
$approved++;
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$errors[] = "{$expense->expense_number}: {$e->getMessage()}";
|
||||
}
|
||||
}
|
||||
|
||||
$message = "{$approved} expense(s) approved.";
|
||||
if (count($errors) > 0) {
|
||||
$message .= ' Errors: '.implode('; ', $errors);
|
||||
}
|
||||
|
||||
return back()->with($errors ? 'warning' : 'success', $message);
|
||||
}
|
||||
}
|
||||
167
app/Http/Controllers/Seller/Management/FinanceController.php
Normal file
167
app/Http/Controllers/Seller/Management/FinanceController.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\ArService;
|
||||
use App\Services\Accounting\FinanceAnalyticsService;
|
||||
use App\Services\Accounting\ReportExportService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class FinanceController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected FinanceAnalyticsService $analyticsService,
|
||||
protected ArService $arService,
|
||||
protected ReportExportService $exportService
|
||||
) {}
|
||||
|
||||
public function apAging(Request $request, Business $business)
|
||||
{
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
$aging = $this->analyticsService->getAPAging($business, $filterData['business_ids']);
|
||||
$byDivision = $this->analyticsService->getAPBreakdownByDivision($business, $filterData['business_ids']);
|
||||
$byVendor = $this->analyticsService->getAPBreakdownByVendor($business, $filterData['business_ids']);
|
||||
|
||||
return view('seller.management.finance.ap-aging', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'aging' => $aging,
|
||||
'byDivision' => $byDivision,
|
||||
'byVendor' => $byVendor,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
public function cashForecast(Request $request, Business $business)
|
||||
{
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
$days = $request->integer('days', 30);
|
||||
$days = in_array($days, [7, 14, 30]) ? $days : 30;
|
||||
$forecast = $this->analyticsService->getCashForecast($business, $days, $filterData['business_ids']);
|
||||
|
||||
return view('seller.management.finance.cash-forecast', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'forecast' => $forecast,
|
||||
'days' => $days,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
public function divisionRollup(Request $request, Business $business)
|
||||
{
|
||||
if (! $this->analyticsService->isParentCompany($business)) {
|
||||
abort(403, 'Only parent companies can view divisional rollups.');
|
||||
}
|
||||
|
||||
$divisions = $this->analyticsService->getDivisionRollup($business);
|
||||
$totals = [
|
||||
// AP Totals
|
||||
'ap_outstanding' => $divisions->sum('ap_outstanding'),
|
||||
'ap_overdue' => $divisions->sum('ap_overdue'),
|
||||
'ytd_payments' => $divisions->sum('ytd_payments'),
|
||||
'pending_approval' => $divisions->sum('pending_approval'),
|
||||
// AR Totals
|
||||
'ar_total' => $divisions->sum('ar_total'),
|
||||
'ar_overdue' => $divisions->sum('ar_overdue'),
|
||||
'ar_at_risk' => $divisions->sum('ar_at_risk'),
|
||||
'ar_on_hold' => $divisions->sum('ar_on_hold'),
|
||||
];
|
||||
|
||||
return view('seller.management.finance.divisions', compact('business', 'divisions', 'totals'));
|
||||
}
|
||||
|
||||
public function vendorSpend(Request $request, Business $business)
|
||||
{
|
||||
$isParent = $this->analyticsService->isParentCompany($business);
|
||||
$divisions = collect();
|
||||
$selectedDivisionId = null;
|
||||
$selectedDivision = null;
|
||||
|
||||
if ($isParent) {
|
||||
$divisions = $this->analyticsService->getChildBusinesses($business);
|
||||
$divisionIdParam = $request->get('division_id');
|
||||
|
||||
if ($divisionIdParam && $divisionIdParam !== 'all') {
|
||||
$selectedDivisionId = (int) $divisionIdParam;
|
||||
$selectedDivision = $divisions->firstWhere('id', $selectedDivisionId);
|
||||
if (! $selectedDivision) {
|
||||
$selectedDivisionId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$spend = $this->analyticsService->getVendorSpend($business, $selectedDivisionId);
|
||||
|
||||
return view('seller.management.finance.vendor-spend', compact(
|
||||
'business', 'spend', 'isParent', 'divisions', 'selectedDivisionId', 'selectedDivision'
|
||||
));
|
||||
}
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
// AP Data
|
||||
$aging = $this->analyticsService->getAPAging($business, $filterData['business_ids']);
|
||||
$forecast = $this->analyticsService->getCashForecast($business, 7, $filterData['business_ids']);
|
||||
|
||||
// AR Data
|
||||
$arSummary = $this->arService->getArSummary($business, $filterData['business_ids']);
|
||||
$topArAccounts = $this->arService->getTopArAccounts($business, 5, $filterData['business_ids']);
|
||||
|
||||
return view('seller.management.finance.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'aging' => $aging,
|
||||
'forecast' => $forecast,
|
||||
'arSummary' => $arSummary,
|
||||
'topArAccounts' => $topArAccounts,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export AP Aging report as CSV.
|
||||
*/
|
||||
public function exportApAging(Request $request, Business $business): StreamedResponse
|
||||
{
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
$byVendor = $this->analyticsService->getAPBreakdownByVendor($business, $filterData['business_ids']);
|
||||
|
||||
$filename = 'ap_aging_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
|
||||
|
||||
return $this->exportService->exportApAging($byVendor, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export AR Aging report as CSV.
|
||||
*/
|
||||
public function exportArAging(Request $request, Business $business): StreamedResponse
|
||||
{
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
$arAccounts = $this->arService->getArAgingReport($business, $filterData['business_ids']);
|
||||
|
||||
$filename = 'ar_aging_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
|
||||
|
||||
return $this->exportService->exportArAging($arAccounts, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Cash Flow Forecast as CSV.
|
||||
*/
|
||||
public function exportCashForecast(Request $request, Business $business): StreamedResponse
|
||||
{
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
$days = $request->integer('days', 30);
|
||||
$forecast = $this->analyticsService->getCashForecast($business, $days, $filterData['business_ids']);
|
||||
|
||||
$filename = 'cash_forecast_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
|
||||
|
||||
return $this->exportService->exportCashFlowForecast($forecast, $filename);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use App\Services\Accounting\PeriodLockService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class FinanceRolesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PeriodLockService $periodLockService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display finance role settings.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requirePermission($business, $request->user(), 'can_manage_finance_roles');
|
||||
|
||||
// Get all users in this business
|
||||
$users = User::whereHas('businesses', function ($query) use ($business) {
|
||||
$query->where('businesses.id', $business->id);
|
||||
})->with(['businesses' => function ($query) use ($business) {
|
||||
$query->where('businesses.id', $business->id);
|
||||
}])->orderBy('name')->get();
|
||||
|
||||
// Add finance roles to each user
|
||||
$users->each(function ($user) use ($business) {
|
||||
$user->finance_roles = $this->periodLockService->getUserFinanceRoles($business, $user);
|
||||
$user->finance_permissions = $this->periodLockService->getUserPermissions($business, $user);
|
||||
});
|
||||
|
||||
$availableRoles = config('finance_roles.roles', []);
|
||||
$allPermissions = config('finance_roles.permissions', []);
|
||||
|
||||
return view('seller.management.settings.finance-roles', [
|
||||
'business' => $business,
|
||||
'users' => $users,
|
||||
'availableRoles' => $availableRoles,
|
||||
'allPermissions' => $allPermissions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update finance roles for a user.
|
||||
*/
|
||||
public function update(Request $request, Business $business, User $user): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requirePermission($business, $request->user(), 'can_manage_finance_roles');
|
||||
|
||||
$validated = $request->validate([
|
||||
'finance_roles' => 'nullable|array',
|
||||
'finance_roles.*' => 'string|in:'.implode(',', array_keys(config('finance_roles.roles', []))),
|
||||
]);
|
||||
|
||||
// Ensure user belongs to this business
|
||||
$pivot = $user->businesses()->where('businesses.id', $business->id)->first()?->pivot;
|
||||
|
||||
if (! $pivot) {
|
||||
return back()->with('error', 'User does not belong to this business.');
|
||||
}
|
||||
|
||||
// Update the pivot record
|
||||
$user->businesses()->updateExistingPivot($business->id, [
|
||||
'finance_roles' => json_encode($validated['finance_roles'] ?? []),
|
||||
]);
|
||||
|
||||
return back()->with('success', "Finance roles updated for {$user->name}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update finance roles for multiple users.
|
||||
*/
|
||||
public function bulkUpdate(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requirePermission($business, $request->user(), 'can_manage_finance_roles');
|
||||
|
||||
$validated = $request->validate([
|
||||
'users' => 'required|array',
|
||||
'users.*.id' => 'required|exists:users,id',
|
||||
'users.*.finance_roles' => 'nullable|array',
|
||||
'users.*.finance_roles.*' => 'string|in:'.implode(',', array_keys(config('finance_roles.roles', []))),
|
||||
]);
|
||||
|
||||
$updated = 0;
|
||||
|
||||
foreach ($validated['users'] as $userData) {
|
||||
$user = User::find($userData['id']);
|
||||
|
||||
// Ensure user belongs to this business
|
||||
$pivot = $user->businesses()->where('businesses.id', $business->id)->first()?->pivot;
|
||||
|
||||
if ($pivot) {
|
||||
$user->businesses()->updateExistingPivot($business->id, [
|
||||
'finance_roles' => json_encode($userData['finance_roles'] ?? []),
|
||||
]);
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', "Finance roles updated for {$updated} users.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Require Management Suite access.
|
||||
*/
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a specific finance permission.
|
||||
*/
|
||||
private function requirePermission(Business $business, User $user, string $permission): void
|
||||
{
|
||||
// Business owners always have access
|
||||
if ($business->owner_user_id === $user->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check bypass mode
|
||||
if (config('finance_roles.bypass_permissions', false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->periodLockService->userHasPermission($business, $user, $permission)) {
|
||||
abort(403, 'You do not have permission to manage finance roles.');
|
||||
}
|
||||
}
|
||||
}
|
||||
251
app/Http/Controllers/Seller/Management/FinancialsController.php
Normal file
251
app/Http/Controllers/Seller/Management/FinancialsController.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\AccountingReportingService;
|
||||
use App\Services\Accounting\ReportExportService;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class FinancialsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AccountingReportingService $reportingService,
|
||||
protected ReportExportService $exportService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Profit & Loss Statement.
|
||||
*
|
||||
* GET /s/{business}/management/financials/profit-and-loss
|
||||
*/
|
||||
public function profitAndLoss(Request $request, Business $business)
|
||||
{
|
||||
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
|
||||
$toDate = $request->get('to_date', now()->format('Y-m-d'));
|
||||
$includeChildren = $request->boolean('include_children', true);
|
||||
|
||||
$isParent = $this->reportingService->isParentCompany($business);
|
||||
|
||||
$pnl = $this->reportingService->getProfitAndLoss(
|
||||
$business,
|
||||
$fromDate,
|
||||
$toDate,
|
||||
$isParent && $includeChildren
|
||||
);
|
||||
|
||||
// Get prior period for comparison (same duration, previous period)
|
||||
$periodDays = now()->parse($fromDate)->diffInDays(now()->parse($toDate));
|
||||
$priorFromDate = now()->parse($fromDate)->subDays($periodDays + 1)->format('Y-m-d');
|
||||
$priorToDate = now()->parse($fromDate)->subDay()->format('Y-m-d');
|
||||
|
||||
$priorPnl = $this->reportingService->getProfitAndLoss(
|
||||
$business,
|
||||
$priorFromDate,
|
||||
$priorToDate,
|
||||
$isParent && $includeChildren
|
||||
);
|
||||
|
||||
return view('seller.management.financials.profit-and-loss', compact(
|
||||
'business',
|
||||
'pnl',
|
||||
'priorPnl',
|
||||
'fromDate',
|
||||
'toDate',
|
||||
'includeChildren',
|
||||
'isParent'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance Sheet.
|
||||
*
|
||||
* GET /s/{business}/management/financials/balance-sheet
|
||||
*/
|
||||
public function balanceSheet(Request $request, Business $business)
|
||||
{
|
||||
$asOfDate = $request->get('as_of_date', now()->format('Y-m-d'));
|
||||
$includeChildren = $request->boolean('include_children', true);
|
||||
|
||||
$isParent = $this->reportingService->isParentCompany($business);
|
||||
|
||||
$balanceSheet = $this->reportingService->getBalanceSheet(
|
||||
$business,
|
||||
$asOfDate,
|
||||
$isParent && $includeChildren
|
||||
);
|
||||
|
||||
return view('seller.management.financials.balance-sheet', compact(
|
||||
'business',
|
||||
'balanceSheet',
|
||||
'asOfDate',
|
||||
'includeChildren',
|
||||
'isParent'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash Flow Statement (Indirect Method).
|
||||
*
|
||||
* GET /s/{business}/management/financials/cash-flow
|
||||
*/
|
||||
public function cashFlow(Request $request, Business $business)
|
||||
{
|
||||
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
|
||||
$toDate = $request->get('to_date', now()->format('Y-m-d'));
|
||||
$includeChildren = $request->boolean('include_children', true);
|
||||
|
||||
$isParent = $this->reportingService->isParentCompany($business);
|
||||
|
||||
$cashFlow = $this->reportingService->getCashFlowIndirect(
|
||||
$business,
|
||||
$fromDate,
|
||||
$toDate,
|
||||
$isParent && $includeChildren
|
||||
);
|
||||
|
||||
return view('seller.management.financials.cash-flow', compact(
|
||||
'business',
|
||||
'cashFlow',
|
||||
'fromDate',
|
||||
'toDate',
|
||||
'includeChildren',
|
||||
'isParent'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated P&L - Side-by-side comparison of all divisions.
|
||||
*
|
||||
* GET /s/{business}/management/financials/consolidated-pnl
|
||||
*/
|
||||
public function consolidatedPnl(Request $request, Business $business)
|
||||
{
|
||||
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
|
||||
$toDate = $request->get('to_date', now()->format('Y-m-d'));
|
||||
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
$divisions = Business::where('parent_id', $parentBusiness->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get P&L for each division
|
||||
$divisionPnls = [];
|
||||
foreach ($divisions as $division) {
|
||||
$divisionPnls[$division->id] = [
|
||||
'division' => $division,
|
||||
'pnl' => $this->reportingService->getProfitAndLoss($division, $fromDate, $toDate, false),
|
||||
];
|
||||
}
|
||||
|
||||
// Get consolidated (total) P&L
|
||||
$consolidatedPnl = $this->reportingService->getProfitAndLoss(
|
||||
$parentBusiness,
|
||||
$fromDate,
|
||||
$toDate,
|
||||
true
|
||||
);
|
||||
|
||||
return view('seller.management.financials.consolidated-pnl', compact(
|
||||
'business',
|
||||
'parentBusiness',
|
||||
'divisions',
|
||||
'divisionPnls',
|
||||
'consolidatedPnl',
|
||||
'fromDate',
|
||||
'toDate'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated Balance Sheet - Side-by-side comparison of all divisions.
|
||||
*
|
||||
* GET /s/{business}/management/financials/consolidated-balance-sheet
|
||||
*/
|
||||
public function consolidatedBalanceSheet(Request $request, Business $business)
|
||||
{
|
||||
$asOfDate = $request->get('as_of_date', now()->format('Y-m-d'));
|
||||
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
$divisions = Business::where('parent_id', $parentBusiness->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get Balance Sheet for each division
|
||||
$divisionBalanceSheets = [];
|
||||
foreach ($divisions as $division) {
|
||||
$divisionBalanceSheets[$division->id] = [
|
||||
'division' => $division,
|
||||
'balanceSheet' => $this->reportingService->getBalanceSheet($division, $asOfDate, false),
|
||||
];
|
||||
}
|
||||
|
||||
// Get consolidated Balance Sheet
|
||||
$consolidatedBalanceSheet = $this->reportingService->getBalanceSheet(
|
||||
$parentBusiness,
|
||||
$asOfDate,
|
||||
true
|
||||
);
|
||||
|
||||
return view('seller.management.financials.consolidated-balance-sheet', compact(
|
||||
'business',
|
||||
'parentBusiness',
|
||||
'divisions',
|
||||
'divisionBalanceSheets',
|
||||
'consolidatedBalanceSheet',
|
||||
'asOfDate'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Profit & Loss as CSV.
|
||||
*
|
||||
* GET /s/{business}/management/financials/profit-and-loss/export
|
||||
*/
|
||||
public function exportProfitAndLoss(Request $request, Business $business): StreamedResponse
|
||||
{
|
||||
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
|
||||
$toDate = $request->get('to_date', now()->format('Y-m-d'));
|
||||
$includeChildren = $request->boolean('include_children', true);
|
||||
|
||||
$isParent = $this->reportingService->isParentCompany($business);
|
||||
|
||||
$pnl = $this->reportingService->getProfitAndLoss(
|
||||
$business,
|
||||
$fromDate,
|
||||
$toDate,
|
||||
$isParent && $includeChildren
|
||||
);
|
||||
|
||||
$filename = "profit_loss_{$business->slug}_{$fromDate}_to_{$toDate}.csv";
|
||||
|
||||
return $this->exportService->exportProfitLoss($pnl, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Balance Sheet as CSV.
|
||||
*
|
||||
* GET /s/{business}/management/financials/balance-sheet/export
|
||||
*/
|
||||
public function exportBalanceSheet(Request $request, Business $business): StreamedResponse
|
||||
{
|
||||
$asOfDate = $request->get('as_of_date', now()->format('Y-m-d'));
|
||||
$includeChildren = $request->boolean('include_children', true);
|
||||
|
||||
$isParent = $this->reportingService->isParentCompany($business);
|
||||
|
||||
$balanceSheet = $this->reportingService->getBalanceSheet(
|
||||
$business,
|
||||
$asOfDate,
|
||||
$isParent && $includeChildren
|
||||
);
|
||||
|
||||
$filename = "balance_sheet_{$business->slug}_{$asOfDate}.csv";
|
||||
|
||||
return $this->exportService->exportBalanceSheet($balanceSheet, $filename);
|
||||
}
|
||||
}
|
||||
314
app/Http/Controllers/Seller/Management/FixedAssetsController.php
Normal file
314
app/Http/Controllers/Seller/Management/FixedAssetsController.php
Normal file
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\FixedAsset;
|
||||
use App\Models\Accounting\GlAccount;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\FixedAssetService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class FixedAssetsController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected FixedAssetService $assetService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate that the asset belongs to the current business or its divisions.
|
||||
* Prevents cross-tenant access via route model binding.
|
||||
*/
|
||||
private function validateAssetOwnership(Business $business, FixedAsset $asset): void
|
||||
{
|
||||
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
|
||||
|
||||
if (! in_array($asset->business_id, $allowedBusinessIds)) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the business has Management Suite access for mutating actions.
|
||||
*/
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the business is not a child division (read-only access only).
|
||||
* Only parent companies can create/update/delete assets.
|
||||
*/
|
||||
private function requireParentCompany(Business $business): void
|
||||
{
|
||||
if ($business->isDivision()) {
|
||||
abort(403, 'Divisions have read-only access to fixed assets.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display fixed assets listing.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
$query = FixedAsset::whereIn('business_id', $filterData['business_ids'])
|
||||
->with(['vendor', 'business']);
|
||||
|
||||
// Filter by status
|
||||
if ($status = $request->get('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
if ($category = $request->get('category')) {
|
||||
$query->where('category', $category);
|
||||
}
|
||||
|
||||
$assets = $query->orderBy('name')->paginate(25)->withQueryString();
|
||||
$metrics = $this->assetService->getAssetMetrics($business, $filterData['business_ids']);
|
||||
|
||||
return view('seller.management.fixed-assets.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'assets' => $assets,
|
||||
'metrics' => $metrics,
|
||||
'categories' => FixedAsset::getCategories(),
|
||||
'statuses' => FixedAsset::getStatuses(),
|
||||
'currentStatus' => $request->get('status'),
|
||||
'currentCategory' => $request->get('category'),
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create asset form.
|
||||
* Requires Management Suite and parent company access.
|
||||
*/
|
||||
public function create(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requireParentCompany($business);
|
||||
|
||||
$glAccounts = GlAccount::where('business_id', $business->id)
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
return view('seller.management.fixed-assets.create', [
|
||||
'business' => $business,
|
||||
'categories' => FixedAsset::getCategories(),
|
||||
'glAccounts' => $glAccounts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store new asset.
|
||||
* Requires Management Suite and parent company access.
|
||||
*/
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requireParentCompany($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'category' => 'required|string|in:'.implode(',', array_keys(FixedAsset::getCategories())),
|
||||
'location' => 'nullable|string|max:255',
|
||||
'serial_number' => 'nullable|string|max:255',
|
||||
'acquisition_date' => 'required|date',
|
||||
'acquisition_cost' => 'required|numeric|min:0',
|
||||
'acquisition_method' => 'required|string|in:purchase,lease,donation',
|
||||
'useful_life_months' => 'required|integer|min:1',
|
||||
'salvage_value' => 'nullable|numeric|min:0',
|
||||
'depreciation_account_id' => 'nullable|exists:gl_accounts,id',
|
||||
'accumulated_depreciation_account_id' => 'nullable|exists:gl_accounts,id',
|
||||
'expense_account_id' => 'nullable|exists:gl_accounts,id',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$validated['depreciation_method'] = FixedAsset::METHOD_STRAIGHT_LINE;
|
||||
$validated['salvage_value'] = $validated['salvage_value'] ?? 0;
|
||||
|
||||
$asset = $this->assetService->createAsset($business, $validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.fixed-assets.show', [$business, $asset])
|
||||
->with('success', 'Fixed asset created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show asset details.
|
||||
* Parent companies can view all divisions' assets.
|
||||
* Divisions can only view their own assets.
|
||||
*/
|
||||
public function show(Request $request, Business $business, FixedAsset $fixedAsset): View
|
||||
{
|
||||
$this->validateAssetOwnership($business, $fixedAsset);
|
||||
|
||||
$fixedAsset->load(['vendor', 'improvements', 'depreciationRuns', 'disposal']);
|
||||
|
||||
return view('seller.management.fixed-assets.show', [
|
||||
'business' => $business,
|
||||
'asset' => $fixedAsset,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit form.
|
||||
* Requires Management Suite, parent company access, and asset ownership.
|
||||
*/
|
||||
public function edit(Request $request, Business $business, FixedAsset $fixedAsset): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requireParentCompany($business);
|
||||
$this->validateAssetOwnership($business, $fixedAsset);
|
||||
|
||||
$glAccounts = GlAccount::where('business_id', $business->id)
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
return view('seller.management.fixed-assets.edit', [
|
||||
'business' => $business,
|
||||
'asset' => $fixedAsset,
|
||||
'categories' => FixedAsset::getCategories(),
|
||||
'glAccounts' => $glAccounts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update asset.
|
||||
* Requires Management Suite, parent company access, and asset ownership.
|
||||
*/
|
||||
public function update(Request $request, Business $business, FixedAsset $fixedAsset): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requireParentCompany($business);
|
||||
$this->validateAssetOwnership($business, $fixedAsset);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'category' => 'required|string|in:'.implode(',', array_keys(FixedAsset::getCategories())),
|
||||
'location' => 'nullable|string|max:255',
|
||||
'serial_number' => 'nullable|string|max:255',
|
||||
'useful_life_months' => 'required|integer|min:1',
|
||||
'salvage_value' => 'nullable|numeric|min:0',
|
||||
'depreciation_account_id' => 'nullable|exists:gl_accounts,id',
|
||||
'accumulated_depreciation_account_id' => 'nullable|exists:gl_accounts,id',
|
||||
'expense_account_id' => 'nullable|exists:gl_accounts,id',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$this->assetService->updateAsset($fixedAsset, $validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.fixed-assets.show', [$business, $fixedAsset])
|
||||
->with('success', 'Fixed asset updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an improvement.
|
||||
* Requires Management Suite, parent company access, and asset ownership.
|
||||
*/
|
||||
public function storeImprovement(Request $request, Business $business, FixedAsset $fixedAsset): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requireParentCompany($business);
|
||||
$this->validateAssetOwnership($business, $fixedAsset);
|
||||
|
||||
$validated = $request->validate([
|
||||
'description' => 'required|string|max:255',
|
||||
'improvement_date' => 'required|date',
|
||||
'cost' => 'required|numeric|min:0',
|
||||
'extends_life' => 'boolean',
|
||||
'additional_life_months' => 'nullable|integer|min:1',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$this->assetService->recordImprovement($fixedAsset, $validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.fixed-assets.show', [$business, $fixedAsset])
|
||||
->with('success', 'Improvement recorded successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run depreciation for a period.
|
||||
* Requires Management Suite and parent company access.
|
||||
*/
|
||||
public function runDepreciation(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requireParentCompany($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'period_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$periodDate = Carbon::parse($validated['period_date']);
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
$runs = $this->assetService->runBatchDepreciation($business, $periodDate, $filterData['business_ids']);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.fixed-assets.index', $business)
|
||||
->with('success', "Depreciation run complete. {$runs->count()} assets depreciated.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show disposal form.
|
||||
* Requires Management Suite, parent company access, and asset ownership.
|
||||
*/
|
||||
public function showDisposeForm(Request $request, Business $business, FixedAsset $fixedAsset): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requireParentCompany($business);
|
||||
$this->validateAssetOwnership($business, $fixedAsset);
|
||||
|
||||
return view('seller.management.fixed-assets.dispose', [
|
||||
'business' => $business,
|
||||
'asset' => $fixedAsset,
|
||||
'methods' => \App\Models\Accounting\FixedAssetDisposal::getMethods(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of an asset.
|
||||
* Requires Management Suite, parent company access, and asset ownership.
|
||||
*/
|
||||
public function dispose(Request $request, Business $business, FixedAsset $fixedAsset): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requireParentCompany($business);
|
||||
$this->validateAssetOwnership($business, $fixedAsset);
|
||||
|
||||
$validated = $request->validate([
|
||||
'disposal_date' => 'required|date',
|
||||
'disposal_method' => 'required|string',
|
||||
'proceeds' => 'nullable|numeric|min:0',
|
||||
'buyer_name' => 'nullable|string|max:255',
|
||||
'buyer_contact' => 'nullable|string|max:255',
|
||||
'reason' => 'nullable|string',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$validated['proceeds'] = $validated['proceeds'] ?? 0;
|
||||
|
||||
$this->assetService->disposeAsset($fixedAsset, $validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.fixed-assets.index', $business)
|
||||
->with('success', 'Asset disposed successfully.');
|
||||
}
|
||||
}
|
||||
184
app/Http/Controllers/Seller/Management/ForecastingController.php
Normal file
184
app/Http/Controllers/Seller/Management/ForecastingController.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ForecastingController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
$businessIds = $filterData['business_ids'];
|
||||
|
||||
// Generate 12-month forecast
|
||||
$forecast = $this->generateForecast($businessIds);
|
||||
|
||||
return view('seller.management.forecasting.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'forecast' => $forecast,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Require Management Suite access.
|
||||
*/
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function generateForecast(array $businessIds): array
|
||||
{
|
||||
// Get historical data for the past 12 months
|
||||
$historicalData = $this->getHistoricalData($businessIds);
|
||||
|
||||
// Calculate trends
|
||||
$revenueTrend = $this->calculateTrend($historicalData['revenue']);
|
||||
$expenseTrend = $this->calculateTrend($historicalData['expenses']);
|
||||
|
||||
// Generate forecast for next 12 months
|
||||
$forecastMonths = [];
|
||||
$lastRevenue = end($historicalData['revenue'])['amount'] ?? 0;
|
||||
$lastExpenses = end($historicalData['expenses'])['amount'] ?? 0;
|
||||
|
||||
for ($i = 1; $i <= 12; $i++) {
|
||||
$month = Carbon::now()->addMonths($i);
|
||||
$projectedRevenue = max(0, $lastRevenue * (1 + ($revenueTrend / 100)));
|
||||
$projectedExpenses = max(0, $lastExpenses * (1 + ($expenseTrend / 100)));
|
||||
|
||||
$forecastMonths[] = [
|
||||
'month' => $month->format('M Y'),
|
||||
'month_key' => $month->format('Y-m'),
|
||||
'projected_revenue' => $projectedRevenue,
|
||||
'projected_expenses' => $projectedExpenses,
|
||||
'projected_net' => $projectedRevenue - $projectedExpenses,
|
||||
];
|
||||
|
||||
$lastRevenue = $projectedRevenue;
|
||||
$lastExpenses = $projectedExpenses;
|
||||
}
|
||||
|
||||
return [
|
||||
'historical' => $historicalData,
|
||||
'forecast' => $forecastMonths,
|
||||
'trends' => [
|
||||
'revenue' => $revenueTrend,
|
||||
'expenses' => $expenseTrend,
|
||||
],
|
||||
'summary' => [
|
||||
'total_projected_revenue' => collect($forecastMonths)->sum('projected_revenue'),
|
||||
'total_projected_expenses' => collect($forecastMonths)->sum('projected_expenses'),
|
||||
'total_projected_net' => collect($forecastMonths)->sum('projected_net'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHistoricalData(array $businessIds): array
|
||||
{
|
||||
$startDate = Carbon::now()->subMonths(12)->startOfMonth();
|
||||
$endDate = Carbon::now()->endOfMonth();
|
||||
|
||||
// Revenue (from orders)
|
||||
$revenueByMonth = DB::table('orders')
|
||||
->whereIn('business_id', $businessIds)
|
||||
->where('status', 'completed')
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->select(
|
||||
DB::raw("TO_CHAR(created_at, 'YYYY-MM') as month_key"),
|
||||
DB::raw('SUM(total) as amount')
|
||||
)
|
||||
->groupBy('month_key')
|
||||
->orderBy('month_key')
|
||||
->get()
|
||||
->keyBy('month_key');
|
||||
|
||||
// Expenses (from AP bills)
|
||||
$expensesByMonth = DB::table('ap_bills')
|
||||
->whereIn('business_id', $businessIds)
|
||||
->whereIn('status', ['approved', 'paid'])
|
||||
->whereBetween('bill_date', [$startDate, $endDate])
|
||||
->select(
|
||||
DB::raw("TO_CHAR(bill_date, 'YYYY-MM') as month_key"),
|
||||
DB::raw('SUM(total) as amount')
|
||||
)
|
||||
->groupBy('month_key')
|
||||
->orderBy('month_key')
|
||||
->get()
|
||||
->keyBy('month_key');
|
||||
|
||||
// Fill in missing months with zeros
|
||||
$revenue = [];
|
||||
$expenses = [];
|
||||
$current = $startDate->copy();
|
||||
|
||||
while ($current <= $endDate) {
|
||||
$key = $current->format('Y-m');
|
||||
$revenue[] = [
|
||||
'month' => $current->format('M Y'),
|
||||
'month_key' => $key,
|
||||
'amount' => $revenueByMonth[$key]->amount ?? 0,
|
||||
];
|
||||
$expenses[] = [
|
||||
'month' => $current->format('M Y'),
|
||||
'month_key' => $key,
|
||||
'amount' => $expensesByMonth[$key]->amount ?? 0,
|
||||
];
|
||||
$current->addMonth();
|
||||
}
|
||||
|
||||
return [
|
||||
'revenue' => $revenue,
|
||||
'expenses' => $expenses,
|
||||
];
|
||||
}
|
||||
|
||||
protected function calculateTrend(array $data): float
|
||||
{
|
||||
if (count($data) < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$amounts = array_column($data, 'amount');
|
||||
$n = count($amounts);
|
||||
|
||||
// Simple linear regression
|
||||
$sumX = 0;
|
||||
$sumY = 0;
|
||||
$sumXY = 0;
|
||||
$sumXX = 0;
|
||||
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$sumX += $i;
|
||||
$sumY += $amounts[$i];
|
||||
$sumXY += $i * $amounts[$i];
|
||||
$sumXX += $i * $i;
|
||||
}
|
||||
|
||||
$denominator = ($n * $sumXX - $sumX * $sumX);
|
||||
if ($denominator == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$slope = ($n * $sumXY - $sumX * $sumY) / $denominator;
|
||||
$avgY = $sumY / $n;
|
||||
|
||||
if ($avgY == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Convert slope to percentage trend
|
||||
return ($slope / $avgY) * 100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\InterBusinessSettlement;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\InterBusinessSettlementService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Inter-Business Settlement - Manage balances and settlements between divisions.
|
||||
*
|
||||
* Allows CFOs to:
|
||||
* - View inter-business balance matrix
|
||||
* - Create settlements to zero out balances
|
||||
* - Post settlements (creates journal entries)
|
||||
*/
|
||||
class InterBusinessSettlementController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected InterBusinessSettlementService $settlementService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display inter-business balances and settlement history.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
|
||||
// Get balance matrix
|
||||
$matrixData = $this->settlementService->getBalanceMatrix($parentBusiness);
|
||||
|
||||
// Get outstanding balances for quick view
|
||||
$outstandingBalances = $this->settlementService->getOutstandingBalances($parentBusiness);
|
||||
|
||||
// Get recent settlements
|
||||
$settlements = InterBusinessSettlement::where('parent_business_id', $parentBusiness->id)
|
||||
->with(['lines.fromBusiness', 'lines.toBusiness', 'createdByUser', 'postedByUser'])
|
||||
->orderByDesc('created_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Calculate totals
|
||||
$totalOutstanding = $outstandingBalances->sum('balance');
|
||||
|
||||
return view('seller.management.inter-business.index', compact(
|
||||
'business',
|
||||
'parentBusiness',
|
||||
'matrixData',
|
||||
'outstandingBalances',
|
||||
'settlements',
|
||||
'totalOutstanding'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show form to create a new settlement.
|
||||
*/
|
||||
public function create(Request $request, Business $business): View
|
||||
{
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
|
||||
// Get divisions
|
||||
$divisions = Business::where('parent_id', $parentBusiness->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Suggest settlements based on outstanding balances
|
||||
$suggestedLines = $this->settlementService->suggestSettlements($parentBusiness);
|
||||
|
||||
return view('seller.management.inter-business.create', compact(
|
||||
'business',
|
||||
'parentBusiness',
|
||||
'divisions',
|
||||
'suggestedLines'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new settlement (as draft).
|
||||
*/
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
|
||||
$validated = $request->validate([
|
||||
'description' => 'nullable|string|max:500',
|
||||
'lines' => 'required|array|min:1',
|
||||
'lines.*.from_business_id' => 'required|exists:businesses,id',
|
||||
'lines.*.to_business_id' => 'required|exists:businesses,id|different:lines.*.from_business_id',
|
||||
'lines.*.amount' => 'required|numeric|min:0.01',
|
||||
'lines.*.description' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$settlement = $this->settlementService->createSettlement(
|
||||
$parentBusiness,
|
||||
$validated['lines'],
|
||||
$validated['description'],
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.inter-business.show', [$business, $settlement])
|
||||
->with('success', "Settlement {$settlement->settlement_number} created as draft.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show settlement details.
|
||||
*/
|
||||
public function show(Request $request, Business $business, InterBusinessSettlement $settlement): View
|
||||
{
|
||||
$settlement->load(['lines.fromBusiness', 'lines.toBusiness', 'createdByUser', 'postedByUser']);
|
||||
|
||||
return view('seller.management.inter-business.show', compact(
|
||||
'business',
|
||||
'settlement'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a settlement (create journal entries).
|
||||
*/
|
||||
public function post(Request $request, Business $business, InterBusinessSettlement $settlement): RedirectResponse
|
||||
{
|
||||
if (! $settlement->isDraft()) {
|
||||
return back()->with('error', 'Settlement has already been posted.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->settlementService->postSettlement($settlement, auth()->id());
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.inter-business.show', [$business, $settlement])
|
||||
->with('success', "Settlement {$settlement->settlement_number} posted successfully.");
|
||||
} catch (\RuntimeException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick settle all outstanding balances.
|
||||
*/
|
||||
public function settleAll(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
|
||||
$suggestedLines = $this->settlementService->suggestSettlements($parentBusiness);
|
||||
|
||||
if (empty($suggestedLines)) {
|
||||
return back()->with('warning', 'No outstanding inter-business balances to settle.');
|
||||
}
|
||||
|
||||
$settlement = $this->settlementService->createSettlement(
|
||||
$parentBusiness,
|
||||
$suggestedLines,
|
||||
'Complete inter-business settlement',
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.inter-business.show', [$business, $settlement])
|
||||
->with('success', "Settlement {$settlement->settlement_number} created for all outstanding balances. Review and post when ready.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\InventoryValuationService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class InventoryValuationController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected InventoryValuationService $valuationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ensure the business has Management Suite access.
|
||||
*/
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the inventory valuation dashboard.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
// Determine scope - use snake_case keys from getDivisionFilterData()
|
||||
$targetBusiness = $filterData['selected_division'] ?? $business;
|
||||
$includeChildren = $filterData['selected_division'] === null && $business->hasChildBusinesses();
|
||||
$businessIds = $filterData['business_ids'];
|
||||
|
||||
// Get valuation data
|
||||
$summary = $this->valuationService->getValuationSummary($businessIds);
|
||||
$byType = $this->valuationService->getValuationByType($businessIds);
|
||||
$byDivision = $includeChildren ? $this->valuationService->getValuationByDivision($businessIds) : collect();
|
||||
$byCategory = $this->valuationService->getValuationByCategory($businessIds);
|
||||
$byLocation = $this->valuationService->getValuationByLocation($businessIds);
|
||||
$topItems = $this->valuationService->getTopItemsByValue($businessIds, 10);
|
||||
$aging = $this->valuationService->getInventoryAging($businessIds);
|
||||
$atRisk = $this->valuationService->getInventoryAtRisk($businessIds);
|
||||
|
||||
return view('seller.management.inventory-valuation.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'summary' => $summary,
|
||||
'byType' => $byType,
|
||||
'byDivision' => $byDivision,
|
||||
'byCategory' => $byCategory,
|
||||
'byLocation' => $byLocation,
|
||||
'topItems' => $topItems,
|
||||
'aging' => $aging,
|
||||
'atRisk' => $atRisk,
|
||||
'isParent' => $business->hasChildBusinesses(),
|
||||
], $filterData));
|
||||
}
|
||||
}
|
||||
119
app/Http/Controllers/Seller/Management/OperationsController.php
Normal file
119
app/Http/Controllers/Seller/Management/OperationsController.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class OperationsController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
$businessIds = $filterData['business_ids'];
|
||||
|
||||
// Collect operations data
|
||||
$operations = $this->collectOperationsData($businessIds);
|
||||
|
||||
return view('seller.management.operations.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'operations' => $operations,
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Require Management Suite access.
|
||||
*/
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function collectOperationsData(array $businessIds): array
|
||||
{
|
||||
$today = Carbon::today();
|
||||
$startOfMonth = Carbon::now()->startOfMonth();
|
||||
$startOfWeek = Carbon::now()->startOfWeek();
|
||||
|
||||
// Order stats
|
||||
$orderStats = DB::table('orders')
|
||||
->whereIn('business_id', $businessIds)
|
||||
->select([
|
||||
DB::raw('COUNT(CASE WHEN status = \'pending\' THEN 1 END) as pending_orders'),
|
||||
DB::raw('COUNT(CASE WHEN status = \'processing\' THEN 1 END) as processing_orders'),
|
||||
DB::raw('COUNT(CASE WHEN status = \'completed\' AND created_at >= \''.$startOfMonth->toDateString().'\' THEN 1 END) as completed_this_month'),
|
||||
DB::raw('COUNT(CASE WHEN created_at >= \''.$startOfWeek->toDateString().'\' THEN 1 END) as orders_this_week'),
|
||||
])
|
||||
->first();
|
||||
|
||||
// Product stats
|
||||
$productStats = DB::table('products')
|
||||
->join('brands', 'products.brand_id', '=', 'brands.id')
|
||||
->whereIn('brands.business_id', $businessIds)
|
||||
->select([
|
||||
DB::raw('COUNT(*) as total_products'),
|
||||
DB::raw('COUNT(CASE WHEN products.is_active = true THEN 1 END) as active_products'),
|
||||
DB::raw('COUNT(CASE WHEN products.quantity_on_hand <= products.low_stock_threshold AND products.quantity_on_hand > 0 THEN 1 END) as low_stock_products'),
|
||||
DB::raw('COUNT(CASE WHEN products.quantity_on_hand = 0 THEN 1 END) as out_of_stock_products'),
|
||||
])
|
||||
->first();
|
||||
|
||||
// Customer stats (AR customers)
|
||||
$customerStats = DB::table('ar_customers')
|
||||
->whereIn('business_id', $businessIds)
|
||||
->where('is_active', true)
|
||||
->select([
|
||||
DB::raw('COUNT(*) as total_customers'),
|
||||
DB::raw('COUNT(CASE WHEN created_at >= \''.$startOfMonth->toDateString().'\' THEN 1 END) as new_this_month'),
|
||||
])
|
||||
->first();
|
||||
|
||||
// Bill stats
|
||||
$billStats = DB::table('ap_bills')
|
||||
->whereIn('business_id', $businessIds)
|
||||
->select([
|
||||
DB::raw('COUNT(CASE WHEN status = \'pending\' THEN 1 END) as pending_bills'),
|
||||
DB::raw('COUNT(CASE WHEN status = \'approved\' THEN 1 END) as approved_bills'),
|
||||
DB::raw('COUNT(CASE WHEN status = \'overdue\' THEN 1 END) as overdue_bills'),
|
||||
DB::raw('COALESCE(SUM(CASE WHEN status IN (\'pending\', \'approved\') THEN total ELSE 0 END), 0) as pending_amount'),
|
||||
])
|
||||
->first();
|
||||
|
||||
// Expense stats
|
||||
$expenseStats = DB::table('expenses')
|
||||
->whereIn('business_id', $businessIds)
|
||||
->select([
|
||||
DB::raw('COUNT(CASE WHEN status = \'pending\' THEN 1 END) as pending_expenses'),
|
||||
DB::raw('COALESCE(SUM(CASE WHEN status = \'pending\' THEN total_amount ELSE 0 END), 0) as pending_amount'),
|
||||
])
|
||||
->first();
|
||||
|
||||
// Recent activity
|
||||
$recentOrders = DB::table('orders')
|
||||
->join('businesses', 'orders.business_id', '=', 'businesses.id')
|
||||
->whereIn('orders.business_id', $businessIds)
|
||||
->orderByDesc('orders.created_at')
|
||||
->limit(5)
|
||||
->select(['orders.*', 'businesses.name as business_name'])
|
||||
->get();
|
||||
|
||||
return [
|
||||
'orders' => $orderStats,
|
||||
'products' => $productStats,
|
||||
'customers' => $customerStats,
|
||||
'bills' => $billStats,
|
||||
'expenses' => $expenseStats,
|
||||
'recent_orders' => $recentOrders,
|
||||
];
|
||||
}
|
||||
}
|
||||
152
app/Http/Controllers/Seller/Management/PermissionsController.php
Normal file
152
app/Http/Controllers/Seller/Management/PermissionsController.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use App\Services\Management\ManagementPermissionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Management Suite Permissions Controller.
|
||||
*
|
||||
* Allows CFOs/Admins to manage fine-grained permissions for users
|
||||
* within the Management Suite.
|
||||
*/
|
||||
class PermissionsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ManagementPermissionService $permissionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display list of users and their permission levels.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
|
||||
$users = $this->permissionService->getUsersWithAccess($parentBusiness);
|
||||
|
||||
// Get permission summary for each user
|
||||
$userSummaries = [];
|
||||
foreach ($users as $user) {
|
||||
$userSummaries[$user->id] = [
|
||||
'user' => $user,
|
||||
'role' => $user->businesses->first()?->pivot?->role ?? 'member',
|
||||
'summary' => $this->permissionService->getPermissionSummary($user, $parentBusiness),
|
||||
'permissions' => $this->permissionService->getUserPermissions($user, $parentBusiness),
|
||||
];
|
||||
}
|
||||
|
||||
$roleTemplates = $this->permissionService->getRoleTemplates();
|
||||
|
||||
return view('seller.management.permissions.index', compact(
|
||||
'business',
|
||||
'parentBusiness',
|
||||
'users',
|
||||
'userSummaries',
|
||||
'roleTemplates'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit form for a user's permissions.
|
||||
*/
|
||||
public function edit(Request $request, Business $business, User $user): View
|
||||
{
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
|
||||
// Verify user belongs to this business
|
||||
$pivot = $user->businesses()
|
||||
->where('businesses.id', $parentBusiness->id)
|
||||
->first()?->pivot;
|
||||
|
||||
if (! $pivot) {
|
||||
abort(404, 'User not found in this business.');
|
||||
}
|
||||
|
||||
$permissionCategories = $this->permissionService->getPermissionDefinitions();
|
||||
$currentPermissions = $this->permissionService->getUserPermissions($user, $parentBusiness);
|
||||
$roleTemplates = $this->permissionService->getRoleTemplates();
|
||||
|
||||
return view('seller.management.permissions.edit', compact(
|
||||
'business',
|
||||
'parentBusiness',
|
||||
'user',
|
||||
'pivot',
|
||||
'permissionCategories',
|
||||
'currentPermissions',
|
||||
'roleTemplates'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user's permissions.
|
||||
*/
|
||||
public function update(Request $request, Business $business, User $user): RedirectResponse
|
||||
{
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
|
||||
// Verify user belongs to this business
|
||||
$pivot = $user->businesses()
|
||||
->where('businesses.id', $parentBusiness->id)
|
||||
->first()?->pivot;
|
||||
|
||||
if (! $pivot) {
|
||||
abort(404, 'User not found in this business.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'permissions' => 'nullable|array',
|
||||
'permissions.*' => 'string',
|
||||
]);
|
||||
|
||||
$permissions = $validated['permissions'] ?? [];
|
||||
|
||||
$this->permissionService->setUserPermissions($user, $parentBusiness, $permissions);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.permissions.index', $business)
|
||||
->with('success', "Permissions updated for {$user->name}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a role template to a user.
|
||||
*/
|
||||
public function applyTemplate(Request $request, Business $business, User $user): RedirectResponse
|
||||
{
|
||||
$parentBusiness = $business->parent ?? $business;
|
||||
|
||||
// Verify user belongs to this business
|
||||
$pivot = $user->businesses()
|
||||
->where('businesses.id', $parentBusiness->id)
|
||||
->first()?->pivot;
|
||||
|
||||
if (! $pivot) {
|
||||
abort(404, 'User not found in this business.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'template' => 'required|string',
|
||||
]);
|
||||
|
||||
$templates = $this->permissionService->getRoleTemplates();
|
||||
if (! isset($templates[$validated['template']])) {
|
||||
return back()->with('error', 'Invalid role template.');
|
||||
}
|
||||
|
||||
$this->permissionService->applyRoleTemplate($user, $parentBusiness, $validated['template']);
|
||||
|
||||
$templateLabel = $templates[$validated['template']]['label'];
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.permissions.edit', [$business, $user])
|
||||
->with('success', "{$templateLabel} template applied to {$user->name}.");
|
||||
}
|
||||
}
|
||||
419
app/Http/Controllers/Seller/Management/RecurringController.php
Normal file
419
app/Http/Controllers/Seller/Management/RecurringController.php
Normal file
@@ -0,0 +1,419 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ApVendor;
|
||||
use App\Models\Accounting\ArCustomer;
|
||||
use App\Models\Accounting\GlAccount;
|
||||
use App\Models\Accounting\RecurringApTemplate;
|
||||
use App\Models\Accounting\RecurringArTemplate;
|
||||
use App\Models\Accounting\RecurringJournalEntryTemplate;
|
||||
use App\Models\Accounting\RecurringSchedule;
|
||||
use App\Models\Business;
|
||||
use App\Models\Department;
|
||||
use App\Services\Accounting\RecurringSchedulerService;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RecurringController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function __construct(
|
||||
protected RecurringSchedulerService $schedulerService
|
||||
) {}
|
||||
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List recurring schedules.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filters = [
|
||||
'type' => $request->type,
|
||||
'is_active' => $request->has('is_active') ? (bool) $request->is_active : null,
|
||||
];
|
||||
|
||||
$schedules = $this->schedulerService->getSchedulesForBusiness($business, $filters);
|
||||
|
||||
return view('seller.management.recurring.index', [
|
||||
'business' => $business,
|
||||
'schedules' => $schedules,
|
||||
'types' => RecurringSchedule::getTypes(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create form.
|
||||
*/
|
||||
public function create(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$type = $request->type ?? RecurringSchedule::TYPE_AR_INVOICE;
|
||||
|
||||
return view('seller.management.recurring.create', [
|
||||
'business' => $business,
|
||||
'type' => $type,
|
||||
'types' => RecurringSchedule::getTypes(),
|
||||
'frequencies' => RecurringSchedule::getFrequencies(),
|
||||
'weekdays' => RecurringSchedule::getWeekdays(),
|
||||
'customers' => ArCustomer::where('business_id', $business->id)->orderBy('name')->get(),
|
||||
'vendors' => ApVendor::where('business_id', $business->id)->orderBy('name')->get(),
|
||||
'glAccounts' => GlAccount::where('business_id', $business->id)->orderBy('account_number')->get(),
|
||||
'departments' => Department::where('business_id', $business->id)->where('is_active', true)->orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store new recurring schedule.
|
||||
*/
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|in:ar_invoice,ap_bill,journal_entry',
|
||||
'frequency' => 'required|in:weekly,monthly,quarterly,yearly,custom',
|
||||
'interval' => 'required|integer|min:1|max:365',
|
||||
'day_of_month' => 'nullable|integer|min:1|max:31',
|
||||
'weekday' => 'nullable|string',
|
||||
'next_run_date' => 'required|date|after_or_equal:today',
|
||||
'end_date' => 'nullable|date|after:next_run_date',
|
||||
'auto_post' => 'boolean',
|
||||
'create_as_draft' => 'boolean',
|
||||
// AR template fields
|
||||
'ar_customer_id' => 'required_if:type,ar_invoice|nullable|exists:ar_customers,id',
|
||||
'ar_terms' => 'nullable|string|max:50',
|
||||
'ar_memo' => 'nullable|string|max:255',
|
||||
'ar_items' => 'required_if:type,ar_invoice|nullable|array|min:1',
|
||||
'ar_items.*.description' => 'required_with:ar_items|string',
|
||||
'ar_items.*.quantity' => 'required_with:ar_items|numeric|min:0.0001',
|
||||
'ar_items.*.unit_price' => 'required_with:ar_items|numeric|min:0',
|
||||
'ar_items.*.gl_revenue_account_id' => 'required_with:ar_items|exists:gl_accounts,id',
|
||||
// AP template fields
|
||||
'vendor_id' => 'required_if:type,ap_bill|nullable|exists:ap_vendors,id',
|
||||
'ap_terms' => 'nullable|string|max:50',
|
||||
'ap_memo' => 'nullable|string|max:255',
|
||||
'ap_items' => 'required_if:type,ap_bill|nullable|array|min:1',
|
||||
'ap_items.*.description' => 'required_with:ap_items|string',
|
||||
'ap_items.*.amount' => 'required_with:ap_items|numeric|min:0',
|
||||
'ap_items.*.gl_expense_account_id' => 'required_with:ap_items|exists:gl_accounts,id',
|
||||
'ap_items.*.department_id' => 'nullable|exists:departments,id',
|
||||
// JE template fields
|
||||
'je_memo' => 'nullable|string|max:255',
|
||||
'je_lines' => 'required_if:type,journal_entry|nullable|array|min:2',
|
||||
'je_lines.*.gl_account_id' => 'required_with:je_lines|exists:gl_accounts,id',
|
||||
'je_lines.*.department_id' => 'nullable|exists:departments,id',
|
||||
'je_lines.*.debit' => 'nullable|numeric|min:0',
|
||||
'je_lines.*.credit' => 'nullable|numeric|min:0',
|
||||
'je_lines.*.description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Validate JE balance
|
||||
if ($validated['type'] === 'journal_entry' && ! empty($validated['je_lines'])) {
|
||||
$totalDebit = collect($validated['je_lines'])->sum(fn ($l) => (float) ($l['debit'] ?? 0));
|
||||
$totalCredit = collect($validated['je_lines'])->sum(fn ($l) => (float) ($l['credit'] ?? 0));
|
||||
|
||||
if (abs($totalDebit - $totalCredit) > 0.01) {
|
||||
return back()->withInput()->withErrors(['je_lines' => 'Journal entry must be balanced (debits = credits).']);
|
||||
}
|
||||
}
|
||||
|
||||
// Create schedule
|
||||
$schedule = RecurringSchedule::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'type' => $validated['type'],
|
||||
'frequency' => $validated['frequency'],
|
||||
'interval' => $validated['interval'],
|
||||
'day_of_month' => $validated['day_of_month'] ?? null,
|
||||
'weekday' => $validated['weekday'] ?? null,
|
||||
'next_run_date' => $validated['next_run_date'],
|
||||
'end_date' => $validated['end_date'] ?? null,
|
||||
'auto_post' => $validated['auto_post'] ?? false,
|
||||
'create_as_draft' => $validated['create_as_draft'] ?? true,
|
||||
'is_active' => true,
|
||||
'created_by_user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Create template based on type
|
||||
match ($validated['type']) {
|
||||
'ar_invoice' => $this->createArTemplate($schedule, $validated),
|
||||
'ap_bill' => $this->createApTemplate($schedule, $validated),
|
||||
'journal_entry' => $this->createJeTemplate($schedule, $validated),
|
||||
};
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.recurring.show', [$business, $schedule])
|
||||
->with('success', 'Recurring schedule created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show schedule details.
|
||||
*/
|
||||
public function show(Request $request, Business $business, RecurringSchedule $recurring): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($recurring->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$recurring->load([
|
||||
'arTemplate.items.glAccount',
|
||||
'arTemplate.customer',
|
||||
'apTemplate.items.glAccount',
|
||||
'apTemplate.items.department',
|
||||
'apTemplate.vendor',
|
||||
'journalEntryTemplate.lines.glAccount',
|
||||
'journalEntryTemplate.lines.department',
|
||||
'createdBy',
|
||||
]);
|
||||
|
||||
$generatedTransactions = $this->schedulerService->getGeneratedTransactions($recurring);
|
||||
|
||||
return view('seller.management.recurring.show', [
|
||||
'business' => $business,
|
||||
'schedule' => $recurring,
|
||||
'generatedTransactions' => $generatedTransactions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit form.
|
||||
*/
|
||||
public function edit(Request $request, Business $business, RecurringSchedule $recurring): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($recurring->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$recurring->load([
|
||||
'arTemplate.items',
|
||||
'apTemplate.items',
|
||||
'journalEntryTemplate.lines',
|
||||
]);
|
||||
|
||||
return view('seller.management.recurring.edit', [
|
||||
'business' => $business,
|
||||
'schedule' => $recurring,
|
||||
'types' => RecurringSchedule::getTypes(),
|
||||
'frequencies' => RecurringSchedule::getFrequencies(),
|
||||
'weekdays' => RecurringSchedule::getWeekdays(),
|
||||
'customers' => ArCustomer::where('business_id', $business->id)->orderBy('name')->get(),
|
||||
'vendors' => ApVendor::where('business_id', $business->id)->orderBy('name')->get(),
|
||||
'glAccounts' => GlAccount::where('business_id', $business->id)->orderBy('account_number')->get(),
|
||||
'departments' => Department::where('business_id', $business->id)->where('is_active', true)->orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update schedule.
|
||||
*/
|
||||
public function update(Request $request, Business $business, RecurringSchedule $recurring): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($recurring->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'frequency' => 'required|in:weekly,monthly,quarterly,yearly,custom',
|
||||
'interval' => 'required|integer|min:1|max:365',
|
||||
'day_of_month' => 'nullable|integer|min:1|max:31',
|
||||
'weekday' => 'nullable|string',
|
||||
'next_run_date' => 'required|date',
|
||||
'end_date' => 'nullable|date|after:next_run_date',
|
||||
'auto_post' => 'boolean',
|
||||
'create_as_draft' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$recurring->update([
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'frequency' => $validated['frequency'],
|
||||
'interval' => $validated['interval'],
|
||||
'day_of_month' => $validated['day_of_month'] ?? null,
|
||||
'weekday' => $validated['weekday'] ?? null,
|
||||
'next_run_date' => $validated['next_run_date'],
|
||||
'end_date' => $validated['end_date'] ?? null,
|
||||
'auto_post' => $validated['auto_post'] ?? false,
|
||||
'create_as_draft' => $validated['create_as_draft'] ?? true,
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.recurring.show', [$business, $recurring])
|
||||
->with('success', 'Recurring schedule updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active status.
|
||||
*/
|
||||
public function toggle(Request $request, Business $business, RecurringSchedule $recurring): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($recurring->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$recurring->update(['is_active' => ! $recurring->is_active]);
|
||||
|
||||
$status = $recurring->is_active ? 'activated' : 'deactivated';
|
||||
|
||||
return back()->with('success', "Schedule {$status}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete schedule.
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, RecurringSchedule $recurring): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($recurring->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$recurring->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.recurring.index', $business)
|
||||
->with('success', 'Recurring schedule deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show review queue for draft recurring transactions.
|
||||
*/
|
||||
public function review(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
$drafts = $this->schedulerService->getDraftTransactionsForReview($business, $filterData['business_ids']);
|
||||
|
||||
return view('seller.management.recurring.review', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'arInvoices' => $drafts['ar_invoices'],
|
||||
'apBills' => $drafts['ap_bills'],
|
||||
'journalEntries' => $drafts['journal_entries'],
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run schedule manually.
|
||||
*/
|
||||
public function runNow(Request $request, Business $business, RecurringSchedule $recurring): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
if ($recurring->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->schedulerService->runSchedule($recurring, now());
|
||||
|
||||
if ($result) {
|
||||
$type = class_basename($result);
|
||||
|
||||
return back()->with('success', "{$type} generated successfully.");
|
||||
}
|
||||
|
||||
return back()->with('error', 'Schedule is not due for execution.');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', 'Failed to run schedule: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create AR template with items.
|
||||
*/
|
||||
private function createArTemplate(RecurringSchedule $schedule, array $data): void
|
||||
{
|
||||
$template = RecurringArTemplate::create([
|
||||
'recurring_schedule_id' => $schedule->id,
|
||||
'ar_customer_id' => $data['ar_customer_id'],
|
||||
'terms' => $data['ar_terms'] ?? null,
|
||||
'default_memo' => $data['ar_memo'] ?? null,
|
||||
'currency' => 'USD',
|
||||
]);
|
||||
|
||||
foreach ($data['ar_items'] as $item) {
|
||||
$template->items()->create([
|
||||
'description' => $item['description'],
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unit_price'],
|
||||
'gl_revenue_account_id' => $item['gl_revenue_account_id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create AP template with items.
|
||||
*/
|
||||
private function createApTemplate(RecurringSchedule $schedule, array $data): void
|
||||
{
|
||||
$template = RecurringApTemplate::create([
|
||||
'recurring_schedule_id' => $schedule->id,
|
||||
'vendor_id' => $data['vendor_id'],
|
||||
'terms' => $data['ap_terms'] ?? null,
|
||||
'default_memo' => $data['ap_memo'] ?? null,
|
||||
'currency' => 'USD',
|
||||
]);
|
||||
|
||||
foreach ($data['ap_items'] as $item) {
|
||||
$template->items()->create([
|
||||
'description' => $item['description'],
|
||||
'amount' => $item['amount'],
|
||||
'gl_expense_account_id' => $item['gl_expense_account_id'],
|
||||
'department_id' => $item['department_id'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create JE template with lines.
|
||||
*/
|
||||
private function createJeTemplate(RecurringSchedule $schedule, array $data): void
|
||||
{
|
||||
$template = RecurringJournalEntryTemplate::create([
|
||||
'recurring_schedule_id' => $schedule->id,
|
||||
'memo' => $data['je_memo'] ?? $schedule->name,
|
||||
]);
|
||||
|
||||
foreach ($data['je_lines'] as $line) {
|
||||
$template->lines()->create([
|
||||
'gl_account_id' => $line['gl_account_id'],
|
||||
'department_id' => $line['department_id'] ?? null,
|
||||
'debit' => $line['debit'] ?? 0,
|
||||
'credit' => $line['credit'] ?? 0,
|
||||
'description' => $line['description'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ApVendor;
|
||||
use App\Models\Business;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Purchasing\PurchaseRequisition;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Controller for Management Suite requisition approval workflow.
|
||||
*
|
||||
* Parent companies (Canopy) use this to:
|
||||
* - View all requisitions from child businesses
|
||||
* - Approve or reject requisitions
|
||||
* - Convert approved requisitions to Purchase Orders
|
||||
*/
|
||||
class RequisitionsApprovalController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
/**
|
||||
* Display list of requisitions from all divisions.
|
||||
*
|
||||
* GET /s/{business}/management/requisitions
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
// Only parent companies can access this
|
||||
if (! $business->isParentCompany()) {
|
||||
abort(403, 'Only parent companies can manage requisition approvals.');
|
||||
}
|
||||
|
||||
$filterData = $this->getDivisionFilterData($business, $request);
|
||||
|
||||
// Get requisitions from child businesses (not from parent itself)
|
||||
$childIds = Business::where('parent_id', $business->id)->pluck('id')->toArray();
|
||||
$queryBusinessIds = $filterData['selected_division_id']
|
||||
? [$filterData['selected_division_id']]
|
||||
: $childIds;
|
||||
|
||||
$query = PurchaseRequisition::whereIn('business_id', $queryBusinessIds)
|
||||
->with(['requestedBy', 'vendor', 'department', 'approvedBy', 'business'])
|
||||
->withCount('items');
|
||||
|
||||
// Status filter
|
||||
if ($status = $request->get('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if ($priority = $request->get('priority')) {
|
||||
$query->where('priority', $priority);
|
||||
}
|
||||
|
||||
// Search
|
||||
if ($search = $request->get('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('requisition_number', 'like', "%{$search}%")
|
||||
->orWhere('notes', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$requisitions = $query->orderByDesc('created_at')->paginate(20)->withQueryString();
|
||||
|
||||
// Status counts for all child businesses
|
||||
$statusCounts = PurchaseRequisition::whereIn('business_id', $childIds)
|
||||
->selectRaw('status, COUNT(*) as count')
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status');
|
||||
|
||||
// Summary stats
|
||||
$stats = [
|
||||
'awaiting_approval' => PurchaseRequisition::whereIn('business_id', $childIds)
|
||||
->whereIn('status', [PurchaseRequisition::STATUS_SUBMITTED, PurchaseRequisition::STATUS_UNDER_REVIEW])
|
||||
->count(),
|
||||
'approved_pending_po' => PurchaseRequisition::whereIn('business_id', $childIds)
|
||||
->where('status', PurchaseRequisition::STATUS_APPROVED)
|
||||
->whereNull('linked_po_id')
|
||||
->count(),
|
||||
'urgent_count' => PurchaseRequisition::whereIn('business_id', $childIds)
|
||||
->whereIn('status', [PurchaseRequisition::STATUS_SUBMITTED, PurchaseRequisition::STATUS_UNDER_REVIEW])
|
||||
->where('priority', PurchaseRequisition::PRIORITY_URGENT)
|
||||
->count(),
|
||||
];
|
||||
|
||||
return view('seller.management.requisitions.index', $this->withDivisionFilter([
|
||||
'business' => $business,
|
||||
'requisitions' => $requisitions,
|
||||
'statusCounts' => $statusCounts,
|
||||
'stats' => $stats,
|
||||
'filters' => $request->only(['status', 'priority', 'search']),
|
||||
], $filterData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a single requisition for review/approval.
|
||||
*
|
||||
* GET /s/{business}/management/requisitions/{requisition}
|
||||
*/
|
||||
public function show(Request $request, Business $business, PurchaseRequisition $requisition): View
|
||||
{
|
||||
if (! $business->isParentCompany()) {
|
||||
abort(403, 'Only parent companies can manage requisition approvals.');
|
||||
}
|
||||
|
||||
// Verify the requisition is from a child business
|
||||
$childIds = Business::where('parent_id', $business->id)->pluck('id')->toArray();
|
||||
if (! in_array($requisition->business_id, $childIds)) {
|
||||
abort(403, 'This requisition does not belong to your divisions.');
|
||||
}
|
||||
|
||||
$requisition->load([
|
||||
'items.suggestedVendor',
|
||||
'items.glAccount',
|
||||
'requestedBy',
|
||||
'approvedBy',
|
||||
'vendor',
|
||||
'department',
|
||||
'purchaseOrder',
|
||||
'business',
|
||||
]);
|
||||
|
||||
// Get available vendors for PO creation (from parent business)
|
||||
$vendors = ApVendor::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.management.requisitions.show', [
|
||||
'business' => $business,
|
||||
'requisition' => $requisition,
|
||||
'vendors' => $vendors,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a requisition as under review.
|
||||
*
|
||||
* POST /s/{business}/management/requisitions/{requisition}/review
|
||||
*/
|
||||
public function markUnderReview(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
|
||||
{
|
||||
$this->authorizeAction($business, $requisition);
|
||||
|
||||
if (! $requisition->isSubmitted()) {
|
||||
return back()->with('error', 'Only submitted requisitions can be marked under review.');
|
||||
}
|
||||
|
||||
$requisition->markUnderReview();
|
||||
|
||||
return back()->with('success', 'Requisition marked as under review.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a requisition.
|
||||
*
|
||||
* POST /s/{business}/management/requisitions/{requisition}/approve
|
||||
*/
|
||||
public function approve(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
|
||||
{
|
||||
$this->authorizeAction($business, $requisition);
|
||||
|
||||
if (! $requisition->canBeApproved()) {
|
||||
return back()->with('error', 'This requisition cannot be approved.');
|
||||
}
|
||||
|
||||
$requisition->approve(auth()->user());
|
||||
|
||||
return back()->with('success', 'Requisition approved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a requisition.
|
||||
*
|
||||
* POST /s/{business}/management/requisitions/{requisition}/reject
|
||||
*/
|
||||
public function reject(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
|
||||
{
|
||||
$this->authorizeAction($business, $requisition);
|
||||
|
||||
if (! $requisition->canBeApproved()) {
|
||||
return back()->with('error', 'This requisition cannot be rejected.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'rejection_reason' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
$requisition->reject(auth()->user(), $validated['rejection_reason']);
|
||||
|
||||
return back()->with('success', 'Requisition rejected.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an approved requisition to a Purchase Order.
|
||||
*
|
||||
* POST /s/{business}/management/requisitions/{requisition}/convert-to-po
|
||||
*/
|
||||
public function convertToPo(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
|
||||
{
|
||||
$this->authorizeAction($business, $requisition);
|
||||
|
||||
if (! $requisition->canBeConvertedToPo()) {
|
||||
return back()->with('error', 'This requisition cannot be converted to a PO. It must be approved and not already linked to a PO.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'vendor_id' => 'nullable|exists:ap_vendors,id',
|
||||
'supplier_name' => 'required_without:vendor_id|nullable|string|max:255',
|
||||
'expected_delivery_date' => 'nullable|date|after:today',
|
||||
'notes' => 'nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
// Use vendor from requisition or from form
|
||||
$vendor = null;
|
||||
$supplierName = $validated['supplier_name'] ?? null;
|
||||
|
||||
if (! empty($validated['vendor_id'])) {
|
||||
$vendor = ApVendor::find($validated['vendor_id']);
|
||||
$supplierName = $vendor?->name;
|
||||
} elseif ($requisition->vendor_id) {
|
||||
$vendor = $requisition->vendor;
|
||||
$supplierName = $vendor?->name;
|
||||
}
|
||||
|
||||
// Create the PO on the PARENT business (Canopy)
|
||||
$po = PurchaseOrder::create([
|
||||
'business_id' => $business->id, // Parent creates the PO
|
||||
'po_number' => $this->generatePoNumber($business),
|
||||
'supplier_name' => $supplierName ?? 'Unknown Supplier',
|
||||
'supplier_contact' => $vendor?->contact_name,
|
||||
'supplier_phone' => $vendor?->phone,
|
||||
'supplier_email' => $vendor?->email,
|
||||
'product_type' => 'materials',
|
||||
'quantity' => $requisition->items->sum('quantity'),
|
||||
'unit' => 'ea',
|
||||
'price_per_unit' => $requisition->estimated_total / max(1, $requisition->items->sum('quantity')),
|
||||
'price_unit' => 'ea',
|
||||
'status' => 'pending',
|
||||
'order_date' => now(),
|
||||
'expected_delivery_date' => $validated['expected_delivery_date'] ?? $requisition->needed_by_date,
|
||||
'notes' => "Created from requisition {$requisition->requisition_number} (Division: {$requisition->business->name})\n\n".($validated['notes'] ?? ''),
|
||||
'created_by_user_id' => auth()->id(),
|
||||
'metadata' => [
|
||||
'source_requisition_id' => $requisition->id,
|
||||
'source_requisition_number' => $requisition->requisition_number,
|
||||
'source_business_id' => $requisition->business_id,
|
||||
'source_business_name' => $requisition->business->name,
|
||||
'items' => $requisition->items->map(fn ($item) => [
|
||||
'description' => $item->description,
|
||||
'quantity' => $item->quantity,
|
||||
'unit' => $item->unit,
|
||||
'est_unit_cost' => $item->est_unit_cost,
|
||||
])->toArray(),
|
||||
],
|
||||
]);
|
||||
|
||||
// Link the requisition to the PO
|
||||
$requisition->markConvertedToPo($po);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.management.requisitions.show', [$business, $requisition])
|
||||
->with('success', "Purchase Order #{$po->po_number} created successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize that the current user can perform actions on this requisition.
|
||||
*/
|
||||
protected function authorizeAction(Business $business, PurchaseRequisition $requisition): void
|
||||
{
|
||||
if (! $business->isParentCompany()) {
|
||||
abort(403, 'Only parent companies can manage requisition approvals.');
|
||||
}
|
||||
|
||||
$childIds = Business::where('parent_id', $business->id)->pluck('id')->toArray();
|
||||
if (! in_array($requisition->business_id, $childIds)) {
|
||||
abort(403, 'This requisition does not belong to your divisions.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a PO number for the business.
|
||||
*/
|
||||
protected function generatePoNumber(Business $business): string
|
||||
{
|
||||
$prefix = 'PO';
|
||||
$year = now()->format('y');
|
||||
|
||||
$lastPo = PurchaseOrder::where('business_id', $business->id)
|
||||
->whereYear('created_at', now()->year)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($lastPo && preg_match('/PO-\d{2}-(\d+)/', $lastPo->po_number ?? '', $matches)) {
|
||||
$nextNum = (int) $matches[1] + 1;
|
||||
} else {
|
||||
$nextNum = 1;
|
||||
}
|
||||
|
||||
return sprintf('%s-%s-%04d', $prefix, $year, $nextNum);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Support\ManagementDivisionFilter;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UsageBillingController extends Controller
|
||||
{
|
||||
use ManagementDivisionFilter;
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$divisions = $this->getChildDivisionsIfAny($business);
|
||||
$selectedDivision = $this->getSelectedDivision($request, $business);
|
||||
$includeChildren = $this->shouldIncludeChildren($request);
|
||||
|
||||
$businessIds = $this->getBusinessIdsForScope($business, $selectedDivision, $includeChildren);
|
||||
|
||||
// Collect usage data
|
||||
$usage = $this->collectUsageData($business, $businessIds);
|
||||
|
||||
return view('seller.management.usage-billing.index', [
|
||||
'business' => $business,
|
||||
'divisions' => $divisions,
|
||||
'selectedDivision' => $selectedDivision,
|
||||
'includeChildren' => $includeChildren,
|
||||
'usage' => $usage,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function collectUsageData(Business $parentBusiness, array $businessIds): array
|
||||
{
|
||||
$startOfMonth = Carbon::now()->startOfMonth();
|
||||
$endOfMonth = Carbon::now()->endOfMonth();
|
||||
|
||||
// Get suite limits from config
|
||||
$defaults = config('suites.defaults.sales_suite', []);
|
||||
|
||||
// Count active brands
|
||||
$brandCount = DB::table('brands')
|
||||
->whereIn('business_id', $businessIds)
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
|
||||
// Count active products (SKUs)
|
||||
$skuCount = DB::table('products')
|
||||
->join('brands', 'products.brand_id', '=', 'brands.id')
|
||||
->whereIn('brands.business_id', $businessIds)
|
||||
->where('products.is_active', true)
|
||||
->count();
|
||||
|
||||
// Count messages sent this month
|
||||
$messageCount = DB::table('messages')
|
||||
->whereIn('business_id', $businessIds)
|
||||
->whereBetween('created_at', [$startOfMonth, $endOfMonth])
|
||||
->count();
|
||||
|
||||
// Count menu sends this month
|
||||
$menuSendCount = DB::table('menu_sends')
|
||||
->whereIn('business_id', $businessIds)
|
||||
->whereBetween('created_at', [$startOfMonth, $endOfMonth])
|
||||
->count();
|
||||
|
||||
// Count CRM contacts
|
||||
$contactCount = DB::table('contacts')
|
||||
->whereIn('business_id', $businessIds)
|
||||
->count();
|
||||
|
||||
// Calculate limits based on number of brands
|
||||
$brandLimit = $parentBusiness->brand_limit ?? $defaults['brand_limit'] ?? 1;
|
||||
$skuLimitPerBrand = $defaults['sku_limit_per_brand'] ?? 15;
|
||||
$messageLimitPerBrand = $defaults['message_limit_per_brand'] ?? 500;
|
||||
$menuLimitPerBrand = $defaults['menu_limit_per_brand'] ?? 100;
|
||||
$contactLimitPerBrand = $defaults['contact_limit_per_brand'] ?? 1000;
|
||||
|
||||
$totalSkuLimit = $brandCount * $skuLimitPerBrand;
|
||||
$totalMessageLimit = $brandCount * $messageLimitPerBrand;
|
||||
$totalMenuLimit = $brandCount * $menuLimitPerBrand;
|
||||
$totalContactLimit = $brandCount * $contactLimitPerBrand;
|
||||
|
||||
// Is enterprise plan?
|
||||
$isEnterprise = $parentBusiness->is_enterprise_plan ?? false;
|
||||
|
||||
// Get suites enabled
|
||||
$enabledSuites = $this->getEnabledSuites($parentBusiness);
|
||||
|
||||
// Usage by division
|
||||
$usageByDivision = [];
|
||||
if (count($businessIds) > 1) {
|
||||
$usageByDivision = DB::table('businesses')
|
||||
->whereIn('businesses.id', $businessIds)
|
||||
->leftJoin('brands', 'brands.business_id', '=', 'businesses.id')
|
||||
->leftJoin('products', 'products.brand_id', '=', 'brands.id')
|
||||
->select(
|
||||
'businesses.id',
|
||||
'businesses.name',
|
||||
DB::raw('COUNT(DISTINCT brands.id) as brand_count'),
|
||||
DB::raw('COUNT(DISTINCT products.id) as sku_count')
|
||||
)
|
||||
->groupBy('businesses.id', 'businesses.name')
|
||||
->get();
|
||||
}
|
||||
|
||||
return [
|
||||
'brands' => [
|
||||
'current' => $brandCount,
|
||||
'limit' => $isEnterprise ? null : $brandLimit,
|
||||
'percentage' => $brandLimit > 0 ? min(100, ($brandCount / $brandLimit) * 100) : 0,
|
||||
],
|
||||
'skus' => [
|
||||
'current' => $skuCount,
|
||||
'limit' => $isEnterprise ? null : $totalSkuLimit,
|
||||
'percentage' => $totalSkuLimit > 0 ? min(100, ($skuCount / $totalSkuLimit) * 100) : 0,
|
||||
],
|
||||
'messages' => [
|
||||
'current' => $messageCount,
|
||||
'limit' => $isEnterprise ? null : $totalMessageLimit,
|
||||
'percentage' => $totalMessageLimit > 0 ? min(100, ($messageCount / $totalMessageLimit) * 100) : 0,
|
||||
],
|
||||
'menu_sends' => [
|
||||
'current' => $menuSendCount,
|
||||
'limit' => $isEnterprise ? null : $totalMenuLimit,
|
||||
'percentage' => $totalMenuLimit > 0 ? min(100, ($menuSendCount / $totalMenuLimit) * 100) : 0,
|
||||
],
|
||||
'contacts' => [
|
||||
'current' => $contactCount,
|
||||
'limit' => $isEnterprise ? null : $totalContactLimit,
|
||||
'percentage' => $totalContactLimit > 0 ? min(100, ($contactCount / $totalContactLimit) * 100) : 0,
|
||||
],
|
||||
'is_enterprise' => $isEnterprise,
|
||||
'enabled_suites' => $enabledSuites,
|
||||
'usage_by_division' => $usageByDivision,
|
||||
'billing_period' => [
|
||||
'start' => $startOfMonth->format('M j, Y'),
|
||||
'end' => $endOfMonth->format('M j, Y'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getEnabledSuites(Business $business): array
|
||||
{
|
||||
$suites = [];
|
||||
|
||||
if ($business->hasSalesSuite()) {
|
||||
$suites[] = ['name' => 'Sales Suite', 'key' => 'sales'];
|
||||
}
|
||||
if ($business->hasProcessingSuite()) {
|
||||
$suites[] = ['name' => 'Processing Suite', 'key' => 'processing'];
|
||||
}
|
||||
if ($business->hasManufacturingSuite()) {
|
||||
$suites[] = ['name' => 'Manufacturing Suite', 'key' => 'manufacturing'];
|
||||
}
|
||||
if ($business->hasDeliverySuite()) {
|
||||
$suites[] = ['name' => 'Delivery Suite', 'key' => 'delivery'];
|
||||
}
|
||||
if ($business->hasManagementSuite()) {
|
||||
$suites[] = ['name' => 'Management Suite', 'key' => 'management'];
|
||||
}
|
||||
if ($business->hasDispensarySuite()) {
|
||||
$suites[] = ['name' => 'Dispensary Suite', 'key' => 'dispensary'];
|
||||
}
|
||||
|
||||
return $suites;
|
||||
}
|
||||
|
||||
protected function getBusinessIdsForScope(Business $business, ?Business $selectedDivision, bool $includeChildren): array
|
||||
{
|
||||
if ($selectedDivision) {
|
||||
if ($includeChildren) {
|
||||
return $selectedDivision->divisions()->pluck('id')
|
||||
->prepend($selectedDivision->id)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return [$selectedDivision->id];
|
||||
}
|
||||
|
||||
if ($includeChildren && $business->hasChildBusinesses()) {
|
||||
return $business->divisions()->pluck('id')
|
||||
->prepend($business->id)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return [$business->id];
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Seller\Crm\InboxController as CrmInboxController;
|
||||
use App\Http\Controllers\Seller\Crm\ThreadController as CrmThreadController;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\Message;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -21,8 +21,8 @@ class MessagingController extends Controller
|
||||
public function index(Request $request, \App\Models\Business $business)
|
||||
{
|
||||
// If CRM is enabled, use the enhanced CRM inbox
|
||||
if ($business->has_crm) {
|
||||
return app(CrmInboxController::class)->index($request, $business);
|
||||
if ($business->hasCrmAccess()) {
|
||||
return app(CrmThreadController::class)->index($request, $business);
|
||||
}
|
||||
|
||||
// Basic conversations view
|
||||
@@ -66,8 +66,8 @@ class MessagingController extends Controller
|
||||
public function show(Request $request, \App\Models\Business $business, Conversation $conversation)
|
||||
{
|
||||
// If CRM is enabled, use the enhanced CRM inbox view
|
||||
if ($business->has_crm) {
|
||||
return app(CrmInboxController::class)->show($request, $business, $conversation);
|
||||
if ($business->hasCrmAccess()) {
|
||||
return app(CrmThreadController::class)->show($request, $business, $conversation);
|
||||
}
|
||||
|
||||
// Ensure business owns this conversation
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Seller\Processing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WashReportController extends Controller
|
||||
@@ -10,10 +11,8 @@ class WashReportController extends Controller
|
||||
/**
|
||||
* Display list of wash reports.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
// TODO: Implement wash reports listing
|
||||
// This will show all wash batches for the business
|
||||
|
||||
@@ -25,10 +24,8 @@ class WashReportController extends Controller
|
||||
/**
|
||||
* Display active washes dashboard.
|
||||
*/
|
||||
public function activeDashboard(Request $request)
|
||||
public function activeDashboard(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
// TODO: Implement active washes dashboard
|
||||
// This will show currently active wash batches
|
||||
|
||||
@@ -40,10 +37,8 @@ class WashReportController extends Controller
|
||||
/**
|
||||
* Display daily performance report.
|
||||
*/
|
||||
public function dailyPerformance(Request $request)
|
||||
public function dailyPerformance(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
// TODO: Implement daily performance reporting
|
||||
// This will show wash performance metrics for today
|
||||
|
||||
@@ -55,10 +50,8 @@ class WashReportController extends Controller
|
||||
/**
|
||||
* Display historical wash search.
|
||||
*/
|
||||
public function search(Request $request)
|
||||
public function search(Request $request, Business $business)
|
||||
{
|
||||
$business = $request->user()->business;
|
||||
|
||||
// TODO: Implement wash report search
|
||||
// This will allow searching historical wash data
|
||||
|
||||
|
||||
@@ -79,29 +79,36 @@ class ProductController extends Controller
|
||||
$sortDir = $request->get('sort_dir', 'asc');
|
||||
$query->orderBy($sortBy, $sortDir);
|
||||
|
||||
// Get all products and format as arrays for the view
|
||||
$products = $query->get()
|
||||
// Paginate for performance (925 products = 400KB JSON payload)
|
||||
$perPage = $request->get('per_page', 50);
|
||||
$paginator = $query->paginate($perPage);
|
||||
|
||||
// Get paginated products and format as arrays for the view
|
||||
$products = $paginator->getCollection()
|
||||
->filter(fn ($product) => ! empty($product->hashid)) // Skip products without hashid (pre-migration)
|
||||
->map(function ($product) use ($business) {
|
||||
// TODO: Replace mock metrics with real data from analytics/order tracking
|
||||
// Image URL uses product image or falls back to brand logo
|
||||
$imageUrl = $product->getImageUrl('thumb');
|
||||
|
||||
// Map varieties for nested display
|
||||
$varieties = $product->varieties->map(function ($variety) use ($business) {
|
||||
return [
|
||||
'id' => $variety->id,
|
||||
'hashid' => $variety->hashid,
|
||||
'name' => $variety->name,
|
||||
'sku' => $variety->sku ?? 'N/A',
|
||||
'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]),
|
||||
'units_sold' => $variety->orderItems()->sum('quantity'),
|
||||
'stock' => $variety->available_quantity,
|
||||
'is_unlimited' => $variety->isUnlimited(),
|
||||
];
|
||||
})->values()->toArray();
|
||||
// Map varieties for nested display (avoid N+1 queries)
|
||||
$varieties = $product->varieties
|
||||
->filter(fn ($variety) => ! empty($variety->hashid)) // Skip varieties without hashid
|
||||
->map(function ($variety) use ($business) {
|
||||
return [
|
||||
'id' => $variety->id,
|
||||
'hashid' => $variety->hashid,
|
||||
'name' => $variety->name,
|
||||
'sku' => $variety->sku ?? 'N/A',
|
||||
'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]),
|
||||
'units_sold' => 0, // TODO: Eager load with withSum() for performance
|
||||
'stock' => $variety->available_quantity,
|
||||
'is_unlimited' => $variety->isUnlimited(),
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
return [
|
||||
'id' => $product->id,
|
||||
@@ -127,7 +134,17 @@ class ProductController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
return view('seller.products.index', compact('business', 'products', 'missingBomCount'));
|
||||
// Pass pagination info to the view
|
||||
$pagination = [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'from' => $paginator->firstItem(),
|
||||
'to' => $paginator->lastItem(),
|
||||
];
|
||||
|
||||
return view('seller.products.index', compact('business', 'products', 'missingBomCount', 'paginator', 'pagination'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -991,6 +1008,7 @@ class ProductController extends Controller
|
||||
$products = Product::whereIn('brand_id', $brandIds)
|
||||
->with(['brand', 'images'])
|
||||
->get()
|
||||
->filter(fn ($product) => ! empty($product->hashid)) // Skip products without hashid (pre-migration)
|
||||
->map(function ($product) use ($business) {
|
||||
// TODO: Replace mock metrics with real data from analytics/order tracking
|
||||
return [
|
||||
|
||||
@@ -38,6 +38,13 @@ class PurchaseOrderController extends Controller
|
||||
*/
|
||||
public function create(Business $business)
|
||||
{
|
||||
// Child businesses (divisions) must use the requisition workflow
|
||||
if ($business->parent_id !== null) {
|
||||
return redirect()
|
||||
->route('seller.business.purchasing.requisitions.create', $business)
|
||||
->with('info', 'Please submit a Purchase Requisition. Direct PO creation is managed by the parent company.');
|
||||
}
|
||||
|
||||
return view('seller.purchase-orders.create', compact('business'));
|
||||
}
|
||||
|
||||
@@ -46,6 +53,11 @@ class PurchaseOrderController extends Controller
|
||||
*/
|
||||
public function store(Business $business, Request $request)
|
||||
{
|
||||
// Child businesses (divisions) cannot create POs directly
|
||||
if ($business->parent_id !== null) {
|
||||
abort(403, 'Divisions cannot create Purchase Orders directly. Please use the Purchase Requisition workflow.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'supplier_name' => 'required|string|max:255',
|
||||
'supplier_contact' => 'nullable|string|max:255',
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Purchasing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ApVendor;
|
||||
use App\Models\Accounting\GlAccount;
|
||||
use App\Models\Business;
|
||||
use App\Models\Department;
|
||||
use App\Models\Purchasing\PurchaseRequisition;
|
||||
use App\Models\Purchasing\PurchaseRequisitionItem;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RequisitionsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display list of requisitions for the business.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$query = PurchaseRequisition::forBusiness($business->id)
|
||||
->with(['requestedBy', 'vendor', 'department', 'approvedBy'])
|
||||
->withCount('items');
|
||||
|
||||
// Non-owners see only their own requisitions
|
||||
if ($business->owner_user_id !== $user->id && ! $this->userCanViewAllRequisitions($user, $business)) {
|
||||
$query->where('requested_by_user_id', $user->id);
|
||||
}
|
||||
|
||||
// Filters
|
||||
if ($status = $request->get('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
if ($priority = $request->get('priority')) {
|
||||
$query->where('priority', $priority);
|
||||
}
|
||||
|
||||
if ($search = $request->get('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('requisition_number', 'like', "%{$search}%")
|
||||
->orWhere('notes', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$requisitions = $query->orderByDesc('created_at')->paginate(20);
|
||||
|
||||
// Get counts for tabs
|
||||
$statusCounts = PurchaseRequisition::forBusiness($business->id)
|
||||
->selectRaw('status, COUNT(*) as count')
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status');
|
||||
|
||||
return view('seller.purchasing.requisitions.index', [
|
||||
'business' => $business,
|
||||
'requisitions' => $requisitions,
|
||||
'statusCounts' => $statusCounts,
|
||||
'filters' => $request->only(['status', 'priority', 'search']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show form to create a new requisition.
|
||||
*/
|
||||
public function create(Request $request, Business $business): View
|
||||
{
|
||||
$this->authorizeCreate($business);
|
||||
|
||||
$vendors = ApVendor::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$departments = Department::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$glAccounts = GlAccount::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->whereIn('account_type', ['expense', 'asset'])
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
return view('seller.purchasing.requisitions.create', [
|
||||
'business' => $business,
|
||||
'vendors' => $vendors,
|
||||
'departments' => $departments,
|
||||
'glAccounts' => $glAccounts,
|
||||
'priorities' => PurchaseRequisition::getPriorityOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new requisition.
|
||||
*/
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$this->authorizeCreate($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'department_id' => 'nullable|exists:departments,id',
|
||||
'vendor_id' => 'nullable|exists:ap_vendors,id',
|
||||
'priority' => 'required|in:low,normal,high,urgent',
|
||||
'needed_by_date' => 'nullable|date|after:today',
|
||||
'notes' => 'nullable|string|max:2000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.description' => 'required|string|max:500',
|
||||
'items.*.quantity' => 'required|numeric|min:0.0001',
|
||||
'items.*.unit' => 'nullable|string|max:50',
|
||||
'items.*.est_unit_cost' => 'nullable|numeric|min:0',
|
||||
'items.*.suggested_vendor_id' => 'nullable|exists:ap_vendors,id',
|
||||
'items.*.gl_account_id' => 'nullable|exists:gl_accounts,id',
|
||||
'items.*.notes' => 'nullable|string|max:500',
|
||||
'submit_action' => 'required|in:save_draft,submit',
|
||||
]);
|
||||
|
||||
$requisition = PurchaseRequisition::create([
|
||||
'business_id' => $business->id,
|
||||
'department_id' => $validated['department_id'] ?? null,
|
||||
'requested_by_user_id' => auth()->id(),
|
||||
'vendor_id' => $validated['vendor_id'] ?? null,
|
||||
'priority' => $validated['priority'],
|
||||
'needed_by_date' => $validated['needed_by_date'] ?? null,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
'status' => PurchaseRequisition::STATUS_DRAFT,
|
||||
]);
|
||||
|
||||
foreach ($validated['items'] as $itemData) {
|
||||
$requisition->items()->create([
|
||||
'description' => $itemData['description'],
|
||||
'quantity' => $itemData['quantity'],
|
||||
'unit' => $itemData['unit'] ?? null,
|
||||
'est_unit_cost' => $itemData['est_unit_cost'] ?? null,
|
||||
'suggested_vendor_id' => $itemData['suggested_vendor_id'] ?? null,
|
||||
'gl_account_id' => $itemData['gl_account_id'] ?? null,
|
||||
'notes' => $itemData['notes'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Submit if requested
|
||||
if ($validated['submit_action'] === 'submit') {
|
||||
$requisition->submit();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.purchasing.requisitions.show', [$business, $requisition])
|
||||
->with('success', 'Requisition submitted for approval.');
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.purchasing.requisitions.show', [$business, $requisition])
|
||||
->with('success', 'Requisition draft saved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a single requisition.
|
||||
*/
|
||||
public function show(Request $request, Business $business, PurchaseRequisition $requisition): View
|
||||
{
|
||||
$this->authorizeView($business, $requisition);
|
||||
|
||||
$requisition->load(['items.suggestedVendor', 'items.glAccount', 'requestedBy', 'approvedBy', 'vendor', 'department', 'purchaseOrder']);
|
||||
|
||||
return view('seller.purchasing.requisitions.show', [
|
||||
'business' => $business,
|
||||
'requisition' => $requisition,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show form to edit a requisition.
|
||||
*/
|
||||
public function edit(Request $request, Business $business, PurchaseRequisition $requisition): View
|
||||
{
|
||||
$this->authorizeEdit($business, $requisition);
|
||||
|
||||
$requisition->load('items');
|
||||
|
||||
$vendors = ApVendor::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$departments = Department::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$glAccounts = GlAccount::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->whereIn('account_type', ['expense', 'asset'])
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
return view('seller.purchasing.requisitions.edit', [
|
||||
'business' => $business,
|
||||
'requisition' => $requisition,
|
||||
'vendors' => $vendors,
|
||||
'departments' => $departments,
|
||||
'glAccounts' => $glAccounts,
|
||||
'priorities' => PurchaseRequisition::getPriorityOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a requisition.
|
||||
*/
|
||||
public function update(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
|
||||
{
|
||||
$this->authorizeEdit($business, $requisition);
|
||||
|
||||
$validated = $request->validate([
|
||||
'department_id' => 'nullable|exists:departments,id',
|
||||
'vendor_id' => 'nullable|exists:ap_vendors,id',
|
||||
'priority' => 'required|in:low,normal,high,urgent',
|
||||
'needed_by_date' => 'nullable|date|after:today',
|
||||
'notes' => 'nullable|string|max:2000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.id' => 'nullable|exists:purchase_requisition_items,id',
|
||||
'items.*.description' => 'required|string|max:500',
|
||||
'items.*.quantity' => 'required|numeric|min:0.0001',
|
||||
'items.*.unit' => 'nullable|string|max:50',
|
||||
'items.*.est_unit_cost' => 'nullable|numeric|min:0',
|
||||
'items.*.suggested_vendor_id' => 'nullable|exists:ap_vendors,id',
|
||||
'items.*.gl_account_id' => 'nullable|exists:gl_accounts,id',
|
||||
'items.*.notes' => 'nullable|string|max:500',
|
||||
'submit_action' => 'required|in:save_draft,submit',
|
||||
]);
|
||||
|
||||
$requisition->update([
|
||||
'department_id' => $validated['department_id'] ?? null,
|
||||
'vendor_id' => $validated['vendor_id'] ?? null,
|
||||
'priority' => $validated['priority'],
|
||||
'needed_by_date' => $validated['needed_by_date'] ?? null,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
// Sync items - delete removed, update existing, create new
|
||||
$existingItemIds = collect($validated['items'])
|
||||
->pluck('id')
|
||||
->filter()
|
||||
->toArray();
|
||||
|
||||
// Delete items not in the update
|
||||
$requisition->items()
|
||||
->whereNotIn('id', $existingItemIds)
|
||||
->delete();
|
||||
|
||||
foreach ($validated['items'] as $itemData) {
|
||||
if (! empty($itemData['id'])) {
|
||||
// Update existing item
|
||||
PurchaseRequisitionItem::where('id', $itemData['id'])
|
||||
->update([
|
||||
'description' => $itemData['description'],
|
||||
'quantity' => $itemData['quantity'],
|
||||
'unit' => $itemData['unit'] ?? null,
|
||||
'est_unit_cost' => $itemData['est_unit_cost'] ?? null,
|
||||
'suggested_vendor_id' => $itemData['suggested_vendor_id'] ?? null,
|
||||
'gl_account_id' => $itemData['gl_account_id'] ?? null,
|
||||
'notes' => $itemData['notes'] ?? null,
|
||||
]);
|
||||
} else {
|
||||
// Create new item
|
||||
$requisition->items()->create([
|
||||
'description' => $itemData['description'],
|
||||
'quantity' => $itemData['quantity'],
|
||||
'unit' => $itemData['unit'] ?? null,
|
||||
'est_unit_cost' => $itemData['est_unit_cost'] ?? null,
|
||||
'suggested_vendor_id' => $itemData['suggested_vendor_id'] ?? null,
|
||||
'gl_account_id' => $itemData['gl_account_id'] ?? null,
|
||||
'notes' => $itemData['notes'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Submit if requested and still in draft
|
||||
if ($validated['submit_action'] === 'submit' && $requisition->isDraft()) {
|
||||
$requisition->submit();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.purchasing.requisitions.show', [$business, $requisition])
|
||||
->with('success', 'Requisition submitted for approval.');
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.purchasing.requisitions.show', [$business, $requisition])
|
||||
->with('success', 'Requisition updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a draft requisition for approval.
|
||||
*/
|
||||
public function submit(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
|
||||
{
|
||||
$this->authorizeEdit($business, $requisition);
|
||||
|
||||
if (! $requisition->isDraft()) {
|
||||
return back()->with('error', 'Only draft requisitions can be submitted.');
|
||||
}
|
||||
|
||||
if ($requisition->items->isEmpty()) {
|
||||
return back()->with('error', 'Requisition must have at least one item.');
|
||||
}
|
||||
|
||||
$requisition->submit();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.purchasing.requisitions.show', [$business, $requisition])
|
||||
->with('success', 'Requisition submitted for approval.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a draft requisition.
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
|
||||
{
|
||||
$this->authorizeDelete($business, $requisition);
|
||||
|
||||
if (! $requisition->isDraft()) {
|
||||
return back()->with('error', 'Only draft requisitions can be deleted.');
|
||||
}
|
||||
|
||||
$requisition->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.purchasing.requisitions.index', $business)
|
||||
->with('success', 'Requisition deleted.');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// AUTHORIZATION HELPERS
|
||||
// =========================================================================
|
||||
|
||||
protected function authorizeCreate(Business $business): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// Check if user has permission to submit requisitions
|
||||
if (! $this->userCanSubmitRequisitions($user, $business)) {
|
||||
abort(403, 'You do not have permission to create requisitions.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function authorizeView(Business $business, PurchaseRequisition $requisition): void
|
||||
{
|
||||
if ($requisition->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
// Owner can view all
|
||||
if ($business->owner_user_id === $user->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// User can view their own
|
||||
if ($requisition->requested_by_user_id === $user->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Users with view all permission
|
||||
if ($this->userCanViewAllRequisitions($user, $business)) {
|
||||
return;
|
||||
}
|
||||
|
||||
abort(403, 'You do not have permission to view this requisition.');
|
||||
}
|
||||
|
||||
protected function authorizeEdit(Business $business, PurchaseRequisition $requisition): void
|
||||
{
|
||||
$this->authorizeView($business, $requisition);
|
||||
|
||||
if (! $requisition->canBeEdited()) {
|
||||
abort(403, 'This requisition cannot be edited.');
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
// Only the requester can edit their own requisition (unless owner)
|
||||
if ($requisition->requested_by_user_id !== $user->id && $business->owner_user_id !== $user->id) {
|
||||
abort(403, 'You can only edit your own requisitions.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function authorizeDelete(Business $business, PurchaseRequisition $requisition): void
|
||||
{
|
||||
$this->authorizeEdit($business, $requisition);
|
||||
}
|
||||
|
||||
protected function userCanSubmitRequisitions($user, Business $business): bool
|
||||
{
|
||||
// Owner always can
|
||||
if ($business->owner_user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check pivot permission
|
||||
$pivot = $user->businesses()->where('businesses.id', $business->id)->first()?->pivot;
|
||||
|
||||
if (! $pivot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$permissions = $pivot->permissions ?? [];
|
||||
|
||||
return in_array('can_submit_requisition', $permissions) || in_array('*', $permissions);
|
||||
}
|
||||
|
||||
protected function userCanViewAllRequisitions($user, Business $business): bool
|
||||
{
|
||||
// Owner always can
|
||||
if ($business->owner_user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$pivot = $user->businesses()->where('businesses.id', $business->id)->first()?->pivot;
|
||||
|
||||
if (! $pivot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$permissions = $pivot->permissions ?? [];
|
||||
|
||||
return in_array('can_view_all_requisitions', $permissions)
|
||||
|| in_array('can_approve_requisition', $permissions)
|
||||
|| in_array('*', $permissions);
|
||||
}
|
||||
}
|
||||
349
app/Http/Controllers/Seller/Settings/BrandSettingsController.php
Normal file
349
app/Http/Controllers/Seller/Settings/BrandSettingsController.php
Normal file
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BrandSettingsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the brands list.
|
||||
*/
|
||||
public function index(Business $business)
|
||||
{
|
||||
$this->authorizeOwnerAccess($business);
|
||||
|
||||
$brands = $business->brands()
|
||||
->withCount('products')
|
||||
->with(['users' => fn ($q) => $q->select('users.id', 'users.first_name', 'users.last_name', 'users.email')])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get internal team members (users associated with this business, not brand managers)
|
||||
$teamMembers = $business->users()
|
||||
->wherePivot('contact_type', '!=', 'brand_manager')
|
||||
->orWherePivotNull('contact_type')
|
||||
->orderBy('first_name')
|
||||
->get();
|
||||
|
||||
return view('seller.settings.brands', compact('business', 'brands', 'teamMembers'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new brand.
|
||||
*/
|
||||
public function store(Business $business, Request $request)
|
||||
{
|
||||
$this->authorizeOwnerAccess($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'sku_prefix' => 'nullable|string|max:10|alpha_num',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'is_sales_enabled' => 'nullable|boolean',
|
||||
'logo' => 'nullable|image|max:2048',
|
||||
]);
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$brand = Brand::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'slug' => Str::slug($validated['name']),
|
||||
'sku_prefix' => $validated['sku_prefix'] ?? null,
|
||||
'description' => $validated['description'] ?? null,
|
||||
'is_active' => $request->has('is_active'),
|
||||
'is_sales_enabled' => $request->has('is_sales_enabled'),
|
||||
'sort_order' => $business->brands()->max('sort_order') + 1,
|
||||
]);
|
||||
|
||||
// Handle logo upload
|
||||
if ($request->hasFile('logo')) {
|
||||
$path = $request->file('logo')->store('brands/'.$brand->hashid.'/logos', 'public');
|
||||
$brand->update(['logo_path' => $path]);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.brands.index', $business->slug)
|
||||
->with('success', "Brand \"{$brand->name}\" created successfully.");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
report($e);
|
||||
|
||||
return back()
|
||||
->withInput()
|
||||
->withErrors(['error' => 'Failed to create brand. Please try again.']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit form for a brand.
|
||||
*/
|
||||
public function edit(Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorizeOwnerAccess($business);
|
||||
$this->authorizeBrandAccess($business, $brand);
|
||||
|
||||
// Get internal team members for user assignment
|
||||
$teamMembers = $business->users()
|
||||
->where(function ($q) {
|
||||
$q->wherePivot('contact_type', '!=', 'brand_manager')
|
||||
->orWherePivotNull('contact_type');
|
||||
})
|
||||
->orderBy('first_name')
|
||||
->get();
|
||||
|
||||
// Get users currently assigned to this brand
|
||||
$brandUsers = $brand->users()->get();
|
||||
|
||||
return view('seller.settings.brands-edit', compact('business', 'brand', 'teamMembers', 'brandUsers'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a brand.
|
||||
*/
|
||||
public function update(Business $business, Brand $brand, Request $request)
|
||||
{
|
||||
$this->authorizeOwnerAccess($business);
|
||||
$this->authorizeBrandAccess($business, $brand);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'sku_prefix' => 'nullable|string|max:10|alpha_num',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'tagline' => 'nullable|string|max:255',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'is_sales_enabled' => 'nullable|boolean',
|
||||
'is_public' => 'nullable|boolean',
|
||||
'is_featured' => 'nullable|boolean',
|
||||
'logo' => 'nullable|image|max:2048',
|
||||
'remove_logo' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
// Handle logo removal
|
||||
if ($request->has('remove_logo') && $brand->logo_path) {
|
||||
Storage::disk('public')->delete($brand->logo_path);
|
||||
$validated['logo_path'] = null;
|
||||
}
|
||||
|
||||
// Handle logo upload
|
||||
if ($request->hasFile('logo')) {
|
||||
// Delete old logo
|
||||
if ($brand->logo_path) {
|
||||
Storage::disk('public')->delete($brand->logo_path);
|
||||
}
|
||||
$validated['logo_path'] = $request->file('logo')->store('brands/'.$brand->hashid.'/logos', 'public');
|
||||
}
|
||||
|
||||
// Convert checkbox values
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
$validated['is_sales_enabled'] = $request->has('is_sales_enabled');
|
||||
$validated['is_public'] = $request->has('is_public');
|
||||
$validated['is_featured'] = $request->has('is_featured');
|
||||
|
||||
// Remove file inputs from validated data
|
||||
unset($validated['logo'], $validated['remove_logo']);
|
||||
|
||||
$brand->update($validated);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.brands.index', $business->slug)
|
||||
->with('success', "Brand \"{$brand->name}\" updated successfully.");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
report($e);
|
||||
|
||||
return back()
|
||||
->withInput()
|
||||
->withErrors(['error' => 'Failed to update brand. Please try again.']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle brand active status.
|
||||
*/
|
||||
public function toggleActive(Business $business, Brand $brand, Request $request)
|
||||
{
|
||||
$this->authorizeOwnerAccess($business);
|
||||
$this->authorizeBrandAccess($business, $brand);
|
||||
|
||||
$brand->update(['is_active' => ! $brand->is_active]);
|
||||
|
||||
$status = $brand->is_active ? 'activated' : 'deactivated';
|
||||
|
||||
return back()->with('success', "Brand \"{$brand->name}\" has been {$status}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a brand (soft delete).
|
||||
*/
|
||||
public function destroy(Business $business, Brand $brand, Request $request)
|
||||
{
|
||||
$this->authorizeOwnerAccess($business);
|
||||
$this->authorizeBrandAccess($business, $brand);
|
||||
|
||||
// Check if brand has products
|
||||
if ($brand->products()->count() > 0) {
|
||||
return back()->withErrors([
|
||||
'error' => "Cannot delete brand \"{$brand->name}\" because it has associated products. Please move or delete the products first.",
|
||||
]);
|
||||
}
|
||||
|
||||
$brandName = $brand->name;
|
||||
$brand->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.brands.index', $business->slug)
|
||||
->with('success', "Brand \"{$brandName}\" has been deleted.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user access for a brand.
|
||||
*/
|
||||
public function updateUsers(Business $business, Brand $brand, Request $request)
|
||||
{
|
||||
$this->authorizeOwnerAccess($business);
|
||||
$this->authorizeBrandAccess($business, $brand);
|
||||
|
||||
$validated = $request->validate([
|
||||
'users' => 'nullable|array',
|
||||
'users.*.id' => 'required|exists:users,id',
|
||||
'users.*.permissions' => 'nullable|array',
|
||||
'users.*.permissions.*' => 'string',
|
||||
]);
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
// Get current brand users
|
||||
$currentUserIds = $brand->users()->pluck('users.id')->toArray();
|
||||
|
||||
// Build new user list
|
||||
$newUserIds = collect($validated['users'] ?? [])->pluck('id')->toArray();
|
||||
|
||||
// Verify all users belong to this business
|
||||
$validUserIds = $business->users()
|
||||
->whereIn('users.id', $newUserIds)
|
||||
->pluck('users.id')
|
||||
->toArray();
|
||||
|
||||
// Detach removed users
|
||||
$usersToRemove = array_diff($currentUserIds, $validUserIds);
|
||||
if (! empty($usersToRemove)) {
|
||||
$brand->users()->detach($usersToRemove);
|
||||
}
|
||||
|
||||
// Add or update users
|
||||
foreach ($validated['users'] ?? [] as $userData) {
|
||||
if (! in_array($userData['id'], $validUserIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$permissions = $userData['permissions'] ?? [];
|
||||
|
||||
if ($brand->users()->where('users.id', $userData['id'])->exists()) {
|
||||
$brand->users()->updateExistingPivot($userData['id'], [
|
||||
'permissions' => json_encode($permissions),
|
||||
]);
|
||||
} else {
|
||||
$brand->users()->attach($userData['id'], [
|
||||
'role' => 'member',
|
||||
'is_primary' => false,
|
||||
'permissions' => json_encode($permissions),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return back()->with('success', 'Brand user access updated successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
report($e);
|
||||
|
||||
return back()->withErrors(['error' => 'Failed to update user access. Please try again.']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder brands.
|
||||
*/
|
||||
public function reorder(Business $business, Request $request)
|
||||
{
|
||||
$this->authorizeOwnerAccess($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'order' => 'required|array',
|
||||
'order.*' => 'integer|exists:brands,id',
|
||||
]);
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach ($validated['order'] as $position => $brandId) {
|
||||
Brand::where('id', $brandId)
|
||||
->where('business_id', $business->id)
|
||||
->update(['sort_order' => $position]);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
report($e);
|
||||
|
||||
return response()->json(['success' => false, 'error' => 'Failed to reorder brands.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize that current user can manage brands for this business.
|
||||
* Allows: business owner, super admin, or users with owner/admin role in the business.
|
||||
*/
|
||||
private function authorizeOwnerAccess(Business $business): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$isOwner = $business->owner_user_id === $user->id;
|
||||
$isSuperAdmin = $user->user_type === 'admin';
|
||||
|
||||
// Check if user has admin role in this business via pivot
|
||||
$businessRole = $business->users()
|
||||
->where('users.id', $user->id)
|
||||
->first()
|
||||
?->pivot
|
||||
?->role;
|
||||
$isBusinessAdmin = in_array($businessRole, ['owner', 'admin', 'manager']);
|
||||
|
||||
if (! $isOwner && ! $isSuperAdmin && ! $isBusinessAdmin) {
|
||||
abort(403, 'Only business owners and administrators can manage brands.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize that the brand belongs to the business.
|
||||
*/
|
||||
private function authorizeBrandAccess(Business $business, Brand $brand): void
|
||||
{
|
||||
if ($brand->business_id !== $business->id) {
|
||||
abort(404, 'Brand not found.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\DeliveryWindow;
|
||||
use App\Models\Department;
|
||||
use App\Models\DepartmentSuitePermission;
|
||||
use App\Models\Driver;
|
||||
use App\Models\Suite;
|
||||
use App\Models\User;
|
||||
use App\Models\Vehicle;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -141,11 +143,6 @@ class SettingsController extends Controller
|
||||
{
|
||||
$query = $business->users();
|
||||
|
||||
// Exclude the business owner from the list
|
||||
if ($business->owner_user_id) {
|
||||
$query->where('users.id', '!=', $business->owner_user_id);
|
||||
}
|
||||
|
||||
// Search
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
@@ -155,10 +152,15 @@ class SettingsController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by account type (role)
|
||||
if ($request->filled('account_type')) {
|
||||
$query->whereHas('roles', function ($q) use ($request) {
|
||||
$q->where('name', $request->account_type);
|
||||
// Filter by role
|
||||
if ($request->filled('role')) {
|
||||
$query->wherePivot('role', $request->role);
|
||||
}
|
||||
|
||||
// Filter by department
|
||||
if ($request->filled('department_id')) {
|
||||
$query->whereHas('departments', function ($q) use ($request) {
|
||||
$q->where('departments.id', $request->department_id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,9 +172,20 @@ class SettingsController extends Controller
|
||||
$query->where('last_login_at', '<=', $request->last_login_end.' 23:59:59');
|
||||
}
|
||||
|
||||
$users = $query->with('roles')->paginate(15);
|
||||
$users = $query->with(['roles', 'departments'])->paginate(15);
|
||||
|
||||
return view('seller.settings.users', compact('business', 'users'));
|
||||
// Get all departments for this business
|
||||
$departments = Department::where('business_id', $business->id)
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
// Get business owner info
|
||||
$owner = $business->owner;
|
||||
|
||||
// Get the suites assigned to this business for permission reference
|
||||
$businessSuites = $business->suites()->active()->get();
|
||||
|
||||
return view('seller.settings.users', compact('business', 'users', 'departments', 'owner', 'businessSuites'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,7 +199,9 @@ class SettingsController extends Controller
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'position' => 'nullable|string|max:255',
|
||||
'role' => 'required|string|in:company-owner,company-manager,company-user,company-sales,company-accounting,company-manufacturing,company-processing',
|
||||
'role' => 'required|string|in:owner,admin,manager,member',
|
||||
'department_ids' => 'nullable|array',
|
||||
'department_ids.*' => 'exists:departments,id',
|
||||
'is_point_of_contact' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
@@ -196,21 +211,32 @@ class SettingsController extends Controller
|
||||
// Create user and associate with business
|
||||
$user = \App\Models\User::create([
|
||||
'name' => $fullName,
|
||||
'first_name' => $validated['first_name'],
|
||||
'last_name' => $validated['last_name'],
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'],
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'position' => $validated['position'] ?? null,
|
||||
'user_type' => $business->business_type, // Match business type
|
||||
'password' => bcrypt(str()->random(32)), // Temporary password
|
||||
]);
|
||||
|
||||
// Assign role
|
||||
$user->assignRole($validated['role']);
|
||||
|
||||
// Associate with business with additional pivot data
|
||||
// Associate with business with role
|
||||
$business->users()->attach($user->id, [
|
||||
'role' => $validated['role'],
|
||||
'is_primary' => false,
|
||||
'contact_type' => $request->has('is_point_of_contact') ? 'primary' : null,
|
||||
'permissions' => [], // Empty initially, assigned via department
|
||||
]);
|
||||
|
||||
// Assign to departments if specified
|
||||
if (! empty($validated['department_ids'])) {
|
||||
foreach ($validated['department_ids'] as $departmentId) {
|
||||
$user->departments()->attach($departmentId, [
|
||||
'role' => 'operator', // Default department role
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Send invitation email with password reset link
|
||||
|
||||
return redirect()
|
||||
@@ -264,10 +290,33 @@ class SettingsController extends Controller
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
// Define permission categories (existing permissions system)
|
||||
$permissionCategories = $this->getPermissionCategories();
|
||||
// Get the suites assigned to this business
|
||||
$businessSuites = $business->suites()->active()->get();
|
||||
|
||||
return view('seller.settings.users-edit', compact('business', 'user', 'isOwner', 'departments', 'permissionCategories'));
|
||||
// Build suite permissions structure based on business's assigned suites
|
||||
$suitePermissions = $this->getSuitePermissions($businessSuites);
|
||||
|
||||
// Get user's current permissions from pivot
|
||||
$pivotPermissions = $business->users()
|
||||
->where('users.id', $user->id)
|
||||
->first()
|
||||
->pivot
|
||||
->permissions ?? [];
|
||||
|
||||
// Ensure permissions is an array (JSON column may return string in PostgreSQL)
|
||||
$userPermissions = is_array($pivotPermissions)
|
||||
? $pivotPermissions
|
||||
: (is_string($pivotPermissions) ? json_decode($pivotPermissions, true) ?? [] : []);
|
||||
|
||||
return view('seller.settings.users-edit', compact(
|
||||
'business',
|
||||
'user',
|
||||
'isOwner',
|
||||
'departments',
|
||||
'businessSuites',
|
||||
'suitePermissions',
|
||||
'userPermissions'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,63 +373,122 @@ class SettingsController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permission categories for the permissions system.
|
||||
* Get suite-based permission structure for the assigned suites.
|
||||
*
|
||||
* Groups permissions by functional area for better UX.
|
||||
*/
|
||||
private function getPermissionCategories(): array
|
||||
private function getSuitePermissions($businessSuites): array
|
||||
{
|
||||
return [
|
||||
'ecommerce' => [
|
||||
'name' => 'Ecommerce',
|
||||
'icon' => 'lucide--shopping-cart',
|
||||
'permissions' => [
|
||||
'view_orders' => ['name' => 'View Orders', 'description' => 'View all orders and order details'],
|
||||
'manage_orders' => ['name' => 'Manage Orders', 'description' => 'Accept, reject, and update orders'],
|
||||
'view_invoices' => ['name' => 'View Invoices', 'description' => 'View invoices and payment information'],
|
||||
'manage_invoices' => ['name' => 'Manage Invoices', 'description' => 'Create and edit invoices'],
|
||||
],
|
||||
],
|
||||
'products' => [
|
||||
'name' => 'Products & Inventory',
|
||||
'icon' => 'lucide--package',
|
||||
'permissions' => [
|
||||
'view_products' => ['name' => 'View Products', 'description' => 'View product catalog'],
|
||||
'manage_products' => ['name' => 'Manage Products', 'description' => 'Create, edit, and delete products'],
|
||||
'view_inventory' => ['name' => 'View Inventory', 'description' => 'View inventory levels and stock'],
|
||||
'manage_inventory' => ['name' => 'Manage Inventory', 'description' => 'Update inventory and stock levels'],
|
||||
],
|
||||
],
|
||||
'data_visibility' => [
|
||||
'name' => 'Data & Analytics Visibility',
|
||||
'icon' => 'lucide--eye',
|
||||
'permissions' => [
|
||||
'view_sales_data' => ['name' => 'View Sales Data', 'description' => 'See revenue, pricing, profit margins, and sales metrics'],
|
||||
'view_performance_data' => ['name' => 'View Performance Data', 'description' => 'See yields, efficiency, quality metrics, and production stats'],
|
||||
'view_cost_data' => ['name' => 'View Cost Data', 'description' => 'See material costs, labor costs, and expense breakdowns'],
|
||||
'view_customer_data' => ['name' => 'View Customer Data', 'description' => 'See customer names, contact info, and purchase history'],
|
||||
],
|
||||
],
|
||||
'manufacturing' => [
|
||||
'name' => 'Manufacturing & Processing',
|
||||
'icon' => 'lucide--factory',
|
||||
'permissions' => [
|
||||
'view_work_orders' => ['name' => 'View Work Orders', 'description' => 'View work orders (limited to own department)'],
|
||||
'manage_work_orders' => ['name' => 'Manage Work Orders', 'description' => 'Create, edit, and complete work orders'],
|
||||
'view_wash_reports' => ['name' => 'View Wash Reports', 'description' => 'View solventless wash reports and data'],
|
||||
'manage_wash_reports' => ['name' => 'Manage Wash Reports', 'description' => 'Create and edit wash reports'],
|
||||
'view_all_departments' => ['name' => 'View All Departments', 'description' => 'See data across all departments (not just own)'],
|
||||
],
|
||||
],
|
||||
'business' => [
|
||||
'name' => 'Business Management',
|
||||
'icon' => 'lucide--building-2',
|
||||
'permissions' => [
|
||||
'view_settings' => ['name' => 'View Settings', 'description' => 'View business settings and configuration'],
|
||||
'manage_settings' => ['name' => 'Manage Settings', 'description' => 'Update business settings and configuration'],
|
||||
'view_users' => ['name' => 'View Users', 'description' => 'View user list and permissions'],
|
||||
'manage_users' => ['name' => 'Manage Users', 'description' => 'Invite and manage user permissions'],
|
||||
],
|
||||
],
|
||||
$suitePermissions = [];
|
||||
|
||||
foreach ($businessSuites as $suite) {
|
||||
$permissions = DepartmentSuitePermission::getAvailablePermissions($suite->key);
|
||||
|
||||
if (empty($permissions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Group permissions by functional area
|
||||
$grouped = $this->groupPermissionsByArea($permissions, $suite->key);
|
||||
|
||||
$suitePermissions[$suite->key] = [
|
||||
'name' => $suite->name,
|
||||
'description' => $suite->description,
|
||||
'icon' => $suite->icon,
|
||||
'color' => $suite->color,
|
||||
'groups' => $grouped,
|
||||
];
|
||||
}
|
||||
|
||||
return $suitePermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group permissions by functional area for better organization.
|
||||
*/
|
||||
private function groupPermissionsByArea(array $permissions, string $suiteKey): array
|
||||
{
|
||||
// Define permission groupings based on prefix patterns
|
||||
$areaPatterns = [
|
||||
'dashboard' => ['view_dashboard', 'view_org_dashboard', 'view_analytics', 'view_all_analytics', 'export_analytics'],
|
||||
'products' => ['view_products', 'manage_products', 'view_inventory', 'adjust_inventory', 'view_costs', 'view_margin'],
|
||||
'orders' => ['view_orders', 'create_orders', 'manage_orders', 'view_invoices', 'create_invoices'],
|
||||
'menus' => ['view_menus', 'manage_menus', 'view_promotions', 'manage_promotions'],
|
||||
'campaigns' => ['view_campaigns', 'manage_campaigns', 'send_campaigns', 'manage_templates'],
|
||||
'crm' => ['view_pipeline', 'edit_pipeline', 'manage_accounts', 'view_buyer_intelligence'],
|
||||
'automations' => ['view_automations', 'manage_automations', 'use_copilot'],
|
||||
'batches' => ['view_batches', 'manage_batches', 'create_batches'],
|
||||
'processing' => ['view_wash_reports', 'create_wash_reports', 'edit_wash_reports', 'manage_extractions', 'view_yields', 'manage_biomass_intake', 'manage_material_transfers'],
|
||||
'manufacturing' => ['view_bom', 'manage_bom', 'create_bom', 'view_production_queue', 'manage_production', 'manage_packaging', 'manage_labeling', 'create_skus', 'manage_lot_tracking'],
|
||||
'work_orders' => ['view_work_orders', 'create_work_orders', 'manage_work_orders'],
|
||||
'delivery' => ['view_pick_pack', 'manage_pick_pack', 'view_manifests', 'create_manifests', 'view_delivery_windows', 'manage_delivery_windows', 'view_drivers', 'manage_drivers', 'view_vehicles', 'manage_vehicles', 'view_routes', 'manage_routing', 'view_deliveries', 'complete_deliveries', 'manage_proof_of_delivery'],
|
||||
'compliance' => ['view_compliance', 'manage_licenses', 'manage_coas', 'view_compliance_reports'],
|
||||
'finance' => ['view_ap', 'manage_ap', 'pay_bills', 'view_ar', 'manage_ar', 'view_budgets', 'manage_budgets', 'approve_budget_exceptions', 'view_inter_company_ledger', 'manage_inter_company', 'view_financial_reports', 'export_financial_data', 'view_forecasting', 'manage_forecasting', 'view_kpis', 'view_usage_billing', 'manage_billing', 'view_cross_business'],
|
||||
'messaging' => ['view_conversations', 'send_messages', 'manage_contacts'],
|
||||
'procurement' => ['view_vendors', 'manage_vendors', 'view_requisitions', 'create_requisitions', 'approve_requisitions', 'view_purchase_orders', 'create_purchase_orders', 'receive_goods'],
|
||||
'tools' => ['manage_settings', 'manage_users', 'manage_departments', 'view_audit_log', 'manage_integrations'],
|
||||
'marketplace' => ['view_marketplace', 'browse_products', 'manage_cart', 'manage_favorites', 'view_buyer_portal', 'view_account'],
|
||||
'brand_view' => ['view_sales', 'view_buyers'],
|
||||
];
|
||||
|
||||
$areaLabels = [
|
||||
'dashboard' => ['name' => 'Dashboard & Analytics', 'icon' => 'lucide--layout-dashboard'],
|
||||
'products' => ['name' => 'Products & Inventory', 'icon' => 'lucide--package'],
|
||||
'orders' => ['name' => 'Orders & Invoicing', 'icon' => 'lucide--shopping-cart'],
|
||||
'menus' => ['name' => 'Menus & Promotions', 'icon' => 'lucide--menu'],
|
||||
'campaigns' => ['name' => 'Campaigns & Marketing', 'icon' => 'lucide--megaphone'],
|
||||
'crm' => ['name' => 'CRM & Accounts', 'icon' => 'lucide--users'],
|
||||
'automations' => ['name' => 'Automations & AI', 'icon' => 'lucide--bot'],
|
||||
'batches' => ['name' => 'Batches', 'icon' => 'lucide--layers'],
|
||||
'processing' => ['name' => 'Processing Operations', 'icon' => 'lucide--flask-conical'],
|
||||
'manufacturing' => ['name' => 'Manufacturing', 'icon' => 'lucide--factory'],
|
||||
'work_orders' => ['name' => 'Work Orders', 'icon' => 'lucide--clipboard-list'],
|
||||
'delivery' => ['name' => 'Delivery & Fulfillment', 'icon' => 'lucide--truck'],
|
||||
'compliance' => ['name' => 'Compliance', 'icon' => 'lucide--shield-check'],
|
||||
'finance' => ['name' => 'Finance & Budgets', 'icon' => 'lucide--banknote'],
|
||||
'messaging' => ['name' => 'Messaging', 'icon' => 'lucide--message-square'],
|
||||
'procurement' => ['name' => 'Procurement', 'icon' => 'lucide--clipboard-list'],
|
||||
'tools' => ['name' => 'Tools & Settings', 'icon' => 'lucide--settings'],
|
||||
'marketplace' => ['name' => 'Marketplace', 'icon' => 'lucide--store'],
|
||||
'brand_view' => ['name' => 'Brand Data', 'icon' => 'lucide--eye'],
|
||||
];
|
||||
|
||||
$grouped = [];
|
||||
$assigned = [];
|
||||
|
||||
foreach ($areaPatterns as $area => $areaPermissions) {
|
||||
$matchedPermissions = [];
|
||||
foreach ($permissions as $key => $description) {
|
||||
if (in_array($key, $areaPermissions) && ! isset($assigned[$key])) {
|
||||
$matchedPermissions[$key] = $description;
|
||||
$assigned[$key] = true;
|
||||
}
|
||||
}
|
||||
if (! empty($matchedPermissions)) {
|
||||
$grouped[$area] = [
|
||||
'name' => $areaLabels[$area]['name'] ?? ucwords(str_replace('_', ' ', $area)),
|
||||
'icon' => $areaLabels[$area]['icon'] ?? 'lucide--folder',
|
||||
'permissions' => $matchedPermissions,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining permissions to "Other"
|
||||
$remaining = [];
|
||||
foreach ($permissions as $key => $description) {
|
||||
if (! isset($assigned[$key])) {
|
||||
$remaining[$key] = $description;
|
||||
}
|
||||
}
|
||||
if (! empty($remaining)) {
|
||||
$grouped['other'] = [
|
||||
'name' => 'Other',
|
||||
'icon' => 'lucide--more-horizontal',
|
||||
'permissions' => $remaining,
|
||||
];
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -426,19 +534,6 @@ class SettingsController extends Controller
|
||||
->with('success', 'Order settings updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the brands management page.
|
||||
*/
|
||||
public function brands(Business $business)
|
||||
{
|
||||
$brands = $business->brands()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.settings.brands', compact('business', 'brands'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the payment settings page.
|
||||
*/
|
||||
@@ -601,7 +696,7 @@ class SettingsController extends Controller
|
||||
$validated['manual_order_emails_internal_only'] = $request->has('manual_order_emails_internal_only');
|
||||
|
||||
// CRM notification checkbox values
|
||||
if ($business->has_crm) {
|
||||
if ($business->hasCrmAccess()) {
|
||||
$validated['crm_task_reminder_enabled'] = $request->has('crm_task_reminder_enabled');
|
||||
$validated['crm_event_reminder_enabled'] = $request->has('crm_event_reminder_enabled');
|
||||
$validated['crm_daily_digest_enabled'] = $request->has('crm_daily_digest_enabled');
|
||||
|
||||
@@ -9,10 +9,12 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
/**
|
||||
* Middleware to ensure the user has Brand Portal access.
|
||||
*
|
||||
* Brand Portal access requires:
|
||||
* 1. User must be in "Brand Partner" department only (no other departments)
|
||||
* 2. User must have at least one linked brand for this business
|
||||
* 3. Business must have the brand_portal suite enabled
|
||||
* Brand Portal access is granted to:
|
||||
* 1. Brand Portal users (in "Brand Partner" department with linked brands)
|
||||
* 2. Brand Manager users (contact_type = 'brand_manager' with linked brands)
|
||||
*
|
||||
* Both access modes provide read-only, brand-scoped access to:
|
||||
* - Orders, Accounts, Inventory, Promotions, Conversations
|
||||
*
|
||||
* This middleware is applied to /s/{business}/brand-portal/* routes.
|
||||
*/
|
||||
@@ -38,15 +40,21 @@ class EnsureBrandPortalAccess
|
||||
abort(404, 'Business not found.');
|
||||
}
|
||||
|
||||
// Check if user is in Brand Portal mode for this business
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
// If user is NOT in Brand Portal mode but is trying to access Brand Portal routes,
|
||||
// redirect them to the main dashboard
|
||||
// Check if user has Brand Portal OR Brand Manager access
|
||||
$hasBrandPortalAccess = $user->isBrandPortalUser($business);
|
||||
$hasBrandManagerAccess = $user->isBrandManagerUser($business);
|
||||
|
||||
if (! $hasBrandPortalAccess && ! $hasBrandManagerAccess) {
|
||||
// User doesn't have either type of brand access
|
||||
return redirect()
|
||||
->route('seller.business.dashboard', $business->slug)
|
||||
->with('error', 'You do not have Brand Portal access. Contact your administrator.');
|
||||
}
|
||||
|
||||
// Set attributes for use in controllers (which type of access)
|
||||
$request->attributes->set('is_brand_portal_user', $hasBrandPortalAccess);
|
||||
$request->attributes->set('is_brand_manager_user', $hasBrandManagerAccess);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,10 +52,8 @@ class EnsureBusinessHasCrm
|
||||
// This relies on route model binding: Route::prefix('{business}')
|
||||
$business = $request->route('business');
|
||||
|
||||
// Check if business has Sales Suite or CRM feature
|
||||
// CRM is included in the Sales Suite
|
||||
$hasAccess = $business
|
||||
&& ($business->hasSalesSuite() || $business->hasSuiteFeature('crm'));
|
||||
// Check if business has CRM access (via Sales Suite or standalone CRM feature)
|
||||
$hasAccess = $business && $business->hasCrmAccess();
|
||||
|
||||
if (! $hasAccess) {
|
||||
return response()->view('seller.crm.feature-disabled', [
|
||||
|
||||
151
app/Http/Middleware/EnsureCanopyChildVisibility.php
Normal file
151
app/Http/Middleware/EnsureCanopyChildVisibility.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Services\CanopyVisibilityService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Middleware to enforce Canopy parent/child visibility rules.
|
||||
*
|
||||
* This middleware checks if the user can view child business financial data
|
||||
* when navigating through the Canopy parent context.
|
||||
*
|
||||
* BEFORE 12/1/2025:
|
||||
* - Only business owners can see their own division's data through Canopy
|
||||
* - Canopy management users cannot see any child data
|
||||
*
|
||||
* AFTER 12/1/2025:
|
||||
* - Normal parent visibility: Canopy can see all child financial data
|
||||
*
|
||||
* Usage:
|
||||
* - Apply to all Management routes under /s/{parent}/management/...
|
||||
* - The middleware detects if a child business is being viewed via:
|
||||
* 1. Route parameter (division_id, child_business)
|
||||
* 2. Request input (business_id, division_id filters)
|
||||
* 3. Session context
|
||||
*
|
||||
* @see App\Services\CanopyVisibilityService
|
||||
* @see config/canopy.php
|
||||
*/
|
||||
class EnsureCanopyChildVisibility
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Get the business from the route
|
||||
$business = $request->route('business');
|
||||
if (! $business instanceof Business) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Only apply visibility rules when viewing through Canopy parent
|
||||
if (! CanopyVisibilityService::isCanopyParent($business)) {
|
||||
// Child business context - normal access, no special visibility rules
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// We're viewing through Canopy (parent) - check for child data access
|
||||
$childBusiness = $this->detectChildBusinessContext($request, $business);
|
||||
|
||||
if ($childBusiness) {
|
||||
// User is trying to access a specific child's data through Canopy
|
||||
if (! CanopyVisibilityService::canopyCanSeeChildData($user, $childBusiness)) {
|
||||
return $this->denyAccess($request);
|
||||
}
|
||||
}
|
||||
|
||||
// Store visible children in request for controllers to use
|
||||
$visibleChildren = CanopyVisibilityService::getVisibleChildBusinesses($user);
|
||||
$request->attributes->set('canopy_visible_children', $visibleChildren);
|
||||
$request->attributes->set('canopy_visibility_restricted', CanopyVisibilityService::isVisibilityRestricted());
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the request is targeting a specific child business.
|
||||
*/
|
||||
protected function detectChildBusinessContext(Request $request, Business $parent): ?Business
|
||||
{
|
||||
// Check route parameters first
|
||||
$divisionId = $request->route('division_id')
|
||||
?? $request->route('division')
|
||||
?? $request->route('child_business');
|
||||
|
||||
if ($divisionId) {
|
||||
return $this->findChildBusiness($divisionId, $parent);
|
||||
}
|
||||
|
||||
// Check request input (query params or form data)
|
||||
$businessId = $request->input('business_id')
|
||||
?? $request->input('division_id')
|
||||
?? $request->input('child_business_id');
|
||||
|
||||
if ($businessId) {
|
||||
return $this->findChildBusiness($businessId, $parent);
|
||||
}
|
||||
|
||||
// Check for division filter in query string
|
||||
$divisionFilter = $request->input('division');
|
||||
if ($divisionFilter && $divisionFilter !== 'all') {
|
||||
return $this->findChildBusiness($divisionFilter, $parent);
|
||||
}
|
||||
|
||||
// No specific child business targeted
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a child business by ID or slug.
|
||||
*/
|
||||
protected function findChildBusiness(mixed $identifier, Business $parent): ?Business
|
||||
{
|
||||
if (is_numeric($identifier)) {
|
||||
return Business::where('id', $identifier)
|
||||
->where('parent_id', $parent->id)
|
||||
->first();
|
||||
}
|
||||
|
||||
// Try by slug
|
||||
return Business::where('slug', $identifier)
|
||||
->where('parent_id', $parent->id)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny access with appropriate response.
|
||||
*/
|
||||
protected function denyAccess(Request $request): Response
|
||||
{
|
||||
$message = CanopyVisibilityService::getRestrictedMessage();
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'error' => 'Visibility Restricted',
|
||||
'message' => $message,
|
||||
], 403);
|
||||
}
|
||||
|
||||
// For web requests, redirect back with error or show 403
|
||||
if ($request->hasSession()) {
|
||||
return redirect()
|
||||
->back()
|
||||
->with('error', $message);
|
||||
}
|
||||
|
||||
abort(403, $message);
|
||||
}
|
||||
}
|
||||
59
app/Http/Middleware/EnsureFinancePermission.php
Normal file
59
app/Http/Middleware/EnsureFinancePermission.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\PeriodLockService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureFinancePermission
|
||||
{
|
||||
public function __construct(
|
||||
protected PeriodLockService $periodLockService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* Usage: ->middleware('finance.permission:can_approve_ap')
|
||||
* Multiple permissions: ->middleware('finance.permission:can_approve_ap,can_view_ap')
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string ...$permissions): Response
|
||||
{
|
||||
// Get business from route
|
||||
$business = $request->route('business');
|
||||
|
||||
if (! $business instanceof Business) {
|
||||
abort(404, 'Business not found.');
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user) {
|
||||
abort(401, 'Authentication required.');
|
||||
}
|
||||
|
||||
// Check bypass mode
|
||||
if (config('finance_roles.bypass_permissions', false)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Business owners have all permissions
|
||||
if ($business->owner_user_id === $user->id) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Check if user has any of the required permissions
|
||||
foreach ($permissions as $permission) {
|
||||
if ($this->periodLockService->userHasPermission($business, $user, $permission)) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
abort(403, 'You do not have permission to perform this action.');
|
||||
}
|
||||
}
|
||||
137
app/Http/Middleware/EnsureSuitePermission.php
Normal file
137
app/Http/Middleware/EnsureSuitePermission.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Suite Permission Middleware
|
||||
*
|
||||
* Two-level permission check:
|
||||
* 1. Business must have the suite assigned (enables features for the business)
|
||||
* 2. User's department must have the specific permission (controls who can access)
|
||||
*
|
||||
* Usage in routes:
|
||||
* ->middleware('suite:sales,view_pipeline') // Single permission
|
||||
* ->middleware('suite:sales,view_pipeline|edit_pipeline') // Any of these permissions
|
||||
*
|
||||
* Permission Flow:
|
||||
* Business has Suite → Enables all features in suite
|
||||
* Owner assigns Department Permissions → Controls which users can access what
|
||||
* User in Department → Gets department's permissions
|
||||
*
|
||||
* Special Cases:
|
||||
* - Business owners/admins bypass department checks (they have full access)
|
||||
* - Users without a department are denied (must be assigned to a department)
|
||||
*
|
||||
* @see App\Models\DepartmentSuitePermission::SUITE_PERMISSIONS for available permissions
|
||||
* @see docs/architecture/SUITES_AND_PRICING_MODEL.md
|
||||
*/
|
||||
class EnsureSuitePermission
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param string $suite The suite key (e.g., 'sales', 'processing')
|
||||
* @param string $permissions Pipe-separated permissions (e.g., 'view_pipeline|edit_pipeline')
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string $suite, string $permissions): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$business = $request->route('business');
|
||||
|
||||
if (! $user || ! $business) {
|
||||
abort(403, 'Authentication required');
|
||||
}
|
||||
|
||||
// Level 1: Check if business has the suite
|
||||
if (! $business->hasSuite($suite)) {
|
||||
return $this->denyBusinessAccess($business, $suite);
|
||||
}
|
||||
|
||||
// Level 2: Check user's department permissions
|
||||
// Business owners and admins bypass department checks
|
||||
if ($this->isBusinessOwnerOrAdmin($user, $business)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Parse permissions (pipe-separated means "any of these")
|
||||
$requiredPermissions = explode('|', $permissions);
|
||||
|
||||
// Check if user has ANY of the required permissions via their department
|
||||
foreach ($requiredPermissions as $permission) {
|
||||
if ($user->hasSuitePermission($suite, trim($permission), $business)) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->denyUserAccess($user, $business, $suite, $requiredPermissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is a business owner or admin (bypasses department checks).
|
||||
*/
|
||||
protected function isBusinessOwnerOrAdmin($user, $business): bool
|
||||
{
|
||||
// Check if user is the business owner
|
||||
if ($business->owner_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user has admin role for this business
|
||||
$pivot = $user->businesses()->where('business_id', $business->id)->first()?->pivot;
|
||||
|
||||
return $pivot && in_array($pivot->role ?? '', ['owner', 'admin']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny access when business doesn't have the suite.
|
||||
*/
|
||||
protected function denyBusinessAccess($business, string $suite): Response
|
||||
{
|
||||
$suiteNames = [
|
||||
'sales' => 'Sales Suite',
|
||||
'processing' => 'Processing Suite',
|
||||
'manufacturing' => 'Manufacturing Suite',
|
||||
'delivery' => 'Delivery Suite',
|
||||
'management' => 'Management Suite',
|
||||
'dispensary' => 'Dispensary Suite',
|
||||
];
|
||||
|
||||
$suiteName = $suiteNames[$suite] ?? ucfirst($suite).' Suite';
|
||||
|
||||
return response()->view('errors.suite-required', [
|
||||
'business' => $business,
|
||||
'suite' => $suite,
|
||||
'suiteName' => $suiteName,
|
||||
'message' => "This feature requires the {$suiteName}.",
|
||||
], 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny access when user's department doesn't have permission.
|
||||
*/
|
||||
protected function denyUserAccess($user, $business, string $suite, array $permissions): Response
|
||||
{
|
||||
$department = $user->departments()
|
||||
->where('business_id', $business->id)
|
||||
->first();
|
||||
|
||||
if (! $department) {
|
||||
$message = 'You must be assigned to a department to access this feature. Please contact your administrator.';
|
||||
} else {
|
||||
$message = "Your department ({$department->name}) does not have permission to access this feature. Please contact your administrator.";
|
||||
}
|
||||
|
||||
return response()->view('errors.permission-denied', [
|
||||
'business' => $business,
|
||||
'user' => $user,
|
||||
'department' => $department,
|
||||
'suite' => $suite,
|
||||
'permissions' => $permissions,
|
||||
'message' => $message,
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
@@ -67,9 +67,9 @@ class ProcessCrmCommandJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if business has CRM enabled
|
||||
if (! $business->has_crm) {
|
||||
Log::debug('CRM command job: Business does not have CRM enabled', [
|
||||
// Check if business has CRM access (via Sales Suite or standalone feature)
|
||||
if (! $business->hasCrmAccess()) {
|
||||
Log::debug('CRM command job: Business does not have CRM access', [
|
||||
'business_id' => $business->id,
|
||||
]);
|
||||
|
||||
|
||||
198
app/Models/Accounting/AccountingPeriod.php
Normal file
198
app/Models/Accounting/AccountingPeriod.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Accounting;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AccountingPeriod extends Model
|
||||
{
|
||||
public const STATUS_OPEN = 'open';
|
||||
|
||||
public const STATUS_SOFT_CLOSED = 'soft_closed';
|
||||
|
||||
public const STATUS_HARD_CLOSED = 'hard_closed';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'status',
|
||||
'closed_by_user_id',
|
||||
'closed_at',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'period_start' => 'date',
|
||||
'period_end' => 'date',
|
||||
'closed_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function closedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'closed_by_user_id');
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeOpen($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_OPEN);
|
||||
}
|
||||
|
||||
public function scopeClosed($query)
|
||||
{
|
||||
return $query->whereIn('status', [self::STATUS_SOFT_CLOSED, self::STATUS_HARD_CLOSED]);
|
||||
}
|
||||
|
||||
public function scopeHardClosed($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_HARD_CLOSED);
|
||||
}
|
||||
|
||||
public function scopeContainingDate($query, $date)
|
||||
{
|
||||
$date = Carbon::parse($date)->toDateString();
|
||||
|
||||
return $query->where('period_start', '<=', $date)
|
||||
->where('period_end', '>=', $date);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_OPEN;
|
||||
}
|
||||
|
||||
public function isSoftClosed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_SOFT_CLOSED;
|
||||
}
|
||||
|
||||
public function isHardClosed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_HARD_CLOSED;
|
||||
}
|
||||
|
||||
public function isClosed(): bool
|
||||
{
|
||||
return $this->isSoftClosed() || $this->isHardClosed();
|
||||
}
|
||||
|
||||
public function containsDate($date): bool
|
||||
{
|
||||
$date = Carbon::parse($date);
|
||||
|
||||
return $date->between($this->period_start, $this->period_end);
|
||||
}
|
||||
|
||||
public function close(string $status, User $user, ?string $notes = null): self
|
||||
{
|
||||
$this->update([
|
||||
'status' => $status,
|
||||
'closed_by_user_id' => $user->id,
|
||||
'closed_at' => now(),
|
||||
'notes' => $notes,
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function reopen(?string $notes = null): self
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_OPEN,
|
||||
'closed_by_user_id' => null,
|
||||
'closed_at' => null,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPeriodLabelAttribute(): string
|
||||
{
|
||||
if ($this->period_start->isSameMonth($this->period_end)) {
|
||||
return $this->period_start->format('F Y');
|
||||
}
|
||||
|
||||
return $this->period_start->format('M Y').' - '.$this->period_end->format('M Y');
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_OPEN => 'Open',
|
||||
self::STATUS_SOFT_CLOSED => 'Soft Closed',
|
||||
self::STATUS_HARD_CLOSED => 'Hard Closed',
|
||||
default => ucfirst($this->status),
|
||||
};
|
||||
}
|
||||
|
||||
public function getStatusBadgeClassAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_OPEN => 'badge-success',
|
||||
self::STATUS_SOFT_CLOSED => 'badge-warning',
|
||||
self::STATUS_HARD_CLOSED => 'badge-error',
|
||||
default => 'badge-ghost',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate monthly periods for a fiscal year.
|
||||
*/
|
||||
public static function generateMonthlyPeriods(int $businessId, int $year): array
|
||||
{
|
||||
$periods = [];
|
||||
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$start = Carbon::create($year, $month, 1);
|
||||
$end = $start->copy()->endOfMonth();
|
||||
|
||||
$periods[] = self::firstOrCreate(
|
||||
[
|
||||
'business_id' => $businessId,
|
||||
'period_start' => $start->toDateString(),
|
||||
'period_end' => $end->toDateString(),
|
||||
],
|
||||
[
|
||||
'status' => self::STATUS_OPEN,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return $periods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the period for a given date, creating it if it doesn't exist.
|
||||
*/
|
||||
public static function forDate(int $businessId, $date): ?self
|
||||
{
|
||||
$date = Carbon::parse($date);
|
||||
|
||||
return self::where('business_id', $businessId)
|
||||
->containingDate($date)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
186
app/Models/Accounting/ApBill.php
Normal file
186
app/Models/Accounting/ApBill.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Accounting;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
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\SoftDeletes;
|
||||
use OwenIt\Auditing\Auditable;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
class ApBill extends Model implements AuditableContract
|
||||
{
|
||||
use Auditable, SoftDeletes;
|
||||
|
||||
protected $table = 'ap_bills';
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
|
||||
public const STATUS_PARTIAL = 'partial';
|
||||
|
||||
public const STATUS_PAID = 'paid';
|
||||
|
||||
public const STATUS_VOID = 'void';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_DRAFT => 'Draft',
|
||||
self::STATUS_PENDING => 'Pending Approval',
|
||||
self::STATUS_APPROVED => 'Approved',
|
||||
self::STATUS_PARTIAL => 'Partially Paid',
|
||||
self::STATUS_PAID => 'Paid',
|
||||
self::STATUS_VOID => 'Void',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'vendor_id',
|
||||
'purchase_order_id',
|
||||
'bill_number',
|
||||
'vendor_invoice_number',
|
||||
'bill_date',
|
||||
'due_date',
|
||||
'payment_terms',
|
||||
'subtotal',
|
||||
'tax_amount',
|
||||
'total',
|
||||
'amount_paid',
|
||||
'balance_due',
|
||||
'currency',
|
||||
'status',
|
||||
'department_id',
|
||||
'document_path',
|
||||
'notes',
|
||||
'approved_at',
|
||||
'approved_by_user_id',
|
||||
'created_by_user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'bill_date' => 'date',
|
||||
'due_date' => 'date',
|
||||
'approved_at' => 'datetime',
|
||||
'subtotal' => 'decimal:2',
|
||||
'tax_amount' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
'amount_paid' => 'decimal:2',
|
||||
'balance_due' => 'decimal:2',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function vendor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApVendor::class, 'vendor_id');
|
||||
}
|
||||
|
||||
public function glAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GlAccount::class, 'gl_account_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApBillItem::class, 'ap_bill_id');
|
||||
}
|
||||
|
||||
public function paymentApplications(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApPaymentApplication::class, 'ap_bill_id');
|
||||
}
|
||||
|
||||
public function payments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(ApPayment::class, 'ap_payment_applications', 'ap_bill_id', 'ap_payment_id')
|
||||
->withPivot('amount_applied', 'discount_taken')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeForBusiness(Builder $query, int $businessId): Builder
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForBusinesses(Builder $query, array $businessIds): Builder
|
||||
{
|
||||
return $query->whereIn('business_id', $businessIds);
|
||||
}
|
||||
|
||||
public function scopeStatus(Builder $query, string $status): Builder
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
public function scopeDraft(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_DRAFT);
|
||||
}
|
||||
|
||||
public function scopePending(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
public function scopeApproved(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_APPROVED);
|
||||
}
|
||||
|
||||
public function scopeUnpaid(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotIn('status', [self::STATUS_PAID, self::STATUS_VOID])
|
||||
->where('balance_due', '>', 0);
|
||||
}
|
||||
|
||||
public function scopeOverdue(Builder $query): Builder
|
||||
{
|
||||
return $query->where('due_date', '<', now())
|
||||
->whereNotIn('status', [self::STATUS_PAID, self::STATUS_VOID])
|
||||
->where('balance_due', '>', 0);
|
||||
}
|
||||
|
||||
// Accessors
|
||||
|
||||
public function isOverdue(): bool
|
||||
{
|
||||
return $this->due_date && $this->due_date->isPast() && $this->balance_due > 0;
|
||||
}
|
||||
|
||||
public function getDaysOverdueAttribute(): int
|
||||
{
|
||||
if (! $this->isOverdue()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $this->due_date->diffInDays(now());
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return self::STATUSES[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PAID;
|
||||
}
|
||||
|
||||
public function isVoid(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_VOID;
|
||||
}
|
||||
}
|
||||
59
app/Models/Accounting/ApBillItem.php
Normal file
59
app/Models/Accounting/ApBillItem.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Accounting;
|
||||
|
||||
use App\Models\Department;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use OwenIt\Auditing\Auditable;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
class ApBillItem extends Model implements AuditableContract
|
||||
{
|
||||
use Auditable, HasFactory;
|
||||
|
||||
protected $table = 'ap_bill_items';
|
||||
|
||||
protected $fillable = [
|
||||
'ap_bill_id',
|
||||
'purchase_order_item_id',
|
||||
'description',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'line_total',
|
||||
'gl_account_id',
|
||||
'department_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:2',
|
||||
'unit_price' => 'decimal:2',
|
||||
'line_total' => 'decimal:2',
|
||||
];
|
||||
|
||||
protected $auditExclude = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
public function bill(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApBill::class, 'ap_bill_id');
|
||||
}
|
||||
|
||||
public function purchaseOrderItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrderItem::class, 'purchase_order_item_id');
|
||||
}
|
||||
|
||||
public function glAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GlAccount::class, 'gl_account_id');
|
||||
}
|
||||
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class);
|
||||
}
|
||||
}
|
||||
133
app/Models/Accounting/ApPayment.php
Normal file
133
app/Models/Accounting/ApPayment.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Accounting;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
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\SoftDeletes;
|
||||
use OwenIt\Auditing\Auditable;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
class ApPayment extends Model implements AuditableContract
|
||||
{
|
||||
use Auditable, BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
protected $table = 'ap_payments';
|
||||
|
||||
public const METHOD_CHECK = 'check';
|
||||
|
||||
public const METHOD_ACH = 'ach';
|
||||
|
||||
public const METHOD_WIRE = 'wire';
|
||||
|
||||
public const METHOD_CARD = 'card';
|
||||
|
||||
public const METHOD_CASH = 'cash';
|
||||
|
||||
public const METHODS = [
|
||||
self::METHOD_CHECK,
|
||||
self::METHOD_ACH,
|
||||
self::METHOD_WIRE,
|
||||
self::METHOD_CARD,
|
||||
self::METHOD_CASH,
|
||||
];
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_VOIDED = 'voided';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_VOIDED,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'vendor_id',
|
||||
'payment_number',
|
||||
'payment_date',
|
||||
'payment_method',
|
||||
'reference_number',
|
||||
'amount',
|
||||
'currency',
|
||||
'bank_account_id',
|
||||
'memo',
|
||||
'status',
|
||||
'voided_at',
|
||||
'voided_by_user_id',
|
||||
'created_by_user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payment_date' => 'date',
|
||||
'voided_at' => 'datetime',
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
protected $auditExclude = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function vendor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApVendor::class, 'vendor_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function voidedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'voided_by_user_id');
|
||||
}
|
||||
|
||||
public function applications(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApPaymentApplication::class, 'ap_payment_id');
|
||||
}
|
||||
|
||||
public function bills(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(ApBill::class, 'ap_payment_applications', 'ap_payment_id', 'ap_bill_id')
|
||||
->withPivot('amount_applied', 'discount_taken')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function scopeStatus($query, string $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
public function scopeCompleted($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_COMPLETED);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function isVoided(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_VOIDED;
|
||||
}
|
||||
}
|
||||
48
app/Models/Accounting/ApPaymentApplication.php
Normal file
48
app/Models/Accounting/ApPaymentApplication.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Accounting;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use OwenIt\Auditing\Auditable;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
class ApPaymentApplication extends Model implements AuditableContract
|
||||
{
|
||||
use Auditable, HasFactory;
|
||||
|
||||
protected $table = 'ap_payment_applications';
|
||||
|
||||
protected $fillable = [
|
||||
'ap_payment_id',
|
||||
'ap_bill_id',
|
||||
'amount_applied',
|
||||
'discount_taken',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount_applied' => 'decimal:2',
|
||||
'discount_taken' => 'decimal:2',
|
||||
];
|
||||
|
||||
protected $auditExclude = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
public function payment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApPayment::class, 'ap_payment_id');
|
||||
}
|
||||
|
||||
public function bill(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApBill::class, 'ap_bill_id');
|
||||
}
|
||||
|
||||
public function getTotalAppliedAttribute(): float
|
||||
{
|
||||
return $this->amount_applied + $this->discount_taken;
|
||||
}
|
||||
}
|
||||
84
app/Models/Accounting/ApVendor.php
Normal file
84
app/Models/Accounting/ApVendor.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Accounting;
|
||||
|
||||
use App\Models\Business;
|
||||
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\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use OwenIt\Auditing\Auditable;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
class ApVendor extends Model implements AuditableContract
|
||||
{
|
||||
use Auditable, BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
protected $table = 'ap_vendors';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'code',
|
||||
'name',
|
||||
'legal_name',
|
||||
'tax_id',
|
||||
'default_payment_terms',
|
||||
'default_gl_account_id',
|
||||
'contact_name',
|
||||
'contact_email',
|
||||
'contact_phone',
|
||||
'address_line1',
|
||||
'address_line2',
|
||||
'city',
|
||||
'state',
|
||||
'postal_code',
|
||||
'country',
|
||||
'is_1099',
|
||||
'is_active',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'default_payment_terms' => 'integer',
|
||||
'is_1099' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
protected $auditExclude = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function defaultGlAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GlAccount::class, 'default_gl_account_id');
|
||||
}
|
||||
|
||||
public function bills(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApBill::class, 'vendor_id');
|
||||
}
|
||||
|
||||
public function payments(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApPayment::class, 'vendor_id');
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
}
|
||||
141
app/Models/Accounting/ArCustomer.php
Normal file
141
app/Models/Accounting/ArCustomer.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Accounting;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ArCustomer extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'ar_customers';
|
||||
|
||||
public const CREDIT_STATUS_GOOD = 'good';
|
||||
|
||||
public const CREDIT_STATUS_WATCH = 'watch';
|
||||
|
||||
public const CREDIT_STATUS_HOLD = 'hold';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'linked_business_id',
|
||||
'name',
|
||||
'email',
|
||||
'phone',
|
||||
'address_line_1',
|
||||
'address_line_2',
|
||||
'city',
|
||||
'state',
|
||||
'postal_code',
|
||||
'country',
|
||||
'payment_terms',
|
||||
'payment_terms_days',
|
||||
'credit_limit',
|
||||
'credit_granted',
|
||||
'credit_limit_approved_by',
|
||||
'credit_approved_at',
|
||||
'on_credit_hold',
|
||||
'credit_status',
|
||||
'hold_reason',
|
||||
'ar_notes',
|
||||
'notes',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'credit_limit' => 'decimal:2',
|
||||
'credit_granted' => 'boolean',
|
||||
'credit_approved_at' => 'datetime',
|
||||
'on_credit_hold' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The buyer business this AR customer is linked to (for B2B transactions).
|
||||
*/
|
||||
public function linkedBusiness(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'linked_business_id');
|
||||
}
|
||||
|
||||
public function creditApprovedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'credit_limit_approved_by');
|
||||
}
|
||||
|
||||
public function invoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArInvoice::class, 'customer_id');
|
||||
}
|
||||
|
||||
public function payments(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArPayment::class, 'customer_id');
|
||||
}
|
||||
|
||||
public function getOutstandingBalanceAttribute(): float
|
||||
{
|
||||
return (float) $this->invoices()
|
||||
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->sum('balance_due');
|
||||
}
|
||||
|
||||
public function getPastDueAmountAttribute(): float
|
||||
{
|
||||
return (float) $this->invoices()
|
||||
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('due_date', '<', now())
|
||||
->sum('balance_due');
|
||||
}
|
||||
|
||||
public function isOverCreditLimit(): bool
|
||||
{
|
||||
if (! $this->credit_limit || $this->credit_limit <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->outstanding_balance > $this->credit_limit;
|
||||
}
|
||||
|
||||
public function getAvailableCreditAttribute(): float
|
||||
{
|
||||
if (! $this->credit_limit || $this->credit_limit <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(0, $this->credit_limit - $this->outstanding_balance);
|
||||
}
|
||||
|
||||
public function scopeOnHold($query)
|
||||
{
|
||||
return $query->where('on_credit_hold', true);
|
||||
}
|
||||
|
||||
public function scopeAtRisk($query)
|
||||
{
|
||||
return $query->where(function ($q) {
|
||||
$q->where('on_credit_hold', true)
|
||||
->orWhere('credit_status', self::CREDIT_STATUS_WATCH)
|
||||
->orWhere('credit_status', self::CREDIT_STATUS_HOLD)
|
||||
->orWhereHas('invoices', function ($inv) {
|
||||
$inv->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
|
||||
->where('due_date', '<', now());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
}
|
||||
106
app/Models/Accounting/ArInvoice.php
Normal file
106
app/Models/Accounting/ArInvoice.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Accounting;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmInvoice;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ArInvoice extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'ar_invoices';
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_SENT = 'sent';
|
||||
|
||||
public const STATUS_PARTIAL = 'partial';
|
||||
|
||||
public const STATUS_PAID = 'paid';
|
||||
|
||||
public const STATUS_VOID = 'void';
|
||||
|
||||
public const STATUS_OVERDUE = 'overdue';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'customer_id',
|
||||
'crm_invoice_id',
|
||||
'invoice_number',
|
||||
'invoice_date',
|
||||
'due_date',
|
||||
'description',
|
||||
'subtotal',
|
||||
'tax_amount',
|
||||
'total_amount',
|
||||
'balance_due',
|
||||
'currency',
|
||||
'status',
|
||||
'gl_account_id',
|
||||
'memo',
|
||||
'sent_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'invoice_date' => 'date',
|
||||
'due_date' => 'date',
|
||||
'sent_at' => 'datetime',
|
||||
'subtotal' => 'decimal:2',
|
||||
'tax_amount' => 'decimal:2',
|
||||
'total_amount' => 'decimal:2',
|
||||
'balance_due' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ArCustomer::class, 'customer_id');
|
||||
}
|
||||
|
||||
public function glAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GlAccount::class, 'gl_account_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The CRM invoice this AR invoice was created from (if any).
|
||||
* Links operational billing (Sales Suite) to financial layer (Management Suite).
|
||||
*/
|
||||
public function crmInvoice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CrmInvoice::class, 'crm_invoice_id');
|
||||
}
|
||||
|
||||
public function payments(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArPayment::class, 'invoice_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArInvoiceItem::class, 'invoice_id');
|
||||
}
|
||||
|
||||
public function isOverdue(): bool
|
||||
{
|
||||
return $this->due_date && $this->due_date->isPast() && $this->balance_due > 0;
|
||||
}
|
||||
|
||||
public function getDaysOverdueAttribute(): int
|
||||
{
|
||||
if (! $this->isOverdue()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $this->due_date->diffInDays(now());
|
||||
}
|
||||
}
|
||||
36
app/Models/Accounting/ArInvoiceItem.php
Normal file
36
app/Models/Accounting/ArInvoiceItem.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Accounting;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ArInvoiceItem extends Model
|
||||
{
|
||||
protected $table = 'ar_invoice_items';
|
||||
|
||||
protected $fillable = [
|
||||
'invoice_id',
|
||||
'description',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'amount',
|
||||
'gl_account_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:4',
|
||||
'unit_price' => 'decimal:2',
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function invoice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ArInvoice::class, 'invoice_id');
|
||||
}
|
||||
|
||||
public function glAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GlAccount::class, 'gl_account_id');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user