Compare commits
133 Commits
fix/crm-ro
...
feat/sideb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08f5a3adac | ||
|
|
329c01523a | ||
|
|
5fb26f901d | ||
|
|
6baadf5744 | ||
|
|
a3508c57a2 | ||
|
|
38cba2cd72 | ||
|
|
735e09ab90 | ||
|
|
05ef21cd71 | ||
|
|
65c65bf9cc | ||
|
|
e33f0d0182 | ||
|
|
c8faf2f2d6 | ||
|
|
50bb3fce77 | ||
|
|
c7fdc67060 | ||
|
|
c7e2b0e4ac | ||
|
|
0cf83744db | ||
|
|
defeeffa07 | ||
|
|
0fbf99c005 | ||
|
|
67eb679c7e | ||
|
|
3b7f3acaa6 | ||
|
|
3d1f3b1057 | ||
|
|
7a2748e904 | ||
|
|
4f2061cd00 | ||
|
|
8bb9044f2d | ||
|
|
7da52677d5 | ||
|
|
a049db38a9 | ||
|
|
bb60a772f9 | ||
|
|
95d92f27d3 | ||
|
|
f08910bbf4 | ||
|
|
e043137269 | ||
|
|
de988d9abd | ||
|
|
72df0cfe88 | ||
|
|
65a752f4d8 | ||
|
|
7d0230be5f | ||
|
|
75305a01b0 | ||
|
|
f2ce0dfee3 | ||
|
|
1222610080 | ||
|
|
c1d0cdf477 | ||
|
|
a55ea906ac | ||
|
|
70e274415d | ||
|
|
fca89475cc | ||
|
|
a88eeb7981 | ||
|
|
eed4df0c4a | ||
|
|
915b0407cf | ||
|
|
f173254700 | ||
|
|
539cd0e4e1 | ||
|
|
050a446ba0 | ||
|
|
8fe4213178 | ||
|
|
d7413784ea | ||
|
|
b6b049e321 | ||
|
|
11509c4af0 | ||
|
|
8651e5a9e6 | ||
|
|
e0d931d72c | ||
|
|
6c7a0d2a35 | ||
|
|
95684ffae0 | ||
|
|
b30f5db061 | ||
|
|
266bb3ff9c | ||
|
|
f227a53ac1 | ||
|
|
6d0adb0b02 | ||
|
|
61b2a2beb6 | ||
|
|
fdfe132545 | ||
|
|
c9e191ee7e | ||
|
|
d42c964c30 | ||
|
|
b8e7ebc3ac | ||
|
|
e156716002 | ||
|
|
b5c1d92397 | ||
|
|
72e96b7e0e | ||
|
|
4489377762 | ||
|
|
eedd4c9cef | ||
|
|
2370f31a18 | ||
|
|
27c8395d5a | ||
|
|
dbee401f61 | ||
|
|
b17bc590bb | ||
|
|
6ce5ca14e2 | ||
|
|
454b85ffb1 | ||
|
|
e13d7cd7ad | ||
|
|
f3436d35ec | ||
|
|
a46b44055e | ||
|
|
a3dda1520e | ||
|
|
4068bfc0b2 | ||
|
|
497523ee0c | ||
|
|
94d68f80e4 | ||
|
|
c091c3c168 | ||
|
|
7c54ece253 | ||
|
|
f7294fcf83 | ||
|
|
6d64d9527a | ||
|
|
08df003b20 | ||
|
|
59cd09eb5b | ||
|
|
3a6ab1c207 | ||
|
|
404a731bd9 | ||
|
|
2b30deed11 | ||
|
|
109d9cd39d | ||
|
|
aadd7a500a | ||
|
|
111ef20684 | ||
|
|
85fdb71f92 | ||
|
|
08e2eb3ac6 | ||
|
|
87e8384aca | ||
|
|
e56ad20568 | ||
|
|
fafb05e29b | ||
|
|
a322d7609b | ||
|
|
2aefba3619 | ||
|
|
b47fc35857 | ||
|
|
e5e1dea055 | ||
|
|
e5e485d636 | ||
|
|
3d383e0490 | ||
|
|
df188e21ce | ||
|
|
55016f7009 | ||
|
|
9cf89c7b1a | ||
|
|
0d810dff27 | ||
|
|
624a36d2c5 | ||
|
|
92e3e171e1 | ||
|
|
58ca83c8c2 | ||
|
|
7f175709a5 | ||
|
|
26a903bdd9 | ||
|
|
e871426817 | ||
|
|
c99511d696 | ||
|
|
963f00cd39 | ||
|
|
0db70220c7 | ||
|
|
4bcd0cca8a | ||
|
|
6c7d7016c9 | ||
|
|
6d92f37ea7 | ||
|
|
318d6b4fe8 | ||
|
|
9ea69447ec | ||
|
|
a24fbaac9a | ||
|
|
412a3beeed | ||
|
|
d0e9369795 | ||
|
|
085ca6c415 | ||
|
|
1d363d7157 | ||
|
|
4adc611e83 | ||
|
|
3c88bbfb4d | ||
|
|
3496421264 | ||
|
|
91f1ae217a | ||
|
|
5b7a2dd7bf | ||
|
|
c991d3f141 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -82,3 +82,4 @@ SESSION_*
|
||||
# AI workflow personal context files
|
||||
CLAUDE.local.md
|
||||
claude.*.md
|
||||
cannabrands_dev_backup.dump
|
||||
|
||||
@@ -7,17 +7,26 @@
|
||||
# - tags (2025.X) → cannabrands.app (versioned production releases)
|
||||
#
|
||||
# Pipeline Strategy:
|
||||
# - PRs: Run tests (lint, style, phpunit)
|
||||
# - PRs: Run tests (lint, style, phpunit) IN PARALLEL
|
||||
# - Push to develop/master: Skip tests (already passed on PR), build + deploy
|
||||
# - Tags: Build versioned release
|
||||
#
|
||||
# Optimization Notes:
|
||||
# - php-lint, code-style, and tests run in parallel after composer install
|
||||
# - Uses parallel-lint for faster PHP syntax checking
|
||||
# - PostgreSQL tuned for CI (fsync disabled)
|
||||
# - Cache rebuild only on merge builds
|
||||
|
||||
when:
|
||||
- branch: [develop, master]
|
||||
event: push
|
||||
- event: [pull_request, tag]
|
||||
|
||||
# Install dependencies first (needed for php-lint to resolve traits/classes)
|
||||
steps:
|
||||
# ============================================
|
||||
# DEPENDENCY INSTALLATION (Sequential)
|
||||
# ============================================
|
||||
|
||||
# Restore Composer cache
|
||||
restore-composer-cache:
|
||||
image: meltwater/drone-cache:dev
|
||||
@@ -34,6 +43,8 @@ steps:
|
||||
# Install dependencies (uses pre-built Laravel image with all extensions)
|
||||
composer-install:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- restore-composer-cache
|
||||
commands:
|
||||
- echo "Creating minimal .env for package discovery..."
|
||||
- |
|
||||
@@ -63,9 +74,11 @@ steps:
|
||||
fi
|
||||
- echo "✅ Composer dependencies ready!"
|
||||
|
||||
# Rebuild Composer cache
|
||||
# Rebuild Composer cache (only on merge builds, not PRs)
|
||||
rebuild-composer-cache:
|
||||
image: meltwater/drone-cache:dev
|
||||
depends_on:
|
||||
- composer-install
|
||||
settings:
|
||||
backend: "filesystem"
|
||||
rebuild: true
|
||||
@@ -75,22 +88,31 @@ steps:
|
||||
- "vendor"
|
||||
volumes:
|
||||
- /tmp/woodpecker-cache:/tmp/cache
|
||||
when:
|
||||
branch: [develop, master]
|
||||
event: push
|
||||
|
||||
# PHP Syntax Check (PRs only - skipped on merge since tests already passed)
|
||||
# ============================================
|
||||
# PR CHECKS (Run in Parallel for Speed)
|
||||
# ============================================
|
||||
|
||||
# PHP Syntax Check - Uses parallel-lint for 5-10x speed improvement
|
||||
php-lint:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
commands:
|
||||
- echo "Checking PHP syntax..."
|
||||
- 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 "Checking PHP syntax (parallel)..."
|
||||
- ./vendor/bin/parallel-lint app routes database config --colors --blame
|
||||
- echo "✅ PHP syntax check complete!"
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# Run Laravel Pint (PRs only - skipped on merge since tests already passed)
|
||||
# Run Laravel Pint (code style)
|
||||
code-style:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
commands:
|
||||
- echo "Checking code style with Laravel Pint..."
|
||||
- ./vendor/bin/pint --test
|
||||
@@ -98,11 +120,13 @@ steps:
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# Run PHPUnit Tests (PRs only - skipped on merge since tests already passed)
|
||||
# Run PHPUnit Tests
|
||||
# Note: Uses array cache/session for speed and isolation (Laravel convention)
|
||||
# Redis + Reverb services used for real-time broadcasting tests
|
||||
tests:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
when:
|
||||
event: pull_request
|
||||
environment:
|
||||
@@ -128,20 +152,35 @@ steps:
|
||||
- echo "Setting up Laravel environment..."
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- echo "Waiting for PostgreSQL to be ready..."
|
||||
- |
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if pg_isready -h postgres -p 5432 -U testing 2>/dev/null; then
|
||||
echo "✅ PostgreSQL is ready!"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for postgres... attempt $i/10"
|
||||
sleep 3
|
||||
done
|
||||
- echo "Starting Reverb server in background..."
|
||||
- php artisan reverb:start --host=0.0.0.0 --port=8080 > /dev/null 2>&1 &
|
||||
- sleep 2
|
||||
- echo "Running tests..."
|
||||
- echo "Running tests in parallel..."
|
||||
- php artisan test --parallel
|
||||
- echo "Tests complete!"
|
||||
- echo "✅ Tests complete!"
|
||||
|
||||
# Validate seeders that run in dev/staging environments
|
||||
# This prevents deployment failures caused by seeder errors (e.g., fake() crashes)
|
||||
# Uses APP_ENV=development to match K8s init container behavior
|
||||
validate-seeders:
|
||||
# ============================================
|
||||
# MERGE BUILD STEPS (Sequential, after PR passes)
|
||||
# ============================================
|
||||
|
||||
# Validate migrations before deployment
|
||||
# Only runs pending migrations - never fresh or seed
|
||||
validate-migrations:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
environment:
|
||||
APP_ENV: development
|
||||
APP_ENV: production
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
@@ -152,20 +191,21 @@ steps:
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
commands:
|
||||
- echo "Validating seeders (matches K8s init container)..."
|
||||
- echo "Validating migrations..."
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- echo "Running migrate:fresh --seed with APP_ENV=development..."
|
||||
- php artisan migrate:fresh --seed --force
|
||||
- echo "✅ Seeder validation complete!"
|
||||
- echo "Running pending migrations only..."
|
||||
- php artisan migrate --force
|
||||
- echo "✅ Migration validation complete!"
|
||||
when:
|
||||
branch: [develop, master]
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for DEV environment (develop branch)
|
||||
build-image-dev:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
depends_on:
|
||||
- validate-migrations
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
@@ -194,11 +234,12 @@ steps:
|
||||
when:
|
||||
branch: develop
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Auto-deploy to dev.cannabrands.app (develop branch only)
|
||||
deploy-dev:
|
||||
image: bitnami/kubectl:latest
|
||||
depends_on:
|
||||
- build-image-dev
|
||||
environment:
|
||||
KUBECONFIG_CONTENT:
|
||||
from_secret: kubeconfig_dev
|
||||
@@ -218,20 +259,6 @@ steps:
|
||||
-n cannabrands-dev
|
||||
# Wait for rollout to complete (timeout 5 minutes)
|
||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-dev --timeout=300s
|
||||
# Restore Cannabrands seed data (idempotent - uses ON CONFLICT DO NOTHING)
|
||||
- |
|
||||
echo ""
|
||||
echo "📦 Restoring Cannabrands seed data..."
|
||||
POD=$(kubectl get pods -n cannabrands-dev -l app=cannabrands-hub -o jsonpath='{.items[0].metadata.name}')
|
||||
kubectl exec -n cannabrands-dev $POD -- php artisan db:restore-cannabrands
|
||||
echo "✅ Cannabrands data restore complete!"
|
||||
# Seed suites for Cannabrands (assigns Sales Suite to Cannabrands business)
|
||||
- |
|
||||
echo ""
|
||||
echo "📦 Seeding suites for Cannabrands..."
|
||||
POD=$(kubectl get pods -n cannabrands-dev -l app=cannabrands-hub -o jsonpath='{.items[0].metadata.name}')
|
||||
kubectl exec -n cannabrands-dev $POD -- php artisan db:seed --class=DevSuitesSeeder --force
|
||||
echo "✅ Suites seeded!"
|
||||
# Verify deployment health
|
||||
- |
|
||||
echo ""
|
||||
@@ -245,11 +272,12 @@ steps:
|
||||
when:
|
||||
branch: develop
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for PRODUCTION (master branch)
|
||||
build-image-production:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
depends_on:
|
||||
- validate-migrations
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
@@ -274,11 +302,12 @@ steps:
|
||||
when:
|
||||
branch: master
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Deploy to production (master branch)
|
||||
deploy-production:
|
||||
image: bitnami/kubectl:latest
|
||||
depends_on:
|
||||
- build-image-production
|
||||
environment:
|
||||
KUBECONFIG_CONTENT:
|
||||
from_secret: kubeconfig_prod
|
||||
@@ -302,11 +331,12 @@ steps:
|
||||
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
|
||||
depends_on:
|
||||
- composer-install
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
@@ -327,7 +357,6 @@ steps:
|
||||
provenance: false
|
||||
when:
|
||||
event: tag
|
||||
status: success
|
||||
|
||||
# Success notification
|
||||
success:
|
||||
@@ -398,7 +427,7 @@ steps:
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
fi
|
||||
|
||||
# Services for tests
|
||||
# Services for tests (optimized for CI speed)
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
@@ -406,6 +435,9 @@ services:
|
||||
POSTGRES_USER: testing
|
||||
POSTGRES_PASSWORD: testing
|
||||
POSTGRES_DB: testing
|
||||
# CI-optimized settings via environment (faster writes, safe for ephemeral test DB)
|
||||
POSTGRES_INITDB_ARGS: "--data-checksums"
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
|
||||
139
CLAUDE.md
139
CLAUDE.md
@@ -191,6 +191,101 @@ if ($product->image_path) {
|
||||
|
||||
**This has caused multiple production outages - review docs before ANY storage changes!**
|
||||
|
||||
### 12. Dashboard & Metrics Performance (CRITICAL!)
|
||||
|
||||
**Production outages have occurred from violating these rules.**
|
||||
|
||||
#### The Golden Rule
|
||||
**NEVER compute aggregations in HTTP controllers. Dashboard data comes from Redis, period.**
|
||||
|
||||
#### What Goes Where
|
||||
|
||||
| Location | Allowed | Not Allowed |
|
||||
|----------|---------|-------------|
|
||||
| Controller | `Redis::get()`, simple lookups by ID | `->sum()`, `->count()`, `->avg()`, loops with queries |
|
||||
| Background Job | All aggregations, joins, complex queries | N/A |
|
||||
|
||||
#### ❌ BANNED Patterns in Controllers:
|
||||
|
||||
```php
|
||||
// BANNED: Aggregation in controller
|
||||
$revenue = Order::sum('total');
|
||||
|
||||
// BANNED: N+1 in loop
|
||||
$items->map(fn($i) => Order::where('product_id', $i->id)->sum('qty'));
|
||||
|
||||
// BANNED: Query per day/iteration
|
||||
for ($i = 0; $i < 30; $i++) {
|
||||
$data[] = Order::whereDate('created_at', $date)->sum('total');
|
||||
}
|
||||
|
||||
// BANNED: Selecting columns that don't exist
|
||||
->select('id', 'stage_1_metadata') // Column doesn't exist!
|
||||
```
|
||||
|
||||
#### ✅ REQUIRED Pattern:
|
||||
|
||||
```php
|
||||
// Controller: Just read Redis
|
||||
public function analytics(Business $business)
|
||||
{
|
||||
$data = Redis::get("dashboard:{$business->id}:analytics");
|
||||
|
||||
if (!$data) {
|
||||
CalculateDashboardMetrics::dispatch($business->id);
|
||||
return view('dashboard.analytics', ['data' => $this->emptyState()]);
|
||||
}
|
||||
|
||||
return view('dashboard.analytics', ['data' => json_decode($data, true)]);
|
||||
}
|
||||
|
||||
// Background Job: Do all the heavy lifting
|
||||
public function handle()
|
||||
{
|
||||
// Batch query - ONE query for all products
|
||||
$salesByProduct = OrderItem::whereIn('product_id', $productIds)
|
||||
->groupBy('product_id')
|
||||
->selectRaw('product_id, SUM(quantity) as total')
|
||||
->pluck('total', 'product_id');
|
||||
|
||||
Redis::setex("dashboard:{$businessId}:analytics", 900, json_encode($data));
|
||||
}
|
||||
```
|
||||
|
||||
#### Before Merging Dashboard PRs:
|
||||
|
||||
1. Search for `->sum(`, `->count(`, `->avg(` in the controller
|
||||
2. Search for `->map(function` with queries inside
|
||||
3. If found → Move to background job
|
||||
4. Query count must be < 20 for any dashboard page
|
||||
|
||||
#### The Architecture
|
||||
|
||||
```
|
||||
BACKGROUND (every 10 min) HTTP REQUEST
|
||||
======================== =============
|
||||
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ CalculateMetricsJob │ │ DashboardController │
|
||||
│ │ │ │
|
||||
│ - Heavy queries │ │ - Redis::get() only │
|
||||
│ - Joins │──► Redis ──►│ - No aggregations │
|
||||
│ - Aggregations │ │ - No loops+queries │
|
||||
│ - Loops are OK here │ │ │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
Takes 5-30 sec Takes 10ms
|
||||
Runs in background User waits for this
|
||||
```
|
||||
|
||||
#### Prevention Checklist for Future Dashboard Work
|
||||
|
||||
- [ ] All `->sum()`, `->count()`, `->avg()` are in background jobs, not controllers
|
||||
- [ ] No `->map(function` with queries inside in controllers
|
||||
- [ ] Redis keys exist after job runs (`redis-cli KEYS "dashboard:*"`)
|
||||
- [ ] Job completes without errors (check `storage/logs/worker.log`)
|
||||
- [ ] Controller only does `Redis::get()` for metrics
|
||||
- [ ] Column names in `->select()` match actual database schema
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack by Area
|
||||
@@ -307,6 +402,48 @@ Product::where('is_active', true)->get(); // No business_id filter!
|
||||
|
||||
---
|
||||
|
||||
## Performance Requirements
|
||||
|
||||
**Database Queries:**
|
||||
- NEVER write N+1 queries - always use eager loading (`with()`) for relationships
|
||||
- NEVER run queries inside loops - batch them before the loop
|
||||
- Avoid multiple queries when one JOIN or subquery works
|
||||
- Dashboard/index pages should use MAX 5-10 queries total, not 50+
|
||||
- Use `DB::enableQueryLog()` mentally - if a page would log 20+ queries, refactor
|
||||
- Cache expensive aggregations (Redis, 5-min TTL) instead of recalculating every request
|
||||
- Test with `DB::listen()` or Laravel Debugbar before committing controller code
|
||||
|
||||
**Before submitting controller code, verify:**
|
||||
1. No queries inside foreach/map loops
|
||||
2. All relationships eager loaded
|
||||
3. Aggregations done in SQL, not PHP collections
|
||||
4. Would this cause a 503 under load? If unsure, simplify.
|
||||
|
||||
**Examples:**
|
||||
```php
|
||||
// ❌ N+1 query - DON'T DO THIS
|
||||
$orders = Order::all();
|
||||
foreach ($orders as $order) {
|
||||
echo $order->customer->name; // Query per iteration!
|
||||
}
|
||||
|
||||
// ✅ Eager loaded - DO THIS
|
||||
$orders = Order::with('customer')->get();
|
||||
|
||||
// ❌ Query in loop - DON'T DO THIS
|
||||
foreach ($products as $product) {
|
||||
$stock = Inventory::where('product_id', $product->id)->sum('quantity');
|
||||
}
|
||||
|
||||
// ✅ Batch query - DO THIS
|
||||
$stocks = Inventory::whereIn('product_id', $products->pluck('id'))
|
||||
->groupBy('product_id')
|
||||
->selectRaw('product_id, SUM(quantity) as total')
|
||||
->pluck('total', 'product_id');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What You Often Forget
|
||||
|
||||
✅ Scope by business_id BEFORE finding by ID
|
||||
@@ -315,3 +452,5 @@ Product::where('is_active', true)->get(); // No business_id filter!
|
||||
✅ DaisyUI for buyer/seller, Filament only for admin
|
||||
✅ NO inline styles - use Tailwind/DaisyUI classes only
|
||||
✅ Run tests before committing
|
||||
✅ Eager load relationships to prevent N+1 queries
|
||||
✅ No queries inside loops - batch before the loop
|
||||
|
||||
61
app/Console/Commands/CalculateDashboardMetricsCommand.php
Normal file
61
app/Console/Commands/CalculateDashboardMetricsCommand.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CalculateDashboardMetrics;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CalculateDashboardMetricsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'dashboard:calculate-metrics
|
||||
{--business= : Specific business ID to calculate (optional)}
|
||||
{--sync : Run synchronously instead of queuing}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Pre-calculate dashboard metrics and store in Redis';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$businessId = $this->option('business');
|
||||
$sync = $this->option('sync');
|
||||
|
||||
if ($businessId) {
|
||||
$business = Business::find($businessId);
|
||||
if (! $business) {
|
||||
$this->error("Business {$businessId} not found");
|
||||
|
||||
return 1;
|
||||
}
|
||||
$this->info("Calculating metrics for business: {$business->name}");
|
||||
} else {
|
||||
$count = Business::where('type', 'seller')->where('status', 'approved')->count();
|
||||
$this->info("Calculating metrics for {$count} businesses");
|
||||
}
|
||||
|
||||
$job = new CalculateDashboardMetrics($businessId ? (int) $businessId : null);
|
||||
|
||||
if ($sync) {
|
||||
$this->info('Running synchronously...');
|
||||
$job->handle();
|
||||
$this->info('Done!');
|
||||
} else {
|
||||
CalculateDashboardMetrics::dispatch($businessId ? (int) $businessId : null);
|
||||
$this->info('Job dispatched to queue');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
42
app/Console/Commands/DispatchScheduledCampaigns.php
Normal file
42
app/Console/Commands/DispatchScheduledCampaigns.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\SendMarketingCampaignJob;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* DispatchScheduledCampaigns - Dispatch scheduled marketing campaigns.
|
||||
*
|
||||
* Run via scheduler: Schedule::command('marketing:dispatch-scheduled-campaigns')->everyMinute();
|
||||
*/
|
||||
class DispatchScheduledCampaigns extends Command
|
||||
{
|
||||
protected $signature = 'marketing:dispatch-scheduled-campaigns';
|
||||
|
||||
protected $description = 'Dispatch scheduled marketing campaigns that are ready to send';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$campaigns = MarketingCampaign::readyToSend()->get();
|
||||
|
||||
if ($campaigns->isEmpty()) {
|
||||
$this->info('No scheduled campaigns ready to send.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$campaigns->count()} campaign(s) ready to send.");
|
||||
|
||||
foreach ($campaigns as $campaign) {
|
||||
$this->info("Dispatching campaign: {$campaign->name} (ID: {$campaign->id})");
|
||||
|
||||
SendMarketingCampaignJob::dispatch($campaign->id);
|
||||
}
|
||||
|
||||
$this->info('Done.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
108
app/Console/Commands/RunDueMarketingAutomations.php
Normal file
108
app/Console/Commands/RunDueMarketingAutomations.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\RunMarketingAutomationJob;
|
||||
use App\Models\Marketing\MarketingAutomation;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RunDueMarketingAutomations extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'marketing:run-due-automations
|
||||
{--business= : Only process automations for a specific business ID}
|
||||
{--dry-run : Show which automations would run without executing them}
|
||||
{--sync : Run synchronously instead of dispatching to queue}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Check and run all due marketing automations';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$businessId = $this->option('business');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$sync = $this->option('sync');
|
||||
|
||||
$this->info('Checking for due marketing automations...');
|
||||
|
||||
// Query active automations
|
||||
$query = MarketingAutomation::where('is_active', true)
|
||||
->whereIn('trigger_type', [
|
||||
MarketingAutomation::TRIGGER_SCHEDULED_CANNAIQ_CHECK,
|
||||
MarketingAutomation::TRIGGER_SCHEDULED_STORE_CHECK,
|
||||
]);
|
||||
|
||||
if ($businessId) {
|
||||
$query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
$automations = $query->get();
|
||||
|
||||
if ($automations->isEmpty()) {
|
||||
$this->info('No active automations found.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$automations->count()} active automation(s).");
|
||||
|
||||
$dueCount = 0;
|
||||
|
||||
foreach ($automations as $automation) {
|
||||
if (! $automation->isDue()) {
|
||||
$this->line(" - <comment>{$automation->name}</comment>: Not due yet");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$dueCount++;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" - <info>{$automation->name}</info>: Would run (dry-run mode)");
|
||||
$this->line(" Trigger: {$automation->trigger_type_label}");
|
||||
$this->line(" Frequency: {$automation->frequency_label}");
|
||||
$this->line(' Last run: '.($automation->last_run_at?->diffForHumans() ?? 'Never'));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(" - <info>{$automation->name}</info>: Dispatching...");
|
||||
|
||||
if ($sync) {
|
||||
// Run synchronously
|
||||
try {
|
||||
$job = new RunMarketingAutomationJob($automation->id);
|
||||
$job->handle(app(\App\Services\Marketing\AutomationRunner::class));
|
||||
$this->line(' <info>Completed</info>');
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" Failed: {$e->getMessage()}");
|
||||
}
|
||||
} else {
|
||||
// Dispatch to queue
|
||||
RunMarketingAutomationJob::dispatch($automation->id);
|
||||
$this->line(' <info>Dispatched to queue</info>');
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->newLine();
|
||||
$this->info("Dry run complete. {$dueCount} automation(s) would have been executed.");
|
||||
} else {
|
||||
$this->newLine();
|
||||
$this->info("Done. {$dueCount} automation(s) ".($sync ? 'executed' : 'dispatched').'.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
175
app/Console/Commands/RunFixedAssetDepreciation.php
Normal file
175
app/Console/Commands/RunFixedAssetDepreciation.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\FixedAssetService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Run monthly depreciation for fixed assets.
|
||||
*
|
||||
* This command calculates and posts depreciation entries for all
|
||||
* eligible fixed assets. Can be run for a specific business or all
|
||||
* businesses with Management Suite enabled.
|
||||
*
|
||||
* Safe to run multiple times in the same month - assets that have
|
||||
* already been depreciated for the period will be skipped.
|
||||
*/
|
||||
class RunFixedAssetDepreciation extends Command
|
||||
{
|
||||
protected $signature = 'fixed-assets:run-depreciation
|
||||
{business_id? : Specific business ID to run for}
|
||||
{--period= : Period date (Y-m-d format, defaults to end of current month)}
|
||||
{--dry-run : Show what would be depreciated without making changes}';
|
||||
|
||||
protected $description = 'Run monthly depreciation for fixed assets';
|
||||
|
||||
public function __construct(
|
||||
protected FixedAssetService $assetService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$businessId = $this->argument('business_id');
|
||||
$periodOption = $this->option('period');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
// Parse period date
|
||||
$periodDate = $periodOption
|
||||
? Carbon::parse($periodOption)->endOfMonth()
|
||||
: Carbon::now()->endOfMonth();
|
||||
|
||||
$this->info("Running depreciation for period: {$periodDate->format('Y-m')}");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No changes will be made');
|
||||
}
|
||||
|
||||
// Get businesses to process
|
||||
$businesses = $this->getBusinesses($businessId);
|
||||
|
||||
if ($businesses->isEmpty()) {
|
||||
$this->warn('No businesses found to process.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$totalRuns = 0;
|
||||
$totalAmount = 0;
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$this->line('');
|
||||
$this->info("Processing: {$business->name}");
|
||||
|
||||
if ($dryRun) {
|
||||
$results = $this->previewDepreciation($business, $periodDate);
|
||||
} else {
|
||||
$results = $this->assetService->runBatchDepreciation($business, $periodDate);
|
||||
}
|
||||
|
||||
$count = $results->count();
|
||||
$amount = $results->sum('depreciation_amount');
|
||||
|
||||
if ($count > 0) {
|
||||
$this->line(" - Depreciated {$count} assets");
|
||||
$this->line(" - Total amount: \${$amount}");
|
||||
$totalRuns += $count;
|
||||
$totalAmount += $amount;
|
||||
} else {
|
||||
$this->line(' - No assets to depreciate');
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->info('=== Summary ===');
|
||||
$this->info("Total assets depreciated: {$totalRuns}");
|
||||
$this->info("Total depreciation amount: \${$totalAmount}");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('This was a dry run. Run without --dry-run to apply changes.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get businesses to process.
|
||||
*/
|
||||
protected function getBusinesses(?string $businessId): \Illuminate\Support\Collection
|
||||
{
|
||||
if ($businessId) {
|
||||
$business = Business::find($businessId);
|
||||
|
||||
if (! $business) {
|
||||
$this->error("Business with ID {$businessId} not found.");
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
if (! $business->hasManagementSuite()) {
|
||||
$this->warn("Business {$business->name} does not have Management Suite enabled.");
|
||||
}
|
||||
|
||||
return collect([$business]);
|
||||
}
|
||||
|
||||
// Get all businesses with Management Suite
|
||||
return Business::whereHas('suites', function ($query) {
|
||||
$query->where('key', 'management');
|
||||
})->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview depreciation without making changes.
|
||||
*/
|
||||
protected function previewDepreciation(Business $business, Carbon $periodDate): \Illuminate\Support\Collection
|
||||
{
|
||||
$period = $periodDate->format('Y-m');
|
||||
|
||||
$assets = \App\Models\Accounting\FixedAsset::where('business_id', $business->id)
|
||||
->where('status', \App\Models\Accounting\FixedAsset::STATUS_ACTIVE)
|
||||
->where('category', '!=', \App\Models\Accounting\FixedAsset::CATEGORY_LAND)
|
||||
->get();
|
||||
|
||||
$results = collect();
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
// Skip if already depreciated for this period
|
||||
$existing = \App\Models\Accounting\FixedAssetDepreciationRun::where('fixed_asset_id', $asset->id)
|
||||
->where('period', $period)
|
||||
->where('is_reversed', false)
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if fully depreciated
|
||||
if ($asset->book_value <= $asset->salvage_value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$depreciationAmount = $asset->monthly_depreciation;
|
||||
$maxDepreciation = $asset->book_value - $asset->salvage_value;
|
||||
$depreciationAmount = min($depreciationAmount, $maxDepreciation);
|
||||
|
||||
if ($depreciationAmount > 0) {
|
||||
$results->push((object) [
|
||||
'fixed_asset_id' => $asset->id,
|
||||
'asset_name' => $asset->name,
|
||||
'depreciation_amount' => $depreciationAmount,
|
||||
]);
|
||||
|
||||
$this->line(" - {$asset->name}: \${$depreciationAmount}");
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
103
app/Console/Commands/RunRecurringSchedules.php
Normal file
103
app/Console/Commands/RunRecurringSchedules.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Accounting\RecurringSchedulerService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RunRecurringSchedules extends Command
|
||||
{
|
||||
protected $signature = 'recurring:run
|
||||
{--date= : The date to run schedules for (YYYY-MM-DD, default: today)}
|
||||
{--business= : Specific business ID to run schedules for}
|
||||
{--dry-run : Preview what would be generated without actually creating transactions}';
|
||||
|
||||
protected $description = 'Run due recurring schedules to generate AR invoices, AP bills, and journal entries';
|
||||
|
||||
public function __construct(
|
||||
protected RecurringSchedulerService $schedulerService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dateString = $this->option('date');
|
||||
$businessId = $this->option('business') ? (int) $this->option('business') : null;
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$date = $dateString ? Carbon::parse($dateString) : now();
|
||||
|
||||
$this->info("Running recurring schedules for {$date->toDateString()}...");
|
||||
|
||||
if ($businessId) {
|
||||
$this->info("Filtering to business ID: {$businessId}");
|
||||
}
|
||||
|
||||
// Get due schedules
|
||||
$dueSchedules = $this->schedulerService->getDueSchedules($date, $businessId);
|
||||
|
||||
if ($dueSchedules->isEmpty()) {
|
||||
$this->info('No schedules are due for execution.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$dueSchedules->count()} schedule(s) due for execution.");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No transactions will be created.');
|
||||
$this->table(
|
||||
['ID', 'Name', 'Type', 'Business', 'Next Run Date', 'Auto Post'],
|
||||
$dueSchedules->map(fn ($s) => [
|
||||
$s->id,
|
||||
$s->name,
|
||||
$s->type_label,
|
||||
$s->business->name ?? 'N/A',
|
||||
$s->next_run_date->toDateString(),
|
||||
$s->auto_post ? 'Yes' : 'No',
|
||||
])
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Run all due schedules
|
||||
$results = $this->schedulerService->runAllDue($date, $businessId);
|
||||
|
||||
// Output results
|
||||
$this->newLine();
|
||||
$this->info('Execution Summary:');
|
||||
$this->line(" Processed: {$results['processed']}");
|
||||
$this->line(" Successful: {$results['success']}");
|
||||
$this->line(" Failed: {$results['failed']}");
|
||||
|
||||
if (! empty($results['generated'])) {
|
||||
$this->newLine();
|
||||
$this->info('Generated Transactions:');
|
||||
$this->table(
|
||||
['Schedule', 'Type', 'Result ID'],
|
||||
collect($results['generated'])->map(fn ($g) => [
|
||||
$g['schedule_name'],
|
||||
$g['type'],
|
||||
$g['result_id'],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if (! empty($results['errors'])) {
|
||||
$this->newLine();
|
||||
$this->error('Errors:');
|
||||
foreach ($results['errors'] as $error) {
|
||||
$this->line(" [{$error['schedule_id']}] {$error['schedule_name']}: {$error['error']}");
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
43
app/Console/Commands/SafeFreshCommand.php
Normal file
43
app/Console/Commands/SafeFreshCommand.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Database\Console\Migrations\FreshCommand;
|
||||
|
||||
/**
|
||||
* Override migrate:fresh to prevent accidental data loss.
|
||||
*
|
||||
* This command blocks migrate:fresh in all environments except when
|
||||
* explicitly targeting a test database (DB_DATABASE=testing or *_test_*).
|
||||
*/
|
||||
class SafeFreshCommand extends FreshCommand
|
||||
{
|
||||
public function handle()
|
||||
{
|
||||
// Check both config and direct env (env var may not be in config yet)
|
||||
$database = env('DB_DATABASE', config('database.connections.pgsql.database'));
|
||||
|
||||
// Allow migrate:fresh ONLY for test databases
|
||||
$isTestDatabase = $database === 'testing'
|
||||
|| str_contains($database, '_test_')
|
||||
|| str_contains($database, 'testing_');
|
||||
|
||||
if (! $isTestDatabase) {
|
||||
$this->components->error('migrate:fresh is BLOCKED to prevent data loss!');
|
||||
$this->components->warn("Database: {$database}");
|
||||
$this->newLine();
|
||||
$this->components->bulletList([
|
||||
'This command drops ALL tables and destroys ALL data.',
|
||||
'It is blocked in local, dev, staging, and production.',
|
||||
'For testing: DB_DATABASE=testing ./vendor/bin/sail artisan migrate:fresh',
|
||||
'To seed existing data: php artisan db:seed --class=ProductionSyncSeeder',
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->components->info("Running migrate:fresh on TEST database: {$database}");
|
||||
|
||||
return parent::handle();
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,17 @@ class Kernel extends ConsoleKernel
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// DASHBOARD METRICS PRE-CALCULATION
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Pre-calculate dashboard metrics every 10 minutes
|
||||
// Stores aggregations in Redis for instant page loads
|
||||
$schedule->job(new \App\Jobs\CalculateDashboardMetrics)
|
||||
->everyTenMinutes()
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// HOUSEKEEPING & MAINTENANCE
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
208
app/Filament/Pages/SiteBranding.php
Normal file
208
app/Filament/Pages/SiteBranding.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\SiteSetting;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
|
||||
class SiteBranding extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-paint-brush';
|
||||
|
||||
protected static ?string $navigationLabel = 'Site Branding';
|
||||
|
||||
protected static ?string $title = 'Site Branding';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Platform Settings';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected string $view = 'filament.pages.site-branding';
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'site_name' => SiteSetting::get('site_name', 'Cannabrands Hub'),
|
||||
'favicon' => SiteSetting::get('favicon_path') ? [SiteSetting::get('favicon_path')] : [],
|
||||
'logo_light' => SiteSetting::get('logo_light_path') ? [SiteSetting::get('logo_light_path')] : [],
|
||||
'logo_dark' => SiteSetting::get('logo_dark_path') ? [SiteSetting::get('logo_dark_path')] : [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Site Identity')
|
||||
->description('Configure the site name and branding assets.')
|
||||
->schema([
|
||||
TextInput::make('site_name')
|
||||
->label('Site Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Displayed in browser tabs and emails.'),
|
||||
]),
|
||||
|
||||
Section::make('Favicon')
|
||||
->description('The small icon displayed in browser tabs. Recommended: 32x32 or 64x64 PNG/ICO.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('current_favicon')
|
||||
->label('Current')
|
||||
->content(function () {
|
||||
$path = SiteSetting::get('favicon_path');
|
||||
if (! $path) {
|
||||
return new HtmlString(
|
||||
'<div class="flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">'.
|
||||
'<span class="text-gray-400 text-xs">Not set</span>'.
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return new HtmlString(
|
||||
'<div class="inline-flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-lg">'.
|
||||
'<img src="'.SiteSetting::getFaviconUrl().'" alt="Favicon" class="w-8 h-8">'.
|
||||
'</div>'
|
||||
);
|
||||
}),
|
||||
FileUpload::make('favicon')
|
||||
->label('Upload New')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('branding')
|
||||
->visibility('public')
|
||||
->acceptedFileTypes(['image/png', 'image/x-icon', 'image/ico', 'image/vnd.microsoft.icon'])
|
||||
->maxSize(512)
|
||||
->imagePreviewHeight('64')
|
||||
->helperText('Upload a PNG or ICO file (max 512KB).'),
|
||||
]),
|
||||
|
||||
Section::make('Logos')
|
||||
->description('Upload logo variants for different backgrounds.')
|
||||
->schema([
|
||||
Section::make('Logo (Light/White)')
|
||||
->description('For dark backgrounds (sidebar, etc.)')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('current_logo_light')
|
||||
->label('Current')
|
||||
->content(function () {
|
||||
$path = SiteSetting::get('logo_light_path');
|
||||
if (! $path) {
|
||||
return new HtmlString(
|
||||
'<div class="flex items-center justify-center h-16 w-40 bg-gray-800 rounded-lg border-2 border-dashed border-gray-600">'.
|
||||
'<span class="text-gray-400 text-xs">Not set</span>'.
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return new HtmlString(
|
||||
'<div class="inline-flex items-center justify-center h-16 px-4 bg-gray-800 rounded-lg">'.
|
||||
'<img src="'.SiteSetting::getLogoLightUrl().'" alt="Logo Light" class="h-8 max-w-[150px] object-contain">'.
|
||||
'</div>'
|
||||
);
|
||||
}),
|
||||
FileUpload::make('logo_light')
|
||||
->label('Upload New')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('branding')
|
||||
->visibility('public')
|
||||
->maxSize(2048)
|
||||
->imagePreviewHeight('100'),
|
||||
]),
|
||||
|
||||
Section::make('Logo (Dark)')
|
||||
->description('For light backgrounds.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('current_logo_dark')
|
||||
->label('Current')
|
||||
->content(function () {
|
||||
$path = SiteSetting::get('logo_dark_path');
|
||||
if (! $path) {
|
||||
return new HtmlString(
|
||||
'<div class="flex items-center justify-center h-16 w-40 bg-gray-100 rounded-lg border-2 border-dashed border-gray-300">'.
|
||||
'<span class="text-gray-400 text-xs">Not set</span>'.
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return new HtmlString(
|
||||
'<div class="inline-flex items-center justify-center h-16 px-4 bg-gray-100 rounded-lg">'.
|
||||
'<img src="'.SiteSetting::getLogoDarkUrl().'" alt="Logo Dark" class="h-8 max-w-[150px] object-contain">'.
|
||||
'</div>'
|
||||
);
|
||||
}),
|
||||
FileUpload::make('logo_dark')
|
||||
->label('Upload New')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('branding')
|
||||
->visibility('public')
|
||||
->maxSize(2048)
|
||||
->imagePreviewHeight('100'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
|
||||
// Save site name
|
||||
SiteSetting::set('site_name', $data['site_name']);
|
||||
|
||||
// Save file paths
|
||||
$this->saveFileSetting('favicon_path', $data['favicon'] ?? []);
|
||||
$this->saveFileSetting('logo_light_path', $data['logo_light'] ?? []);
|
||||
$this->saveFileSetting('logo_dark_path', $data['logo_dark'] ?? []);
|
||||
|
||||
// Clear cache
|
||||
SiteSetting::clearCache();
|
||||
|
||||
Notification::make()
|
||||
->title('Branding settings saved')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
protected function saveFileSetting(string $key, array $files): void
|
||||
{
|
||||
$path = ! empty($files) ? $files[0] : null;
|
||||
|
||||
// Handle TemporaryUploadedFile objects
|
||||
if ($path instanceof TemporaryUploadedFile) {
|
||||
$path = $path->store('branding', 'public');
|
||||
}
|
||||
|
||||
SiteSetting::set($key, $path);
|
||||
}
|
||||
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return [
|
||||
Forms\Components\Actions\Action::make('save')
|
||||
->label('Save Changes')
|
||||
->submit('save'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -701,6 +701,17 @@ class BusinessResource extends Resource
|
||||
}),
|
||||
]),
|
||||
|
||||
// ===== CANNAIQ SECTION =====
|
||||
// CannaiQ Marketing Intelligence Engine
|
||||
Section::make('CannaiQ')
|
||||
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
|
||||
->schema([
|
||||
Toggle::make('cannaiq_enabled')
|
||||
->label('Enable CannaiQ')
|
||||
->helperText('When enabled, this business gets access to Intelligence and Promos features under the Growth menu.')
|
||||
->default(false),
|
||||
]),
|
||||
|
||||
// ===== SUITE ASSIGNMENT SECTION =====
|
||||
// Suites control feature access (menus, screens, capabilities)
|
||||
Section::make('Suite Assignment')
|
||||
@@ -722,53 +733,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')
|
||||
|
||||
@@ -28,6 +28,14 @@ class EditBusiness extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('view_marketing_portal')
|
||||
->label('Marketing Portal')
|
||||
->icon('heroicon-o-megaphone')
|
||||
->color('info')
|
||||
->url(fn () => route('portal.dashboard', $this->record->slug))
|
||||
->openUrlInNewTab()
|
||||
->visible(fn () => $this->record->status === 'approved' && $this->record->business_type === 'buyer'),
|
||||
|
||||
Actions\Action::make('approve_application')
|
||||
->label('Approve Application')
|
||||
->icon('heroicon-o-check-circle')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,9 +73,9 @@ class OrderController extends Controller
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('order_number', 'like', "%{$search}%")
|
||||
$q->where('order_number', 'ILIKE', "%{$search}%")
|
||||
->orWhereHas('business', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%");
|
||||
$q->where('name', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
249
app/Http/Controllers/Portal/CampaignController.php
Normal file
249
app/Http/Controllers/Portal/CampaignController.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\SendMarketingCampaignJob;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class CampaignController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$campaigns = MarketingCampaign::where('business_id', $business->id)
|
||||
->with('list')
|
||||
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
||||
->when($request->channel, fn ($q, $channel) => $q->where('channel', $channel))
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
$statuses = [
|
||||
'draft' => 'Draft',
|
||||
'scheduled' => 'Scheduled',
|
||||
'sending' => 'Sending',
|
||||
'sent' => 'Sent',
|
||||
'completed' => 'Completed',
|
||||
'cancelled' => 'Cancelled',
|
||||
'failed' => 'Failed',
|
||||
];
|
||||
|
||||
$channels = MarketingCampaign::CHANNELS;
|
||||
|
||||
return view('portal.campaigns.index', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'campaigns',
|
||||
'statuses',
|
||||
'channels'
|
||||
));
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
// Get lists for this business
|
||||
$lists = MarketingList::where('business_id', $business->id)
|
||||
->withCount('contacts')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Pre-populate from promo if provided
|
||||
$promo = null;
|
||||
if ($request->query('promo_id')) {
|
||||
$promo = MarketingPromo::where('business_id', $business->id)
|
||||
->find($request->query('promo_id'));
|
||||
}
|
||||
|
||||
// Pre-select channel if provided
|
||||
$preselectedChannel = $request->query('channel', 'email');
|
||||
|
||||
$channels = MarketingCampaign::CHANNELS;
|
||||
|
||||
return view('portal.campaigns.create', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'lists',
|
||||
'promo',
|
||||
'preselectedChannel',
|
||||
'channels'
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'channel' => 'required|in:email,sms',
|
||||
'list_id' => 'required|exists:marketing_lists,id',
|
||||
'subject' => 'required_if:channel,email|nullable|string|max:255',
|
||||
'body' => 'required|string',
|
||||
'send_at' => 'nullable|date|after:now',
|
||||
'promo_id' => 'nullable|exists:marketing_promos,id',
|
||||
]);
|
||||
|
||||
// Verify list belongs to this business
|
||||
$list = MarketingList::where('business_id', $business->id)
|
||||
->findOrFail($validated['list_id']);
|
||||
|
||||
// Build campaign data
|
||||
$campaignData = [
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'channel' => $validated['channel'],
|
||||
'list_id' => $list->id,
|
||||
'subject' => $validated['subject'] ?? null,
|
||||
'body' => $validated['body'],
|
||||
'status' => 'draft',
|
||||
'created_by' => Auth::id(),
|
||||
// Use branding defaults for from fields
|
||||
'from_name' => $branding->effective_from_name,
|
||||
'from_email' => $branding->effective_from_email,
|
||||
];
|
||||
|
||||
// Link to promo if provided
|
||||
if (! empty($validated['promo_id'])) {
|
||||
$promo = MarketingPromo::where('business_id', $business->id)
|
||||
->find($validated['promo_id']);
|
||||
|
||||
if ($promo) {
|
||||
$campaignData['source_type'] = 'promo';
|
||||
$campaignData['source_id'] = $promo->id;
|
||||
}
|
||||
}
|
||||
|
||||
// Set schedule if provided
|
||||
if (! empty($validated['send_at'])) {
|
||||
$campaignData['send_at'] = $validated['send_at'];
|
||||
$campaignData['status'] = 'scheduled';
|
||||
}
|
||||
|
||||
$campaign = MarketingCampaign::create($campaignData);
|
||||
|
||||
if ($campaign->status === 'scheduled') {
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', 'Campaign scheduled successfully.');
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', 'Campaign created as draft. Review and send when ready.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
$campaign->load(['list', 'logs']);
|
||||
|
||||
// Get stats
|
||||
$stats = [
|
||||
'total_recipients' => $campaign->total_recipients,
|
||||
'sent' => $campaign->total_sent,
|
||||
'delivered' => $campaign->total_delivered,
|
||||
'opened' => $campaign->total_opened,
|
||||
'clicked' => $campaign->total_clicked,
|
||||
'failed' => $campaign->total_failed,
|
||||
];
|
||||
|
||||
return view('portal.campaigns.show', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'campaign',
|
||||
'stats'
|
||||
));
|
||||
}
|
||||
|
||||
public function sendNow(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
||||
return back()->with('error', 'This campaign cannot be sent.');
|
||||
}
|
||||
|
||||
// Count recipients
|
||||
$recipientCount = $campaign->list?->contacts()->count() ?? 0;
|
||||
|
||||
if ($recipientCount === 0) {
|
||||
return back()->with('error', 'No recipients in the selected list.');
|
||||
}
|
||||
|
||||
// Update campaign
|
||||
$campaign->update([
|
||||
'status' => 'sending',
|
||||
'total_recipients' => $recipientCount,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
// Dispatch job
|
||||
SendMarketingCampaignJob::dispatch($campaign);
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', "Campaign is now sending to {$recipientCount} recipients.");
|
||||
}
|
||||
|
||||
public function schedule(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($campaign->status !== 'draft') {
|
||||
return back()->with('error', 'Only draft campaigns can be scheduled.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'send_at' => 'required|date|after:now',
|
||||
]);
|
||||
|
||||
$campaign->update([
|
||||
'status' => 'scheduled',
|
||||
'send_at' => $validated['send_at'],
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', 'Campaign scheduled for '.$campaign->send_at->format('M j, Y g:i A'));
|
||||
}
|
||||
|
||||
public function cancel(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
||||
return back()->with('error', 'This campaign cannot be cancelled.');
|
||||
}
|
||||
|
||||
$campaign->update([
|
||||
'status' => 'cancelled',
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.index', $business->slug)
|
||||
->with('success', 'Campaign cancelled.');
|
||||
}
|
||||
}
|
||||
80
app/Http/Controllers/Portal/DashboardController.php
Normal file
80
app/Http/Controllers/Portal/DashboardController.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Services\Marketing\PromoRecommendationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PromoRecommendationService $promoService
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
// Get recommended promos for this business
|
||||
$recommendedPromos = collect();
|
||||
try {
|
||||
// Get store external IDs for this business if available
|
||||
$storeExternalIds = $business->cannaiqStores()
|
||||
->pluck('external_id')
|
||||
->toArray();
|
||||
|
||||
if (! empty($storeExternalIds)) {
|
||||
$recommendations = $this->promoService->getRecommendations(
|
||||
$business,
|
||||
$storeExternalIds[0] ?? null,
|
||||
limit: 5
|
||||
);
|
||||
$recommendedPromos = collect($recommendations['recommendations'] ?? []);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// CannaiQ not configured or error - that's fine, show empty
|
||||
}
|
||||
|
||||
// Get recent campaigns for this business
|
||||
$recentCampaigns = MarketingCampaign::where('business_id', $business->id)
|
||||
->with('list')
|
||||
->latest()
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Get active promos
|
||||
$activePromos = MarketingPromo::forBusiness($business->id)
|
||||
->currentlyActive()
|
||||
->with('brand')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Get campaign stats
|
||||
$campaignStats = [
|
||||
'total' => MarketingCampaign::where('business_id', $business->id)->count(),
|
||||
'sent' => MarketingCampaign::where('business_id', $business->id)
|
||||
->whereIn('status', ['sent', 'completed'])
|
||||
->count(),
|
||||
'draft' => MarketingCampaign::where('business_id', $business->id)
|
||||
->where('status', 'draft')
|
||||
->count(),
|
||||
'scheduled' => MarketingCampaign::where('business_id', $business->id)
|
||||
->where('status', 'scheduled')
|
||||
->count(),
|
||||
];
|
||||
|
||||
return view('portal.dashboard', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'recommendedPromos',
|
||||
'recentCampaigns',
|
||||
'activePromos',
|
||||
'campaignStats'
|
||||
));
|
||||
}
|
||||
}
|
||||
83
app/Http/Controllers/Portal/ListController.php
Normal file
83
app/Http/Controllers/Portal/ListController.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ListController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$lists = MarketingList::where('business_id', $business->id)
|
||||
->withCount('contacts')
|
||||
->orderBy('name')
|
||||
->paginate(15);
|
||||
|
||||
return view('portal.lists.index', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'lists'
|
||||
));
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
$types = MarketingList::getTypes();
|
||||
|
||||
return view('portal.lists.create', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'types'
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|in:static,smart',
|
||||
]);
|
||||
|
||||
$list = MarketingList::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'],
|
||||
'type' => $validated['type'],
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('portal.lists.show', [$business->slug, $list])
|
||||
->with('success', 'List created successfully.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, MarketingList $list)
|
||||
{
|
||||
// Ensure list belongs to this business
|
||||
if ($list->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$contacts = $list->contacts()
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(25);
|
||||
|
||||
return view('portal.lists.show', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'list',
|
||||
'contacts'
|
||||
));
|
||||
}
|
||||
}
|
||||
75
app/Http/Controllers/Portal/PromoController.php
Normal file
75
app/Http/Controllers/Portal/PromoController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Services\Marketing\PromoRecommendationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PromoController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PromoRecommendationService $promoService
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
// Get recommended promos from CannaiQ
|
||||
$recommendedPromos = collect();
|
||||
try {
|
||||
$storeExternalIds = $business->cannaiqStores()
|
||||
->pluck('external_id')
|
||||
->toArray();
|
||||
|
||||
if (! empty($storeExternalIds)) {
|
||||
$recommendations = $this->promoService->getRecommendations(
|
||||
$business,
|
||||
$storeExternalIds[0] ?? null,
|
||||
limit: 20
|
||||
);
|
||||
$recommendedPromos = collect($recommendations['recommendations'] ?? []);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// CannaiQ not available
|
||||
}
|
||||
|
||||
// Get existing promos for this business
|
||||
$existingPromos = MarketingPromo::forBusiness($business->id)
|
||||
->with('brand')
|
||||
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
||||
->latest()
|
||||
->paginate(12);
|
||||
|
||||
$statuses = MarketingPromo::getStatuses();
|
||||
|
||||
return view('portal.promos.index', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'recommendedPromos',
|
||||
'existingPromos',
|
||||
'statuses'
|
||||
));
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, MarketingPromo $promo)
|
||||
{
|
||||
// Ensure promo belongs to this business
|
||||
if ($promo->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
$promo->load('brand');
|
||||
|
||||
return view('portal.promos.show', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'promo'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,17 +102,28 @@ class BatchController extends Controller
|
||||
$maxValue = ($request->cannabinoid_unit ?? '%') === '%' ? 100 : 1000;
|
||||
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
// Accept either product_id or component_id (form sends component_id)
|
||||
'product_id' => 'required_without:component_id|exists:products,id',
|
||||
'component_id' => 'required_without:product_id|exists:products,id',
|
||||
'batch_type' => 'nullable|string|in:component,homogenized',
|
||||
'cannabinoid_unit' => 'nullable|string|in:%,MG/ML,MG/G,MG/UNIT',
|
||||
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number',
|
||||
'quantity_produced' => 'nullable|integer|min:0',
|
||||
'batch_number' => 'required|string|max:100|unique:batches,batch_number',
|
||||
'internal_code' => 'nullable|string|max:100',
|
||||
// Accept either quantity_produced or quantity_total (form sends quantity_total)
|
||||
'quantity_produced' => 'nullable|numeric|min:0',
|
||||
'quantity_total' => 'nullable|numeric|min:0',
|
||||
'quantity_remaining' => 'nullable|numeric|min:0',
|
||||
'quantity_unit' => 'nullable|string|max:50',
|
||||
'quantity_allocated' => 'nullable|integer|min:0',
|
||||
'expiration_date' => 'nullable|date',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'is_active' => 'nullable',
|
||||
'production_date' => 'nullable|date',
|
||||
'harvest_date' => 'nullable|date',
|
||||
'package_date' => 'nullable|date',
|
||||
'test_date' => 'nullable|date',
|
||||
'test_id' => 'nullable|string|max:100',
|
||||
'lot_number' => 'nullable|string|max:100',
|
||||
'license_number' => 'nullable|string|max:255',
|
||||
'lab_name' => 'nullable|string|max:255',
|
||||
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
@@ -126,10 +137,18 @@ class BatchController extends Controller
|
||||
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
|
||||
]);
|
||||
|
||||
// Map component_id to product_id if provided
|
||||
$productId = $validated['product_id'] ?? $validated['component_id'];
|
||||
|
||||
// Verify product belongs to this business
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($validated['product_id']);
|
||||
})->findOrFail($productId);
|
||||
|
||||
// Map form fields to model fields
|
||||
$validated['product_id'] = $productId;
|
||||
$validated['quantity_produced'] = $validated['quantity_total'] ?? $validated['quantity_produced'] ?? 0;
|
||||
$validated['quantity_available'] = $validated['quantity_remaining'] ?? $validated['quantity_produced'];
|
||||
|
||||
// Set business_id and defaults
|
||||
$validated['business_id'] = $business->id;
|
||||
|
||||
@@ -9,12 +9,14 @@ use App\Http\Requests\UpdateBrandRequest;
|
||||
use App\Models\Brand;
|
||||
use App\Models\BrandOrchestratorProfile;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmChannel;
|
||||
use App\Models\Menu;
|
||||
use App\Models\OrchestratorTask;
|
||||
use App\Models\PromoRecommendation;
|
||||
use App\Models\Promotion;
|
||||
use App\Services\Promo\InBrandPromoHelper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -42,7 +44,29 @@ class BrandController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.brands.index', compact('business', 'brands'));
|
||||
// Pre-compute expensive operations for Alpine.js (prevents N+1 route() calls in Blade)
|
||||
$brandsJson = $brands->filter(fn ($brand) => $brand->hashid)->map(function ($brand) use ($business) {
|
||||
return [
|
||||
'id' => $brand->id,
|
||||
'hashid' => $brand->hashid,
|
||||
'name' => $brand->name,
|
||||
'tagline' => $brand->tagline,
|
||||
'logo_url' => $brand->hasLogo() ? $brand->getLogoUrl(160) : null,
|
||||
'is_active' => $brand->is_active,
|
||||
'is_public' => $brand->is_public,
|
||||
'is_featured' => $brand->is_featured,
|
||||
'products_count' => $brand->products_count ?? 0,
|
||||
'updated_at' => $brand->updated_at?->diffForHumans(),
|
||||
'website_url' => $brand->website_url,
|
||||
'preview_url' => route('seller.business.brands.preview', [$business->slug, $brand]),
|
||||
'dashboard_url' => route('seller.business.brands.dashboard', [$business->slug, $brand]),
|
||||
'stats_url' => route('seller.business.brands.stats', [$business->slug, $brand]),
|
||||
'edit_url' => route('seller.business.brands.edit', [$business->slug, $brand]),
|
||||
'isNewBrand' => $brand->created_at && $brand->created_at->diffInDays(now()) <= 30,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return view('seller.brands.index', compact('business', 'brands', 'brandsJson'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,112 +169,168 @@ class BrandController extends Controller
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// Load relationships
|
||||
// Determine active tab - only load data for that tab
|
||||
$activeTab = $request->input('tab', 'overview');
|
||||
|
||||
// Load minimal brand data with products for metrics display
|
||||
$brand->load(['business', 'products']);
|
||||
|
||||
// Get stats data for Analytics tab (default to this month)
|
||||
$preset = $request->input('preset', 'this_month');
|
||||
$startDate = null;
|
||||
$endDate = null;
|
||||
|
||||
switch ($preset) {
|
||||
case 'this_week':
|
||||
$startDate = now()->startOfWeek();
|
||||
$endDate = now()->endOfWeek();
|
||||
break;
|
||||
case 'last_week':
|
||||
$startDate = now()->subWeek()->startOfWeek();
|
||||
$endDate = now()->subWeek()->endOfWeek();
|
||||
break;
|
||||
case 'this_month':
|
||||
$startDate = now()->startOfMonth();
|
||||
$endDate = now()->endOfMonth();
|
||||
break;
|
||||
case 'last_month':
|
||||
$startDate = now()->subMonth()->startOfMonth();
|
||||
$endDate = now()->subMonth()->endOfMonth();
|
||||
break;
|
||||
case 'this_year':
|
||||
$startDate = now()->startOfYear();
|
||||
$endDate = now()->endOfYear();
|
||||
break;
|
||||
case 'custom':
|
||||
$startDate = $request->input('start_date') ? \Carbon\Carbon::parse($request->input('start_date'))->startOfDay() : now()->startOfMonth();
|
||||
$endDate = $request->input('end_date') ? \Carbon\Carbon::parse($request->input('end_date'))->endOfDay() : now();
|
||||
break;
|
||||
case 'all_time':
|
||||
default:
|
||||
// Query from earliest order for this brand, or default to brand creation date if no orders
|
||||
$earliestOrder = \App\Models\Order::whereHas('items.product', function ($query) use ($brand) {
|
||||
$query->where('brand_id', $brand->id);
|
||||
})->oldest('created_at')->first();
|
||||
|
||||
// If no orders, use the brand's creation date as the starting point
|
||||
$startDate = $earliestOrder
|
||||
? $earliestOrder->created_at->startOfDay()
|
||||
: ($brand->created_at ? $brand->created_at->startOfDay() : now()->subYears(3)->startOfDay());
|
||||
$endDate = now()->endOfDay();
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate stats for analytics tab
|
||||
$stats = $this->calculateBrandStats($brand, $startDate, $endDate);
|
||||
|
||||
// Load promotions filtered by brand
|
||||
$promotions = Promotion::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->withCount('products')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
// Load upcoming promotions (scheduled within next 7 days)
|
||||
$upcomingPromotions = Promotion::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->upcomingWithinDays(7)
|
||||
->withCount('products')
|
||||
->orderBy('starts_at', 'asc')
|
||||
->get();
|
||||
|
||||
// Load active promotions for quick display
|
||||
$activePromotions = Promotion::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->active()
|
||||
->withCount('products')
|
||||
->orderBy('ends_at', 'asc')
|
||||
->get();
|
||||
|
||||
// Load menus filtered by brand
|
||||
$menus = Menu::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->withCount('products')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
// Load promo recommendations for this brand
|
||||
$recommendations = PromoRecommendation::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->pending()
|
||||
->notExpired()
|
||||
->with(['product'])
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN priority = 'high' THEN 1
|
||||
WHEN priority = 'medium' THEN 2
|
||||
WHEN priority = 'low' THEN 3
|
||||
ELSE 4
|
||||
END
|
||||
")
|
||||
->orderByDesc('confidence')
|
||||
->get();
|
||||
|
||||
// Load all brands for the brand selector dropdown
|
||||
// Load all brands for the brand selector dropdown (lightweight, always needed)
|
||||
$brands = $business->brands()
|
||||
->where('is_active', true)
|
||||
->withCount('products')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Load products for this brand (newest first) with pagination
|
||||
// Get date range for stats (used by overview and analytics)
|
||||
$preset = $request->input('preset', 'this_month');
|
||||
[$startDate, $endDate] = $this->getDateRangeForPreset($preset, $request, $brand);
|
||||
|
||||
// Initialize empty data - will be populated based on active tab
|
||||
$viewData = [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
'brands' => $brands,
|
||||
'preset' => $preset,
|
||||
'startDate' => $startDate,
|
||||
'endDate' => $endDate,
|
||||
'activeTab' => $activeTab,
|
||||
// Empty defaults for all tab data
|
||||
'promotions' => collect(),
|
||||
'activePromotions' => collect(),
|
||||
'upcomingPromotions' => collect(),
|
||||
'recommendations' => collect(),
|
||||
'menus' => collect(),
|
||||
'products' => collect(),
|
||||
'productsPagination' => [],
|
||||
'productsPaginator' => null,
|
||||
'collections' => collect(),
|
||||
'brandInsights' => [],
|
||||
// Empty stats defaults
|
||||
'totalOrders' => 0,
|
||||
'totalRevenue' => 0,
|
||||
'totalUnits' => 0,
|
||||
'avgOrderValue' => 0,
|
||||
'totalProducts' => 0,
|
||||
'activeProducts' => 0,
|
||||
'revenueChange' => 0,
|
||||
'ordersChange' => 0,
|
||||
'revenueByDay' => collect(),
|
||||
'productStats' => collect(),
|
||||
'bestSellingSku' => null,
|
||||
'topBuyers' => collect(),
|
||||
];
|
||||
|
||||
// Load data based on active tab
|
||||
switch ($activeTab) {
|
||||
case 'overview':
|
||||
$viewData = array_merge($viewData, $this->loadOverviewTabData($brand, $business, $startDate, $endDate));
|
||||
break;
|
||||
case 'products':
|
||||
$viewData = array_merge($viewData, $this->loadProductsTabData($brand, $business, $request));
|
||||
break;
|
||||
case 'promotions':
|
||||
$viewData = array_merge($viewData, $this->loadPromotionsTabData($brand, $business));
|
||||
break;
|
||||
case 'menus':
|
||||
$viewData = array_merge($viewData, $this->loadMenusTabData($brand, $business));
|
||||
break;
|
||||
case 'analytics':
|
||||
$viewData = array_merge($viewData, $this->loadAnalyticsTabData($brand, $business, $startDate, $endDate, $preset));
|
||||
break;
|
||||
case 'settings':
|
||||
case 'storefront':
|
||||
case 'collections':
|
||||
// These tabs don't need additional data loading
|
||||
break;
|
||||
}
|
||||
|
||||
return view('seller.brands.dashboard', $viewData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date range based on preset selection.
|
||||
*/
|
||||
private function getDateRangeForPreset(string $preset, Request $request, Brand $brand): array
|
||||
{
|
||||
switch ($preset) {
|
||||
case 'this_week':
|
||||
return [now()->startOfWeek(), now()->endOfWeek()];
|
||||
case 'last_week':
|
||||
return [now()->subWeek()->startOfWeek(), now()->subWeek()->endOfWeek()];
|
||||
case 'this_month':
|
||||
return [now()->startOfMonth(), now()->endOfMonth()];
|
||||
case 'last_month':
|
||||
return [now()->subMonth()->startOfMonth(), now()->subMonth()->endOfMonth()];
|
||||
case 'this_year':
|
||||
return [now()->startOfYear(), now()->endOfYear()];
|
||||
case 'custom':
|
||||
$startDate = $request->input('start_date') ? \Carbon\Carbon::parse($request->input('start_date'))->startOfDay() : now()->startOfMonth();
|
||||
$endDate = $request->input('end_date') ? \Carbon\Carbon::parse($request->input('end_date'))->endOfDay() : now();
|
||||
|
||||
return [$startDate, $endDate];
|
||||
case 'all_time':
|
||||
default:
|
||||
$earliestOrder = \App\Models\Order::whereHas('items.product', function ($query) use ($brand) {
|
||||
$query->where('brand_id', $brand->id);
|
||||
})->oldest('created_at')->first();
|
||||
|
||||
$startDate = $earliestOrder
|
||||
? $earliestOrder->created_at->startOfDay()
|
||||
: ($brand->created_at ? $brand->created_at->startOfDay() : now()->subYears(3)->startOfDay());
|
||||
|
||||
return [$startDate, now()->endOfDay()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data for Overview tab (lightweight stats + insights).
|
||||
*/
|
||||
private function loadOverviewTabData(Brand $brand, Business $business, $startDate, $endDate): array
|
||||
{
|
||||
// Cache brand insights for 15 minutes
|
||||
$cacheKey = "brand:{$brand->id}:insights:{$startDate->format('Y-m-d')}:{$endDate->format('Y-m-d')}";
|
||||
$brandInsights = Cache::remember($cacheKey, 900, fn () => $this->calculateBrandInsights($brand, $business, $startDate, $endDate));
|
||||
|
||||
// Load active promotions for quick display (lightweight)
|
||||
$activePromotions = Promotion::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->active()
|
||||
->withCount('products')
|
||||
->orderBy('ends_at', 'asc')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Load recommendations (lightweight - limit to 5)
|
||||
$recommendations = PromoRecommendation::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->pending()
|
||||
->notExpired()
|
||||
->with(['product'])
|
||||
->orderByRaw("CASE WHEN priority = 'high' THEN 1 WHEN priority = 'medium' THEN 2 WHEN priority = 'low' THEN 3 ELSE 4 END")
|
||||
->orderByDesc('confidence')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Get basic counts (very fast single query)
|
||||
$productCounts = $brand->products()
|
||||
->selectRaw('COUNT(*) as total, SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'brandInsights' => $brandInsights,
|
||||
'activePromotions' => $activePromotions,
|
||||
'recommendations' => $recommendations,
|
||||
'totalProducts' => $productCounts->total ?? 0,
|
||||
'activeProducts' => $productCounts->active ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data for Products tab.
|
||||
*/
|
||||
private function loadProductsTabData(Brand $brand, Business $business, Request $request): array
|
||||
{
|
||||
$perPage = $request->get('per_page', 50);
|
||||
$productsPaginator = $brand->products()
|
||||
->with('images')
|
||||
@@ -259,7 +339,6 @@ class BrandController extends Controller
|
||||
|
||||
$products = $productsPaginator->getCollection()
|
||||
->map(function ($product) use ($business, $brand) {
|
||||
// Set brand relationship so getImageUrl() can fall back to brand logo
|
||||
$product->setRelation('brand', $brand);
|
||||
|
||||
return [
|
||||
@@ -277,33 +356,98 @@ 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,
|
||||
'brands' => $brands,
|
||||
'preset' => $preset,
|
||||
'startDate' => $startDate,
|
||||
'endDate' => $endDate,
|
||||
'promotions' => $promotions,
|
||||
'activePromotions' => $activePromotions,
|
||||
'upcomingPromotions' => $upcomingPromotions,
|
||||
'recommendations' => $recommendations,
|
||||
'menus' => $menus,
|
||||
return [
|
||||
'products' => $products,
|
||||
'productsPagination' => $productsPagination,
|
||||
'productsPagination' => [
|
||||
'current_page' => $productsPaginator->currentPage(),
|
||||
'last_page' => $productsPaginator->lastPage(),
|
||||
'per_page' => $productsPaginator->perPage(),
|
||||
'total' => $productsPaginator->total(),
|
||||
'from' => $productsPaginator->firstItem(),
|
||||
'to' => $productsPaginator->lastItem(),
|
||||
],
|
||||
'productsPaginator' => $productsPaginator,
|
||||
'collections' => collect(), // Placeholder for future collections feature
|
||||
]));
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data for Promotions tab.
|
||||
*/
|
||||
private function loadPromotionsTabData(Brand $brand, Business $business): array
|
||||
{
|
||||
$promotions = Promotion::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->withCount('products')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$upcomingPromotions = Promotion::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->upcomingWithinDays(7)
|
||||
->withCount('products')
|
||||
->orderBy('starts_at', 'asc')
|
||||
->get();
|
||||
|
||||
$activePromotions = Promotion::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->active()
|
||||
->withCount('products')
|
||||
->orderBy('ends_at', 'asc')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'promotions' => $promotions,
|
||||
'upcomingPromotions' => $upcomingPromotions,
|
||||
'activePromotions' => $activePromotions,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data for Menus tab.
|
||||
*/
|
||||
private function loadMenusTabData(Brand $brand, Business $business): array
|
||||
{
|
||||
$menus = Menu::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->withCount('products')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return ['menus' => $menus];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data for Analytics tab (cached for 15 minutes).
|
||||
*/
|
||||
private function loadAnalyticsTabData(Brand $brand, Business $business, $startDate, $endDate, string $preset): array
|
||||
{
|
||||
// Cache stats for 15 minutes (keyed by brand + date range)
|
||||
$cacheKey = "brand:{$brand->id}:stats:{$preset}:{$startDate->format('Y-m-d')}:{$endDate->format('Y-m-d')}";
|
||||
|
||||
return Cache::remember($cacheKey, 900, fn () => $this->calculateBrandStats($brand, $startDate, $endDate));
|
||||
}
|
||||
|
||||
/**
|
||||
* API endpoint for lazy-loading tab data via AJAX.
|
||||
*/
|
||||
public function tabData(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
$tab = $request->input('tab', 'overview');
|
||||
$preset = $request->input('preset', 'this_month');
|
||||
[$startDate, $endDate] = $this->getDateRangeForPreset($preset, $request, $brand);
|
||||
|
||||
$data = match ($tab) {
|
||||
'overview' => $this->loadOverviewTabData($brand, $business, $startDate, $endDate),
|
||||
'products' => $this->loadProductsTabData($brand, $business, $request),
|
||||
'promotions' => $this->loadPromotionsTabData($brand, $business),
|
||||
'menus' => $this->loadMenusTabData($brand, $business),
|
||||
'analytics' => $this->loadAnalyticsTabData($brand, $business, $startDate, $endDate, $preset),
|
||||
default => [],
|
||||
};
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,7 +503,14 @@ class BrandController extends Controller
|
||||
{
|
||||
$this->authorize('update', [$brand, $business]);
|
||||
|
||||
return view('seller.brands.edit', compact('business', 'brand'));
|
||||
// Get available email channels for CRM inbound routing
|
||||
$emailChannels = CrmChannel::forBusiness($business->id)
|
||||
->where('type', CrmChannel::TYPE_EMAIL)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.brands.edit', compact('business', 'brand', 'emailChannels'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -456,6 +607,19 @@ class BrandController extends Controller
|
||||
$brand->inbound_email = $request->input('inbound_email');
|
||||
$brand->sms_number = $request->input('sms_number');
|
||||
|
||||
// CRM Channel Assignment (validate channel belongs to this business)
|
||||
if ($request->has('inbound_email_channel_id')) {
|
||||
$channelId = $request->input('inbound_email_channel_id');
|
||||
if ($channelId) {
|
||||
$channel = CrmChannel::where('business_id', $business->id)
|
||||
->where('id', $channelId)
|
||||
->first();
|
||||
$validated['inbound_email_channel_id'] = $channel ? $channel->id : null;
|
||||
} else {
|
||||
$validated['inbound_email_channel_id'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update brand
|
||||
$brand->update($validated);
|
||||
|
||||
@@ -1274,48 +1438,49 @@ class BrandController extends Controller
|
||||
*/
|
||||
private function calculateBrandStats(Brand $brand, $startDate, $endDate): array
|
||||
{
|
||||
// Eager load products with their varieties
|
||||
$brand->load([
|
||||
'products' => function ($query) {
|
||||
$query->with('varieties');
|
||||
},
|
||||
]);
|
||||
// Calculate product counts with efficient queries (not loading all products)
|
||||
$productCounts = $brand->products()
|
||||
->selectRaw('COUNT(*) as total, SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active')
|
||||
->first();
|
||||
$totalProducts = $productCounts->total ?? 0;
|
||||
$activeProducts = $productCounts->active ?? 0;
|
||||
|
||||
// Calculate overall brand metrics
|
||||
$totalProducts = $brand->products->count();
|
||||
$activeProducts = $brand->products->where('is_active', true)->count();
|
||||
// Get product IDs for this brand (for use in subqueries)
|
||||
$brandProductIds = $brand->products()->pluck('id');
|
||||
|
||||
// Get all order items for this brand's products in the selected date range
|
||||
// WITH eager loading to prevent N+1 queries
|
||||
$orderItems = \App\Models\OrderItem::whereHas('product', function ($query) use ($brand) {
|
||||
$query->where('brand_id', $brand->id);
|
||||
})
|
||||
// Calculate current period metrics with single efficient query
|
||||
$currentStats = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
|
||||
->whereHas('order', function ($query) use ($startDate, $endDate) {
|
||||
$query->whereBetween('created_at', [$startDate, $endDate]);
|
||||
})
|
||||
->with('order.business', 'product')
|
||||
->get();
|
||||
->selectRaw('
|
||||
COUNT(DISTINCT order_id) as total_orders,
|
||||
COALESCE(SUM(line_total), 0) as total_revenue,
|
||||
COALESCE(SUM(quantity), 0) as total_units
|
||||
')
|
||||
->first();
|
||||
|
||||
// Calculate metrics
|
||||
$totalOrders = $orderItems->pluck('order_id')->unique()->count();
|
||||
$totalRevenue = $orderItems->sum('line_total');
|
||||
$totalUnits = $orderItems->sum('quantity');
|
||||
$totalOrders = $currentStats->total_orders ?? 0;
|
||||
$totalRevenue = $currentStats->total_revenue ?? 0;
|
||||
$totalUnits = $currentStats->total_units ?? 0;
|
||||
|
||||
// Previous period comparison (same duration before start date)
|
||||
$daysDiff = $startDate->diffInDays($endDate);
|
||||
$previousStartDate = $startDate->copy()->subDays($daysDiff + 1);
|
||||
$previousEndDate = $startDate->copy()->subDay();
|
||||
|
||||
$previousOrderItems = \App\Models\OrderItem::whereHas('product', function ($query) use ($brand) {
|
||||
$query->where('brand_id', $brand->id);
|
||||
})
|
||||
$previousStats = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
|
||||
->whereHas('order', function ($query) use ($previousStartDate, $previousEndDate) {
|
||||
$query->whereBetween('created_at', [$previousStartDate, $previousEndDate]);
|
||||
})
|
||||
->get();
|
||||
->selectRaw('
|
||||
COUNT(DISTINCT order_id) as total_orders,
|
||||
COALESCE(SUM(line_total), 0) as total_revenue
|
||||
')
|
||||
->first();
|
||||
|
||||
$previousRevenue = $previousOrderItems->sum('line_total');
|
||||
$previousOrders = $previousOrderItems->pluck('order_id')->unique()->count();
|
||||
$previousRevenue = $previousStats->total_revenue ?? 0;
|
||||
$previousOrders = $previousStats->total_orders ?? 0;
|
||||
|
||||
// Calculate percent changes
|
||||
$revenueChange = $previousRevenue > 0 ? (($totalRevenue - $previousRevenue) / $previousRevenue) * 100 : 0;
|
||||
@@ -1324,71 +1489,106 @@ class BrandController extends Controller
|
||||
// Average order value
|
||||
$avgOrderValue = $totalOrders > 0 ? $totalRevenue / $totalOrders : 0;
|
||||
|
||||
// Revenue by day
|
||||
$revenueByDay = $orderItems->groupBy(function ($item) {
|
||||
return $item->order->created_at->format('Y-m-d');
|
||||
})->map(function ($items) {
|
||||
return $items->sum('line_total');
|
||||
})->sortKeys();
|
||||
// Revenue by day - using database aggregation
|
||||
$revenueByDay = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
|
||||
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->whereBetween('orders.created_at', [$startDate, $endDate])
|
||||
->selectRaw('DATE(orders.created_at) as date, SUM(order_items.line_total) as revenue')
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->pluck('revenue', 'date');
|
||||
|
||||
// Build a map of product_id => order items for efficient lookup
|
||||
$productOrderItemsMap = $orderItems->groupBy('product_id');
|
||||
|
||||
// Top products by revenue (with varieties nested under parents)
|
||||
// Filter to only show parent products (exclude varieties from top level)
|
||||
$productStats = $brand->products
|
||||
->filter(function ($product) {
|
||||
return is_null($product->parent_product_id); // Only parent products
|
||||
// Top products by revenue - using database aggregation (limit to top 20)
|
||||
$topProductsData = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
|
||||
->whereHas('order', function ($query) use ($startDate, $endDate) {
|
||||
$query->whereBetween('created_at', [$startDate, $endDate]);
|
||||
})
|
||||
->map(function ($product) use ($productOrderItemsMap) {
|
||||
// Get order items for this product from the map (no additional query!)
|
||||
$items = $productOrderItemsMap->get($product->id, collect());
|
||||
->selectRaw('
|
||||
product_id,
|
||||
SUM(line_total) as revenue,
|
||||
SUM(quantity) as units,
|
||||
COUNT(DISTINCT order_id) as orders
|
||||
')
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('revenue')
|
||||
->limit(20)
|
||||
->get()
|
||||
->keyBy('product_id');
|
||||
|
||||
$revenue = $items->sum('line_total');
|
||||
$units = $items->sum('quantity');
|
||||
$orders = $items->pluck('order_id')->unique()->count();
|
||||
// Load only the products we need for display
|
||||
$topProductIds = $topProductsData->keys();
|
||||
$products = \App\Models\Product::whereIn('id', $topProductIds)
|
||||
->whereNull('parent_product_id')
|
||||
->with(['varieties' => function ($q) use ($topProductsData) {
|
||||
$q->whereIn('id', $topProductsData->keys());
|
||||
}])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Always get variety breakdown if product has varieties
|
||||
$varietyStats = [];
|
||||
if ($product->has_varieties) {
|
||||
$varietyStats = $product->varieties->map(function ($variety) use ($productOrderItemsMap) {
|
||||
// Get order items for this variety from the map (no additional query!)
|
||||
$varietyItems = $productOrderItemsMap->get($variety->id, collect());
|
||||
// Build product stats with preloaded data
|
||||
$productStats = $topProductsData
|
||||
->filter(function ($data) use ($products) {
|
||||
return $products->has($data->product_id);
|
||||
})
|
||||
->map(function ($data) use ($products, $topProductsData) {
|
||||
$product = $products->get($data->product_id);
|
||||
|
||||
$varietyStats = collect();
|
||||
if ($product && $product->has_varieties) {
|
||||
$varietyStats = $product->varieties->map(function ($variety) use ($topProductsData) {
|
||||
$varietyData = $topProductsData->get($variety->id);
|
||||
|
||||
return [
|
||||
'product' => $variety,
|
||||
'revenue' => $varietyItems->sum('line_total'),
|
||||
'units' => $varietyItems->sum('quantity'),
|
||||
'orders' => $varietyItems->pluck('order_id')->unique()->count(),
|
||||
'revenue' => $varietyData->revenue ?? 0,
|
||||
'units' => $varietyData->units ?? 0,
|
||||
'orders' => $varietyData->orders ?? 0,
|
||||
];
|
||||
})->sortByDesc('revenue');
|
||||
}
|
||||
|
||||
return [
|
||||
'product' => $product,
|
||||
'revenue' => $revenue,
|
||||
'units' => $units,
|
||||
'orders' => $orders,
|
||||
'revenue' => $data->revenue,
|
||||
'units' => $data->units,
|
||||
'orders' => $data->orders,
|
||||
'varieties' => $varietyStats,
|
||||
];
|
||||
})->sortByDesc('revenue');
|
||||
})
|
||||
->sortByDesc('revenue');
|
||||
|
||||
// Get best selling SKU
|
||||
$bestSellingSku = $productStats->first();
|
||||
|
||||
// Top buyers by revenue
|
||||
$topBuyers = $orderItems->groupBy(function ($item) {
|
||||
return $item->order->business_id;
|
||||
})->map(function ($items) {
|
||||
$business = $items->first()->order->business;
|
||||
// Top buyers by revenue - using database aggregation
|
||||
$topBuyersData = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
|
||||
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->whereBetween('orders.created_at', [$startDate, $endDate])
|
||||
->selectRaw('
|
||||
orders.business_id,
|
||||
SUM(order_items.line_total) as revenue,
|
||||
COUNT(DISTINCT orders.id) as orders,
|
||||
SUM(order_items.quantity) as units
|
||||
')
|
||||
->groupBy('orders.business_id')
|
||||
->orderByDesc('revenue')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Load buyer businesses in single query
|
||||
$buyerBusinesses = \App\Models\Business::whereIn('id', $topBuyersData->pluck('business_id'))
|
||||
->select('id', 'name')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$topBuyers = $topBuyersData->map(function ($data) use ($buyerBusinesses) {
|
||||
return [
|
||||
'business' => $business,
|
||||
'revenue' => $items->sum('line_total'),
|
||||
'orders' => $items->pluck('order_id')->unique()->count(),
|
||||
'units' => $items->sum('quantity'),
|
||||
'business' => $buyerBusinesses->get($data->business_id),
|
||||
'revenue' => $data->revenue,
|
||||
'orders' => $data->orders,
|
||||
'units' => $data->units,
|
||||
];
|
||||
})->sortByDesc('revenue')->take(5);
|
||||
});
|
||||
|
||||
return [
|
||||
'totalProducts' => $totalProducts,
|
||||
@@ -1675,4 +1875,77 @@ class BrandController extends Controller
|
||||
->route('seller.business.brands.index', $business->slug)
|
||||
->with('success', 'Brand deleted successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate lightweight brand insights for the dashboard
|
||||
*/
|
||||
private function calculateBrandInsights(Brand $brand, Business $business, $startDate, $endDate): array
|
||||
{
|
||||
// Eager load images to avoid N+1 and lazy loading errors
|
||||
$products = $brand->products()->with('images')->get();
|
||||
|
||||
// Top Performer - product with highest revenue in date range
|
||||
$topPerformer = null;
|
||||
$topPerformerData = \App\Models\Order::whereHas('items.product', function ($query) use ($brand) {
|
||||
$query->where('brand_id', $brand->id);
|
||||
})
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->whereIn('status', ['confirmed', 'completed', 'shipped', 'delivered'])
|
||||
->with(['items.product' => function ($query) use ($brand) {
|
||||
$query->where('brand_id', $brand->id);
|
||||
}])
|
||||
->get()
|
||||
->flatMap(function ($order) use ($brand) {
|
||||
return $order->items->filter(function ($item) use ($brand) {
|
||||
return $item->product && $item->product->brand_id === $brand->id;
|
||||
});
|
||||
})
|
||||
->groupBy('product_id')
|
||||
->map(function ($items) {
|
||||
$product = $items->first()->product;
|
||||
|
||||
return [
|
||||
'product' => $product,
|
||||
'revenue' => $items->sum(function ($item) {
|
||||
return $item->quantity * $item->price;
|
||||
}),
|
||||
'orders' => $items->count(),
|
||||
];
|
||||
})
|
||||
->sortByDesc('revenue')
|
||||
->first();
|
||||
|
||||
if ($topPerformerData) {
|
||||
$topPerformer = [
|
||||
'name' => $topPerformerData['product']->name,
|
||||
'hashid' => $topPerformerData['product']->hashid,
|
||||
'revenue' => $topPerformerData['revenue'],
|
||||
'orders' => $topPerformerData['orders'],
|
||||
];
|
||||
}
|
||||
|
||||
// Needs Attention - aggregate counts for quick issues
|
||||
$missingImages = $products->filter(fn ($p) => empty($p->image_path) && $p->images->isEmpty())->count();
|
||||
$hiddenProducts = $products->filter(fn ($p) => ! $p->is_active)->count();
|
||||
$draftProducts = $products->filter(fn ($p) => $p->status === 'draft')->count();
|
||||
// Note: Out of stock would require inventory data - hardcoded to 0 for now
|
||||
$outOfStock = 0;
|
||||
|
||||
$totalIssues = $missingImages + $hiddenProducts + $draftProducts + $outOfStock;
|
||||
|
||||
// Visibility Issues - hidden + draft count
|
||||
$visibilityIssues = $hiddenProducts + $draftProducts;
|
||||
|
||||
return [
|
||||
'topPerformer' => $topPerformer,
|
||||
'needsAttention' => [
|
||||
'total' => $totalIssues,
|
||||
'missingImages' => $missingImages,
|
||||
'hiddenProducts' => $hiddenProducts,
|
||||
'draftProducts' => $draftProducts,
|
||||
'outOfStock' => $outOfStock,
|
||||
],
|
||||
'visibilityIssues' => $visibilityIssues,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -18,12 +18,22 @@ class BrandSwitcherController extends Controller
|
||||
{
|
||||
$brandId = $request->input('brand_id');
|
||||
$brandHashid = $request->input('brand_hashid');
|
||||
$redirectTo = $request->input('redirect_to');
|
||||
|
||||
// If both are empty, clear the session (show all brands)
|
||||
if (empty($brandId) && empty($brandHashid)) {
|
||||
// Clear cache for current user before removing session
|
||||
$user = auth()->user();
|
||||
$business = $user?->primaryBusiness();
|
||||
$oldBrandId = session('selected_brand_id');
|
||||
|
||||
if ($user && $business && $oldBrandId) {
|
||||
\Illuminate\Support\Facades\Cache::forget("selected_brand:{$user->id}:{$business->id}:{$oldBrandId}");
|
||||
}
|
||||
|
||||
session()->forget('selected_brand_id');
|
||||
|
||||
return back();
|
||||
return $redirectTo ? redirect($redirectTo) : back();
|
||||
}
|
||||
|
||||
// Verify the brand exists and belongs to user's business
|
||||
@@ -56,6 +66,7 @@ class BrandSwitcherController extends Controller
|
||||
|
||||
/**
|
||||
* Get the currently selected brand (helper method).
|
||||
* Cached for 5 minutes to avoid repeated queries on every page load.
|
||||
*/
|
||||
public static function getSelectedBrand(): ?Brand
|
||||
{
|
||||
@@ -72,9 +83,14 @@ class BrandSwitcherController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
return Brand::forBusiness($business)
|
||||
->where('id', $brandId)
|
||||
->first();
|
||||
// Cache by user + business + brand to avoid repeated queries
|
||||
$cacheKey = "selected_brand:{$user->id}:{$business->id}:{$brandId}";
|
||||
|
||||
return \Illuminate\Support\Facades\Cache::remember($cacheKey, 300, function () use ($business, $brandId) {
|
||||
return Brand::forBusiness($business)
|
||||
->where('id', $brandId)
|
||||
->first();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,12 +14,17 @@ class ContactController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of contacts (CRM Core)
|
||||
* Shows all contacts who have interacted with this seller business
|
||||
* Shows all contacts from buyer businesses (accounts)
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Get all contact IDs that have interacted with this business
|
||||
// through orders, conversations, or messages
|
||||
// Get all contacts from buyer businesses (accounts)
|
||||
// This gives a complete view of all contacts in the CRM
|
||||
$query = Contact::whereHas('business', function ($q) {
|
||||
$q->where('type', 'buyer');
|
||||
})->with(['business', 'user']);
|
||||
|
||||
// Also track which contacts have engaged for stats
|
||||
$orderContactIds = Order::whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})->whereNotNull('contact_id')->pluck('contact_id');
|
||||
@@ -28,11 +33,7 @@ class ContactController extends Controller
|
||||
->whereNotNull('primary_contact_id')
|
||||
->pluck('primary_contact_id');
|
||||
|
||||
$contactIds = $orderContactIds->merge($conversationContactIds)->unique();
|
||||
|
||||
// Build query
|
||||
$query = Contact::whereIn('id', $contactIds)
|
||||
->with(['business', 'user']);
|
||||
$engagedContactIds = $orderContactIds->merge($conversationContactIds)->unique();
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('search')) {
|
||||
@@ -60,6 +61,8 @@ class ContactController extends Controller
|
||||
$query->whereIn('id', $orderContactIds);
|
||||
} elseif ($request->activity === 'has_conversations') {
|
||||
$query->whereIn('id', $conversationContactIds);
|
||||
} elseif ($request->activity === 'engaged') {
|
||||
$query->whereIn('id', $engagedContactIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,12 +78,14 @@ class ContactController extends Controller
|
||||
|
||||
$contacts = $query->paginate(20)->withQueryString();
|
||||
|
||||
// Get stats
|
||||
// Get stats - count all buyer contacts and engaged contacts
|
||||
$allBuyerContactsQuery = Contact::whereHas('business', fn ($q) => $q->where('type', 'buyer'));
|
||||
$stats = [
|
||||
'total' => Contact::whereIn('id', $contactIds)->count(),
|
||||
'active' => Contact::whereIn('id', $contactIds)->where('is_active', true)->count(),
|
||||
'with_orders' => Contact::whereIn('id', $orderContactIds)->count(),
|
||||
'with_conversations' => Contact::whereIn('id', $conversationContactIds)->count(),
|
||||
'total' => (clone $allBuyerContactsQuery)->count(),
|
||||
'active' => (clone $allBuyerContactsQuery)->where('is_active', true)->count(),
|
||||
'with_orders' => $orderContactIds->count(),
|
||||
'with_conversations' => $conversationContactIds->count(),
|
||||
'engaged' => $engagedContactIds->count(),
|
||||
];
|
||||
|
||||
return view('seller.contacts.index', compact('business', 'contacts', 'stats'));
|
||||
@@ -107,41 +112,45 @@ class ContactController extends Controller
|
||||
// Load contact relationships
|
||||
$contact->load(['business', 'user']);
|
||||
|
||||
// Get conversations
|
||||
// Get conversations (limit for profile view)
|
||||
$conversations = Conversation::where('business_id', $business->id)
|
||||
->where('primary_contact_id', $contact->id)
|
||||
->with('latestMessage')
|
||||
->orderBy('last_message_at', 'desc')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Get orders
|
||||
// Get orders (limit for profile view, select only needed columns)
|
||||
$orders = Order::whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->where('contact_id', $contact->id)
|
||||
->with(['business', 'items.product'])
|
||||
->with(['business:id,name', 'items:id,order_id,product_id,quantity,unit_price', 'items.product:id,name,sku'])
|
||||
->latest()
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Get invoices
|
||||
// Get invoices (limit for profile view)
|
||||
$invoices = Invoice::whereHas('order', function ($q) use ($contact) {
|
||||
$q->where('contact_id', $contact->id);
|
||||
})
|
||||
->whereHas('order.items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->with('order')
|
||||
->with('order:id,order_number')
|
||||
->latest()
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Get backorders (orders with status 'backorder')
|
||||
// Get backorders (limit for profile view)
|
||||
$backorders = Order::whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->where('contact_id', $contact->id)
|
||||
->where('status', 'backorder')
|
||||
->with(['business', 'items.product'])
|
||||
->with(['business:id,name', 'items:id,order_id,product_id,quantity', 'items.product:id,name,sku'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Premium features (gated by has_marketing)
|
||||
@@ -172,14 +181,18 @@ class ContactController extends Controller
|
||||
|
||||
/**
|
||||
* Build unified activity timeline (Premium feature)
|
||||
* Limited to most recent 30 items for performance
|
||||
*/
|
||||
private function buildTimeline(Contact $contact, Business $business): array
|
||||
{
|
||||
$timeline = [];
|
||||
|
||||
// Get all related activities
|
||||
// Get recent conversations (limit for performance)
|
||||
$conversations = Conversation::where('business_id', $business->id)
|
||||
->where('primary_contact_id', $contact->id)
|
||||
->select('id', 'subject', 'created_at')
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
foreach ($conversations as $conversation) {
|
||||
@@ -193,10 +206,14 @@ class ContactController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
// Get recent orders (limit for performance)
|
||||
$orders = Order::whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->where('contact_id', $contact->id)
|
||||
->select('id', 'order_number', 'total', 'created_at')
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
foreach ($orders as $order) {
|
||||
@@ -210,12 +227,16 @@ class ContactController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
// Get recent invoices (limit for performance)
|
||||
$invoices = Invoice::whereHas('order', function ($q) use ($contact) {
|
||||
$q->where('contact_id', $contact->id);
|
||||
})
|
||||
->whereHas('order.items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->select('id', 'invoice_number', 'payment_status', 'created_at')
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
@@ -229,11 +250,11 @@ class ContactController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
// Sort by date descending and limit total items
|
||||
usort($timeline, function ($a, $b) {
|
||||
return $b['date'] <=> $a['date'];
|
||||
});
|
||||
|
||||
return $timeline;
|
||||
return array_slice($timeline, 0, 30);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ namespace App\Http\Controllers\Seller\Crm;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Crm\CrmEvent;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\SalesOpportunity;
|
||||
use App\Models\SendMenuLog;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -18,15 +21,143 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->with(['contacts'])
|
||||
->orderBy('name')
|
||||
->paginate(25);
|
||||
$query = Business::where('type', 'buyer')
|
||||
->with(['contacts']);
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('q')) {
|
||||
$search = $request->q;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('dba_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('business_email', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Status filter - default to approved, but allow viewing all
|
||||
if ($request->filled('status') && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
} else {
|
||||
$query->where('status', 'approved');
|
||||
}
|
||||
|
||||
$accounts = $query->orderBy('name')->paginate(25);
|
||||
|
||||
return view('seller.crm.accounts.index', compact('business', 'accounts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create customer form
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
return view('seller.crm.accounts.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new customer (buyer business)
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'license_number' => 'nullable|string|max:100',
|
||||
'business_email' => 'nullable|email|max:255',
|
||||
'business_phone' => 'nullable|string|max:50',
|
||||
'physical_address' => 'nullable|string|max:255',
|
||||
'physical_city' => 'nullable|string|max:100',
|
||||
'physical_state' => 'nullable|string|max:50',
|
||||
'physical_zipcode' => 'nullable|string|max:20',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_title' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
// Create the buyer business
|
||||
$account = Business::create([
|
||||
'name' => $validated['name'],
|
||||
'dba_name' => $validated['dba_name'] ?? null,
|
||||
'license_number' => $validated['license_number'] ?? null,
|
||||
'business_email' => $validated['business_email'] ?? null,
|
||||
'business_phone' => $validated['business_phone'] ?? null,
|
||||
'physical_address' => $validated['physical_address'] ?? null,
|
||||
'physical_city' => $validated['physical_city'] ?? null,
|
||||
'physical_state' => $validated['physical_state'] ?? null,
|
||||
'physical_zipcode' => $validated['physical_zipcode'] ?? null,
|
||||
'type' => 'buyer',
|
||||
'status' => 'approved', // Auto-approve customers created by sellers
|
||||
]);
|
||||
|
||||
// Create primary contact if provided
|
||||
if (! empty($validated['contact_name'])) {
|
||||
$account->contacts()->create([
|
||||
'first_name' => explode(' ', $validated['contact_name'])[0],
|
||||
'last_name' => implode(' ', array_slice(explode(' ', $validated['contact_name']), 1)) ?: null,
|
||||
'email' => $validated['contact_email'] ?? null,
|
||||
'phone' => $validated['contact_phone'] ?? null,
|
||||
'title' => $validated['contact_title'] ?? null,
|
||||
'is_primary' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// Log the creation event
|
||||
CrmEvent::log(
|
||||
sellerBusinessId: $business->id,
|
||||
eventType: 'account_created',
|
||||
summary: "Customer {$account->name} created",
|
||||
buyerBusinessId: $account->id,
|
||||
userId: auth()->id(),
|
||||
channel: 'system'
|
||||
);
|
||||
|
||||
// Return JSON for AJAX requests
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'id' => $account->id,
|
||||
'name' => $account->name,
|
||||
'slug' => $account->slug,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
|
||||
->with('success', 'Customer created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit customer form
|
||||
*/
|
||||
public function edit(Request $request, Business $business, Business $account)
|
||||
{
|
||||
return view('seller.crm.accounts.edit', compact('business', 'account'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a customer (buyer business)
|
||||
*/
|
||||
public function update(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'license_number' => 'nullable|string|max:100',
|
||||
'business_email' => 'nullable|email|max:255',
|
||||
'business_phone' => 'nullable|string|max:50',
|
||||
'physical_address' => 'nullable|string|max:255',
|
||||
'physical_city' => 'nullable|string|max:100',
|
||||
'physical_state' => 'nullable|string|max:50',
|
||||
'physical_zipcode' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
$account->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
|
||||
->with('success', 'Customer updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show account details
|
||||
*/
|
||||
@@ -34,17 +165,37 @@ class AccountController extends Controller
|
||||
{
|
||||
$account->load(['contacts']);
|
||||
|
||||
// Get orders for this account from this seller
|
||||
// Get orders for this account from this seller (with invoices)
|
||||
$orders = $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->with(['invoice'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Get quotes for this account
|
||||
$quotes = CrmQuote::where('business_id', $business->id)
|
||||
->where('account_id', $account->id)
|
||||
->with(['contact', 'items'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Get invoices for this account (via orders)
|
||||
$invoices = Invoice::whereHas('order', function ($q) use ($business, $account) {
|
||||
$q->where('business_id', $account->id)
|
||||
->whereHas('items.product.brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
});
|
||||
})
|
||||
->with(['order', 'payments'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Get 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'])
|
||||
@@ -52,7 +203,6 @@ class AccountController extends Controller
|
||||
->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')
|
||||
@@ -84,26 +234,71 @@ class AccountController extends Controller
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Compute stats for this account (orders from this seller)
|
||||
$ordersQuery = $account->orders()
|
||||
// Compute stats for this account with efficient queries
|
||||
$orderStats = $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
});
|
||||
})
|
||||
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
|
||||
->first();
|
||||
|
||||
$pipelineValue = $opportunities->where('status', 'open')->sum('value');
|
||||
$opportunityStats = SalesOpportunity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->where('status', 'open')
|
||||
->selectRaw('COUNT(*) as open_count, COALESCE(SUM(value), 0) as pipeline_value')
|
||||
->first();
|
||||
|
||||
// Financial stats from invoices
|
||||
$financialStats = Invoice::whereHas('order', function ($q) use ($business, $account) {
|
||||
$q->where('business_id', $account->id)
|
||||
->whereHas('items.product.brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
});
|
||||
})
|
||||
->selectRaw('
|
||||
COALESCE(SUM(amount_due), 0) as outstanding_balance,
|
||||
COALESCE(SUM(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN amount_due ELSE 0 END), 0) as past_due_amount,
|
||||
COUNT(CASE WHEN amount_due > 0 THEN 1 END) as open_invoice_count,
|
||||
MIN(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN due_date END) as oldest_past_due_date
|
||||
')
|
||||
->first();
|
||||
|
||||
// Get last payment info
|
||||
$lastPayment = \App\Models\InvoicePayment::whereHas('invoice.order', function ($q) use ($business, $account) {
|
||||
$q->where('business_id', $account->id)
|
||||
->whereHas('items.product.brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
});
|
||||
})
|
||||
->latest('payment_date')
|
||||
->first();
|
||||
|
||||
$stats = [
|
||||
'total_orders' => $ordersQuery->count(),
|
||||
'total_revenue' => $ordersQuery->sum('total') ?? 0,
|
||||
'open_opportunities' => $opportunities->where('status', 'open')->count(),
|
||||
'pipeline_value' => $pipelineValue ?? 0,
|
||||
'total_orders' => $orderStats->total_orders ?? 0,
|
||||
'total_revenue' => $orderStats->total_revenue ?? 0,
|
||||
'open_opportunities' => $opportunityStats->open_count ?? 0,
|
||||
'pipeline_value' => $opportunityStats->pipeline_value ?? 0,
|
||||
];
|
||||
|
||||
$financials = [
|
||||
'outstanding_balance' => $financialStats->outstanding_balance ?? 0,
|
||||
'past_due_amount' => $financialStats->past_due_amount ?? 0,
|
||||
'open_invoice_count' => $financialStats->open_invoice_count ?? 0,
|
||||
'oldest_past_due_days' => $financialStats->oldest_past_due_date
|
||||
? now()->diffInDays($financialStats->oldest_past_due_date)
|
||||
: null,
|
||||
'last_payment_amount' => $lastPayment->amount ?? null,
|
||||
'last_payment_date' => $lastPayment->payment_date ?? null,
|
||||
];
|
||||
|
||||
return view('seller.crm.accounts.show', compact(
|
||||
'business',
|
||||
'account',
|
||||
'stats',
|
||||
'financials',
|
||||
'orders',
|
||||
'quotes',
|
||||
'invoices',
|
||||
'opportunities',
|
||||
'tasks',
|
||||
'conversationEvents',
|
||||
@@ -135,7 +330,15 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function orders(Request $request, Business $business, Business $account)
|
||||
{
|
||||
return view('seller.crm.accounts.orders', compact('business', 'account'));
|
||||
$orders = $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->with(['items.product.brand'])
|
||||
->latest()
|
||||
->paginate(25);
|
||||
|
||||
return view('seller.crm.accounts.orders', compact('business', 'account', 'orders'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,7 +346,13 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function activity(Request $request, Business $business, Business $account)
|
||||
{
|
||||
return view('seller.crm.accounts.activity', compact('business', 'account'));
|
||||
$activities = Activity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->with(['causer'])
|
||||
->latest()
|
||||
->paginate(50);
|
||||
|
||||
return view('seller.crm.accounts.activity', compact('business', 'account', 'activities'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,4 +385,104 @@ class AccountController extends Controller
|
||||
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
|
||||
->with('success', 'Note added successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new contact for an account
|
||||
*/
|
||||
public function storeContact(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'first_name' => 'required|string|max:100',
|
||||
'last_name' => 'nullable|string|max:100',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'title' => 'nullable|string|max:100',
|
||||
'is_primary' => 'boolean',
|
||||
]);
|
||||
|
||||
// If setting as primary, unset other primary contacts
|
||||
if ($validated['is_primary'] ?? false) {
|
||||
$account->contacts()->update(['is_primary' => false]);
|
||||
}
|
||||
|
||||
$contact = $account->contacts()->create($validated);
|
||||
|
||||
// Return JSON for AJAX requests
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'id' => $contact->id,
|
||||
'first_name' => $contact->first_name,
|
||||
'last_name' => $contact->last_name,
|
||||
'email' => $contact->email,
|
||||
'phone' => $contact->phone,
|
||||
'title' => $contact->title,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
|
||||
->with('success', 'Contact added successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit contact form
|
||||
*/
|
||||
public function editContact(Request $request, Business $business, Business $account, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to this account
|
||||
if ($contact->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('seller.crm.accounts.contacts-edit', compact('business', 'account', 'contact'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a contact
|
||||
*/
|
||||
public function updateContact(Request $request, Business $business, Business $account, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to this account
|
||||
if ($contact->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'first_name' => 'required|string|max:100',
|
||||
'last_name' => 'nullable|string|max:100',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'title' => 'nullable|string|max:100',
|
||||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
// If setting as primary, unset other primary contacts
|
||||
if ($validated['is_primary'] ?? false) {
|
||||
$account->contacts()->where('id', '!=', $contact->id)->update(['is_primary' => false]);
|
||||
}
|
||||
|
||||
$contact->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
|
||||
->with('success', 'Contact updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a contact
|
||||
*/
|
||||
public function destroyContact(Request $request, Business $business, Business $account, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to this account
|
||||
if ($contact->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$contact->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
|
||||
->with('success', 'Contact deleted successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,19 +22,19 @@ class AutomationController extends Controller
|
||||
->orderByDesc('created_at')
|
||||
->paginate(25);
|
||||
|
||||
return view('seller.crm.automations.index', compact('automations'));
|
||||
return view('seller.crm.automations.index', compact('automations', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show automation builder
|
||||
*/
|
||||
public function create(Request $request)
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$triggers = CrmAutomation::TRIGGERS;
|
||||
$operators = CrmAutomationCondition::OPERATORS;
|
||||
$actionTypes = CrmAutomationAction::TYPES;
|
||||
|
||||
return view('seller.crm.automations.create', compact('triggers', 'operators', 'actionTypes'));
|
||||
return view('seller.crm.automations.create', compact('triggers', 'operators', 'actionTypes', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +97,7 @@ class AutomationController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('seller.crm.automations.show', $automation)
|
||||
return redirect()->route('seller.business.crm.automations.show', [$business, $automation])
|
||||
->with('success', 'Automation created successfully.');
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ class AutomationController extends Controller
|
||||
|
||||
$automation->load(['conditions', 'actions', 'logs' => fn ($q) => $q->latest()->limit(50)]);
|
||||
|
||||
return view('seller.crm.automations.show', compact('automation'));
|
||||
return view('seller.crm.automations.show', compact('automation', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,7 +130,7 @@ class AutomationController extends Controller
|
||||
$operators = CrmAutomationCondition::OPERATORS;
|
||||
$actionTypes = CrmAutomationAction::TYPES;
|
||||
|
||||
return view('seller.crm.automations.edit', compact('automation', 'triggers', 'operators', 'actionTypes'));
|
||||
return view('seller.crm.automations.edit', compact('automation', 'triggers', 'operators', 'actionTypes', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,7 +202,7 @@ class AutomationController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('seller.crm.automations.show', $automation)
|
||||
return redirect()->route('seller.business.crm.automations.show', [$business, $automation])
|
||||
->with('success', 'Automation updated successfully.');
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ class AutomationController extends Controller
|
||||
|
||||
$copy = $automation->duplicate();
|
||||
|
||||
return redirect()->route('seller.crm.automations.edit', $copy)
|
||||
return redirect()->route('seller.business.crm.automations.edit', [$business, $copy])
|
||||
->with('success', 'Automation duplicated. Make your changes and activate when ready.');
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ class AutomationController extends Controller
|
||||
|
||||
$automation->delete();
|
||||
|
||||
return redirect()->route('seller.crm.automations.index')
|
||||
return redirect()->route('seller.business.crm.automations.index', $business)
|
||||
->with('success', 'Automation deleted.');
|
||||
}
|
||||
}
|
||||
|
||||
196
app/Http/Controllers/Seller/Crm/ChannelController.php
Normal file
196
app/Http/Controllers/Seller/Crm/ChannelController.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessEmailIdentity;
|
||||
use App\Models\Crm\CrmChannel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ChannelController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all CRM channels for a business.
|
||||
*/
|
||||
public function index(Business $business)
|
||||
{
|
||||
$channels = CrmChannel::forBusiness($business->id)
|
||||
->orderBy('type')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.channels.index', compact('business', 'channels'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the create channel form.
|
||||
*/
|
||||
public function create(Business $business)
|
||||
{
|
||||
// Get available email identities
|
||||
$emailIdentities = BusinessEmailIdentity::forBusiness($business->id)
|
||||
->active()
|
||||
->with('mailSettings')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.channels.create', [
|
||||
'business' => $business,
|
||||
'channel' => null,
|
||||
'emailIdentities' => $emailIdentities,
|
||||
'types' => [
|
||||
CrmChannel::TYPE_EMAIL => 'Email',
|
||||
CrmChannel::TYPE_SMS => 'SMS',
|
||||
],
|
||||
'departments' => CrmChannel::DEPARTMENTS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new channel.
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'type' => ['required', 'string', Rule::in([CrmChannel::TYPE_EMAIL, CrmChannel::TYPE_SMS])],
|
||||
'department' => ['required', 'string', Rule::in(array_keys(CrmChannel::DEPARTMENTS))],
|
||||
'is_active' => ['boolean'],
|
||||
// Email-specific
|
||||
'identity_id' => ['nullable', 'required_if:type,email', 'exists:business_email_identities,id'],
|
||||
// SMS-specific
|
||||
'phone_number' => ['nullable', 'required_if:type,sms', 'string', 'max:20'],
|
||||
]);
|
||||
|
||||
// Build config based on type
|
||||
$config = ['department' => $validated['department']];
|
||||
$identifier = null;
|
||||
|
||||
if ($validated['type'] === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
|
||||
$identity = BusinessEmailIdentity::where('business_id', $business->id)
|
||||
->findOrFail($validated['identity_id']);
|
||||
|
||||
$config['identity_id'] = $identity->id;
|
||||
$config['mail_settings_id'] = $identity->mail_settings_id;
|
||||
$identifier = $identity->email;
|
||||
}
|
||||
|
||||
if ($validated['type'] === CrmChannel::TYPE_SMS && ! empty($validated['phone_number'])) {
|
||||
$config['phone_number'] = $validated['phone_number'];
|
||||
$identifier = $validated['phone_number'];
|
||||
}
|
||||
|
||||
$channel = CrmChannel::create([
|
||||
'business_id' => $business->id,
|
||||
'type' => $validated['type'],
|
||||
'name' => $validated['name'],
|
||||
'department' => $validated['department'],
|
||||
'identifier' => $identifier,
|
||||
'config' => $config,
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
'can_send' => true,
|
||||
'can_receive' => true,
|
||||
]);
|
||||
|
||||
// Link the email identity to this channel
|
||||
if ($validated['type'] === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
|
||||
BusinessEmailIdentity::where('id', $validated['identity_id'])
|
||||
->update(['crm_channel_id' => $channel->id]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.channels.index', $business)
|
||||
->with('success', 'Channel created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the edit channel form.
|
||||
*/
|
||||
public function edit(Business $business, CrmChannel $channel)
|
||||
{
|
||||
// Security: ensure channel belongs to business
|
||||
if ($channel->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Get available email identities
|
||||
$emailIdentities = BusinessEmailIdentity::forBusiness($business->id)
|
||||
->active()
|
||||
->with('mailSettings')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.channels.edit', [
|
||||
'business' => $business,
|
||||
'channel' => $channel,
|
||||
'emailIdentities' => $emailIdentities,
|
||||
'types' => [
|
||||
CrmChannel::TYPE_EMAIL => 'Email',
|
||||
CrmChannel::TYPE_SMS => 'SMS',
|
||||
],
|
||||
'departments' => CrmChannel::DEPARTMENTS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing channel.
|
||||
*/
|
||||
public function update(Request $request, Business $business, CrmChannel $channel)
|
||||
{
|
||||
// Security: ensure channel belongs to business
|
||||
if ($channel->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'department' => ['required', 'string', Rule::in(array_keys(CrmChannel::DEPARTMENTS))],
|
||||
'is_active' => ['boolean'],
|
||||
// Email-specific
|
||||
'identity_id' => ['nullable', 'exists:business_email_identities,id'],
|
||||
// SMS-specific
|
||||
'phone_number' => ['nullable', 'string', 'max:20'],
|
||||
]);
|
||||
|
||||
// Build config based on type
|
||||
$config = $channel->config ?? [];
|
||||
$config['department'] = $validated['department'];
|
||||
$identifier = $channel->identifier;
|
||||
|
||||
if ($channel->type === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
|
||||
$identity = BusinessEmailIdentity::where('business_id', $business->id)
|
||||
->findOrFail($validated['identity_id']);
|
||||
|
||||
// Unlink old identity if different
|
||||
$oldIdentityId = $config['identity_id'] ?? null;
|
||||
if ($oldIdentityId && $oldIdentityId != $identity->id) {
|
||||
BusinessEmailIdentity::where('id', $oldIdentityId)
|
||||
->update(['crm_channel_id' => null]);
|
||||
}
|
||||
|
||||
$config['identity_id'] = $identity->id;
|
||||
$config['mail_settings_id'] = $identity->mail_settings_id;
|
||||
$identifier = $identity->email;
|
||||
|
||||
// Link new identity
|
||||
$identity->update(['crm_channel_id' => $channel->id]);
|
||||
}
|
||||
|
||||
if ($channel->type === CrmChannel::TYPE_SMS && ! empty($validated['phone_number'])) {
|
||||
$config['phone_number'] = $validated['phone_number'];
|
||||
$identifier = $validated['phone_number'];
|
||||
}
|
||||
|
||||
$channel->update([
|
||||
'name' => $validated['name'],
|
||||
'department' => $validated['department'],
|
||||
'identifier' => $identifier,
|
||||
'config' => $config,
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.channels.index', $business)
|
||||
->with('success', 'Channel updated successfully.');
|
||||
}
|
||||
}
|
||||
240
app/Http/Controllers/Seller/Crm/ContactController.php
Normal file
240
app/Http/Controllers/Seller/Crm/ContactController.php
Normal file
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display all CRM contacts (contacts from buyer businesses).
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$query = Contact::query()
|
||||
->whereHas('business', function ($q) {
|
||||
$q->where('type', 'buyer');
|
||||
})
|
||||
->with(['business', 'location']);
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('q')) {
|
||||
$search = $request->q;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('first_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('last_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('email', 'ILIKE', "%{$search}%")
|
||||
->orWhere('phone', 'ILIKE', "%{$search}%")
|
||||
->orWhere('position', 'ILIKE', "%{$search}%")
|
||||
->orWhereHas('business', function ($q) use ($search) {
|
||||
$q->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('dba_name', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Account filter
|
||||
if ($request->filled('account')) {
|
||||
$query->where('business_id', $request->account);
|
||||
}
|
||||
|
||||
// Contact type filter
|
||||
if ($request->filled('type')) {
|
||||
$query->where('contact_type', $request->type);
|
||||
}
|
||||
|
||||
// Active filter - default to active
|
||||
if ($request->filled('status')) {
|
||||
if ($request->status === 'inactive') {
|
||||
$query->where('is_active', false);
|
||||
} elseif ($request->status === 'all') {
|
||||
// Show all
|
||||
} else {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
} else {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
$contacts = $query
|
||||
->orderBy('last_name')
|
||||
->orderBy('first_name')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
// Get accounts for filter dropdown
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'dba_name']);
|
||||
|
||||
return view('seller.crm.contacts.index', compact('business', 'contacts', 'accounts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new contact.
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'dba_name']);
|
||||
|
||||
$selectedAccount = $request->filled('account')
|
||||
? Business::find($request->account)
|
||||
: null;
|
||||
|
||||
return view('seller.crm.contacts.create', compact('business', 'accounts', 'selectedAccount'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created contact.
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'business_id' => 'required|exists:businesses,id',
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'mobile' => 'nullable|string|max:50',
|
||||
'position' => 'nullable|string|max:255',
|
||||
'contact_type' => 'nullable|string|in:'.implode(',', array_keys(Contact::CONTACT_TYPES)),
|
||||
'preferred_contact_method' => 'nullable|string|in:'.implode(',', array_keys(Contact::COMMUNICATION_METHODS)),
|
||||
'is_primary' => 'nullable|boolean',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Verify the target business is a buyer
|
||||
$targetBusiness = Business::findOrFail($validated['business_id']);
|
||||
if ($targetBusiness->type !== 'buyer') {
|
||||
return redirect()->back()->with('error', 'Contacts can only be added to customer accounts.');
|
||||
}
|
||||
|
||||
// If setting as primary, remove primary from other contacts
|
||||
if ($request->boolean('is_primary')) {
|
||||
Contact::where('business_id', $validated['business_id'])->update(['is_primary' => false]);
|
||||
}
|
||||
|
||||
$contact = Contact::create([
|
||||
'business_id' => $validated['business_id'],
|
||||
'first_name' => $validated['first_name'],
|
||||
'last_name' => $validated['last_name'],
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'mobile' => $validated['mobile'] ?? null,
|
||||
'position' => $validated['position'] ?? null,
|
||||
'contact_type' => $validated['contact_type'] ?? 'general',
|
||||
'preferred_contact_method' => $validated['preferred_contact_method'] ?? 'email',
|
||||
'is_primary' => $request->boolean('is_primary', false),
|
||||
'is_active' => true,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.contacts.index', $business)
|
||||
->with('success', "Contact '{$contact->getFullName()}' created successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing a contact.
|
||||
*/
|
||||
public function edit(Business $business, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to a buyer business
|
||||
if ($contact->business->type !== 'buyer') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'dba_name']);
|
||||
|
||||
return view('seller.crm.contacts.edit', compact('business', 'contact', 'accounts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a contact.
|
||||
*/
|
||||
public function update(Request $request, Business $business, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to a buyer business
|
||||
if ($contact->business->type !== 'buyer') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'business_id' => 'required|exists:businesses,id',
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'mobile' => 'nullable|string|max:50',
|
||||
'position' => 'nullable|string|max:255',
|
||||
'contact_type' => 'nullable|string|in:'.implode(',', array_keys(Contact::CONTACT_TYPES)),
|
||||
'preferred_contact_method' => 'nullable|string|in:'.implode(',', array_keys(Contact::COMMUNICATION_METHODS)),
|
||||
'is_primary' => 'nullable|boolean',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Verify the target business is a buyer
|
||||
$targetBusiness = Business::findOrFail($validated['business_id']);
|
||||
if ($targetBusiness->type !== 'buyer') {
|
||||
return redirect()->back()->with('error', 'Contacts can only belong to customer accounts.');
|
||||
}
|
||||
|
||||
// If setting as primary, remove primary from other contacts
|
||||
if ($request->boolean('is_primary') && ! $contact->is_primary) {
|
||||
Contact::where('business_id', $validated['business_id'])
|
||||
->where('id', '!=', $contact->id)
|
||||
->update(['is_primary' => false]);
|
||||
}
|
||||
|
||||
$contact->update([
|
||||
'business_id' => $validated['business_id'],
|
||||
'first_name' => $validated['first_name'],
|
||||
'last_name' => $validated['last_name'],
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'mobile' => $validated['mobile'] ?? null,
|
||||
'position' => $validated['position'] ?? null,
|
||||
'contact_type' => $validated['contact_type'] ?? 'general',
|
||||
'preferred_contact_method' => $validated['preferred_contact_method'] ?? 'email',
|
||||
'is_primary' => $request->boolean('is_primary', false),
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.contacts.index', $business)
|
||||
->with('success', "Contact '{$contact->getFullName()}' updated successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive/delete a contact.
|
||||
*/
|
||||
public function destroy(Business $business, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to a buyer business
|
||||
if ($contact->business->type !== 'buyer') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$name = $contact->getFullName();
|
||||
|
||||
$contact->archive('Deleted via CRM', auth()->user());
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.contacts.index', $business)
|
||||
->with('success', "Contact '{$name}' has been archived.");
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,13 @@ namespace App\Http\Controllers\Seller\Crm;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\Crm\SyncCalendarJob;
|
||||
use App\Models\Business;
|
||||
use App\Models\CalendarEvent;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Crm\CrmCalendarConnection;
|
||||
use App\Models\Crm\CrmMeetingBooking;
|
||||
use App\Models\Crm\CrmSyncedEvent;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\User;
|
||||
use App\Services\Crm\CrmCalendarService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -17,7 +22,7 @@ class CrmCalendarController extends Controller
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Calendar view
|
||||
* Calendar view - unified activity calendar
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
@@ -28,52 +33,402 @@ class CrmCalendarController extends Controller
|
||||
->where('user_id', $user->id)
|
||||
->get();
|
||||
|
||||
// Get events for calendar view
|
||||
$startDate = $request->input('start', now()->startOfMonth());
|
||||
$endDate = $request->input('end', now()->endOfMonth());
|
||||
// Get team members for assignment dropdown
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->select('id', 'first_name', 'last_name', 'email')
|
||||
->get();
|
||||
|
||||
$events = CrmSyncedEvent::whereIn('calendar_connection_id', $connections->pluck('id'))
|
||||
->whereBetween('start_at', [$startDate, $endDate])
|
||||
// Get contacts for event creation
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
$contacts = Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->with('business:id,name')
|
||||
->orderBy('first_name')
|
||||
->limit(200)
|
||||
->get();
|
||||
|
||||
// Get event types and colors for legend/forms
|
||||
$eventTypes = CalendarEvent::TYPES;
|
||||
$eventColors = CalendarEvent::TYPE_COLORS;
|
||||
|
||||
// Pass $business to view for route generation (Premium CRM uses seller.business.crm.* routes)
|
||||
return view('seller.crm.calendar.index', compact(
|
||||
'business',
|
||||
'connections',
|
||||
'teamMembers',
|
||||
'contacts',
|
||||
'eventTypes',
|
||||
'eventColors'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Get all events for date range (unified: internal + synced + bookings + tasks)
|
||||
*/
|
||||
public function events(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'start' => 'required|date',
|
||||
'end' => 'required|date|after:start',
|
||||
]);
|
||||
|
||||
$startDate = $validated['start'];
|
||||
$endDate = $validated['end'];
|
||||
$allEvents = collect();
|
||||
|
||||
// 1. Internal CalendarEvents
|
||||
$internalEvents = CalendarEvent::forSellerBusiness($business->id)
|
||||
->inDateRange($startDate, $endDate)
|
||||
->with(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name'])
|
||||
->get()
|
||||
->map(fn ($e) => [
|
||||
'id' => $e->id,
|
||||
'id' => 'event_'.$e->id,
|
||||
'title' => $e->title,
|
||||
'start' => $e->start_at->toIso8601String(),
|
||||
'end' => $e->end_at?->toIso8601String(),
|
||||
'allDay' => $e->all_day,
|
||||
'color' => $e->connection->provider === 'google' ? '#4285f4' : '#0078d4',
|
||||
'color' => $e->getColor(),
|
||||
'classNames' => ['calendar-event-internal', 'event-type-'.$e->type],
|
||||
'extendedProps' => [
|
||||
'source' => 'internal',
|
||||
'event_id' => $e->id,
|
||||
'type' => $e->type,
|
||||
'type_label' => $e->getTypeLabel(),
|
||||
'status' => $e->status,
|
||||
'location' => $e->location,
|
||||
'description' => $e->description,
|
||||
'attendees' => $e->attendees,
|
||||
'contact_id' => $e->contact_id,
|
||||
'contact_name' => $e->contact ? $e->contact->first_name.' '.$e->contact->last_name : null,
|
||||
'assigned_to' => $e->assigned_to,
|
||||
'assignee_name' => $e->assignee ? $e->assignee->first_name.' '.$e->assignee->last_name : null,
|
||||
'editable' => true,
|
||||
],
|
||||
]);
|
||||
$allEvents = $allEvents->merge($internalEvents);
|
||||
|
||||
// Get meeting bookings
|
||||
$bookings = \App\Models\Crm\CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
|
||||
// 2. Synced external events (Google/Outlook)
|
||||
$connections = CrmCalendarConnection::where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('sync_enabled', true)
|
||||
->pluck('id');
|
||||
|
||||
if ($connections->isNotEmpty()) {
|
||||
$syncedEvents = CrmSyncedEvent::whereIn('calendar_connection_id', $connections)
|
||||
->whereBetween('start_at', [$startDate, $endDate])
|
||||
->with('connection:id,provider')
|
||||
->get()
|
||||
->map(fn ($e) => [
|
||||
'id' => 'synced_'.$e->id,
|
||||
'title' => $e->title,
|
||||
'start' => $e->start_at->toIso8601String(),
|
||||
'end' => $e->end_at?->toIso8601String(),
|
||||
'allDay' => $e->all_day,
|
||||
'color' => $e->connection->provider === 'google' ? '#4285f4' : '#0078d4',
|
||||
'classNames' => ['calendar-event-synced', 'provider-'.$e->connection->provider],
|
||||
'extendedProps' => [
|
||||
'source' => 'synced',
|
||||
'provider' => $e->connection->provider,
|
||||
'location' => $e->location,
|
||||
'description' => $e->description,
|
||||
'attendees' => $e->attendees,
|
||||
'external_link' => $e->external_link,
|
||||
'editable' => false,
|
||||
],
|
||||
]);
|
||||
$allEvents = $allEvents->merge($syncedEvents);
|
||||
}
|
||||
|
||||
// 3. Meeting bookings
|
||||
$bookings = CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
|
||||
$q->where('business_id', $business->id)
|
||||
->where('user_id', $user->id);
|
||||
})
|
||||
->whereBetween('start_at', [$startDate, $endDate])
|
||||
->with(['meetingLink', 'contact'])
|
||||
->where('status', '!=', 'cancelled')
|
||||
->with(['meetingLink:id,name', 'contact:id,first_name,last_name'])
|
||||
->get()
|
||||
->map(fn ($b) => [
|
||||
'id' => 'booking_'.$b->id,
|
||||
'title' => $b->meetingLink->name.' - '.$b->booker_name,
|
||||
'title' => ($b->meetingLink->name ?? 'Meeting').' - '.$b->booker_name,
|
||||
'start' => $b->start_at->toIso8601String(),
|
||||
'end' => $b->end_at->toIso8601String(),
|
||||
'color' => '#10b981',
|
||||
'classNames' => ['calendar-event-booking'],
|
||||
'extendedProps' => [
|
||||
'type' => 'booking',
|
||||
'contact_id' => $b->contact_id,
|
||||
'source' => 'booking',
|
||||
'booking_id' => $b->id,
|
||||
'status' => $b->status,
|
||||
'booker_name' => $b->booker_name,
|
||||
'booker_email' => $b->booker_email,
|
||||
'contact_id' => $b->contact_id,
|
||||
'contact_name' => $b->contact ? $b->contact->first_name.' '.$b->contact->last_name : null,
|
||||
'location' => $b->location,
|
||||
'editable' => false,
|
||||
],
|
||||
]);
|
||||
$allEvents = $allEvents->merge($bookings);
|
||||
|
||||
$allEvents = $events->merge($bookings);
|
||||
// 4. CRM Tasks with due dates (shown as all-day markers)
|
||||
$tasks = CrmTask::forSellerBusiness($business->id)
|
||||
->incomplete()
|
||||
->whereNotNull('due_at')
|
||||
->whereBetween('due_at', [$startDate, $endDate])
|
||||
->with(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name'])
|
||||
->get()
|
||||
->map(fn ($t) => [
|
||||
'id' => 'task_'.$t->id,
|
||||
'title' => '📋 '.$t->title,
|
||||
'start' => $t->due_at->toDateString(),
|
||||
'allDay' => true,
|
||||
'color' => $t->isOverdue() ? '#EF4444' : '#F59E0B',
|
||||
'classNames' => ['calendar-event-task', $t->isOverdue() ? 'task-overdue' : ''],
|
||||
'extendedProps' => [
|
||||
'source' => 'task',
|
||||
'task_id' => $t->id,
|
||||
'type' => $t->type,
|
||||
'priority' => $t->priority,
|
||||
'contact_id' => $t->contact_id,
|
||||
'contact_name' => $t->contact ? $t->contact->first_name.' '.$t->contact->last_name : null,
|
||||
'assigned_to' => $t->assigned_to,
|
||||
'assignee_name' => $t->assignee ? $t->assignee->first_name.' '.$t->assignee->last_name : null,
|
||||
'is_overdue' => $t->isOverdue(),
|
||||
'editable' => false,
|
||||
],
|
||||
]);
|
||||
$allEvents = $allEvents->merge($tasks);
|
||||
|
||||
// Pass $business to view for route generation (Premium CRM uses seller.business.crm.* routes)
|
||||
return view('seller.crm.calendar.index', compact('business', 'connections', 'allEvents'));
|
||||
return response()->json($allEvents->values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new calendar event
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:5000',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'start_at' => 'required|date',
|
||||
'end_at' => 'nullable|date|after:start_at',
|
||||
'all_day' => 'boolean',
|
||||
'type' => 'required|string|in:'.implode(',', array_keys(CalendarEvent::TYPES)),
|
||||
'contact_id' => 'nullable|exists:contacts,id',
|
||||
'assigned_to' => 'nullable|exists:users,id',
|
||||
'reminder_minutes' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
// Security: verify contact belongs to a customer business
|
||||
if (! empty($validated['contact_id'])) {
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->findOrFail($validated['contact_id']);
|
||||
}
|
||||
|
||||
// Security: verify assignee belongs to business
|
||||
if (! empty($validated['assigned_to'])) {
|
||||
User::where('id', $validated['assigned_to'])
|
||||
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
$event = CalendarEvent::create([
|
||||
'seller_business_id' => $business->id,
|
||||
'created_by' => $request->user()->id,
|
||||
'assigned_to' => $validated['assigned_to'] ?? $request->user()->id,
|
||||
'title' => $validated['title'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'location' => $validated['location'] ?? null,
|
||||
'start_at' => $validated['start_at'],
|
||||
'end_at' => $validated['end_at'] ?? null,
|
||||
'all_day' => $validated['all_day'] ?? false,
|
||||
'type' => $validated['type'],
|
||||
'status' => 'scheduled',
|
||||
'contact_id' => $validated['contact_id'] ?? null,
|
||||
'reminder_at' => isset($validated['reminder_minutes']) && $validated['reminder_minutes'] > 0
|
||||
? now()->parse($validated['start_at'])->subMinutes($validated['reminder_minutes'])
|
||||
: null,
|
||||
]);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'event' => $event->load(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name']),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a calendar event
|
||||
*/
|
||||
public function update(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|required|string|max:255',
|
||||
'description' => 'nullable|string|max:5000',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'start_at' => 'sometimes|required|date',
|
||||
'end_at' => 'nullable|date|after:start_at',
|
||||
'all_day' => 'boolean',
|
||||
'type' => 'sometimes|required|string|in:'.implode(',', array_keys(CalendarEvent::TYPES)),
|
||||
'status' => 'sometimes|required|string|in:scheduled,completed,cancelled',
|
||||
'contact_id' => 'nullable|exists:contacts,id',
|
||||
'assigned_to' => 'nullable|exists:users,id',
|
||||
'reminder_minutes' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
// Security checks for contact and assignee
|
||||
if (isset($validated['contact_id']) && $validated['contact_id']) {
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->findOrFail($validated['contact_id']);
|
||||
}
|
||||
|
||||
if (isset($validated['assigned_to']) && $validated['assigned_to']) {
|
||||
User::where('id', $validated['assigned_to'])
|
||||
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
// Handle reminder
|
||||
if (isset($validated['reminder_minutes'])) {
|
||||
$validated['reminder_at'] = $validated['reminder_minutes'] > 0
|
||||
? now()->parse($validated['start_at'] ?? $event->start_at)->subMinutes($validated['reminder_minutes'])
|
||||
: null;
|
||||
$validated['reminder_sent'] = false;
|
||||
unset($validated['reminder_minutes']);
|
||||
}
|
||||
|
||||
$event->update($validated);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'event' => $event->fresh()->load(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name']),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick reschedule via drag-and-drop
|
||||
*/
|
||||
public function reschedule(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'start_at' => 'required|date',
|
||||
'end_at' => 'nullable|date|after:start_at',
|
||||
'all_day' => 'boolean',
|
||||
]);
|
||||
|
||||
$event->reschedule(
|
||||
$validated['start_at'],
|
||||
$validated['end_at'] ?? null,
|
||||
$request->user()
|
||||
);
|
||||
|
||||
if (isset($validated['all_day'])) {
|
||||
$event->update(['all_day' => $validated['all_day']]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'event' => $event->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark event as complete
|
||||
*/
|
||||
public function complete(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$event->markComplete($request->user());
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'event' => $event->fresh()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event marked as complete.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an event
|
||||
*/
|
||||
public function cancel(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$event->cancel($request->user());
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'event' => $event->fresh()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event cancelled.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$event->delete();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single event details (for modal)
|
||||
*/
|
||||
public function show(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$event->load([
|
||||
'contact:id,first_name,last_name,email,phone',
|
||||
'business:id,name',
|
||||
'assignee:id,first_name,last_name,email',
|
||||
'creator:id,first_name,last_name',
|
||||
]);
|
||||
|
||||
return response()->json($event);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +459,7 @@ class CrmCalendarController extends Controller
|
||||
|
||||
$params = http_build_query([
|
||||
'client_id' => config('services.google.client_id'),
|
||||
'redirect_uri' => route('seller.crm.calendar.callback'),
|
||||
'redirect_uri' => route('seller.business.crm.calendar.callback', $business),
|
||||
'response_type' => 'code',
|
||||
'scope' => 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events',
|
||||
'access_type' => 'offline',
|
||||
@@ -128,7 +483,7 @@ class CrmCalendarController extends Controller
|
||||
|
||||
$params = http_build_query([
|
||||
'client_id' => config('services.microsoft.client_id'),
|
||||
'redirect_uri' => route('seller.crm.calendar.callback'),
|
||||
'redirect_uri' => route('seller.business.crm.calendar.callback', $business),
|
||||
'response_type' => 'code',
|
||||
'scope' => 'offline_access Calendars.ReadWrite',
|
||||
'state' => $state,
|
||||
@@ -140,17 +495,17 @@ class CrmCalendarController extends Controller
|
||||
/**
|
||||
* OAuth callback
|
||||
*/
|
||||
public function callback(Request $request)
|
||||
public function callback(Request $request, Business $business)
|
||||
{
|
||||
if ($request->has('error')) {
|
||||
return redirect()->route('seller.crm.calendar.connections')
|
||||
return redirect()->route('seller.business.crm.calendar.connections', $business)
|
||||
->withErrors(['error' => 'Authorization failed: '.$request->input('error_description')]);
|
||||
}
|
||||
|
||||
try {
|
||||
$state = decrypt($request->input('state'));
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->route('seller.crm.calendar.connections')
|
||||
return redirect()->route('seller.business.crm.calendar.connections', $business)
|
||||
->withErrors(['error' => 'Invalid state parameter.']);
|
||||
}
|
||||
|
||||
@@ -161,7 +516,7 @@ class CrmCalendarController extends Controller
|
||||
$tokens = $this->calendarService->exchangeCodeForTokens($provider, $code);
|
||||
|
||||
if (! $tokens) {
|
||||
return redirect()->route('seller.crm.calendar.connections')
|
||||
return redirect()->route('seller.business.crm.calendar.connections', $business)
|
||||
->withErrors(['error' => 'Failed to obtain access token.']);
|
||||
}
|
||||
|
||||
@@ -189,7 +544,7 @@ class CrmCalendarController extends Controller
|
||||
// Queue initial sync
|
||||
SyncCalendarJob::dispatch($state['user_id'], $provider);
|
||||
|
||||
return redirect()->route('seller.crm.calendar.connections')
|
||||
return redirect()->route('seller.business.crm.calendar.connections', $business)
|
||||
->with('success', ucfirst($provider).' Calendar connected successfully.');
|
||||
}
|
||||
|
||||
@@ -238,34 +593,4 @@ class CrmCalendarController extends Controller
|
||||
|
||||
return back()->with('success', 'Calendar sync started. Events will appear shortly.');
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Get events for date range (for calendar JS)
|
||||
*/
|
||||
public function events(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'start' => 'required|date',
|
||||
'end' => 'required|date|after:start',
|
||||
]);
|
||||
|
||||
$connections = CrmCalendarConnection::where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->pluck('id');
|
||||
|
||||
$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_at->toIso8601String(),
|
||||
'end' => $e->end_at?->toIso8601String(),
|
||||
'allDay' => $e->all_day,
|
||||
]);
|
||||
|
||||
return response()->json($events);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,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\CrmPipeline;
|
||||
use App\Models\Crm\CrmRepMetric;
|
||||
use App\Models\Crm\CrmSlaTimer;
|
||||
use App\Models\Crm\CrmThread;
|
||||
@@ -32,6 +33,9 @@ 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);
|
||||
}
|
||||
|
||||
@@ -40,13 +44,26 @@ class CrmDashboardController extends Controller
|
||||
*/
|
||||
public function sales(Request $request, Business $business)
|
||||
{
|
||||
// Get the default pipeline for stage name mapping
|
||||
$defaultPipeline = CrmPipeline::where('business_id', $business->id)
|
||||
->where('is_default', true)
|
||||
->first();
|
||||
|
||||
// Pipeline summary
|
||||
$stageMap = collect($defaultPipeline?->stages ?? [])->mapWithKeys(function ($stage, $index) {
|
||||
return [$index => $stage['name'] ?? "Stage {$index}"];
|
||||
})->all();
|
||||
|
||||
// Pipeline summary - group by stage_id (index into pipeline stages JSON array)
|
||||
$pipelineSummary = CrmDeal::forBusiness($business->id)
|
||||
->open()
|
||||
->selectRaw('stage, count(*) as count, sum(value) as total_value, sum(weighted_value) as weighted_value')
|
||||
->groupBy('stage')
|
||||
->get();
|
||||
->selectRaw('stage_id, count(*) as count, sum(value) as total_value, sum(weighted_value) as weighted_value')
|
||||
->groupBy('stage_id')
|
||||
->get()
|
||||
->map(function ($item) use ($stageMap) {
|
||||
$item->stage_name = $stageMap[$item->stage_id] ?? "Stage {$item->stage_id}";
|
||||
|
||||
return $item;
|
||||
});
|
||||
|
||||
// Won/Lost this month
|
||||
$monthlyStats = [
|
||||
@@ -83,7 +100,8 @@ class CrmDashboardController extends Controller
|
||||
'monthlyStats',
|
||||
'closingThisMonth',
|
||||
'atRiskDeals',
|
||||
'leaderboard'
|
||||
'leaderboard',
|
||||
'business'
|
||||
));
|
||||
}
|
||||
|
||||
@@ -123,7 +141,8 @@ class CrmDashboardController extends Controller
|
||||
'slaMetrics',
|
||||
'repMetrics',
|
||||
'threadDistribution',
|
||||
'dealDistribution'
|
||||
'dealDistribution',
|
||||
'business'
|
||||
));
|
||||
}
|
||||
|
||||
@@ -163,19 +182,33 @@ class CrmDashboardController extends Controller
|
||||
->with('thread.contact')
|
||||
->get();
|
||||
|
||||
// Quick stats
|
||||
// Quick stats - consolidated into efficient queries
|
||||
$threadStats = CrmThread::forBusiness($business->id)
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_threads,
|
||||
SUM(CASE WHEN is_read = false AND status = 'open' THEN 1 ELSE 0 END) as unread_threads
|
||||
")
|
||||
->first();
|
||||
|
||||
$dealStats = CrmDeal::forBusiness($business->id)
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_deals,
|
||||
SUM(CASE WHEN status = 'open' AND owner_id = ? THEN 1 ELSE 0 END) as my_deals,
|
||||
SUM(CASE WHEN status = 'open' THEN value ELSE 0 END) as pipeline_value,
|
||||
SUM(CASE WHEN status = 'open' THEN weighted_value ELSE 0 END) as weighted_pipeline,
|
||||
SUM(CASE WHEN status = 'won' AND EXTRACT(MONTH FROM actual_close_date) = ? AND EXTRACT(YEAR FROM actual_close_date) = ? THEN value ELSE 0 END) as won_this_month
|
||||
", [$user->id, now()->month, now()->year])
|
||||
->first();
|
||||
|
||||
$stats = [
|
||||
'open_threads' => CrmThread::forBusiness($business->id)->open()->count(),
|
||||
'open_threads' => $threadStats->open_threads ?? 0,
|
||||
'my_threads' => $myThreads->count(),
|
||||
'unread_threads' => CrmThread::forBusiness($business->id)->unread()->count(),
|
||||
'open_deals' => CrmDeal::forBusiness($business->id)->open()->count(),
|
||||
'my_deals' => CrmDeal::forBusiness($business->id)->ownedBy($user->id)->open()->count(),
|
||||
'pipeline_value' => CrmDeal::forBusiness($business->id)->open()->sum('value'),
|
||||
'weighted_pipeline' => CrmDeal::forBusiness($business->id)->open()->sum('weighted_value'),
|
||||
'won_this_month' => CrmDeal::forBusiness($business->id)
|
||||
->won()
|
||||
->whereMonth('actual_close_date', now()->month)
|
||||
->sum('value'),
|
||||
'unread_threads' => $threadStats->unread_threads ?? 0,
|
||||
'open_deals' => $dealStats->open_deals ?? 0,
|
||||
'my_deals' => $dealStats->my_deals ?? 0,
|
||||
'pipeline_value' => $dealStats->pipeline_value ?? 0,
|
||||
'weighted_pipeline' => $dealStats->weighted_pipeline ?? 0,
|
||||
'won_this_month' => $dealStats->won_this_month ?? 0,
|
||||
'sla_compliance' => $this->slaService->getMetrics($business->id, 30)['compliance_rate'] ?? 100,
|
||||
];
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class CrmSettingsController extends Controller
|
||||
'tags' => CrmTag::where('business_id', $business->id)->count(),
|
||||
];
|
||||
|
||||
return view('seller.crm.settings.index', compact('stats'));
|
||||
return view('seller.crm.settings.index', compact('stats', 'business'));
|
||||
}
|
||||
|
||||
// ================== CHANNELS ==================
|
||||
@@ -44,17 +44,17 @@ class CrmSettingsController extends Controller
|
||||
->get()
|
||||
->groupBy('type');
|
||||
|
||||
return view('seller.crm.settings.channels.index', compact('channels'));
|
||||
return view('seller.crm.settings.channels.index', compact('channels', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create channel form
|
||||
*/
|
||||
public function createChannel(Request $request)
|
||||
public function createChannel(Request $request, Business $business)
|
||||
{
|
||||
$types = CrmChannel::TYPES;
|
||||
|
||||
return view('seller.crm.settings.channels.create', compact('types'));
|
||||
return view('seller.crm.settings.channels.create', compact('types', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,7 +86,7 @@ class CrmSettingsController extends Controller
|
||||
'is_default' => $validated['is_default'] ?? false,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.settings.channels')
|
||||
return redirect()->route('seller.business.crm.settings.channels', $business)
|
||||
->with('success', 'Channel created successfully.');
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ class CrmSettingsController extends Controller
|
||||
|
||||
$types = CrmChannel::TYPES;
|
||||
|
||||
return view('seller.crm.settings.channels.edit', compact('channel', 'types'));
|
||||
return view('seller.crm.settings.channels.edit', compact('channel', 'types', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,7 +132,7 @@ class CrmSettingsController extends Controller
|
||||
|
||||
$channel->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.settings.channels')
|
||||
return redirect()->route('seller.business.crm.settings.channels', $business)
|
||||
->with('success', 'Channel updated.');
|
||||
}
|
||||
|
||||
@@ -164,15 +164,15 @@ class CrmSettingsController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.settings.pipelines.index', compact('pipelines'));
|
||||
return view('seller.crm.settings.pipelines.index', compact('pipelines', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create pipeline form
|
||||
*/
|
||||
public function createPipeline()
|
||||
public function createPipeline(Request $request, Business $business)
|
||||
{
|
||||
return view('seller.crm.settings.pipelines.create');
|
||||
return view('seller.crm.settings.pipelines.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,7 +206,7 @@ class CrmSettingsController extends Controller
|
||||
'is_default' => $validated['is_default'] ?? false,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.settings.pipelines')
|
||||
return redirect()->route('seller.business.crm.settings.pipelines', $business)
|
||||
->with('success', 'Pipeline created.');
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ class CrmSettingsController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('seller.crm.settings.pipelines.edit', compact('pipeline'));
|
||||
return view('seller.crm.settings.pipelines.edit', compact('pipeline', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,7 +253,7 @@ class CrmSettingsController extends Controller
|
||||
|
||||
$pipeline->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.settings.pipelines')
|
||||
return redirect()->route('seller.business.crm.settings.pipelines', $business)
|
||||
->with('success', 'Pipeline updated.');
|
||||
}
|
||||
|
||||
@@ -288,15 +288,15 @@ class CrmSettingsController extends Controller
|
||||
->orderBy('priority')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.settings.sla.index', compact('policies'));
|
||||
return view('seller.crm.settings.sla.index', compact('policies', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SLA policy form
|
||||
*/
|
||||
public function createSlaPolicy()
|
||||
public function createSlaPolicy(Request $request, Business $business)
|
||||
{
|
||||
return view('seller.crm.settings.sla.create');
|
||||
return view('seller.crm.settings.sla.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,7 +330,7 @@ class CrmSettingsController extends Controller
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.settings.sla')
|
||||
return redirect()->route('seller.business.crm.settings.sla', $business)
|
||||
->with('success', 'SLA policy created.');
|
||||
}
|
||||
|
||||
@@ -344,7 +344,7 @@ class CrmSettingsController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('seller.crm.settings.sla.edit', compact('policy'));
|
||||
return view('seller.crm.settings.sla.edit', compact('policy', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,7 +371,7 @@ class CrmSettingsController extends Controller
|
||||
|
||||
$policy->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.settings.sla')
|
||||
return redirect()->route('seller.business.crm.settings.sla', $business)
|
||||
->with('success', 'SLA policy updated.');
|
||||
}
|
||||
|
||||
@@ -403,7 +403,7 @@ class CrmSettingsController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.settings.tags.index', compact('tags'));
|
||||
return view('seller.crm.settings.tags.index', compact('tags', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -478,7 +478,7 @@ class CrmSettingsController extends Controller
|
||||
->get()
|
||||
->groupBy('category');
|
||||
|
||||
return view('seller.crm.settings.templates.index', compact('templates'));
|
||||
return view('seller.crm.settings.templates.index', compact('templates', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,7 +489,7 @@ class CrmSettingsController extends Controller
|
||||
$categories = CrmMessageTemplate::CATEGORIES;
|
||||
$channels = CrmChannel::TYPES;
|
||||
|
||||
return view('seller.crm.settings.templates.create', compact('categories', 'channels'));
|
||||
return view('seller.crm.settings.templates.create', compact('categories', 'channels', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -519,7 +519,7 @@ class CrmSettingsController extends Controller
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.settings.templates')
|
||||
return redirect()->route('seller.business.crm.settings.templates', $business)
|
||||
->with('success', 'Template created.');
|
||||
}
|
||||
|
||||
@@ -536,7 +536,7 @@ class CrmSettingsController extends Controller
|
||||
$categories = CrmMessageTemplate::CATEGORIES;
|
||||
$channels = CrmChannel::TYPES;
|
||||
|
||||
return view('seller.crm.settings.templates.edit', compact('template', 'categories', 'channels'));
|
||||
return view('seller.crm.settings.templates.edit', compact('template', 'categories', 'channels', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -560,7 +560,7 @@ class CrmSettingsController extends Controller
|
||||
|
||||
$template->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.settings.templates')
|
||||
return redirect()->route('seller.business.crm.settings.templates', $business)
|
||||
->with('success', 'Template updated.');
|
||||
}
|
||||
|
||||
@@ -592,7 +592,7 @@ class CrmSettingsController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.settings.roles.index', compact('roles'));
|
||||
return view('seller.crm.settings.roles.index', compact('roles', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,10 +31,10 @@ class DealController extends Controller
|
||||
?? CrmPipeline::forBusiness($business->id)->default()->first()
|
||||
?? CrmPipeline::createDefault($business->id);
|
||||
|
||||
// Get deals grouped by stage
|
||||
// Build base query for deals
|
||||
$dealsQuery = CrmDeal::forBusiness($business->id)
|
||||
->where('pipeline_id', $pipeline->id)
|
||||
->with(['contact', 'account', 'owner']);
|
||||
->with(['contact:id,first_name,last_name,email', 'account:id,name', 'owner:id,first_name,last_name,email']);
|
||||
|
||||
// Filters
|
||||
if ($request->filled('owner_id')) {
|
||||
@@ -52,26 +52,50 @@ class DealController extends Controller
|
||||
$dealsQuery->open();
|
||||
}
|
||||
|
||||
$deals = $dealsQuery->get()->groupBy('stage');
|
||||
// Get deals grouped by stage using database grouping for efficiency
|
||||
// Limit to reasonable number per stage for board view
|
||||
$stages = $pipeline->stages ?? [];
|
||||
$deals = collect();
|
||||
foreach ($stages as $stage) {
|
||||
$stageDeals = (clone $dealsQuery)
|
||||
->where('stage', $stage['name'] ?? $stage)
|
||||
->orderByDesc('value')
|
||||
->limit(50)
|
||||
->get();
|
||||
$deals[$stage['name'] ?? $stage] = $stageDeals;
|
||||
}
|
||||
|
||||
// Get pipelines for selector
|
||||
$pipelines = CrmPipeline::forBusiness($business->id)->active()->get();
|
||||
// Get pipelines for selector (limited fields)
|
||||
$pipelines = CrmPipeline::forBusiness($business->id)
|
||||
->active()
|
||||
->select('id', 'name', 'stages', 'is_default')
|
||||
->get();
|
||||
|
||||
// Get team members
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
// Get team members (limited fields)
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->select('id', 'first_name', 'last_name', 'email')
|
||||
->get();
|
||||
|
||||
// Calculate stats with single efficient query using selectRaw
|
||||
$statsResult = CrmDeal::forBusiness($business->id)
|
||||
->open()
|
||||
->selectRaw('SUM(value) as total_value, SUM(weighted_value) as weighted_value, COUNT(*) as deals_count')
|
||||
->first();
|
||||
|
||||
$wonThisMonth = CrmDeal::forBusiness($business->id)
|
||||
->won()
|
||||
->whereMonth('actual_close_date', now()->month)
|
||||
->whereYear('actual_close_date', now()->year)
|
||||
->sum('value');
|
||||
|
||||
// Calculate stats
|
||||
$stats = [
|
||||
'total_value' => CrmDeal::forBusiness($business->id)->open()->sum('value'),
|
||||
'weighted_value' => CrmDeal::forBusiness($business->id)->open()->sum('weighted_value'),
|
||||
'deals_count' => CrmDeal::forBusiness($business->id)->open()->count(),
|
||||
'won_this_month' => CrmDeal::forBusiness($business->id)
|
||||
->won()
|
||||
->whereMonth('actual_close_date', now()->month)
|
||||
->sum('value'),
|
||||
'total_value' => $statsResult->total_value ?? 0,
|
||||
'weighted_value' => $statsResult->weighted_value ?? 0,
|
||||
'deals_count' => $statsResult->deals_count ?? 0,
|
||||
'won_this_month' => $wonThisMonth,
|
||||
];
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,15 +103,36 @@ class DealController extends Controller
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$pipelines = CrmPipeline::forBusiness($business->id)->active()->get();
|
||||
$contacts = Contact::where('business_id', $business->id)->get();
|
||||
$pipelines = CrmPipeline::forBusiness($business->id)
|
||||
->active()
|
||||
->select('id', 'name', 'stages', 'is_default')
|
||||
->get();
|
||||
|
||||
// Limit contacts for dropdown - most recent 100
|
||||
$contacts = Contact::where('business_id', $business->id)
|
||||
->select('id', 'first_name', 'last_name', 'email')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
// Limit accounts for dropdown - most recent 100
|
||||
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
|
||||
$q->whereHas('items.product.brand', fn ($b) => $b->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();
|
||||
})
|
||||
->select('id', 'name')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
return view('seller.crm.deals.create', compact('pipelines', 'contacts', 'accounts', 'teamMembers', 'brands'));
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->select('id', 'first_name', 'last_name', 'email')
|
||||
->get();
|
||||
|
||||
$brands = Brand::where('business_id', $business->id)
|
||||
->select('id', 'name')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.deals.create', compact('pipelines', 'contacts', 'accounts', 'teamMembers', 'brands', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,7 +200,7 @@ class DealController extends Controller
|
||||
'status' => CrmDeal::STATUS_OPEN,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.deals.show', $deal)
|
||||
return redirect()->route('seller.business.crm.deals.show', [$business, $deal])
|
||||
->with('success', 'Deal created successfully.');
|
||||
}
|
||||
|
||||
@@ -191,7 +236,7 @@ class DealController extends Controller
|
||||
$deal->refresh();
|
||||
}
|
||||
|
||||
return view('seller.crm.deals.show', compact('deal', 'suggestions'));
|
||||
return view('seller.crm.deals.show', compact('deal', 'suggestions', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,7 +373,7 @@ class DealController extends Controller
|
||||
|
||||
$deal->delete();
|
||||
|
||||
return redirect()->route('seller.crm.deals.index')
|
||||
return redirect()->route('seller.business.crm.deals.index', $business)
|
||||
->with('success', 'Deal deleted.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,24 +32,33 @@ class InvoiceController extends Controller
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('invoice_number', 'like', "%{$request->search}%")
|
||||
->orWhere('title', 'like', "%{$request->search}%");
|
||||
$q->where('invoice_number', 'ILIKE', "%{$request->search}%")
|
||||
->orWhere('title', 'ILIKE', "%{$request->search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$invoices = $query->orderByDesc('created_at')->paginate(25);
|
||||
|
||||
// Stats
|
||||
// Stats - single efficient query with conditional aggregation
|
||||
$invoiceStats = CrmInvoice::forBusiness($business->id)
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN amount_due ELSE 0 END) as outstanding,
|
||||
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN amount_due ELSE 0 END) as overdue
|
||||
")
|
||||
->first();
|
||||
|
||||
$paidThisMonth = CrmInvoicePayment::whereHas('invoice', fn ($q) => $q->where('business_id', $business->id))
|
||||
->whereMonth('payment_date', now()->month)
|
||||
->whereYear('payment_date', now()->year)
|
||||
->sum('amount');
|
||||
|
||||
$stats = [
|
||||
'outstanding' => CrmInvoice::forBusiness($business->id)->outstanding()->sum('amount_due'),
|
||||
'overdue' => CrmInvoice::forBusiness($business->id)->overdue()->sum('amount_due'),
|
||||
'paid_this_month' => CrmInvoicePayment::whereHas('invoice', fn ($q) => $q->where('business_id', $business->id))
|
||||
->whereMonth('payment_date', now()->month)
|
||||
->whereYear('payment_date', now()->year)
|
||||
->sum('amount'),
|
||||
'outstanding' => $invoiceStats->outstanding ?? 0,
|
||||
'overdue' => $invoiceStats->overdue ?? 0,
|
||||
'paid_this_month' => $paidThisMonth,
|
||||
];
|
||||
|
||||
return view('seller.crm.invoices.index', compact('invoices', 'stats'));
|
||||
return view('seller.crm.invoices.index', compact('invoices', 'stats', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +72,7 @@ class InvoiceController extends Controller
|
||||
|
||||
$invoice->load(['contact', 'account', 'quote', 'creator', 'items.product', 'payments']);
|
||||
|
||||
return view('seller.crm.invoices.show', compact('invoice'));
|
||||
return view('seller.crm.invoices.show', compact('invoice', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,13 +80,23 @@ class InvoiceController extends Controller
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$contacts = \App\Models\Contact::where('business_id', $business->id)->get();
|
||||
// Limit contacts for dropdown - most recent 100
|
||||
$contacts = \App\Models\Contact::where('business_id', $business->id)
|
||||
->select('id', 'first_name', 'last_name', 'email', 'company_name')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
// Limit quotes to accepted without invoices
|
||||
$quotes = CrmQuote::forBusiness($business->id)
|
||||
->where('status', CrmQuote::STATUS_ACCEPTED)
|
||||
->whereDoesntHave('invoice')
|
||||
->select('id', 'quote_number', 'title', 'total', 'contact_id')
|
||||
->with('contact:id,first_name,last_name')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
return view('seller.crm.invoices.create', compact('contacts', 'quotes'));
|
||||
return view('seller.crm.invoices.create', compact('contacts', 'quotes', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +163,7 @@ class InvoiceController extends Controller
|
||||
|
||||
$invoice->calculateTotals();
|
||||
|
||||
return redirect()->route('seller.crm.invoices.show', $invoice)
|
||||
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||
->with('success', 'Invoice created successfully.');
|
||||
}
|
||||
|
||||
@@ -259,7 +278,7 @@ class InvoiceController extends Controller
|
||||
|
||||
$invoice->delete();
|
||||
|
||||
return redirect()->route('seller.crm.invoices.index')
|
||||
return redirect()->route('seller.business.crm.invoices.index', $business)
|
||||
->with('success', 'Invoice deleted.');
|
||||
}
|
||||
}
|
||||
|
||||
158
app/Http/Controllers/Seller/Crm/LeadController.php
Normal file
158
app/Http/Controllers/Seller/Crm/LeadController.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmLead;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LeadController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display leads listing
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$query = CrmLead::forSeller($business)
|
||||
->with('assignee')
|
||||
->notConverted();
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('q')) {
|
||||
$search = $request->q;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('company_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('contact_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('contact_email', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ($request->filled('status') && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$leads = $query->latest()->paginate(25);
|
||||
|
||||
return view('seller.crm.leads.index', compact('business', 'leads'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create lead form
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
return view('seller.crm.leads.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new lead
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'company_name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'license_number' => 'nullable|string|max:100',
|
||||
'contact_name' => 'required|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_title' => 'nullable|string|max:100',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'zip_code' => 'nullable|string|max:20',
|
||||
'source' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::SOURCES)),
|
||||
'notes' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
$validated['seller_business_id'] = $business->id;
|
||||
$validated['status'] = 'new';
|
||||
|
||||
$lead = CrmLead::create($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.leads.show', [$business->slug, $lead->hashid])
|
||||
->with('success', 'Lead created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show lead details
|
||||
*/
|
||||
public function show(Request $request, Business $business, CrmLead $lead)
|
||||
{
|
||||
// Ensure lead belongs to this seller
|
||||
if ($lead->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$lead->load('assignee');
|
||||
|
||||
return view('seller.crm.leads.show', compact('business', 'lead'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit lead form
|
||||
*/
|
||||
public function edit(Request $request, Business $business, CrmLead $lead)
|
||||
{
|
||||
// Ensure lead belongs to this seller
|
||||
if ($lead->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('seller.crm.leads.edit', compact('business', 'lead'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a lead
|
||||
*/
|
||||
public function update(Request $request, Business $business, CrmLead $lead)
|
||||
{
|
||||
// Ensure lead belongs to this seller
|
||||
if ($lead->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'company_name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'license_number' => 'nullable|string|max:100',
|
||||
'contact_name' => 'required|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_title' => 'nullable|string|max:100',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'zip_code' => 'nullable|string|max:20',
|
||||
'source' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::SOURCES)),
|
||||
'status' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::STATUSES)),
|
||||
'notes' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
$lead->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.leads.show', [$business->slug, $lead->hashid])
|
||||
->with('success', 'Lead updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a lead
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, CrmLead $lead)
|
||||
{
|
||||
// Ensure lead belongs to this seller
|
||||
if ($lead->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$lead->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.leads.index', $business->slug)
|
||||
->with('success', 'Lead deleted.');
|
||||
}
|
||||
}
|
||||
@@ -28,15 +28,15 @@ class MeetingLinkController extends Controller
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.meetings.links.index', compact('meetingLinks'));
|
||||
return view('seller.crm.meetings.links.index', compact('meetingLinks', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create meeting link form
|
||||
*/
|
||||
public function create()
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
return view('seller.crm.meetings.links.create');
|
||||
return view('seller.crm.meetings.links.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +81,7 @@ class MeetingLinkController extends Controller
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.meetings.links.show', $meetingLink)
|
||||
return redirect()->route('seller.business.crm.meetings.links.show', [$business, $meetingLink])
|
||||
->with('success', 'Meeting link created. Share the booking URL with contacts.');
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ class MeetingLinkController extends Controller
|
||||
|
||||
$meetingLink->load(['bookings' => fn ($q) => $q->upcoming()->with('contact')]);
|
||||
|
||||
return view('seller.crm.meetings.links.show', compact('meetingLink'));
|
||||
return view('seller.crm.meetings.links.show', compact('meetingLink', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,7 +108,7 @@ class MeetingLinkController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('seller.crm.meetings.links.edit', compact('meetingLink'));
|
||||
return view('seller.crm.meetings.links.edit', compact('meetingLink', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +136,7 @@ class MeetingLinkController extends Controller
|
||||
|
||||
$meetingLink->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.meetings.links.show', $meetingLink)
|
||||
return redirect()->route('seller.business.crm.meetings.links.show', [$business, $meetingLink])
|
||||
->with('success', 'Meeting link updated.');
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ class MeetingLinkController extends Controller
|
||||
|
||||
$meetingLink->delete();
|
||||
|
||||
return redirect()->route('seller.crm.meetings.links.index')
|
||||
return redirect()->route('seller.business.crm.meetings.links.index', $business)
|
||||
->with('success', 'Meeting link deleted.');
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ class MeetingLinkController extends Controller
|
||||
->orderBy('start_time')
|
||||
->paginate(25);
|
||||
|
||||
return view('seller.crm.meetings.bookings.index', compact('bookings'));
|
||||
return view('seller.crm.meetings.bookings.index', compact('bookings', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,13 +3,21 @@
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\QuoteMail;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Crm\CrmDeal;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use App\Models\Crm\CrmQuoteItem;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Services\Accounting\ArService;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class QuoteController extends Controller
|
||||
{
|
||||
@@ -36,7 +44,7 @@ class QuoteController extends Controller
|
||||
|
||||
$quotes = $query->orderByDesc('created_at')->paginate(25);
|
||||
|
||||
return view('seller.crm.quotes.index', compact('quotes'));
|
||||
return view('seller.crm.quotes.index', compact('quotes', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,21 +52,24 @@ class QuoteController extends Controller
|
||||
*/
|
||||
public function create(Request $request, Business $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));
|
||||
})->get();
|
||||
$deals = CrmDeal::forBusiness($business->id)->open()->get();
|
||||
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->where('is_active', true)
|
||||
// Get buyer businesses that have contacts (potential and existing customers)
|
||||
// Contacts are loaded dynamically via /search/contacts?customer_id={account_id}
|
||||
// Include locations for delivery address selection
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->whereHas('contacts')
|
||||
->with('locations:id,business_id,name,is_primary')
|
||||
->orderBy('name')
|
||||
->select(['id', 'name', 'slug'])
|
||||
->get();
|
||||
$deals = CrmDeal::forBusiness($business->id)->open()->get();
|
||||
// Products are loaded via AJAX search (/search/products) for better performance
|
||||
|
||||
// Pre-fill from deal if provided
|
||||
$deal = $request->filled('deal_id')
|
||||
? CrmDeal::forBusiness($business->id)->find($request->deal_id)
|
||||
: null;
|
||||
|
||||
return view('seller.crm.quotes.create', compact('contacts', 'accounts', 'deals', 'products', 'deal'));
|
||||
return view('seller.crm.quotes.create', compact('accounts', 'deals', 'deal', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,10 +97,13 @@ class QuoteController extends Controller
|
||||
'items.*.discount_percent' => 'nullable|numeric|min:0|max:100',
|
||||
]);
|
||||
|
||||
// SECURITY: Verify contact belongs to business
|
||||
Contact::where('id', $validated['contact_id'])
|
||||
->where('business_id', $business->id)
|
||||
->firstOrFail();
|
||||
// SECURITY: Verify contact belongs to the selected account (customer business)
|
||||
// Contacts are associated with buyer businesses, not the seller
|
||||
if (! empty($validated['account_id'])) {
|
||||
Contact::where('id', $validated['contact_id'])
|
||||
->where('business_id', $validated['account_id'])
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
// SECURITY: Verify deal belongs to business if provided
|
||||
if (! empty($validated['deal_id'])) {
|
||||
@@ -135,7 +149,7 @@ class QuoteController extends Controller
|
||||
|
||||
$quote->calculateTotals();
|
||||
|
||||
return redirect()->route('seller.crm.quotes.show', $quote)
|
||||
return redirect()->route('seller.business.crm.quotes.show', [$business, $quote])
|
||||
->with('success', 'Quote created successfully.');
|
||||
}
|
||||
|
||||
@@ -150,7 +164,7 @@ class QuoteController extends Controller
|
||||
|
||||
$quote->load(['contact', 'account', 'deal', 'creator', 'items.product', 'invoice', 'files']);
|
||||
|
||||
return view('seller.crm.quotes.show', compact('quote'));
|
||||
return view('seller.crm.quotes.show', compact('quote', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,7 +189,7 @@ class QuoteController extends Controller
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
return view('seller.crm.quotes.edit', compact('quote', 'contacts', 'accounts', 'deals', 'products'));
|
||||
return view('seller.crm.quotes.edit', compact('quote', 'contacts', 'accounts', 'deals', 'products', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,12 +248,12 @@ class QuoteController extends Controller
|
||||
|
||||
$quote->calculateTotals();
|
||||
|
||||
return redirect()->route('seller.crm.quotes.show', $quote)
|
||||
return redirect()->route('seller.business.crm.quotes.show', [$business, $quote])
|
||||
->with('success', 'Quote updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send quote to contact
|
||||
* Send quote via email
|
||||
*/
|
||||
public function send(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
@@ -247,21 +261,256 @@ class QuoteController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $quote->canBeSent()) {
|
||||
return back()->withErrors(['error' => 'This quote cannot be sent.']);
|
||||
$validated = $request->validate([
|
||||
'to' => 'required|email',
|
||||
'cc' => 'nullable|string',
|
||||
'message' => 'nullable|string|max:2000',
|
||||
'attach_pdf' => 'boolean',
|
||||
]);
|
||||
|
||||
// Generate PDF if needed
|
||||
$pdfPath = null;
|
||||
if ($validated['attach_pdf'] ?? true) {
|
||||
$pdfPath = $this->generateQuotePdf($quote, $business);
|
||||
}
|
||||
|
||||
$quote->send($request->user());
|
||||
// Send email
|
||||
$ccEmails = [];
|
||||
if (! empty($validated['cc'])) {
|
||||
$ccEmails = array_map('trim', explode(',', $validated['cc']));
|
||||
}
|
||||
|
||||
// TODO: Send email notification to contact
|
||||
Mail::to($validated['to'])
|
||||
->cc($ccEmails)
|
||||
->send(new QuoteMail($quote, $business, $validated['message'] ?? null, $pdfPath));
|
||||
|
||||
// Update quote status if draft
|
||||
if ($quote->status === CrmQuote::STATUS_DRAFT) {
|
||||
$quote->send($request->user());
|
||||
}
|
||||
|
||||
// Log activity
|
||||
Activity::log(
|
||||
sellerBusinessId: $business->id,
|
||||
subject: $quote,
|
||||
type: 'quote.emailed',
|
||||
description: "Quote {$quote->quote_number} emailed to {$validated['to']}",
|
||||
causer: $request->user(),
|
||||
contactId: $quote->contact_id,
|
||||
);
|
||||
|
||||
return back()->with('success', 'Quote sent successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update quote status (accept/decline/expire)
|
||||
*/
|
||||
public function updateStatus(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|in:accepted,rejected,expired',
|
||||
'note' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$oldStatus = $quote->status;
|
||||
|
||||
if ($validated['status'] === 'accepted') {
|
||||
$quote->accept();
|
||||
} elseif ($validated['status'] === 'rejected') {
|
||||
$quote->reject($validated['note'] ?? 'Declined by seller');
|
||||
} else {
|
||||
$quote->update([
|
||||
'status' => CrmQuote::STATUS_EXPIRED,
|
||||
]);
|
||||
}
|
||||
|
||||
Activity::log(
|
||||
sellerBusinessId: $business->id,
|
||||
subject: $quote,
|
||||
type: 'quote.status_changed',
|
||||
description: "Quote {$quote->quote_number} status changed from {$oldStatus} to {$validated['status']}",
|
||||
causer: $request->user(),
|
||||
contactId: $quote->contact_id,
|
||||
);
|
||||
|
||||
return back()->with('success', 'Quote status updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert quote to order
|
||||
*/
|
||||
public function convertToOrder(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($quote->order_id) {
|
||||
return back()->withErrors(['error' => 'This quote already has an order.']);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'also_create_invoice' => 'boolean',
|
||||
]);
|
||||
|
||||
// Create order from quote
|
||||
$orderNumber = 'ORD-'.strtoupper(uniqid());
|
||||
|
||||
$order = Order::create([
|
||||
'order_number' => $orderNumber,
|
||||
'business_id' => $quote->account_id, // Buyer business
|
||||
'seller_business_id' => $business->id,
|
||||
'contact_id' => $quote->contact_id,
|
||||
'user_id' => $request->user()->id,
|
||||
'subtotal' => $quote->subtotal,
|
||||
'surcharge' => 0,
|
||||
'tax' => $quote->tax_amount,
|
||||
'total' => $quote->total,
|
||||
'status' => 'new',
|
||||
'created_by' => 'seller',
|
||||
'payment_terms' => 'net_30',
|
||||
'notes' => $quote->notes,
|
||||
]);
|
||||
|
||||
// Copy line items
|
||||
foreach ($quote->items as $item) {
|
||||
OrderItem::create([
|
||||
'order_id' => $order->id,
|
||||
'product_id' => $item->product_id,
|
||||
'quantity' => $item->quantity,
|
||||
'unit_price' => $item->unit_price,
|
||||
'line_total' => $item->line_total,
|
||||
'product_name' => $item->product?->name ?? $item->description,
|
||||
'product_sku' => $item->product?->sku ?? '',
|
||||
'brand_name' => $item->product?->brand?->name ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
// Link quote to order and update status
|
||||
$quote->update([
|
||||
'order_id' => $order->id,
|
||||
'status' => CrmQuote::STATUS_ACCEPTED,
|
||||
'accepted_at' => now(),
|
||||
]);
|
||||
|
||||
// Log activity
|
||||
Activity::log(
|
||||
sellerBusinessId: $business->id,
|
||||
subject: $quote,
|
||||
type: 'quote.converted_to_order',
|
||||
description: "Quote {$quote->quote_number} converted to Order {$orderNumber}",
|
||||
causer: $request->user(),
|
||||
contactId: $quote->contact_id,
|
||||
);
|
||||
|
||||
// Optionally create invoice
|
||||
if ($validated['also_create_invoice'] ?? false) {
|
||||
$invoice = $quote->convertToInvoice();
|
||||
|
||||
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||
->with('success', 'Order and invoice created from quote.');
|
||||
}
|
||||
|
||||
return redirect()->route('seller.business.orders.show', [$business, $order])
|
||||
->with('success', 'Order created from quote.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate invoice from quote (or its order)
|
||||
*/
|
||||
public function generateInvoice(Request $request, Business $business, CrmQuote $quote, ArService $arService)
|
||||
{
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($quote->invoice) {
|
||||
return back()->withErrors(['error' => 'This quote already has an invoice.']);
|
||||
}
|
||||
|
||||
// Credit check if there's a buyer account
|
||||
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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$invoice = $quote->convertToInvoice();
|
||||
|
||||
Activity::log(
|
||||
sellerBusinessId: $business->id,
|
||||
subject: $quote,
|
||||
type: 'quote.invoice_generated',
|
||||
description: "Invoice {$invoice->invoice_number} generated from Quote {$quote->quote_number}",
|
||||
causer: $request->user(),
|
||||
contactId: $quote->contact_id,
|
||||
);
|
||||
|
||||
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||
->with('success', 'Invoice created from quote.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and store quote PDF
|
||||
*/
|
||||
protected function generateQuotePdf(CrmQuote $quote, Business $business): ?string
|
||||
{
|
||||
$quote->load(['contact', 'account', 'items.product.brand', 'business']);
|
||||
|
||||
$pdf = Pdf::loadView('pdfs.crm-quote', [
|
||||
'quote' => $quote,
|
||||
'business' => $business,
|
||||
'sellerBusiness' => $business,
|
||||
]);
|
||||
|
||||
$filename = "quotes/{$quote->quote_number}.pdf";
|
||||
Storage::put($filename, $pdf->output());
|
||||
|
||||
$quote->update(['pdf_path' => $filename]);
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* View quote PDF
|
||||
*/
|
||||
public function pdf(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$quote->load(['contact', 'account', 'items.product.brand', 'business']);
|
||||
|
||||
$pdf = Pdf::loadView('pdfs.crm-quote', [
|
||||
'quote' => $quote,
|
||||
'business' => $business,
|
||||
'sellerBusiness' => $business,
|
||||
]);
|
||||
|
||||
return $pdf->inline("{$quote->quote_number}.pdf");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert quote to invoice
|
||||
*/
|
||||
public function convertToInvoice(Request $request, Business $business, CrmQuote $quote)
|
||||
public function convertToInvoice(Request $request, Business $business, CrmQuote $quote, ArService $arService)
|
||||
{
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -275,9 +524,33 @@ 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)
|
||||
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||
->with('success', 'Invoice created from quote.');
|
||||
}
|
||||
|
||||
@@ -305,7 +578,7 @@ class QuoteController extends Controller
|
||||
|
||||
$quote->delete();
|
||||
|
||||
return redirect()->route('seller.crm.quotes.index')
|
||||
return redirect()->route('seller.business.crm.quotes.index', $business)
|
||||
->with('success', 'Quote deleted.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -41,25 +42,32 @@ class TaskController extends Controller
|
||||
|
||||
$tasks = $tasksQuery->paginate(25);
|
||||
|
||||
// Get stats
|
||||
// Get stats with single efficient query
|
||||
$statsQuery = CrmTask::where('seller_business_id', $business->id)
|
||||
->selectRaw('
|
||||
SUM(CASE WHEN assigned_to = ? AND completed_at IS NULL THEN 1 ELSE 0 END) as my_tasks,
|
||||
SUM(CASE WHEN completed_at IS NULL AND due_at < NOW() THEN 1 ELSE 0 END) as overdue,
|
||||
SUM(CASE WHEN completed_at IS NULL AND DATE(due_at) = CURRENT_DATE THEN 1 ELSE 0 END) as due_today
|
||||
', [$user->id])
|
||||
->first();
|
||||
|
||||
$stats = [
|
||||
'my_tasks' => CrmTask::where('seller_business_id', $business->id)
|
||||
->where('assigned_to', $user->id)
|
||||
->whereNull('completed_at')
|
||||
->count(),
|
||||
'overdue' => CrmTask::where('seller_business_id', $business->id)
|
||||
->whereNull('completed_at')
|
||||
->where('due_at', '<', now())
|
||||
->count(),
|
||||
'due_today' => CrmTask::where('seller_business_id', $business->id)
|
||||
->whereNull('completed_at')
|
||||
->whereDate('due_at', today())
|
||||
->count(),
|
||||
'my_tasks' => $statsQuery->my_tasks ?? 0,
|
||||
'overdue' => $statsQuery->overdue ?? 0,
|
||||
'due_today' => $statsQuery->due_today ?? 0,
|
||||
];
|
||||
|
||||
$counts = $stats; // View expects $counts
|
||||
|
||||
return view('seller.crm.tasks.index', compact('business', 'tasks', 'counts'));
|
||||
// Get team members for assignment filter
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
|
||||
// Get buyer businesses (accounts) for filtering
|
||||
$buyerBusinesses = Business::where('type', 'buyer')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
return view('seller.crm.tasks.index', compact('business', 'tasks', 'counts', 'teamMembers', 'buyerBusinesses'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,13 +22,113 @@ class ThreadController extends Controller
|
||||
protected CrmAiService $aiService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Show compose form for new thread
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
// Get customer business IDs (businesses that have ordered from this seller)
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
// Get contacts from customer businesses (accounts)
|
||||
$contacts = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->with('business:id,name')
|
||||
->orderBy('first_name')
|
||||
->limit(200)
|
||||
->get();
|
||||
|
||||
// Get available channels
|
||||
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||
|
||||
// Pre-select contact if provided
|
||||
$selectedContact = null;
|
||||
if ($request->filled('contact_id')) {
|
||||
$selectedContact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->find($request->contact_id);
|
||||
}
|
||||
|
||||
return view('seller.crm.threads.create', compact('business', 'contacts', 'channels', 'selectedContact'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new thread and send initial message
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'contact_id' => 'required|exists:contacts,id',
|
||||
'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app',
|
||||
'subject' => 'nullable|string|max:255',
|
||||
'body' => 'required|string|max:10000',
|
||||
'attachments.*' => 'nullable|file|max:10240',
|
||||
]);
|
||||
|
||||
// Get customer business IDs (businesses that have ordered from this seller)
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
// SECURITY: Verify contact belongs to a customer business
|
||||
$contact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->findOrFail($validated['contact_id']);
|
||||
|
||||
// Determine recipient address
|
||||
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
|
||||
? $contact->email
|
||||
: $contact->phone;
|
||||
|
||||
if (! $to) {
|
||||
return back()->withInput()->withErrors([
|
||||
'channel_type' => 'Contact does not have the required contact info for this channel.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Create thread first
|
||||
$thread = CrmThread::create([
|
||||
'business_id' => $business->id,
|
||||
'contact_id' => $contact->id,
|
||||
'account_id' => $contact->account_id,
|
||||
'subject' => $validated['subject'],
|
||||
'status' => 'open',
|
||||
'priority' => 'normal',
|
||||
'last_channel_type' => $validated['channel_type'],
|
||||
'assigned_to' => $request->user()->id,
|
||||
]);
|
||||
|
||||
// Send the message
|
||||
$success = $this->channelService->sendMessage(
|
||||
businessId: $business->id,
|
||||
channelType: $validated['channel_type'],
|
||||
to: $to,
|
||||
body: $validated['body'],
|
||||
subject: $validated['subject'] ?? null,
|
||||
threadId: $thread->id,
|
||||
contactId: $contact->id,
|
||||
userId: $request->user()->id,
|
||||
attachments: $request->file('attachments', [])
|
||||
);
|
||||
|
||||
if (! $success) {
|
||||
// Delete the thread if message failed
|
||||
$thread->delete();
|
||||
|
||||
return back()->withInput()->withErrors(['body' => 'Failed to send message.']);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.threads.show', [$business, $thread])
|
||||
->with('success', 'Conversation started successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display unified inbox
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$query = CrmThread::forBusiness($business->id)
|
||||
->with(['contact', 'assignee', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||
->with(['contact', 'assignee', 'brand', 'channel', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||
->withCount('messages');
|
||||
|
||||
// Filters
|
||||
@@ -52,6 +152,16 @@ class ThreadController extends Controller
|
||||
$query->withPriority($request->priority);
|
||||
}
|
||||
|
||||
// Department filter
|
||||
if ($request->filled('department')) {
|
||||
$query->forDepartment($request->department);
|
||||
}
|
||||
|
||||
// Brand filter
|
||||
if ($request->filled('brand_id')) {
|
||||
$query->forBrand($request->brand_id);
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('subject', 'like', "%{$request->search}%")
|
||||
@@ -70,7 +180,16 @@ class ThreadController extends Controller
|
||||
// Get available channels
|
||||
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||
|
||||
return view('seller.crm.threads.index', compact('threads', 'teamMembers', 'channels'));
|
||||
// Get brands for filter dropdown
|
||||
$brands = \App\Models\Brand::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get departments for filter dropdown
|
||||
$departments = CrmChannel::DEPARTMENTS;
|
||||
|
||||
return view('seller.crm.threads.index', compact('business', 'threads', 'teamMembers', 'channels', 'brands', 'departments'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +207,8 @@ class ThreadController extends Controller
|
||||
'contact',
|
||||
'account',
|
||||
'assignee',
|
||||
'brand',
|
||||
'channel',
|
||||
'messages.attachments',
|
||||
'messages.user',
|
||||
'deals',
|
||||
@@ -117,6 +238,7 @@ class ThreadController extends Controller
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
|
||||
return view('seller.crm.threads.show', compact(
|
||||
'business',
|
||||
'thread',
|
||||
'otherViewers',
|
||||
'slaStatus',
|
||||
@@ -167,6 +289,12 @@ class ThreadController extends Controller
|
||||
return back()->withErrors(['body' => 'Failed to send message.']);
|
||||
}
|
||||
|
||||
// Auto-assign thread to sender if unassigned
|
||||
if ($thread->assigned_to === null) {
|
||||
$thread->assigned_to = $request->user()->id;
|
||||
$thread->save();
|
||||
}
|
||||
|
||||
// Handle SLA
|
||||
$this->slaService->handleOutboundMessage($thread);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class EmailSettingsController extends Controller
|
||||
'business' => $business,
|
||||
'settings' => $settings,
|
||||
'drivers' => BusinessMailSettings::DRIVERS,
|
||||
'providers' => BusinessMailSettings::PROVIDERS,
|
||||
'encryptions' => BusinessMailSettings::ENCRYPTIONS,
|
||||
'commonPorts' => BusinessMailSettings::COMMON_PORTS,
|
||||
]);
|
||||
@@ -34,6 +35,7 @@ class EmailSettingsController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'driver' => ['required', 'string', Rule::in(array_keys(BusinessMailSettings::DRIVERS))],
|
||||
'provider' => ['required', 'string', Rule::in(array_keys(BusinessMailSettings::PROVIDERS))],
|
||||
'host' => ['nullable', 'string', 'max:255'],
|
||||
'port' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||
'encryption' => ['nullable', 'string', Rule::in(['tls', 'ssl', ''])],
|
||||
@@ -43,6 +45,9 @@ class EmailSettingsController extends Controller
|
||||
'from_email' => ['nullable', 'email', 'max:255'],
|
||||
'reply_to_email' => ['nullable', 'email', 'max:255'],
|
||||
'is_active' => ['boolean'],
|
||||
// Postal-specific config fields
|
||||
'postal_server_url' => ['nullable', 'url', 'max:255'],
|
||||
'postal_webhook_secret' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
// Handle empty encryption value
|
||||
@@ -55,6 +60,21 @@ class EmailSettingsController extends Controller
|
||||
unset($validated['password']);
|
||||
}
|
||||
|
||||
// Build provider_config from provider-specific fields
|
||||
$providerConfig = [];
|
||||
if ($validated['provider'] === BusinessMailSettings::PROVIDER_POSTAL) {
|
||||
if (! empty($validated['postal_server_url'])) {
|
||||
$providerConfig['server_url'] = $validated['postal_server_url'];
|
||||
}
|
||||
if (! empty($validated['postal_webhook_secret'])) {
|
||||
$providerConfig['webhook_secret'] = $validated['postal_webhook_secret'];
|
||||
}
|
||||
}
|
||||
$validated['provider_config'] = ! empty($providerConfig) ? $providerConfig : null;
|
||||
|
||||
// Remove provider-specific fields from main validated array
|
||||
unset($validated['postal_server_url'], $validated['postal_webhook_secret']);
|
||||
|
||||
$settings = BusinessMailSettings::getOrCreate($business);
|
||||
$settings->update($validated);
|
||||
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\Invoices\InvoiceSentMail;
|
||||
use App\Models\Business;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoicePayment;
|
||||
use App\Services\InvoiceService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -25,64 +28,7 @@ class InvoiceController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get all products from brands owned by this business with images, stock levels, and batches
|
||||
$products = \App\Models\Product::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->with(['brand', 'images', 'availableBatches.labs'])
|
||||
->select('id', 'brand_id', 'name', 'sku', 'description', 'wholesale_price', 'msrp_price',
|
||||
'quantity_on_hand', 'quantity_allocated', 'type', 'image_path')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function ($product) use ($business) {
|
||||
// Map batches with their COA data
|
||||
$batches = $product->availableBatches->map(function ($batch) {
|
||||
$latestLab = $batch->getLatestLab();
|
||||
|
||||
return [
|
||||
'id' => $batch->id,
|
||||
'batch_number' => $batch->batch_number,
|
||||
'quantity_available' => $batch->quantity_available,
|
||||
'production_date' => $batch->production_date?->format('M j, Y'),
|
||||
'expiration_date' => $batch->expiration_date?->format('M j, Y'),
|
||||
'is_expiring_soon' => $batch->isExpiringSoon(),
|
||||
'lab' => $latestLab ? [
|
||||
'total_thc' => $latestLab->total_thc,
|
||||
'total_cbd' => $latestLab->total_cbd,
|
||||
'test_date' => $latestLab->test_date->format('M j, Y'),
|
||||
'lab_name' => $latestLab->lab_name,
|
||||
'compliance_pass' => $latestLab->compliance_pass,
|
||||
'terpene_profile' => $latestLab->terpene_profile,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
|
||||
// Calculate inventory from InventoryItem model
|
||||
$totalOnHand = $product->inventoryItems()
|
||||
->where('business_id', $business->id)
|
||||
->sum('quantity_on_hand');
|
||||
$totalAllocated = $product->inventoryItems()
|
||||
->where('business_id', $business->id)
|
||||
->sum('quantity_allocated');
|
||||
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'sku' => $product->sku,
|
||||
'description' => $product->description,
|
||||
'brand_name' => $product->brand?->name,
|
||||
'wholesale_price' => $product->wholesale_price,
|
||||
'msrp_price' => $product->msrp_price,
|
||||
'quantity_on_hand' => $totalOnHand,
|
||||
'quantity_allocated' => $totalAllocated,
|
||||
'quantity_available' => max(0, $totalOnHand - $totalAllocated),
|
||||
'type' => $product->type,
|
||||
'image_url' => $product->images->first()?->path
|
||||
? \Storage::url($product->images->first()->path)
|
||||
: ($product->image_path ? \Storage::url($product->image_path) : null),
|
||||
'batches' => $batches,
|
||||
'has_batches' => $batches->count() > 0,
|
||||
];
|
||||
});
|
||||
// Products are loaded via API search (/search/invoice-products) for better performance
|
||||
|
||||
// Get recently invoiced products (last 30 days, top 10 most common)
|
||||
$recentProducts = \App\Models\Product::forBusiness($business)
|
||||
@@ -118,7 +64,7 @@ class InvoiceController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
return view('seller.invoices.create', compact('business', 'buyers', 'products', 'recentProducts'));
|
||||
return view('seller.invoices.create', compact('business', 'buyers', 'recentProducts'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,22 +120,30 @@ class InvoiceController extends Controller
|
||||
*/
|
||||
public function index(Business $business)
|
||||
{
|
||||
// Get invoices where orders contain items from brands under this business
|
||||
$invoices = Invoice::with(['order.items.product.brand', 'order.contact', 'order.user', 'business'])
|
||||
->whereHas('order.items.product', function ($query) use ($business) {
|
||||
$query->forBusiness($business);
|
||||
})
|
||||
->latest()
|
||||
->get();
|
||||
// Get brand IDs for this business (single query, reused for filtering)
|
||||
$brandIds = $business->brands()->pluck('id');
|
||||
|
||||
// Base query: invoices where orders contain items from this business's brands
|
||||
$baseQuery = Invoice::whereHas('order.items.product', function ($query) use ($brandIds) {
|
||||
$query->whereIn('brand_id', $brandIds);
|
||||
});
|
||||
|
||||
// Calculate stats with efficient database aggregates (not in-memory iteration)
|
||||
$stats = [
|
||||
'total' => $invoices->count(),
|
||||
'unpaid' => $invoices->where('payment_status', 'unpaid')->count(),
|
||||
'partially_paid' => $invoices->where('payment_status', 'partially_paid')->count(),
|
||||
'paid' => $invoices->where('payment_status', 'paid')->count(),
|
||||
'overdue' => $invoices->filter(fn ($inv) => $inv->isOverdue())->count(),
|
||||
'total' => (clone $baseQuery)->count(),
|
||||
'unpaid' => (clone $baseQuery)->where('payment_status', 'unpaid')->count(),
|
||||
'partially_paid' => (clone $baseQuery)->where('payment_status', 'partially_paid')->count(),
|
||||
'paid' => (clone $baseQuery)->where('payment_status', 'paid')->count(),
|
||||
'overdue' => (clone $baseQuery)->where('payment_status', '!=', 'paid')
|
||||
->where('due_date', '<', now())->count(),
|
||||
];
|
||||
|
||||
// Paginate with only the relations needed for display
|
||||
$invoices = (clone $baseQuery)
|
||||
->with(['business:id,name,primary_contact_email,business_email', 'order:id,contact_id,user_id', 'order.contact:id,first_name,last_name,email', 'order.user:id,email'])
|
||||
->latest()
|
||||
->paginate(25);
|
||||
|
||||
return view('seller.invoices.index', compact('business', 'invoices', 'stats'));
|
||||
}
|
||||
|
||||
@@ -199,7 +153,13 @@ class InvoiceController extends Controller
|
||||
public function show(Business $business, Invoice $invoice)
|
||||
{
|
||||
// Verify invoice belongs to this business through order items
|
||||
$invoice->load(['order.items.product.brand', 'business']);
|
||||
$invoice->load([
|
||||
'order.items.product.brand',
|
||||
'order.contact',
|
||||
'order.user',
|
||||
'business',
|
||||
'payments.recordedByUser',
|
||||
]);
|
||||
|
||||
// Check if any of the order's items belong to brands owned by this business
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
@@ -289,4 +249,102 @@ class InvoiceController extends Controller
|
||||
'contacts' => $contacts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send invoice by email.
|
||||
*/
|
||||
public function send(Business $business, Invoice $invoice, Request $request, InvoiceService $invoiceService): Response
|
||||
{
|
||||
// Verify invoice belongs to this business through order items
|
||||
$invoice->load('order.items.product.brand');
|
||||
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
return $item->product && $item->product->belongsToBusiness($business);
|
||||
});
|
||||
|
||||
if (! $belongsToBusiness) {
|
||||
abort(403, 'This invoice does not belong to your business');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'to' => ['required', 'email'],
|
||||
'cc' => ['nullable', 'email'],
|
||||
'message' => ['nullable', 'string', 'max:2000'],
|
||||
'attach_pdf' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
// Generate PDF if requested
|
||||
$pdfContent = null;
|
||||
if ($validated['attach_pdf'] ?? false) {
|
||||
// Regenerate PDF if it doesn't exist
|
||||
if (! $invoice->pdf_path || ! Storage::disk('local')->exists($invoice->pdf_path)) {
|
||||
$invoiceService->regeneratePdf($invoice);
|
||||
$invoice->refresh();
|
||||
}
|
||||
|
||||
if ($invoice->pdf_path && Storage::disk('local')->exists($invoice->pdf_path)) {
|
||||
$pdfContent = Storage::disk('local')->get($invoice->pdf_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Send email
|
||||
$mail = Mail::to($validated['to']);
|
||||
|
||||
if (! empty($validated['cc'])) {
|
||||
$mail->cc($validated['cc']);
|
||||
}
|
||||
|
||||
$mail->send(new InvoiceSentMail(
|
||||
$invoice,
|
||||
$validated['message'] ?? null,
|
||||
$pdfContent
|
||||
));
|
||||
|
||||
return back()->with('success', 'Invoice sent successfully to '.$validated['to']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a payment for an invoice.
|
||||
*/
|
||||
public function recordPayment(Business $business, Invoice $invoice, Request $request): Response
|
||||
{
|
||||
// Verify invoice belongs to this business through order items
|
||||
$invoice->load('order.items.product.brand');
|
||||
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
return $item->product && $item->product->belongsToBusiness($business);
|
||||
});
|
||||
|
||||
if (! $belongsToBusiness) {
|
||||
abort(403, 'This invoice does not belong to your business');
|
||||
}
|
||||
|
||||
if ($invoice->payment_status === 'paid') {
|
||||
return back()->withErrors(['error' => 'This invoice is already fully paid.']);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'amount' => ['required', 'numeric', 'min:0.01', 'max:'.$invoice->amount_due],
|
||||
'payment_date' => ['required', 'date'],
|
||||
'payment_method' => ['required', 'string', 'in:cash,check,wire,ach,credit_card,bank_transfer,other'],
|
||||
'reference' => ['nullable', 'string', 'max:255'],
|
||||
'notes' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
InvoicePayment::create([
|
||||
'invoice_id' => $invoice->id,
|
||||
'amount' => $validated['amount'],
|
||||
'payment_date' => $validated['payment_date'],
|
||||
'payment_method' => $validated['payment_method'],
|
||||
'reference' => $validated['reference'],
|
||||
'notes' => $validated['notes'],
|
||||
'recorded_by' => $request->user()->id,
|
||||
]);
|
||||
|
||||
$statusMessage = $invoice->fresh()->payment_status === 'paid'
|
||||
? 'Payment recorded. Invoice is now fully paid.'
|
||||
: 'Payment recorded successfully.';
|
||||
|
||||
return back()->with('success', $statusMessage);
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Seller/Manufacturing/MfgBatchController.php
Normal file
106
app/Http/Controllers/Seller/Manufacturing/MfgBatchController.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Manufacturing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Manufacturing\MfgBatch;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MfgBatchController extends Controller
|
||||
{
|
||||
public function index(Business $business, Request $request): View
|
||||
{
|
||||
$query = MfgBatch::forBusiness($business->id)
|
||||
->with(['product', 'workOrder']);
|
||||
|
||||
// Filter by status
|
||||
if ($request->filled('status')) {
|
||||
$query->status($request->status);
|
||||
}
|
||||
|
||||
$batches = $query->orderBy('created_at', 'desc')->paginate(20);
|
||||
|
||||
$stats = [
|
||||
'open' => MfgBatch::forBusiness($business->id)->status('open')->count(),
|
||||
'under_qc' => MfgBatch::forBusiness($business->id)->status('under_qc')->count(),
|
||||
'released' => MfgBatch::forBusiness($business->id)->status('released')->count(),
|
||||
'rejected' => MfgBatch::forBusiness($business->id)->status('rejected')->count(),
|
||||
];
|
||||
|
||||
return view('seller.manufacturing.batches.index', [
|
||||
'business' => $business,
|
||||
'batches' => $batches,
|
||||
'stats' => $stats,
|
||||
'currentStatus' => $request->status,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Business $business, MfgBatch $batch): View
|
||||
{
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$batch->load(['product', 'workOrder.recipe', 'inputs.inputProduct']);
|
||||
|
||||
return view('seller.manufacturing.batches.show', [
|
||||
'business' => $business,
|
||||
'batch' => $batch,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send batch to QC.
|
||||
*/
|
||||
public function sendToQc(Business $business, MfgBatch $batch): RedirectResponse
|
||||
{
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($batch->status !== 'open') {
|
||||
return back()->with('error', 'Only open batches can be sent to QC.');
|
||||
}
|
||||
|
||||
$batch->update(['status' => 'under_qc']);
|
||||
|
||||
return back()->with('success', 'Batch sent to QC.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Release batch.
|
||||
*/
|
||||
public function release(Business $business, MfgBatch $batch): RedirectResponse
|
||||
{
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $batch->release()) {
|
||||
return back()->with('error', 'Cannot release this batch. Must be under QC first.');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Batch released.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject batch.
|
||||
*/
|
||||
public function reject(Business $business, MfgBatch $batch, Request $request): RedirectResponse
|
||||
{
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'reason' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$batch->reject($validated['reason'] ?? null);
|
||||
|
||||
return back()->with('success', 'Batch rejected.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Manufacturing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Manufacturing\MfgBatch;
|
||||
use App\Models\Manufacturing\MfgComplianceRecord;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MfgComplianceController extends Controller
|
||||
{
|
||||
public function index(Business $business, Request $request): View
|
||||
{
|
||||
$query = MfgComplianceRecord::forBusiness($business->id)
|
||||
->with('batch');
|
||||
|
||||
if ($request->filled('type')) {
|
||||
$query->where('record_type', $request->type);
|
||||
}
|
||||
|
||||
$records = $query->orderBy('created_at', 'desc')->paginate(20);
|
||||
|
||||
$recordTypes = MfgComplianceRecord::forBusiness($business->id)
|
||||
->distinct()
|
||||
->pluck('record_type')
|
||||
->filter();
|
||||
|
||||
return view('seller.manufacturing.compliance-records.index', [
|
||||
'business' => $business,
|
||||
'records' => $records,
|
||||
'recordTypes' => $recordTypes,
|
||||
'currentType' => $request->type,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Business $business): View
|
||||
{
|
||||
$batches = MfgBatch::forBusiness($business->id)
|
||||
->orderBy('batch_number')
|
||||
->get(['id', 'batch_number']);
|
||||
|
||||
return view('seller.manufacturing.compliance-records.create', [
|
||||
'business' => $business,
|
||||
'batches' => $batches,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'record_type' => 'required|string|max:100',
|
||||
'title' => 'required|string|max:255',
|
||||
'mfg_batch_id' => 'nullable|exists:mfg_batches,id',
|
||||
'description' => 'nullable|string',
|
||||
'document' => 'nullable|file|max:10240|mimes:pdf,doc,docx,jpg,jpeg,png',
|
||||
'issued_at' => 'nullable|date',
|
||||
'expires_at' => 'nullable|date|after:issued_at',
|
||||
'external_reference' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$documentPath = null;
|
||||
if ($request->hasFile('document')) {
|
||||
$documentPath = $request->file('document')->store(
|
||||
"businesses/{$business->id}/mfg-compliance",
|
||||
'private'
|
||||
);
|
||||
}
|
||||
|
||||
MfgComplianceRecord::create([
|
||||
'business_id' => $business->id,
|
||||
'record_type' => $validated['record_type'],
|
||||
'title' => $validated['title'],
|
||||
'mfg_batch_id' => $validated['mfg_batch_id'] ?? null,
|
||||
'description' => $validated['description'] ?? null,
|
||||
'document_path' => $documentPath,
|
||||
'issued_at' => $validated['issued_at'] ?? null,
|
||||
'expires_at' => $validated['expires_at'] ?? null,
|
||||
'external_reference' => $validated['external_reference'] ?? null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.compliance-records.index', $business->slug)
|
||||
->with('success', 'Compliance record created.');
|
||||
}
|
||||
|
||||
public function show(Business $business, MfgComplianceRecord $complianceRecord): View
|
||||
{
|
||||
if ($complianceRecord->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$complianceRecord->load('batch');
|
||||
|
||||
return view('seller.manufacturing.compliance-records.show', [
|
||||
'business' => $business,
|
||||
'record' => $complianceRecord,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the compliance document.
|
||||
*/
|
||||
public function download(Business $business, MfgComplianceRecord $complianceRecord)
|
||||
{
|
||||
if ($complianceRecord->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $complianceRecord->document_path) {
|
||||
abort(404, 'No document attached.');
|
||||
}
|
||||
|
||||
return Storage::disk('private')->download($complianceRecord->document_path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Manufacturing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Manufacturing\MfgCustomer;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MfgCustomerController extends Controller
|
||||
{
|
||||
public function index(Business $business): View
|
||||
{
|
||||
$customers = MfgCustomer::forBusiness($business->id)
|
||||
->withCount('salesOrders')
|
||||
->orderBy('name')
|
||||
->paginate(20);
|
||||
|
||||
return view('seller.manufacturing.customers.index', [
|
||||
'business' => $business,
|
||||
'customers' => $customers,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Business $business): View
|
||||
{
|
||||
return view('seller.manufacturing.customers.create', [
|
||||
'business' => $business,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'nullable|string|max:50',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'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',
|
||||
'notes' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
MfgCustomer::create([
|
||||
'business_id' => $business->id,
|
||||
...$validated,
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.customers.index', $business->slug)
|
||||
->with('success', 'Customer created.');
|
||||
}
|
||||
|
||||
public function show(Business $business, MfgCustomer $customer): View
|
||||
{
|
||||
if ($customer->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$customer->load(['salesOrders' => fn ($q) => $q->latest()->limit(10)]);
|
||||
|
||||
return view('seller.manufacturing.customers.show', [
|
||||
'business' => $business,
|
||||
'customer' => $customer,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Business $business, MfgCustomer $customer): View
|
||||
{
|
||||
if ($customer->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return view('seller.manufacturing.customers.edit', [
|
||||
'business' => $business,
|
||||
'customer' => $customer,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Business $business, MfgCustomer $customer, Request $request): RedirectResponse
|
||||
{
|
||||
if ($customer->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'nullable|string|max:50',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'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',
|
||||
'notes' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$customer->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.customers.index', $business->slug)
|
||||
->with('success', 'Customer updated.');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, MfgCustomer $customer): RedirectResponse
|
||||
{
|
||||
if ($customer->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$customer->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.customers.index', $business->slug)
|
||||
->with('success', 'Customer deleted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Manufacturing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Manufacturing\MfgBatch;
|
||||
use App\Models\Manufacturing\MfgWorkOrder;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MfgDashboardController extends Controller
|
||||
{
|
||||
public function index(Business $business): View
|
||||
{
|
||||
// Today's work orders
|
||||
$todaysWorkOrders = MfgWorkOrder::forBusiness($business->id)
|
||||
->whereDate('scheduled_start_at', today())
|
||||
->with(['product', 'recipe'])
|
||||
->orderBy('scheduled_start_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Open batches (not released or rejected)
|
||||
$openBatches = MfgBatch::forBusiness($business->id)
|
||||
->whereIn('status', ['open', 'under_qc'])
|
||||
->with(['product', 'workOrder'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Work order stats
|
||||
$workOrderStats = [
|
||||
'planned' => MfgWorkOrder::forBusiness($business->id)->status('planned')->count(),
|
||||
'in_progress' => MfgWorkOrder::forBusiness($business->id)->status('in_progress')->count(),
|
||||
'completed_today' => MfgWorkOrder::forBusiness($business->id)
|
||||
->status('completed')
|
||||
->whereDate('actual_end_at', today())
|
||||
->count(),
|
||||
];
|
||||
|
||||
// Batch stats
|
||||
$batchStats = [
|
||||
'open' => MfgBatch::forBusiness($business->id)->status('open')->count(),
|
||||
'under_qc' => MfgBatch::forBusiness($business->id)->status('under_qc')->count(),
|
||||
'released_today' => MfgBatch::forBusiness($business->id)
|
||||
->status('released')
|
||||
->whereDate('updated_at', today())
|
||||
->count(),
|
||||
];
|
||||
|
||||
return view('seller.manufacturing.dashboard', [
|
||||
'business' => $business,
|
||||
'todaysWorkOrders' => $todaysWorkOrders,
|
||||
'openBatches' => $openBatches,
|
||||
'workOrderStats' => $workOrderStats,
|
||||
'batchStats' => $batchStats,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Manufacturing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Manufacturing\MfgInventoryItem;
|
||||
use App\Models\Manufacturing\MfgInventoryMovement;
|
||||
use App\Models\Manufacturing\MfgWarehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MfgInventoryController extends Controller
|
||||
{
|
||||
public function index(Business $business, Request $request): View
|
||||
{
|
||||
$query = MfgInventoryItem::forBusiness($business->id)
|
||||
->with(['product', 'warehouse', 'location']);
|
||||
|
||||
// Filter by warehouse
|
||||
if ($request->filled('warehouse_id')) {
|
||||
$query->where('mfg_warehouse_id', $request->warehouse_id);
|
||||
}
|
||||
|
||||
$items = $query->orderBy('product_id')->paginate(50);
|
||||
|
||||
$warehouses = MfgWarehouse::forBusiness($business->id)
|
||||
->active()
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Summary stats
|
||||
$totalItems = MfgInventoryItem::forBusiness($business->id)->count();
|
||||
$lowStockItems = MfgInventoryItem::forBusiness($business->id)
|
||||
->whereColumn('quantity_on_hand', '<', 'quantity_reserved')
|
||||
->count();
|
||||
|
||||
return view('seller.manufacturing.inventory.index', [
|
||||
'business' => $business,
|
||||
'items' => $items,
|
||||
'warehouses' => $warehouses,
|
||||
'currentWarehouseId' => $request->warehouse_id,
|
||||
'totalItems' => $totalItems,
|
||||
'lowStockItems' => $lowStockItems,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Business $business, MfgInventoryItem $item): View
|
||||
{
|
||||
if ($item->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$item->load(['product', 'warehouse', 'location']);
|
||||
|
||||
// Recent movements for this item
|
||||
$movements = MfgInventoryMovement::forBusiness($business->id)
|
||||
->where('product_id', $item->product_id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
return view('seller.manufacturing.inventory.show', [
|
||||
'business' => $business,
|
||||
'item' => $item,
|
||||
'movements' => $movements,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show inventory movements (ledger).
|
||||
*/
|
||||
public function movements(Business $business, Request $request): View
|
||||
{
|
||||
$query = MfgInventoryMovement::forBusiness($business->id)
|
||||
->with(['product', 'sourceWarehouse', 'targetWarehouse']);
|
||||
|
||||
if ($request->filled('type')) {
|
||||
$query->type($request->type);
|
||||
}
|
||||
|
||||
$movements = $query->orderBy('created_at', 'desc')->paginate(50);
|
||||
|
||||
return view('seller.manufacturing.inventory.movements', [
|
||||
'business' => $business,
|
||||
'movements' => $movements,
|
||||
'currentType' => $request->type,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Manufacturing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Manufacturing\MfgInventoryMovement;
|
||||
use App\Models\Manufacturing\MfgPurchaseOrder;
|
||||
use App\Models\Manufacturing\MfgPurchaseOrderLine;
|
||||
use App\Models\Manufacturing\MfgVendor;
|
||||
use App\Models\Manufacturing\MfgWarehouse;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MfgPurchaseOrderController extends Controller
|
||||
{
|
||||
public function index(Business $business, Request $request): View
|
||||
{
|
||||
$query = MfgPurchaseOrder::forBusiness($business->id)
|
||||
->with(['vendor', 'lines']);
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$purchaseOrders = $query->orderBy('created_at', 'desc')->paginate(20);
|
||||
|
||||
$stats = [
|
||||
'draft' => MfgPurchaseOrder::forBusiness($business->id)->where('status', 'draft')->count(),
|
||||
'submitted' => MfgPurchaseOrder::forBusiness($business->id)->where('status', 'submitted')->count(),
|
||||
'received' => MfgPurchaseOrder::forBusiness($business->id)->where('status', 'received')->count(),
|
||||
];
|
||||
|
||||
return view('seller.manufacturing.purchase-orders.index', [
|
||||
'business' => $business,
|
||||
'purchaseOrders' => $purchaseOrders,
|
||||
'stats' => $stats,
|
||||
'currentStatus' => $request->status,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Business $business): View
|
||||
{
|
||||
$vendors = MfgVendor::forBusiness($business->id)->active()->orderBy('name')->get();
|
||||
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))->orderBy('name')->get();
|
||||
$warehouses = MfgWarehouse::forBusiness($business->id)->active()->orderBy('name')->get();
|
||||
|
||||
return view('seller.manufacturing.purchase-orders.create', [
|
||||
'business' => $business,
|
||||
'vendors' => $vendors,
|
||||
'products' => $products,
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'mfg_vendor_id' => 'required|exists:mfg_vendors,id',
|
||||
'mfg_warehouse_id' => 'required|exists:mfg_warehouses,id',
|
||||
'expected_delivery_at' => 'nullable|date',
|
||||
'notes' => 'nullable|string',
|
||||
'lines' => 'required|array|min:1',
|
||||
'lines.*.product_id' => 'required|exists:products,id',
|
||||
'lines.*.quantity' => 'required|numeric|min:0.0001',
|
||||
'lines.*.unit_price' => 'nullable|numeric|min:0',
|
||||
'lines.*.uom' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($business, $validated) {
|
||||
$po = MfgPurchaseOrder::create([
|
||||
'business_id' => $business->id,
|
||||
'mfg_vendor_id' => $validated['mfg_vendor_id'],
|
||||
'mfg_warehouse_id' => $validated['mfg_warehouse_id'],
|
||||
'po_number' => MfgPurchaseOrder::generatePoNumber($business->id),
|
||||
'status' => 'draft',
|
||||
'expected_delivery_at' => $validated['expected_delivery_at'] ?? null,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
foreach ($validated['lines'] as $index => $line) {
|
||||
MfgPurchaseOrderLine::create([
|
||||
'mfg_purchase_order_id' => $po->id,
|
||||
'product_id' => $line['product_id'],
|
||||
'quantity_ordered' => $line['quantity'],
|
||||
'quantity_received' => 0,
|
||||
'unit_price' => $line['unit_price'] ?? 0,
|
||||
'uom' => $line['uom'] ?? 'unit',
|
||||
'line_number' => $index + 1,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.purchase-orders.index', $business->slug)
|
||||
->with('success', 'Purchase order created.');
|
||||
}
|
||||
|
||||
public function show(Business $business, MfgPurchaseOrder $purchaseOrder): View
|
||||
{
|
||||
if ($purchaseOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$purchaseOrder->load(['vendor', 'warehouse', 'lines.product']);
|
||||
|
||||
return view('seller.manufacturing.purchase-orders.show', [
|
||||
'business' => $business,
|
||||
'purchaseOrder' => $purchaseOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Business $business, MfgPurchaseOrder $purchaseOrder): View
|
||||
{
|
||||
if ($purchaseOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($purchaseOrder->status === 'received') {
|
||||
return redirect()
|
||||
->route('seller.business.mfg.purchase-orders.show', [$business->slug, $purchaseOrder->id])
|
||||
->with('error', 'Cannot edit a received purchase order.');
|
||||
}
|
||||
|
||||
$purchaseOrder->load(['lines.product']);
|
||||
$vendors = MfgVendor::forBusiness($business->id)->active()->orderBy('name')->get();
|
||||
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))->orderBy('name')->get();
|
||||
$warehouses = MfgWarehouse::forBusiness($business->id)->active()->orderBy('name')->get();
|
||||
|
||||
return view('seller.manufacturing.purchase-orders.edit', [
|
||||
'business' => $business,
|
||||
'purchaseOrder' => $purchaseOrder,
|
||||
'vendors' => $vendors,
|
||||
'products' => $products,
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Business $business, MfgPurchaseOrder $purchaseOrder, Request $request): RedirectResponse
|
||||
{
|
||||
if ($purchaseOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($purchaseOrder->status === 'received') {
|
||||
return back()->with('error', 'Cannot edit a received purchase order.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'mfg_vendor_id' => 'required|exists:mfg_vendors,id',
|
||||
'mfg_warehouse_id' => 'required|exists:mfg_warehouses,id',
|
||||
'expected_delivery_at' => 'nullable|date',
|
||||
'notes' => 'nullable|string',
|
||||
'lines' => 'required|array|min:1',
|
||||
'lines.*.id' => 'nullable|exists:mfg_purchase_order_lines,id',
|
||||
'lines.*.product_id' => 'required|exists:products,id',
|
||||
'lines.*.quantity' => 'required|numeric|min:0.0001',
|
||||
'lines.*.unit_price' => 'nullable|numeric|min:0',
|
||||
'lines.*.uom' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($purchaseOrder, $validated) {
|
||||
$purchaseOrder->update([
|
||||
'mfg_vendor_id' => $validated['mfg_vendor_id'],
|
||||
'mfg_warehouse_id' => $validated['mfg_warehouse_id'],
|
||||
'expected_delivery_at' => $validated['expected_delivery_at'] ?? null,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
// Delete existing lines and recreate
|
||||
$purchaseOrder->lines()->delete();
|
||||
|
||||
foreach ($validated['lines'] as $index => $line) {
|
||||
MfgPurchaseOrderLine::create([
|
||||
'mfg_purchase_order_id' => $purchaseOrder->id,
|
||||
'product_id' => $line['product_id'],
|
||||
'quantity_ordered' => $line['quantity'],
|
||||
'quantity_received' => 0,
|
||||
'unit_price' => $line['unit_price'] ?? 0,
|
||||
'uom' => $line['uom'] ?? 'unit',
|
||||
'line_number' => $index + 1,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.purchase-orders.show', [$business->slug, $purchaseOrder->id])
|
||||
->with('success', 'Purchase order updated.');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, MfgPurchaseOrder $purchaseOrder): RedirectResponse
|
||||
{
|
||||
if ($purchaseOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($purchaseOrder->status === 'received') {
|
||||
return back()->with('error', 'Cannot delete a received purchase order.');
|
||||
}
|
||||
|
||||
$purchaseOrder->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.purchase-orders.index', $business->slug)
|
||||
->with('success', 'Purchase order deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the PO to the vendor.
|
||||
*/
|
||||
public function submit(Business $business, MfgPurchaseOrder $purchaseOrder): RedirectResponse
|
||||
{
|
||||
if ($purchaseOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($purchaseOrder->status !== 'draft') {
|
||||
return back()->with('error', 'Only draft purchase orders can be submitted.');
|
||||
}
|
||||
|
||||
$purchaseOrder->update([
|
||||
'status' => 'submitted',
|
||||
'submitted_at' => now(),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Purchase order submitted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the PO as received and create inventory movements.
|
||||
*/
|
||||
public function receive(Business $business, MfgPurchaseOrder $purchaseOrder, Request $request): RedirectResponse
|
||||
{
|
||||
if ($purchaseOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($purchaseOrder->status === 'received') {
|
||||
return back()->with('error', 'Purchase order already received.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'lines' => 'required|array',
|
||||
'lines.*.id' => 'required|exists:mfg_purchase_order_lines,id',
|
||||
'lines.*.quantity_received' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($purchaseOrder, $validated, $business) {
|
||||
// Batch load all lines upfront to avoid N+1
|
||||
$lineIds = collect($validated['lines'])->pluck('id');
|
||||
$lines = MfgPurchaseOrderLine::whereIn('id', $lineIds)
|
||||
->where('mfg_purchase_order_id', $purchaseOrder->id)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$lineDataById = collect($validated['lines'])->keyBy('id');
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$lineData = $lineDataById[$line->id];
|
||||
$line->update([
|
||||
'quantity_received' => $lineData['quantity_received'],
|
||||
]);
|
||||
|
||||
// Create inventory movement for received quantity
|
||||
if ($lineData['quantity_received'] > 0) {
|
||||
MfgInventoryMovement::create([
|
||||
'business_id' => $business->id,
|
||||
'product_id' => $line->product_id,
|
||||
'target_warehouse_id' => $purchaseOrder->mfg_warehouse_id,
|
||||
'quantity' => $lineData['quantity_received'],
|
||||
'uom' => $line->uom,
|
||||
'movement_type' => 'receive',
|
||||
'reference_type' => 'purchase_order',
|
||||
'reference_id' => $purchaseOrder->id,
|
||||
'reason' => 'PO Receipt: '.$purchaseOrder->po_number,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$purchaseOrder->update([
|
||||
'status' => 'received',
|
||||
'received_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Purchase order marked as received. Inventory updated.');
|
||||
}
|
||||
}
|
||||
221
app/Http/Controllers/Seller/Manufacturing/MfgQcController.php
Normal file
221
app/Http/Controllers/Seller/Manufacturing/MfgQcController.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Manufacturing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Manufacturing\MfgBatch;
|
||||
use App\Models\Manufacturing\MfgQcResult;
|
||||
use App\Models\Manufacturing\MfgQcTest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MfgQcController extends Controller
|
||||
{
|
||||
/**
|
||||
* List QC test definitions.
|
||||
*/
|
||||
public function index(Business $business): View
|
||||
{
|
||||
$tests = MfgQcTest::forBusiness($business->id)
|
||||
->withCount('results')
|
||||
->orderBy('name')
|
||||
->paginate(20);
|
||||
|
||||
return view('seller.manufacturing.qc-tests.index', [
|
||||
'business' => $business,
|
||||
'tests' => $tests,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show form to create a QC test definition.
|
||||
*/
|
||||
public function create(Business $business): View
|
||||
{
|
||||
return view('seller.manufacturing.qc-tests.create', [
|
||||
'business' => $business,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new QC test definition.
|
||||
*/
|
||||
public function store(Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'nullable|string|max:50',
|
||||
'category' => 'nullable|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'min_value' => 'nullable|numeric',
|
||||
'max_value' => 'nullable|numeric',
|
||||
'target_value' => 'nullable|numeric',
|
||||
'uom' => 'nullable|string|max:50',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
MfgQcTest::create([
|
||||
'business_id' => $business->id,
|
||||
...$validated,
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.qc-tests.index', $business->slug)
|
||||
->with('success', 'QC test created.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a QC test definition.
|
||||
*/
|
||||
public function show(Business $business, MfgQcTest $qcTest): View
|
||||
{
|
||||
if ($qcTest->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$qcTest->load(['results' => fn ($q) => $q->latest()->limit(20)]);
|
||||
|
||||
return view('seller.manufacturing.qc-tests.show', [
|
||||
'business' => $business,
|
||||
'test' => $qcTest,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show form to edit a QC test definition.
|
||||
*/
|
||||
public function edit(Business $business, MfgQcTest $qcTest): View
|
||||
{
|
||||
if ($qcTest->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return view('seller.manufacturing.qc-tests.edit', [
|
||||
'business' => $business,
|
||||
'test' => $qcTest,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a QC test definition.
|
||||
*/
|
||||
public function update(Business $business, MfgQcTest $qcTest, Request $request): RedirectResponse
|
||||
{
|
||||
if ($qcTest->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'nullable|string|max:50',
|
||||
'category' => 'nullable|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'min_value' => 'nullable|numeric',
|
||||
'max_value' => 'nullable|numeric',
|
||||
'target_value' => 'nullable|numeric',
|
||||
'uom' => 'nullable|string|max:50',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$qcTest->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.qc-tests.index', $business->slug)
|
||||
->with('success', 'QC test updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* List QC results.
|
||||
*/
|
||||
public function results(Business $business, Request $request): View
|
||||
{
|
||||
$query = MfgQcResult::forBusiness($business->id)
|
||||
->with(['test', 'batch']);
|
||||
|
||||
if ($request->filled('batch_id')) {
|
||||
$query->where('mfg_batch_id', $request->batch_id);
|
||||
}
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$results = $query->orderBy('tested_at', 'desc')->paginate(20);
|
||||
|
||||
$batches = MfgBatch::forBusiness($business->id)
|
||||
->orderBy('batch_number')
|
||||
->get(['id', 'batch_number']);
|
||||
|
||||
return view('seller.manufacturing.qc-results.index', [
|
||||
'business' => $business,
|
||||
'results' => $results,
|
||||
'batches' => $batches,
|
||||
'currentBatchId' => $request->batch_id,
|
||||
'currentStatus' => $request->status,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show form to record a QC result.
|
||||
*/
|
||||
public function createResult(Business $business): View
|
||||
{
|
||||
$tests = MfgQcTest::forBusiness($business->id)->active()->orderBy('name')->get();
|
||||
$batches = MfgBatch::forBusiness($business->id)
|
||||
->whereIn('status', ['open', 'under_qc'])
|
||||
->orderBy('batch_number')
|
||||
->get();
|
||||
|
||||
return view('seller.manufacturing.qc-results.create', [
|
||||
'business' => $business,
|
||||
'tests' => $tests,
|
||||
'batches' => $batches,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a QC result.
|
||||
*/
|
||||
public function storeResult(Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'mfg_qc_test_id' => 'required|exists:mfg_qc_tests,id',
|
||||
'mfg_batch_id' => 'required|exists:mfg_batches,id',
|
||||
'tested_at' => 'required|date',
|
||||
'result_value' => 'nullable|numeric',
|
||||
'result_text' => 'nullable|string|max:255',
|
||||
'status' => 'required|in:pass,fail,pending',
|
||||
'tested_by' => 'nullable|string|max:255',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
MfgQcResult::create([
|
||||
'business_id' => $business->id,
|
||||
...$validated,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.qc-results.index', $business->slug)
|
||||
->with('success', 'QC result recorded.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a QC result.
|
||||
*/
|
||||
public function showResult(Business $business, MfgQcResult $qcResult): View
|
||||
{
|
||||
if ($qcResult->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$qcResult->load(['test', 'batch']);
|
||||
|
||||
return view('seller.manufacturing.qc-results.show', [
|
||||
'business' => $business,
|
||||
'result' => $qcResult,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Manufacturing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Manufacturing\MfgRecipe;
|
||||
use App\Models\Manufacturing\MfgRecipeComponent;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MfgRecipeController extends Controller
|
||||
{
|
||||
public function index(Business $business): View
|
||||
{
|
||||
$recipes = MfgRecipe::forBusiness($business->id)
|
||||
->with(['product', 'components.componentProduct'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(20);
|
||||
|
||||
return view('seller.manufacturing.recipes.index', [
|
||||
'business' => $business,
|
||||
'recipes' => $recipes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Business $business): View
|
||||
{
|
||||
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Components can be any product (raw materials, packaging, etc.)
|
||||
$componentProducts = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.manufacturing.recipes.create', [
|
||||
'business' => $business,
|
||||
'products' => $products,
|
||||
'componentProducts' => $componentProducts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'name' => 'nullable|string|max:255',
|
||||
'version' => 'integer|min:1',
|
||||
'status' => 'required|in:draft,active,archived',
|
||||
'yield_target_percent' => 'nullable|numeric|min:0|max:100',
|
||||
'notes' => 'nullable|string',
|
||||
'components' => 'array',
|
||||
'components.*.component_product_id' => 'required|exists:products,id',
|
||||
'components.*.quantity_per_unit' => 'required|numeric|min:0',
|
||||
'components.*.uom' => 'required|string|max:50',
|
||||
'components.*.is_primary' => 'boolean',
|
||||
'components.*.wastage_percent' => 'nullable|numeric|min:0|max:100',
|
||||
'components.*.notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$recipe = MfgRecipe::create([
|
||||
'business_id' => $business->id,
|
||||
'product_id' => $validated['product_id'],
|
||||
'name' => $validated['name'] ?? null,
|
||||
'version' => $validated['version'] ?? 1,
|
||||
'status' => $validated['status'],
|
||||
'yield_target_percent' => $validated['yield_target_percent'] ?? null,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
// Create components
|
||||
if (! empty($validated['components'])) {
|
||||
foreach ($validated['components'] as $component) {
|
||||
MfgRecipeComponent::create([
|
||||
'mfg_recipe_id' => $recipe->id,
|
||||
'component_product_id' => $component['component_product_id'],
|
||||
'quantity_per_unit' => $component['quantity_per_unit'],
|
||||
'uom' => $component['uom'],
|
||||
'is_primary' => $component['is_primary'] ?? true,
|
||||
'wastage_percent' => $component['wastage_percent'] ?? null,
|
||||
'notes' => $component['notes'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.manufacturing.recipes.show', [$business->slug, $recipe->id])
|
||||
->with('success', 'Recipe created successfully.');
|
||||
}
|
||||
|
||||
public function show(Business $business, MfgRecipe $recipe): View
|
||||
{
|
||||
// Ensure recipe belongs to business
|
||||
if ($recipe->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$recipe->load(['product', 'components.componentProduct', 'workOrders']);
|
||||
|
||||
return view('seller.manufacturing.recipes.show', [
|
||||
'business' => $business,
|
||||
'recipe' => $recipe,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Business $business, MfgRecipe $recipe): View
|
||||
{
|
||||
if ($recipe->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$recipe->load(['components']);
|
||||
|
||||
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$componentProducts = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.manufacturing.recipes.edit', [
|
||||
'business' => $business,
|
||||
'recipe' => $recipe,
|
||||
'products' => $products,
|
||||
'componentProducts' => $componentProducts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Business $business, MfgRecipe $recipe, Request $request): RedirectResponse
|
||||
{
|
||||
if ($recipe->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'name' => 'nullable|string|max:255',
|
||||
'version' => 'integer|min:1',
|
||||
'status' => 'required|in:draft,active,archived',
|
||||
'yield_target_percent' => 'nullable|numeric|min:0|max:100',
|
||||
'notes' => 'nullable|string',
|
||||
'components' => 'array',
|
||||
'components.*.component_product_id' => 'required|exists:products,id',
|
||||
'components.*.quantity_per_unit' => 'required|numeric|min:0',
|
||||
'components.*.uom' => 'required|string|max:50',
|
||||
'components.*.is_primary' => 'boolean',
|
||||
'components.*.wastage_percent' => 'nullable|numeric|min:0|max:100',
|
||||
'components.*.notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$recipe->update([
|
||||
'product_id' => $validated['product_id'],
|
||||
'name' => $validated['name'] ?? null,
|
||||
'version' => $validated['version'] ?? $recipe->version,
|
||||
'status' => $validated['status'],
|
||||
'yield_target_percent' => $validated['yield_target_percent'] ?? null,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
// Replace components
|
||||
$recipe->components()->delete();
|
||||
|
||||
if (! empty($validated['components'])) {
|
||||
foreach ($validated['components'] as $component) {
|
||||
MfgRecipeComponent::create([
|
||||
'mfg_recipe_id' => $recipe->id,
|
||||
'component_product_id' => $component['component_product_id'],
|
||||
'quantity_per_unit' => $component['quantity_per_unit'],
|
||||
'uom' => $component['uom'],
|
||||
'is_primary' => $component['is_primary'] ?? true,
|
||||
'wastage_percent' => $component['wastage_percent'] ?? null,
|
||||
'notes' => $component['notes'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.manufacturing.recipes.show', [$business->slug, $recipe->id])
|
||||
->with('success', 'Recipe updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, MfgRecipe $recipe): RedirectResponse
|
||||
{
|
||||
if ($recipe->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$recipe->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.manufacturing.recipes.index', $business->slug)
|
||||
->with('success', 'Recipe deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Manufacturing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Manufacturing\MfgCustomer;
|
||||
use App\Models\Manufacturing\MfgSalesOrder;
|
||||
use App\Models\Manufacturing\MfgSalesOrderLine;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MfgSalesOrderController extends Controller
|
||||
{
|
||||
public function index(Business $business, Request $request): View
|
||||
{
|
||||
$query = MfgSalesOrder::forBusiness($business->id)
|
||||
->with(['customer', 'lines']);
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$salesOrders = $query->orderBy('created_at', 'desc')->paginate(20);
|
||||
|
||||
$stats = [
|
||||
'draft' => MfgSalesOrder::forBusiness($business->id)->where('status', 'draft')->count(),
|
||||
'confirmed' => MfgSalesOrder::forBusiness($business->id)->where('status', 'confirmed')->count(),
|
||||
'shipped' => MfgSalesOrder::forBusiness($business->id)->where('status', 'shipped')->count(),
|
||||
'completed' => MfgSalesOrder::forBusiness($business->id)->where('status', 'completed')->count(),
|
||||
];
|
||||
|
||||
return view('seller.manufacturing.sales-orders.index', [
|
||||
'business' => $business,
|
||||
'salesOrders' => $salesOrders,
|
||||
'stats' => $stats,
|
||||
'currentStatus' => $request->status,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Business $business): View
|
||||
{
|
||||
$customers = MfgCustomer::forBusiness($business->id)->active()->orderBy('name')->get();
|
||||
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))->orderBy('name')->get();
|
||||
|
||||
return view('seller.manufacturing.sales-orders.create', [
|
||||
'business' => $business,
|
||||
'customers' => $customers,
|
||||
'products' => $products,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'mfg_customer_id' => 'required|exists:mfg_customers,id',
|
||||
'requested_delivery_at' => 'nullable|date',
|
||||
'shipping_address' => 'nullable|string',
|
||||
'notes' => 'nullable|string',
|
||||
'lines' => 'required|array|min:1',
|
||||
'lines.*.product_id' => 'required|exists:products,id',
|
||||
'lines.*.quantity' => 'required|numeric|min:0.0001',
|
||||
'lines.*.unit_price' => 'nullable|numeric|min:0',
|
||||
'lines.*.uom' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($business, $validated) {
|
||||
$so = MfgSalesOrder::create([
|
||||
'business_id' => $business->id,
|
||||
'mfg_customer_id' => $validated['mfg_customer_id'],
|
||||
'so_number' => MfgSalesOrder::generateSoNumber($business->id),
|
||||
'status' => 'draft',
|
||||
'requested_delivery_at' => $validated['requested_delivery_at'] ?? null,
|
||||
'shipping_address' => $validated['shipping_address'] ?? null,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
foreach ($validated['lines'] as $index => $line) {
|
||||
MfgSalesOrderLine::create([
|
||||
'mfg_sales_order_id' => $so->id,
|
||||
'product_id' => $line['product_id'],
|
||||
'quantity_ordered' => $line['quantity'],
|
||||
'quantity_shipped' => 0,
|
||||
'unit_price' => $line['unit_price'] ?? 0,
|
||||
'uom' => $line['uom'] ?? 'unit',
|
||||
'line_number' => $index + 1,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.sales-orders.index', $business->slug)
|
||||
->with('success', 'Sales order created.');
|
||||
}
|
||||
|
||||
public function show(Business $business, MfgSalesOrder $salesOrder): View
|
||||
{
|
||||
if ($salesOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$salesOrder->load(['customer', 'lines.product', 'shipments']);
|
||||
|
||||
return view('seller.manufacturing.sales-orders.show', [
|
||||
'business' => $business,
|
||||
'salesOrder' => $salesOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Business $business, MfgSalesOrder $salesOrder): View
|
||||
{
|
||||
if ($salesOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (in_array($salesOrder->status, ['shipped', 'completed'])) {
|
||||
return redirect()
|
||||
->route('seller.business.mfg.sales-orders.show', [$business->slug, $salesOrder->id])
|
||||
->with('error', 'Cannot edit a shipped or completed sales order.');
|
||||
}
|
||||
|
||||
$salesOrder->load(['lines.product']);
|
||||
$customers = MfgCustomer::forBusiness($business->id)->active()->orderBy('name')->get();
|
||||
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))->orderBy('name')->get();
|
||||
|
||||
return view('seller.manufacturing.sales-orders.edit', [
|
||||
'business' => $business,
|
||||
'salesOrder' => $salesOrder,
|
||||
'customers' => $customers,
|
||||
'products' => $products,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Business $business, MfgSalesOrder $salesOrder, Request $request): RedirectResponse
|
||||
{
|
||||
if ($salesOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (in_array($salesOrder->status, ['shipped', 'completed'])) {
|
||||
return back()->with('error', 'Cannot edit a shipped or completed sales order.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'mfg_customer_id' => 'required|exists:mfg_customers,id',
|
||||
'requested_delivery_at' => 'nullable|date',
|
||||
'shipping_address' => 'nullable|string',
|
||||
'notes' => 'nullable|string',
|
||||
'lines' => 'required|array|min:1',
|
||||
'lines.*.id' => 'nullable|exists:mfg_sales_order_lines,id',
|
||||
'lines.*.product_id' => 'required|exists:products,id',
|
||||
'lines.*.quantity' => 'required|numeric|min:0.0001',
|
||||
'lines.*.unit_price' => 'nullable|numeric|min:0',
|
||||
'lines.*.uom' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($salesOrder, $validated) {
|
||||
$salesOrder->update([
|
||||
'mfg_customer_id' => $validated['mfg_customer_id'],
|
||||
'requested_delivery_at' => $validated['requested_delivery_at'] ?? null,
|
||||
'shipping_address' => $validated['shipping_address'] ?? null,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
// Delete existing lines and recreate
|
||||
$salesOrder->lines()->delete();
|
||||
|
||||
foreach ($validated['lines'] as $index => $line) {
|
||||
MfgSalesOrderLine::create([
|
||||
'mfg_sales_order_id' => $salesOrder->id,
|
||||
'product_id' => $line['product_id'],
|
||||
'quantity_ordered' => $line['quantity'],
|
||||
'quantity_shipped' => 0,
|
||||
'unit_price' => $line['unit_price'] ?? 0,
|
||||
'uom' => $line['uom'] ?? 'unit',
|
||||
'line_number' => $index + 1,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.sales-orders.show', [$business->slug, $salesOrder->id])
|
||||
->with('success', 'Sales order updated.');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, MfgSalesOrder $salesOrder): RedirectResponse
|
||||
{
|
||||
if ($salesOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($salesOrder->status !== 'draft') {
|
||||
return back()->with('error', 'Only draft sales orders can be deleted.');
|
||||
}
|
||||
|
||||
$salesOrder->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.sales-orders.index', $business->slug)
|
||||
->with('success', 'Sales order deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the sales order.
|
||||
*/
|
||||
public function confirm(Business $business, MfgSalesOrder $salesOrder): RedirectResponse
|
||||
{
|
||||
if ($salesOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($salesOrder->status !== 'draft') {
|
||||
return back()->with('error', 'Only draft sales orders can be confirmed.');
|
||||
}
|
||||
|
||||
$salesOrder->update([
|
||||
'status' => 'confirmed',
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Sales order confirmed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the sales order.
|
||||
*/
|
||||
public function cancel(Business $business, MfgSalesOrder $salesOrder): RedirectResponse
|
||||
{
|
||||
if ($salesOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (in_array($salesOrder->status, ['shipped', 'completed'])) {
|
||||
return back()->with('error', 'Cannot cancel a shipped or completed sales order.');
|
||||
}
|
||||
|
||||
$salesOrder->update([
|
||||
'status' => 'cancelled',
|
||||
'cancelled_at' => now(),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Sales order cancelled.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Manufacturing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Manufacturing\MfgInventoryMovement;
|
||||
use App\Models\Manufacturing\MfgSalesOrder;
|
||||
use App\Models\Manufacturing\MfgShipment;
|
||||
use App\Models\Manufacturing\MfgShipmentLine;
|
||||
use App\Models\Manufacturing\MfgWarehouse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MfgShipmentController extends Controller
|
||||
{
|
||||
public function index(Business $business, Request $request): View
|
||||
{
|
||||
$query = MfgShipment::forBusiness($business->id)
|
||||
->with(['salesOrder.customer', 'warehouse', 'lines']);
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$shipments = $query->orderBy('created_at', 'desc')->paginate(20);
|
||||
|
||||
$stats = [
|
||||
'pending' => MfgShipment::forBusiness($business->id)->where('status', 'pending')->count(),
|
||||
'packed' => MfgShipment::forBusiness($business->id)->where('status', 'packed')->count(),
|
||||
'shipped' => MfgShipment::forBusiness($business->id)->where('status', 'shipped')->count(),
|
||||
'delivered' => MfgShipment::forBusiness($business->id)->where('status', 'delivered')->count(),
|
||||
];
|
||||
|
||||
return view('seller.manufacturing.shipments.index', [
|
||||
'business' => $business,
|
||||
'shipments' => $shipments,
|
||||
'stats' => $stats,
|
||||
'currentStatus' => $request->status,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Business $business): View
|
||||
{
|
||||
$salesOrders = MfgSalesOrder::forBusiness($business->id)
|
||||
->whereIn('status', ['confirmed'])
|
||||
->with(['customer', 'lines.product'])
|
||||
->orderBy('so_number')
|
||||
->get();
|
||||
|
||||
$warehouses = MfgWarehouse::forBusiness($business->id)->active()->orderBy('name')->get();
|
||||
|
||||
return view('seller.manufacturing.shipments.create', [
|
||||
'business' => $business,
|
||||
'salesOrders' => $salesOrders,
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'mfg_sales_order_id' => 'required|exists:mfg_sales_orders,id',
|
||||
'mfg_warehouse_id' => 'required|exists:mfg_warehouses,id',
|
||||
'carrier' => 'nullable|string|max:100',
|
||||
'tracking_number' => 'nullable|string|max:255',
|
||||
'notes' => 'nullable|string',
|
||||
'lines' => 'required|array|min:1',
|
||||
'lines.*.mfg_sales_order_line_id' => 'required|exists:mfg_sales_order_lines,id',
|
||||
'lines.*.quantity' => 'required|numeric|min:0.0001',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($business, $validated) {
|
||||
$shipment = MfgShipment::create([
|
||||
'business_id' => $business->id,
|
||||
'mfg_sales_order_id' => $validated['mfg_sales_order_id'],
|
||||
'mfg_warehouse_id' => $validated['mfg_warehouse_id'],
|
||||
'shipment_number' => MfgShipment::generateShipmentNumber($business->id),
|
||||
'status' => 'pending',
|
||||
'carrier' => $validated['carrier'] ?? null,
|
||||
'tracking_number' => $validated['tracking_number'] ?? null,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
foreach ($validated['lines'] as $line) {
|
||||
MfgShipmentLine::create([
|
||||
'mfg_shipment_id' => $shipment->id,
|
||||
'mfg_sales_order_line_id' => $line['mfg_sales_order_line_id'],
|
||||
'quantity_shipped' => $line['quantity'],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.shipments.index', $business->slug)
|
||||
->with('success', 'Shipment created.');
|
||||
}
|
||||
|
||||
public function show(Business $business, MfgShipment $shipment): View
|
||||
{
|
||||
if ($shipment->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$shipment->load(['salesOrder.customer', 'warehouse', 'lines.salesOrderLine.product']);
|
||||
|
||||
return view('seller.manufacturing.shipments.show', [
|
||||
'business' => $business,
|
||||
'shipment' => $shipment,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Business $business, MfgShipment $shipment): View
|
||||
{
|
||||
if ($shipment->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (in_array($shipment->status, ['shipped', 'delivered'])) {
|
||||
return redirect()
|
||||
->route('seller.business.mfg.shipments.show', [$business->slug, $shipment->id])
|
||||
->with('error', 'Cannot edit a shipped or delivered shipment.');
|
||||
}
|
||||
|
||||
$shipment->load(['salesOrder.lines.product', 'lines']);
|
||||
$warehouses = MfgWarehouse::forBusiness($business->id)->active()->orderBy('name')->get();
|
||||
|
||||
return view('seller.manufacturing.shipments.edit', [
|
||||
'business' => $business,
|
||||
'shipment' => $shipment,
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Business $business, MfgShipment $shipment, Request $request): RedirectResponse
|
||||
{
|
||||
if ($shipment->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (in_array($shipment->status, ['shipped', 'delivered'])) {
|
||||
return back()->with('error', 'Cannot edit a shipped or delivered shipment.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'mfg_warehouse_id' => 'required|exists:mfg_warehouses,id',
|
||||
'carrier' => 'nullable|string|max:100',
|
||||
'tracking_number' => 'nullable|string|max:255',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$shipment->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.shipments.show', [$business->slug, $shipment->id])
|
||||
->with('success', 'Shipment updated.');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, MfgShipment $shipment): RedirectResponse
|
||||
{
|
||||
if ($shipment->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($shipment->status !== 'pending') {
|
||||
return back()->with('error', 'Only pending shipments can be deleted.');
|
||||
}
|
||||
|
||||
$shipment->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.shipments.index', $business->slug)
|
||||
->with('success', 'Shipment deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark shipment as packed.
|
||||
*/
|
||||
public function pack(Business $business, MfgShipment $shipment): RedirectResponse
|
||||
{
|
||||
if ($shipment->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($shipment->status !== 'pending') {
|
||||
return back()->with('error', 'Only pending shipments can be packed.');
|
||||
}
|
||||
|
||||
$shipment->update([
|
||||
'status' => 'packed',
|
||||
'packed_at' => now(),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Shipment marked as packed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark shipment as shipped and create inventory movements.
|
||||
*/
|
||||
public function ship(Business $business, MfgShipment $shipment): RedirectResponse
|
||||
{
|
||||
if ($shipment->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! in_array($shipment->status, ['pending', 'packed'])) {
|
||||
return back()->with('error', 'Only pending or packed shipments can be shipped.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($shipment, $business) {
|
||||
$shipment->load(['lines.salesOrderLine', 'salesOrder.lines']);
|
||||
|
||||
// Create inventory movements for each line
|
||||
foreach ($shipment->lines as $line) {
|
||||
if ($line->salesOrderLine) {
|
||||
MfgInventoryMovement::create([
|
||||
'business_id' => $business->id,
|
||||
'product_id' => $line->salesOrderLine->product_id,
|
||||
'source_warehouse_id' => $shipment->mfg_warehouse_id,
|
||||
'quantity' => -$line->quantity_shipped, // Negative for outgoing
|
||||
'uom' => $line->salesOrderLine->uom ?? 'unit',
|
||||
'movement_type' => 'ship',
|
||||
'reference_type' => 'shipment',
|
||||
'reference_id' => $shipment->id,
|
||||
'reason' => 'Shipment: '.$shipment->shipment_number,
|
||||
]);
|
||||
|
||||
// Update sales order line shipped quantity
|
||||
$line->salesOrderLine->increment('quantity_shipped', $line->quantity_shipped);
|
||||
}
|
||||
}
|
||||
|
||||
$shipment->update([
|
||||
'status' => 'shipped',
|
||||
'shipped_at' => now(),
|
||||
]);
|
||||
|
||||
// Update sales order status if all lines are shipped
|
||||
$salesOrder = $shipment->salesOrder;
|
||||
if ($salesOrder) {
|
||||
$allShipped = $salesOrder->lines->every(fn ($l) => $l->quantity_shipped >= $l->quantity_ordered);
|
||||
if ($allShipped) {
|
||||
$salesOrder->update(['status' => 'shipped']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return back()->with('success', 'Shipment marked as shipped. Inventory updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark shipment as delivered.
|
||||
*/
|
||||
public function deliver(Business $business, MfgShipment $shipment): RedirectResponse
|
||||
{
|
||||
if ($shipment->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($shipment->status !== 'shipped') {
|
||||
return back()->with('error', 'Only shipped shipments can be delivered.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($shipment) {
|
||||
$shipment->load('salesOrder.shipments');
|
||||
|
||||
$shipment->update([
|
||||
'status' => 'delivered',
|
||||
'delivered_at' => now(),
|
||||
]);
|
||||
|
||||
// Update sales order status if all shipments delivered
|
||||
$salesOrder = $shipment->salesOrder;
|
||||
if ($salesOrder) {
|
||||
$allDelivered = $salesOrder->shipments->every(fn ($s) => $s->id === $shipment->id || $s->status === 'delivered');
|
||||
if ($allDelivered) {
|
||||
$salesOrder->update(['status' => 'completed']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return back()->with('success', 'Shipment marked as delivered.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Manufacturing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Manufacturing\MfgVendor;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MfgVendorController extends Controller
|
||||
{
|
||||
public function index(Business $business): View
|
||||
{
|
||||
$vendors = MfgVendor::forBusiness($business->id)
|
||||
->withCount('purchaseOrders')
|
||||
->orderBy('name')
|
||||
->paginate(20);
|
||||
|
||||
return view('seller.manufacturing.vendors.index', [
|
||||
'business' => $business,
|
||||
'vendors' => $vendors,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Business $business): View
|
||||
{
|
||||
return view('seller.manufacturing.vendors.create', [
|
||||
'business' => $business,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'nullable|string|max:50',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'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',
|
||||
'notes' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
MfgVendor::create([
|
||||
'business_id' => $business->id,
|
||||
...$validated,
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.vendors.index', $business->slug)
|
||||
->with('success', 'Vendor created.');
|
||||
}
|
||||
|
||||
public function show(Business $business, MfgVendor $vendor): View
|
||||
{
|
||||
if ($vendor->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$vendor->load(['purchaseOrders' => fn ($q) => $q->latest()->limit(10)]);
|
||||
|
||||
return view('seller.manufacturing.vendors.show', [
|
||||
'business' => $business,
|
||||
'vendor' => $vendor,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Business $business, MfgVendor $vendor): View
|
||||
{
|
||||
if ($vendor->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return view('seller.manufacturing.vendors.edit', [
|
||||
'business' => $business,
|
||||
'vendor' => $vendor,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Business $business, MfgVendor $vendor, Request $request): RedirectResponse
|
||||
{
|
||||
if ($vendor->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'nullable|string|max:50',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'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',
|
||||
'notes' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$vendor->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.vendors.index', $business->slug)
|
||||
->with('success', 'Vendor updated.');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, MfgVendor $vendor): RedirectResponse
|
||||
{
|
||||
if ($vendor->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$vendor->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.mfg.vendors.index', $business->slug)
|
||||
->with('success', 'Vendor deleted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Manufacturing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Manufacturing\MfgWorkCenter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MfgWorkCenterController extends Controller
|
||||
{
|
||||
public function index(Business $business): View
|
||||
{
|
||||
$workCenters = MfgWorkCenter::forBusiness($business->id)
|
||||
->withCount('operations')
|
||||
->orderBy('name')
|
||||
->paginate(20);
|
||||
|
||||
return view('seller.manufacturing.work-centers.index', [
|
||||
'business' => $business,
|
||||
'workCenters' => $workCenters,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Business $business): View
|
||||
{
|
||||
return view('seller.manufacturing.work-centers.create', [
|
||||
'business' => $business,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'nullable|string|max:50',
|
||||
'type' => 'nullable|string|max:50',
|
||||
'capacity_units_per_hour' => 'nullable|integer|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
MfgWorkCenter::create([
|
||||
'business_id' => $business->id,
|
||||
...$validated,
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.manufacturing.work-centers.index', $business->slug)
|
||||
->with('success', 'Work center created.');
|
||||
}
|
||||
|
||||
public function edit(Business $business, MfgWorkCenter $workCenter): View
|
||||
{
|
||||
if ($workCenter->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return view('seller.manufacturing.work-centers.edit', [
|
||||
'business' => $business,
|
||||
'workCenter' => $workCenter,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Business $business, MfgWorkCenter $workCenter, Request $request): RedirectResponse
|
||||
{
|
||||
if ($workCenter->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'nullable|string|max:50',
|
||||
'type' => 'nullable|string|max:50',
|
||||
'capacity_units_per_hour' => 'nullable|integer|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$workCenter->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.manufacturing.work-centers.index', $business->slug)
|
||||
->with('success', 'Work center updated.');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, MfgWorkCenter $workCenter): RedirectResponse
|
||||
{
|
||||
if ($workCenter->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workCenter->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.manufacturing.work-centers.index', $business->slug)
|
||||
->with('success', 'Work center deleted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Manufacturing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Manufacturing\MfgBatch;
|
||||
use App\Models\Manufacturing\MfgRecipe;
|
||||
use App\Models\Manufacturing\MfgWorkCenter;
|
||||
use App\Models\Manufacturing\MfgWorkOrder;
|
||||
use App\Models\Manufacturing\MfgWorkOrderOperation;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MfgWorkOrderController extends Controller
|
||||
{
|
||||
public function index(Business $business, Request $request): View
|
||||
{
|
||||
$query = MfgWorkOrder::forBusiness($business->id)
|
||||
->with(['product', 'recipe']);
|
||||
|
||||
// Filter by status
|
||||
if ($request->filled('status')) {
|
||||
$query->status($request->status);
|
||||
}
|
||||
|
||||
$workOrders = $query->orderBy('created_at', 'desc')->paginate(20);
|
||||
|
||||
$stats = [
|
||||
'planned' => MfgWorkOrder::forBusiness($business->id)->status('planned')->count(),
|
||||
'in_progress' => MfgWorkOrder::forBusiness($business->id)->status('in_progress')->count(),
|
||||
'completed' => MfgWorkOrder::forBusiness($business->id)->status('completed')->count(),
|
||||
];
|
||||
|
||||
return view('seller.manufacturing.work-orders.index', [
|
||||
'business' => $business,
|
||||
'workOrders' => $workOrders,
|
||||
'stats' => $stats,
|
||||
'currentStatus' => $request->status,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Business $business): View
|
||||
{
|
||||
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$recipes = MfgRecipe::forBusiness($business->id)
|
||||
->active()
|
||||
->with('product')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$workCenters = MfgWorkCenter::forBusiness($business->id)
|
||||
->active()
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.manufacturing.work-orders.create', [
|
||||
'business' => $business,
|
||||
'products' => $products,
|
||||
'recipes' => $recipes,
|
||||
'workCenters' => $workCenters,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'mfg_recipe_id' => 'nullable|exists:mfg_recipes,id',
|
||||
'quantity_planned' => 'required|numeric|min:0.0001',
|
||||
'uom' => 'required|string|max:50',
|
||||
'scheduled_start_at' => 'nullable|date',
|
||||
'scheduled_end_at' => 'nullable|date|after_or_equal:scheduled_start_at',
|
||||
'notes' => 'nullable|string',
|
||||
'operations' => 'array',
|
||||
'operations.*.sequence' => 'required|integer|min:1',
|
||||
'operations.*.operation_code' => 'required|string|max:50',
|
||||
'operations.*.mfg_work_center_id' => 'nullable|exists:mfg_work_centers,id',
|
||||
]);
|
||||
|
||||
$workOrder = MfgWorkOrder::create([
|
||||
'business_id' => $business->id,
|
||||
'product_id' => $validated['product_id'],
|
||||
'mfg_recipe_id' => $validated['mfg_recipe_id'] ?? null,
|
||||
'work_order_number' => MfgWorkOrder::generateWorkOrderNumber($business->id),
|
||||
'status' => 'planned',
|
||||
'quantity_planned' => $validated['quantity_planned'],
|
||||
'uom' => $validated['uom'],
|
||||
'scheduled_start_at' => $validated['scheduled_start_at'] ?? null,
|
||||
'scheduled_end_at' => $validated['scheduled_end_at'] ?? null,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
// Create operations
|
||||
if (! empty($validated['operations'])) {
|
||||
foreach ($validated['operations'] as $operation) {
|
||||
MfgWorkOrderOperation::create([
|
||||
'mfg_work_order_id' => $workOrder->id,
|
||||
'sequence' => $operation['sequence'],
|
||||
'operation_code' => $operation['operation_code'],
|
||||
'mfg_work_center_id' => $operation['mfg_work_center_id'] ?? null,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.manufacturing.work-orders.show', [$business->slug, $workOrder->id])
|
||||
->with('success', 'Work order created: '.$workOrder->work_order_number);
|
||||
}
|
||||
|
||||
public function show(Business $business, MfgWorkOrder $workOrder): View
|
||||
{
|
||||
if ($workOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workOrder->load(['product', 'recipe.components.componentProduct', 'operations.workCenter', 'batches']);
|
||||
|
||||
return view('seller.manufacturing.work-orders.show', [
|
||||
'business' => $business,
|
||||
'workOrder' => $workOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Business $business, MfgWorkOrder $workOrder): View
|
||||
{
|
||||
if ($workOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workOrder->load(['operations']);
|
||||
|
||||
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$recipes = MfgRecipe::forBusiness($business->id)
|
||||
->active()
|
||||
->with('product')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$workCenters = MfgWorkCenter::forBusiness($business->id)
|
||||
->active()
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.manufacturing.work-orders.edit', [
|
||||
'business' => $business,
|
||||
'workOrder' => $workOrder,
|
||||
'products' => $products,
|
||||
'recipes' => $recipes,
|
||||
'workCenters' => $workCenters,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Business $business, MfgWorkOrder $workOrder, Request $request): RedirectResponse
|
||||
{
|
||||
if ($workOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'mfg_recipe_id' => 'nullable|exists:mfg_recipes,id',
|
||||
'quantity_planned' => 'required|numeric|min:0.0001',
|
||||
'uom' => 'required|string|max:50',
|
||||
'scheduled_start_at' => 'nullable|date',
|
||||
'scheduled_end_at' => 'nullable|date|after_or_equal:scheduled_start_at',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$workOrder->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.manufacturing.work-orders.show', [$business->slug, $workOrder->id])
|
||||
->with('success', 'Work order updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a work order.
|
||||
*/
|
||||
public function start(Business $business, MfgWorkOrder $workOrder): RedirectResponse
|
||||
{
|
||||
if ($workOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $workOrder->start()) {
|
||||
return back()->with('error', 'Cannot start this work order.');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Work order started.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a work order and create batch.
|
||||
*/
|
||||
public function complete(Business $business, MfgWorkOrder $workOrder, Request $request): RedirectResponse
|
||||
{
|
||||
if ($workOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'quantity_completed' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
if (! $workOrder->complete($validated['quantity_completed'])) {
|
||||
return back()->with('error', 'Cannot complete this work order.');
|
||||
}
|
||||
|
||||
// Create a batch for the completed work order
|
||||
$batch = MfgBatch::create([
|
||||
'business_id' => $business->id,
|
||||
'batch_number' => MfgBatch::generateBatchNumber($business->id),
|
||||
'product_id' => $workOrder->product_id,
|
||||
'mfg_work_order_id' => $workOrder->id,
|
||||
'status' => 'open',
|
||||
'quantity_produced' => $validated['quantity_completed'],
|
||||
'uom' => $workOrder->uom,
|
||||
'manufactured_at' => now(),
|
||||
]);
|
||||
|
||||
// Link batch to work order
|
||||
$workOrder->update(['mfg_batch_id' => $batch->id]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.manufacturing.batches.show', [$business->slug, $batch->id])
|
||||
->with('success', 'Work order completed. Batch '.$batch->batch_number.' created.');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, MfgWorkOrder $workOrder): RedirectResponse
|
||||
{
|
||||
if ($workOrder->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($workOrder->status !== 'planned') {
|
||||
return back()->with('error', 'Can only delete planned work orders.');
|
||||
}
|
||||
|
||||
$workOrder->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.manufacturing.work-orders.index', $business->slug)
|
||||
->with('success', 'Work order deleted.');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Marketing\Campaign;
|
||||
use App\Models\Marketing\MarketingChannel;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Models\Marketing\MarketingTemplate;
|
||||
use App\Services\AI\TemplatePromptBuilder;
|
||||
use App\Services\Marketing\AIContentService;
|
||||
@@ -45,13 +46,21 @@ class CampaignController extends Controller
|
||||
$preselectedSegment = $request->query('segment');
|
||||
$preselectedBrand = $request->query('brand_id');
|
||||
|
||||
// Pre-populate from Promo if promo_id provided
|
||||
$promo = null;
|
||||
if ($request->query('promo_id')) {
|
||||
$promo = MarketingPromo::where('business_id', $business->id)
|
||||
->find($request->query('promo_id'));
|
||||
}
|
||||
|
||||
return view('seller.marketing.campaigns.create', compact(
|
||||
'business',
|
||||
'brands',
|
||||
'channels',
|
||||
'templates',
|
||||
'preselectedSegment',
|
||||
'preselectedBrand'
|
||||
'preselectedBrand',
|
||||
'promo'
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
148
app/Http/Controllers/Seller/Marketing/IntelligenceController.php
Normal file
148
app/Http/Controllers/Seller/Marketing/IntelligenceController.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Services\Marketing\MarketingIntelligenceService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Marketing Intelligence Controller
|
||||
*
|
||||
* Displays market intelligence data from CannaiQ including:
|
||||
* - Store-level metrics (pricing position, market share, trends)
|
||||
* - Product metrics (velocity, pricing history, competitor positioning)
|
||||
* - Competitor snapshots (out-of-stock, pricing, promotions)
|
||||
*/
|
||||
class IntelligenceController extends Controller
|
||||
{
|
||||
protected MarketingIntelligenceService $intelligence;
|
||||
|
||||
public function __construct(MarketingIntelligenceService $intelligence)
|
||||
{
|
||||
$this->intelligence = $intelligence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the marketing intelligence dashboard
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
// Get brands for filtering
|
||||
$brands = Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get store external ID from business settings or request
|
||||
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
|
||||
|
||||
// Fetch intelligence data if store is configured
|
||||
$storeMetrics = [];
|
||||
$productMetrics = [];
|
||||
$competitorSnapshot = [];
|
||||
$trends = [];
|
||||
|
||||
if ($storeExternalId) {
|
||||
$storeMetrics = $this->intelligence->getStoreIntelligence($business->id, $storeExternalId);
|
||||
$productMetrics = $this->intelligence->getProductIntelligence($business->id, $storeExternalId, 20);
|
||||
$competitorSnapshot = $this->intelligence->getCompetitorSnapshot($business->id, $storeExternalId);
|
||||
$trends = $this->intelligence->getMarketTrends($business->id, $storeExternalId);
|
||||
}
|
||||
|
||||
return view('seller.marketing.intelligence.index', compact(
|
||||
'business',
|
||||
'brands',
|
||||
'storeExternalId',
|
||||
'storeMetrics',
|
||||
'productMetrics',
|
||||
'competitorSnapshot',
|
||||
'trends'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display store-level intelligence details
|
||||
*/
|
||||
public function store(Request $request, $businessSlug, string $storeExternalId)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
$storeData = $this->intelligence->getStoreIntelligence($business->id, $storeExternalId);
|
||||
$productMetrics = $this->intelligence->getProductIntelligence($business->id, $storeExternalId, 50);
|
||||
$trends = $this->intelligence->getMarketTrends($business->id, $storeExternalId);
|
||||
|
||||
return view('seller.marketing.intelligence.store', compact(
|
||||
'business',
|
||||
'storeExternalId',
|
||||
'storeData',
|
||||
'productMetrics',
|
||||
'trends'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display product-level intelligence details
|
||||
*/
|
||||
public function product(Request $request, $businessSlug, string $productExternalId)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
// Get store context
|
||||
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
|
||||
|
||||
$productData = [];
|
||||
$priceHistory = [];
|
||||
$competitorPricing = [];
|
||||
|
||||
if ($storeExternalId) {
|
||||
// Get product data from cached metrics
|
||||
$allProducts = $this->intelligence->getProductIntelligence($business->id, $storeExternalId, 100);
|
||||
$products = $allProducts['products'] ?? [];
|
||||
|
||||
// Find the specific product
|
||||
$productData = collect($products)->firstWhere('product_id', $productExternalId) ?? [];
|
||||
|
||||
// Price history would come from historical snapshots
|
||||
// For now, placeholder
|
||||
$priceHistory = [];
|
||||
$competitorPricing = [];
|
||||
}
|
||||
|
||||
return view('seller.marketing.intelligence.product', compact(
|
||||
'business',
|
||||
'productExternalId',
|
||||
'productData',
|
||||
'priceHistory',
|
||||
'competitorPricing'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh intelligence data from CannaiQ
|
||||
*/
|
||||
public function refresh(Request $request, $businessSlug)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
|
||||
|
||||
if (! $storeExternalId) {
|
||||
return redirect()
|
||||
->route('seller.business.marketing.intelligence.index', $business->slug)
|
||||
->with('error', 'No store configured for intelligence data.');
|
||||
}
|
||||
|
||||
$results = $this->intelligence->refreshIntelligence($business->id, $storeExternalId);
|
||||
|
||||
$successCount = count(array_filter($results));
|
||||
$message = $successCount > 0
|
||||
? "Intelligence data refreshed ({$successCount}/3 data sources updated)."
|
||||
: 'Failed to refresh intelligence data. Please try again later.';
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.intelligence.index', $business->slug)
|
||||
->with($successCount > 0 ? 'success' : 'error', $message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\SendMarketingCampaignJob;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Services\Messaging\EmailSender;
|
||||
use App\Services\Messaging\SmsSender;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MarketingCampaignController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$query = MarketingCampaign::forBusiness($business->id)
|
||||
->with('list')
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
if ($request->filled('channel')) {
|
||||
$query->channel($request->channel);
|
||||
}
|
||||
|
||||
$campaigns = $query->paginate(25)->withQueryString();
|
||||
|
||||
return view('seller.marketing.campaigns.index', [
|
||||
'business' => $business,
|
||||
'campaigns' => $campaigns,
|
||||
'statuses' => MarketingCampaign::STATUSES,
|
||||
'channels' => MarketingCampaign::CHANNELS,
|
||||
'filters' => $request->only(['status', 'channel']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business): View
|
||||
{
|
||||
$lists = MarketingList::forBusiness($business->id)->get();
|
||||
|
||||
// Pre-fill from promo if source=promo
|
||||
$prefill = [];
|
||||
if ($request->source === 'promo' && $request->promo_id) {
|
||||
$promo = MarketingPromo::forBusiness($business->id)->find($request->promo_id);
|
||||
if ($promo) {
|
||||
$prefill = $this->prefillFromPromo($promo, $request->channel ?? 'email');
|
||||
}
|
||||
}
|
||||
|
||||
return view('seller.marketing.campaigns.create', [
|
||||
'business' => $business,
|
||||
'lists' => $lists,
|
||||
'channels' => MarketingCampaign::CHANNELS,
|
||||
'prefill' => $prefill,
|
||||
'source' => $request->source,
|
||||
'sourceId' => $request->promo_id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'channel' => 'required|in:email,sms,multi',
|
||||
'marketing_list_id' => 'nullable|exists:marketing_lists,id',
|
||||
'subject' => 'nullable|string|max:255',
|
||||
'email_preview_text' => 'nullable|string|max:255',
|
||||
'sms_body' => 'nullable|string|max:1600',
|
||||
'email_body_html' => 'nullable|string',
|
||||
'from_name' => 'nullable|string|max:255',
|
||||
'from_email' => 'nullable|email|max:255',
|
||||
'source_type' => 'nullable|string|in:manual,promo,automation',
|
||||
'source_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
// Verify list belongs to business
|
||||
if ($validated['marketing_list_id']) {
|
||||
$list = MarketingList::where('business_id', $business->id)
|
||||
->find($validated['marketing_list_id']);
|
||||
if (! $list) {
|
||||
return back()->withErrors(['marketing_list_id' => 'Invalid list selected.'])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
$campaign = MarketingCampaign::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'channel' => $validated['channel'],
|
||||
'status' => MarketingCampaign::STATUS_DRAFT,
|
||||
'marketing_list_id' => $validated['marketing_list_id'] ?? null,
|
||||
'subject' => $validated['subject'] ?? null,
|
||||
'email_preview_text' => $validated['email_preview_text'] ?? null,
|
||||
'sms_body' => $validated['sms_body'] ?? null,
|
||||
'email_body_html' => $validated['email_body_html'] ?? null,
|
||||
'from_name' => $validated['from_name'] ?? null,
|
||||
'from_email' => $validated['from_email'] ?? null,
|
||||
'source_type' => $validated['source_type'] ?? MarketingCampaign::SOURCE_MANUAL,
|
||||
'source_id' => $validated['source_id'] ?? null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
|
||||
->with('success', 'Campaign created successfully.');
|
||||
}
|
||||
|
||||
public function show(Business $business, MarketingCampaign $campaign): View
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
$campaign->load('list', 'messageLogs');
|
||||
|
||||
return view('seller.marketing.campaigns.show', [
|
||||
'business' => $business,
|
||||
'campaign' => $campaign,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Business $business, MarketingCampaign $campaign): View
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
if (! $campaign->canEdit()) {
|
||||
return redirect()
|
||||
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
|
||||
->with('error', 'Cannot edit a campaign that is sending or sent.');
|
||||
}
|
||||
|
||||
$lists = MarketingList::forBusiness($business->id)->get();
|
||||
|
||||
return view('seller.marketing.campaigns.edit', [
|
||||
'business' => $business,
|
||||
'campaign' => $campaign,
|
||||
'lists' => $lists,
|
||||
'channels' => MarketingCampaign::CHANNELS,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Business $business, MarketingCampaign $campaign): RedirectResponse
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
if (! $campaign->canEdit()) {
|
||||
return back()->with('error', 'Cannot edit a campaign that is sending or sent.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'channel' => 'required|in:email,sms,multi',
|
||||
'marketing_list_id' => 'nullable|exists:marketing_lists,id',
|
||||
'subject' => 'nullable|string|max:255',
|
||||
'email_preview_text' => 'nullable|string|max:255',
|
||||
'sms_body' => 'nullable|string|max:1600',
|
||||
'email_body_html' => 'nullable|string',
|
||||
'from_name' => 'nullable|string|max:255',
|
||||
'from_email' => 'nullable|email|max:255',
|
||||
]);
|
||||
|
||||
// Verify list belongs to business
|
||||
if ($validated['marketing_list_id']) {
|
||||
$list = MarketingList::where('business_id', $business->id)
|
||||
->find($validated['marketing_list_id']);
|
||||
if (! $list) {
|
||||
return back()->withErrors(['marketing_list_id' => 'Invalid list selected.'])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
$campaign->update([
|
||||
'name' => $validated['name'],
|
||||
'channel' => $validated['channel'],
|
||||
'marketing_list_id' => $validated['marketing_list_id'] ?? null,
|
||||
'subject' => $validated['subject'] ?? null,
|
||||
'email_preview_text' => $validated['email_preview_text'] ?? null,
|
||||
'sms_body' => $validated['sms_body'] ?? null,
|
||||
'email_body_html' => $validated['email_body_html'] ?? null,
|
||||
'from_name' => $validated['from_name'] ?? null,
|
||||
'from_email' => $validated['from_email'] ?? null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
|
||||
->with('success', 'Campaign updated successfully.');
|
||||
}
|
||||
|
||||
public function schedule(Request $request, Business $business, MarketingCampaign $campaign): RedirectResponse
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
if (! $campaign->canSchedule()) {
|
||||
return back()->with('error', 'Cannot schedule this campaign.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'send_at' => 'required|date|after:now',
|
||||
]);
|
||||
|
||||
$campaign->schedule(new \DateTime($validated['send_at']));
|
||||
|
||||
return back()->with('success', 'Campaign scheduled for '.date('M j, Y g:i A', strtotime($validated['send_at'])));
|
||||
}
|
||||
|
||||
public function sendNow(Business $business, MarketingCampaign $campaign): RedirectResponse
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
if (! $campaign->canSend()) {
|
||||
return back()->with('error', 'Cannot send this campaign. Make sure a list is selected and campaign is in draft status.');
|
||||
}
|
||||
|
||||
SendMarketingCampaignJob::dispatch($campaign->id);
|
||||
|
||||
return back()->with('success', 'Campaign is now being sent.');
|
||||
}
|
||||
|
||||
public function cancel(Business $business, MarketingCampaign $campaign): RedirectResponse
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
if (! $campaign->canCancel()) {
|
||||
return back()->with('error', 'Cannot cancel this campaign.');
|
||||
}
|
||||
|
||||
$campaign->cancel();
|
||||
|
||||
return back()->with('success', 'Campaign cancelled.');
|
||||
}
|
||||
|
||||
public function testEmail(Request $request, Business $business, MarketingCampaign $campaign, EmailSender $emailSender): JsonResponse
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => 'required|email',
|
||||
]);
|
||||
|
||||
if (! $campaign->hasEmailContent()) {
|
||||
return response()->json(['success' => false, 'message' => 'Campaign has no email content.']);
|
||||
}
|
||||
|
||||
$result = $emailSender->sendTestEmail($campaign, $validated['email']);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function testSms(Request $request, Business $business, MarketingCampaign $campaign, SmsSender $smsSender): JsonResponse
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
$validated = $request->validate([
|
||||
'phone' => 'required|string',
|
||||
]);
|
||||
|
||||
if (! $campaign->hasSmsContent()) {
|
||||
return response()->json(['success' => false, 'message' => 'Campaign has no SMS content.']);
|
||||
}
|
||||
|
||||
$result = $smsSender->sendTestSms($campaign, $validated['phone']);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function destroy(Business $business, MarketingCampaign $campaign): RedirectResponse
|
||||
{
|
||||
$this->authorizeCampaign($business, $campaign);
|
||||
|
||||
if ($campaign->status === MarketingCampaign::STATUS_SENDING) {
|
||||
return back()->with('error', 'Cannot delete a campaign that is currently sending.');
|
||||
}
|
||||
|
||||
$campaign->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.campaigns.index', $business)
|
||||
->with('success', 'Campaign deleted successfully.');
|
||||
}
|
||||
|
||||
protected function authorizeCampaign(Business $business, MarketingCampaign $campaign): void
|
||||
{
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
protected function prefillFromPromo(MarketingPromo $promo, string $channel): array
|
||||
{
|
||||
$name = $promo->name.' - '.($channel === 'sms' ? 'SMS' : 'Email').' Blast';
|
||||
$subject = $promo->name;
|
||||
|
||||
// Build simple description from promo
|
||||
$description = $promo->description ?? '';
|
||||
$dateRange = '';
|
||||
if ($promo->start_date && $promo->end_date) {
|
||||
$dateRange = 'Valid '.$promo->start_date->format('M j').' - '.$promo->end_date->format('M j');
|
||||
}
|
||||
|
||||
// Simple email template
|
||||
$emailHtml = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{$promo->name}</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #1a1a1a;">{$promo->name}</h1>
|
||||
<p>{$description}</p>
|
||||
<p style="font-weight: bold; color: #059669;">{$dateRange}</p>
|
||||
<p>Don't miss out on this limited time offer!</p>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
// Simple SMS
|
||||
$smsBody = $promo->name;
|
||||
if ($description) {
|
||||
$smsBody .= ' - '.substr($description, 0, 100);
|
||||
}
|
||||
if ($dateRange) {
|
||||
$smsBody .= '. '.$dateRange;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'subject' => $subject,
|
||||
'email_body_html' => $emailHtml,
|
||||
'sms_body' => $smsBody,
|
||||
'source_type' => MarketingCampaign::SOURCE_PROMO,
|
||||
'source_id' => $promo->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingContact;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MarketingContactController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$query = MarketingContact::forBusiness($business->id)
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if ($request->filled('type')) {
|
||||
$query->ofType($request->type);
|
||||
}
|
||||
|
||||
if ($request->filled('subscribed')) {
|
||||
if ($request->subscribed === 'email') {
|
||||
$query->subscribedEmail();
|
||||
} elseif ($request->subscribed === 'sms') {
|
||||
$query->subscribedSms();
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('email', 'ILIKE', "%{$search}%")
|
||||
->orWhere('phone', 'ILIKE', "%{$search}%")
|
||||
->orWhere('first_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('last_name', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$contacts = $query->paginate(25)->withQueryString();
|
||||
|
||||
$lists = MarketingList::forBusiness($business->id)->get();
|
||||
|
||||
return view('seller.marketing.contacts.index', [
|
||||
'business' => $business,
|
||||
'contacts' => $contacts,
|
||||
'lists' => $lists,
|
||||
'types' => MarketingContact::TYPES,
|
||||
'filters' => $request->only(['type', 'subscribed', 'search']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Business $business): View
|
||||
{
|
||||
return view('seller.marketing.contacts.create', [
|
||||
'business' => $business,
|
||||
'types' => MarketingContact::TYPES,
|
||||
'sources' => MarketingContact::SOURCES,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'type' => 'required|in:buyer,consumer,internal',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'first_name' => 'nullable|string|max:100',
|
||||
'last_name' => 'nullable|string|max:100',
|
||||
'tags' => 'nullable|array',
|
||||
'is_subscribed_email' => 'boolean',
|
||||
'is_subscribed_sms' => 'boolean',
|
||||
]);
|
||||
|
||||
if (empty($validated['email']) && empty($validated['phone'])) {
|
||||
return back()->withErrors(['email' => 'Either email or phone is required.'])->withInput();
|
||||
}
|
||||
|
||||
$contact = MarketingContact::create([
|
||||
'business_id' => $business->id,
|
||||
'type' => $validated['type'],
|
||||
'email' => $validated['email'] ?? null,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'first_name' => $validated['first_name'] ?? null,
|
||||
'last_name' => $validated['last_name'] ?? null,
|
||||
'tags' => $validated['tags'] ?? [],
|
||||
'source' => MarketingContact::SOURCE_MANUAL,
|
||||
'is_subscribed_email' => $validated['is_subscribed_email'] ?? true,
|
||||
'is_subscribed_sms' => $validated['is_subscribed_sms'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.contacts.index', $business)
|
||||
->with('success', 'Contact created successfully.');
|
||||
}
|
||||
|
||||
public function edit(Business $business, MarketingContact $contact): View
|
||||
{
|
||||
$this->authorizeContact($business, $contact);
|
||||
|
||||
return view('seller.marketing.contacts.edit', [
|
||||
'business' => $business,
|
||||
'contact' => $contact,
|
||||
'types' => MarketingContact::TYPES,
|
||||
'sources' => MarketingContact::SOURCES,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Business $business, MarketingContact $contact): RedirectResponse
|
||||
{
|
||||
$this->authorizeContact($business, $contact);
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => 'required|in:buyer,consumer,internal',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'first_name' => 'nullable|string|max:100',
|
||||
'last_name' => 'nullable|string|max:100',
|
||||
'tags' => 'nullable|array',
|
||||
'is_subscribed_email' => 'boolean',
|
||||
'is_subscribed_sms' => 'boolean',
|
||||
]);
|
||||
|
||||
if (empty($validated['email']) && empty($validated['phone'])) {
|
||||
return back()->withErrors(['email' => 'Either email or phone is required.'])->withInput();
|
||||
}
|
||||
|
||||
$contact->update([
|
||||
'type' => $validated['type'],
|
||||
'email' => $validated['email'] ?? null,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'first_name' => $validated['first_name'] ?? null,
|
||||
'last_name' => $validated['last_name'] ?? null,
|
||||
'tags' => $validated['tags'] ?? [],
|
||||
'is_subscribed_email' => $validated['is_subscribed_email'] ?? true,
|
||||
'is_subscribed_sms' => $validated['is_subscribed_sms'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.contacts.index', $business)
|
||||
->with('success', 'Contact updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, MarketingContact $contact): RedirectResponse
|
||||
{
|
||||
$this->authorizeContact($business, $contact);
|
||||
|
||||
$contact->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.contacts.index', $business)
|
||||
->with('success', 'Contact deleted successfully.');
|
||||
}
|
||||
|
||||
public function addToList(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'contact_ids' => 'required|array',
|
||||
'contact_ids.*' => 'integer|exists:marketing_contacts,id',
|
||||
'list_id' => 'required|integer|exists:marketing_lists,id',
|
||||
]);
|
||||
|
||||
$list = MarketingList::where('business_id', $business->id)
|
||||
->findOrFail($validated['list_id']);
|
||||
|
||||
$contacts = MarketingContact::forBusiness($business->id)
|
||||
->whereIn('id', $validated['contact_ids'])
|
||||
->pluck('id');
|
||||
|
||||
$list->addContacts($contacts->toArray());
|
||||
|
||||
return back()->with('success', count($contacts).' contact(s) added to list.');
|
||||
}
|
||||
|
||||
protected function authorizeContact(Business $business, MarketingContact $contact): void
|
||||
{
|
||||
if ($contact->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MarketingListController extends Controller
|
||||
{
|
||||
public function index(Business $business): View
|
||||
{
|
||||
$lists = MarketingList::forBusiness($business->id)
|
||||
->withCount('contacts')
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(25);
|
||||
|
||||
return view('seller.marketing.lists.index', [
|
||||
'business' => $business,
|
||||
'lists' => $lists,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Business $business): View
|
||||
{
|
||||
return view('seller.marketing.lists.create', [
|
||||
'business' => $business,
|
||||
'types' => MarketingList::TYPES,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'type' => 'required|in:static,smart',
|
||||
'filters' => 'nullable|array',
|
||||
]);
|
||||
|
||||
MarketingList::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'type' => $validated['type'],
|
||||
'filters' => $validated['filters'] ?? null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.lists.index', $business)
|
||||
->with('success', 'List created successfully.');
|
||||
}
|
||||
|
||||
public function show(Business $business, MarketingList $list): View
|
||||
{
|
||||
$this->authorizeList($business, $list);
|
||||
|
||||
$contacts = $list->getContacts()->paginate(25);
|
||||
|
||||
return view('seller.marketing.lists.show', [
|
||||
'business' => $business,
|
||||
'list' => $list,
|
||||
'contacts' => $contacts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Business $business, MarketingList $list): View
|
||||
{
|
||||
$this->authorizeList($business, $list);
|
||||
|
||||
return view('seller.marketing.lists.edit', [
|
||||
'business' => $business,
|
||||
'list' => $list,
|
||||
'types' => MarketingList::TYPES,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Business $business, MarketingList $list): RedirectResponse
|
||||
{
|
||||
$this->authorizeList($business, $list);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'filters' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$list->update([
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'filters' => $validated['filters'] ?? null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.lists.index', $business)
|
||||
->with('success', 'List updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, MarketingList $list): RedirectResponse
|
||||
{
|
||||
$this->authorizeList($business, $list);
|
||||
|
||||
$list->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.lists.index', $business)
|
||||
->with('success', 'List deleted successfully.');
|
||||
}
|
||||
|
||||
public function removeContact(Business $business, MarketingList $list, int $contactId): RedirectResponse
|
||||
{
|
||||
$this->authorizeList($business, $list);
|
||||
|
||||
$list->removeContacts([$contactId]);
|
||||
|
||||
return back()->with('success', 'Contact removed from list.');
|
||||
}
|
||||
|
||||
protected function authorizeList(Business $business, MarketingList $list): void
|
||||
{
|
||||
if ($list->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
382
app/Http/Controllers/Seller/Marketing/PromoController.php
Normal file
382
app/Http/Controllers/Seller/Marketing/PromoController.php
Normal file
@@ -0,0 +1,382 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Services\Marketing\PromoRecommendationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Promo Builder Controller
|
||||
*
|
||||
* Manages promotional offers including:
|
||||
* - Creating promos with AI recommendations
|
||||
* - Targeting stores, brands, or categories
|
||||
* - Estimating lift and margin impact
|
||||
* - Generating SMS/email copy
|
||||
*/
|
||||
class PromoController extends Controller
|
||||
{
|
||||
protected PromoRecommendationService $recommendations;
|
||||
|
||||
public function __construct(PromoRecommendationService $recommendations)
|
||||
{
|
||||
$this->recommendations = $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display list of all promos
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
$promos = MarketingPromo::forBusiness($business->id)
|
||||
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
||||
->when($request->type, fn ($q, $type) => $q->where('type', $type))
|
||||
->when($request->brand_id, fn ($q, $brandId) => $q->where('brand_id', $brandId))
|
||||
->with(['brand', 'creator'])
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
$brands = Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$promoTypes = MarketingPromo::getTypes();
|
||||
$statuses = MarketingPromo::getStatuses();
|
||||
|
||||
return view('seller.marketing.promos.index', compact(
|
||||
'business',
|
||||
'promos',
|
||||
'brands',
|
||||
'promoTypes',
|
||||
'statuses'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create promo form (Promo Builder wizard)
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
$brands = Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$promoTypes = MarketingPromo::getTypes();
|
||||
|
||||
// Get AI recommendations
|
||||
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
|
||||
$recommendations = $this->recommendations->getRecommendations($business->id, $storeExternalId);
|
||||
|
||||
return view('seller.marketing.promos.create', compact(
|
||||
'business',
|
||||
'brands',
|
||||
'promoTypes',
|
||||
'recommendations'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new promo
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|in:'.implode(',', array_keys(MarketingPromo::getTypes())),
|
||||
'brand_id' => 'nullable|exists:brands,id',
|
||||
'store_external_id' => 'nullable|string|max:100',
|
||||
'config' => 'required|array',
|
||||
'expected_lift' => 'nullable|numeric|min:0|max:100',
|
||||
'expected_margin_brand' => 'nullable|numeric',
|
||||
'expected_margin_store' => 'nullable|numeric',
|
||||
'starts_at' => 'nullable|date',
|
||||
'ends_at' => 'nullable|date|after_or_equal:starts_at',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'sms_copy' => 'nullable|string|max:160',
|
||||
'email_copy' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
// Verify brand belongs to business if provided
|
||||
if ($validated['brand_id']) {
|
||||
$brand = Brand::where('business_id', $business->id)
|
||||
->where('id', $validated['brand_id'])
|
||||
->first();
|
||||
|
||||
if (! $brand) {
|
||||
abort(404, 'Brand not found');
|
||||
}
|
||||
}
|
||||
|
||||
$promo = MarketingPromo::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'type' => $validated['type'],
|
||||
'brand_id' => $validated['brand_id'] ?? null,
|
||||
'store_external_id' => $validated['store_external_id'] ?? null,
|
||||
'config' => $validated['config'],
|
||||
'expected_lift' => $validated['expected_lift'] ?? null,
|
||||
'expected_margin_brand' => $validated['expected_margin_brand'] ?? null,
|
||||
'expected_margin_store' => $validated['expected_margin_store'] ?? null,
|
||||
'starts_at' => $validated['starts_at'] ?? null,
|
||||
'ends_at' => $validated['ends_at'] ?? null,
|
||||
'description' => $validated['description'] ?? null,
|
||||
'sms_copy' => $validated['sms_copy'] ?? null,
|
||||
'email_copy' => $validated['email_copy'] ?? null,
|
||||
'status' => MarketingPromo::STATUS_DRAFT,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
|
||||
->with('success', 'Promo created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a single promo
|
||||
*/
|
||||
public function show(Request $request, $businessSlug, MarketingPromo $promo)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$this->authorizePromo($promo, $business);
|
||||
|
||||
$promo->load(['brand', 'creator']);
|
||||
|
||||
return view('seller.marketing.promos.show', compact('business', 'promo'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit form for a promo
|
||||
*/
|
||||
public function edit(Request $request, $businessSlug, MarketingPromo $promo)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$this->authorizePromo($promo, $business);
|
||||
|
||||
$brands = Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$promoTypes = MarketingPromo::getTypes();
|
||||
|
||||
return view('seller.marketing.promos.edit', compact(
|
||||
'business',
|
||||
'promo',
|
||||
'brands',
|
||||
'promoTypes'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a promo
|
||||
*/
|
||||
public function update(Request $request, $businessSlug, MarketingPromo $promo)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$this->authorizePromo($promo, $business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'brand_id' => 'nullable|exists:brands,id',
|
||||
'store_external_id' => 'nullable|string|max:100',
|
||||
'config' => 'nullable|array',
|
||||
'expected_lift' => 'nullable|numeric|min:0|max:100',
|
||||
'expected_margin_brand' => 'nullable|numeric',
|
||||
'expected_margin_store' => 'nullable|numeric',
|
||||
'starts_at' => 'nullable|date',
|
||||
'ends_at' => 'nullable|date|after_or_equal:starts_at',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'sms_copy' => 'nullable|string|max:160',
|
||||
'email_copy' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
// Verify brand belongs to business if provided
|
||||
if ($validated['brand_id']) {
|
||||
$brand = Brand::where('business_id', $business->id)
|
||||
->where('id', $validated['brand_id'])
|
||||
->first();
|
||||
|
||||
if (! $brand) {
|
||||
abort(404, 'Brand not found');
|
||||
}
|
||||
}
|
||||
|
||||
$promo->update([
|
||||
'name' => $validated['name'],
|
||||
'brand_id' => $validated['brand_id'] ?? null,
|
||||
'store_external_id' => $validated['store_external_id'] ?? null,
|
||||
'config' => $validated['config'] ?? $promo->config,
|
||||
'expected_lift' => $validated['expected_lift'] ?? $promo->expected_lift,
|
||||
'expected_margin_brand' => $validated['expected_margin_brand'] ?? $promo->expected_margin_brand,
|
||||
'expected_margin_store' => $validated['expected_margin_store'] ?? $promo->expected_margin_store,
|
||||
'starts_at' => $validated['starts_at'] ?? $promo->starts_at,
|
||||
'ends_at' => $validated['ends_at'] ?? $promo->ends_at,
|
||||
'description' => $validated['description'] ?? $promo->description,
|
||||
'sms_copy' => $validated['sms_copy'] ?? $promo->sms_copy,
|
||||
'email_copy' => $validated['email_copy'] ?? $promo->email_copy,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
|
||||
->with('success', 'Promo updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a promo
|
||||
*/
|
||||
public function destroy(Request $request, $businessSlug, MarketingPromo $promo)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$this->authorizePromo($promo, $business);
|
||||
|
||||
$promo->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.promos.index', $business->slug)
|
||||
->with('success', 'Promo deleted successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a promo
|
||||
*/
|
||||
public function activate(Request $request, $businessSlug, MarketingPromo $promo)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$this->authorizePromo($promo, $business);
|
||||
|
||||
$promo->activate();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
|
||||
->with('success', 'Promo activated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a promo
|
||||
*/
|
||||
public function cancel(Request $request, $businessSlug, MarketingPromo $promo)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$this->authorizePromo($promo, $business);
|
||||
|
||||
$promo->cancel();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
|
||||
->with('success', 'Promo cancelled.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a promo
|
||||
*/
|
||||
public function duplicate(Request $request, $businessSlug, MarketingPromo $promo)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$this->authorizePromo($promo, $business);
|
||||
|
||||
$newPromo = $promo->replicate();
|
||||
$newPromo->name = $promo->name.' (Copy)';
|
||||
$newPromo->status = MarketingPromo::STATUS_DRAFT;
|
||||
$newPromo->created_by = auth()->id();
|
||||
$newPromo->save();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.promos.edit', [$business->slug, $newPromo])
|
||||
->with('success', 'Promo duplicated. Make your changes and save.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AI recommendations for promo
|
||||
*/
|
||||
public function recommend(Request $request, $businessSlug)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
|
||||
|
||||
$recommendations = $this->recommendations->getRecommendations($business->id, $storeExternalId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'recommendations' => $recommendations,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate promo impact
|
||||
*/
|
||||
public function estimate(Request $request, $businessSlug)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => 'required|in:'.implode(',', array_keys(MarketingPromo::getTypes())),
|
||||
'config' => 'required|array',
|
||||
'brand_id' => 'nullable|exists:brands,id',
|
||||
'store_external_id' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$estimate = $this->recommendations->estimateImpact(
|
||||
$validated,
|
||||
$business->id,
|
||||
$validated['store_external_id'] ?? $business->cannaiq_store_id ?? null
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'estimate' => $estimate,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SMS/email copy for promo
|
||||
*/
|
||||
public function generateCopy(Request $request, $businessSlug)
|
||||
{
|
||||
$business = currentBusiness();
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => 'required|in:'.implode(',', array_keys(MarketingPromo::getTypes())),
|
||||
'config' => 'required|array',
|
||||
'channel' => 'required|in:sms,email',
|
||||
'brand_id' => 'nullable|exists:brands,id',
|
||||
]);
|
||||
|
||||
// Get brand name for copy generation
|
||||
$brandName = null;
|
||||
if ($validated['brand_id']) {
|
||||
$brand = Brand::where('business_id', $business->id)
|
||||
->where('id', $validated['brand_id'])
|
||||
->first();
|
||||
$brandName = $brand?->name;
|
||||
}
|
||||
|
||||
$promoConfig = array_merge($validated, ['brand_name' => $brandName ?? 'our products']);
|
||||
|
||||
$copy = $validated['channel'] === 'sms'
|
||||
? $this->recommendations->generateSmsCopy($promoConfig)
|
||||
: $this->recommendations->generateEmailCopy($promoConfig);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'copy' => $copy,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize that the promo belongs to the business
|
||||
*/
|
||||
protected function authorizePromo(MarketingPromo $promo, $business): void
|
||||
{
|
||||
if ($promo->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
240
app/Http/Controllers/Seller/MarketingAutomationController.php
Normal file
240
app/Http/Controllers/Seller/MarketingAutomationController.php
Normal file
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\RunMarketingAutomationJob;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingAutomation;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use App\Models\Marketing\MarketingTemplate;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class MarketingAutomationController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
|
||||
$automations = MarketingAutomation::where('business_id', $business->id)
|
||||
->with('latestRun')
|
||||
->when($request->status === 'active', fn ($q) => $q->where('is_active', true))
|
||||
->when($request->status === 'inactive', fn ($q) => $q->where('is_active', false))
|
||||
->when($request->trigger_type, fn ($q, $type) => $q->where('trigger_type', $type))
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
return view('seller.marketing.automations.index', compact('business', 'automations'));
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
|
||||
$presets = MarketingAutomation::getTypePresets();
|
||||
$selectedPreset = $request->query('preset');
|
||||
|
||||
$lists = MarketingList::where('business_id', $business->id)
|
||||
->withCount('contacts')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$templates = MarketingTemplate::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.marketing.automations.create', compact(
|
||||
'business',
|
||||
'presets',
|
||||
'selectedPreset',
|
||||
'lists',
|
||||
'templates'
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'scope' => 'required|in:internal,portal',
|
||||
'trigger_type' => 'required|in:'.implode(',', array_keys(MarketingAutomation::TRIGGER_TYPES)),
|
||||
'trigger_config' => 'required|json',
|
||||
'condition_config' => 'required|json',
|
||||
'action_config' => 'required|json',
|
||||
]);
|
||||
|
||||
// Decode JSON configs from the form
|
||||
$triggerConfig = json_decode($validated['trigger_config'], true) ?? [];
|
||||
$conditionConfig = json_decode($validated['condition_config'], true) ?? [];
|
||||
$actionConfig = json_decode($validated['action_config'], true) ?? [];
|
||||
|
||||
// Normalize condition config - convert percentage values
|
||||
if (isset($conditionConfig['min_price_advantage']) && $conditionConfig['min_price_advantage'] > 1) {
|
||||
$conditionConfig['min_price_advantage'] = $conditionConfig['min_price_advantage'] / 100;
|
||||
}
|
||||
|
||||
// Map velocity_threshold to velocity_30d_threshold for slow mover clearance
|
||||
if (isset($conditionConfig['velocity_threshold'])) {
|
||||
$conditionConfig['velocity_30d_threshold'] = $conditionConfig['velocity_threshold'];
|
||||
unset($conditionConfig['velocity_threshold']);
|
||||
}
|
||||
|
||||
$automation = MarketingAutomation::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'],
|
||||
'is_active' => true,
|
||||
'scope' => $validated['scope'],
|
||||
'trigger_type' => $validated['trigger_type'],
|
||||
'trigger_config' => $triggerConfig,
|
||||
'condition_config' => $conditionConfig,
|
||||
'action_config' => $actionConfig,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.automations.index', $business)
|
||||
->with('success', "Automation \"{$automation->name}\" created successfully.");
|
||||
}
|
||||
|
||||
public function edit(Request $request, Business $business, MarketingAutomation $automation)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
$this->ensureAutomationBelongsToBusiness($automation, $business);
|
||||
|
||||
$presets = MarketingAutomation::getTypePresets();
|
||||
|
||||
$lists = MarketingList::where('business_id', $business->id)
|
||||
->withCount('contacts')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$templates = MarketingTemplate::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.marketing.automations.edit', compact(
|
||||
'business',
|
||||
'automation',
|
||||
'presets',
|
||||
'lists',
|
||||
'templates'
|
||||
));
|
||||
}
|
||||
|
||||
public function update(Request $request, Business $business, MarketingAutomation $automation)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
$this->ensureAutomationBelongsToBusiness($automation, $business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'scope' => 'required|in:internal,portal',
|
||||
'trigger_type' => 'required|in:'.implode(',', array_keys(MarketingAutomation::TRIGGER_TYPES)),
|
||||
'trigger_config' => 'required|json',
|
||||
'condition_config' => 'required|json',
|
||||
'action_config' => 'required|json',
|
||||
]);
|
||||
|
||||
// Decode JSON configs from the form
|
||||
$triggerConfig = json_decode($validated['trigger_config'], true) ?? [];
|
||||
$conditionConfig = json_decode($validated['condition_config'], true) ?? [];
|
||||
$actionConfig = json_decode($validated['action_config'], true) ?? [];
|
||||
|
||||
// Normalize condition config - convert percentage values
|
||||
if (isset($conditionConfig['min_price_advantage']) && $conditionConfig['min_price_advantage'] > 1) {
|
||||
$conditionConfig['min_price_advantage'] = $conditionConfig['min_price_advantage'] / 100;
|
||||
}
|
||||
|
||||
// Map velocity_threshold to velocity_30d_threshold for slow mover clearance
|
||||
if (isset($conditionConfig['velocity_threshold'])) {
|
||||
$conditionConfig['velocity_30d_threshold'] = $conditionConfig['velocity_threshold'];
|
||||
unset($conditionConfig['velocity_threshold']);
|
||||
}
|
||||
|
||||
$automation->update([
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'],
|
||||
'scope' => $validated['scope'],
|
||||
'trigger_type' => $validated['trigger_type'],
|
||||
'trigger_config' => $triggerConfig,
|
||||
'condition_config' => $conditionConfig,
|
||||
'action_config' => $actionConfig,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.automations.index', $business)
|
||||
->with('success', "Automation \"{$automation->name}\" updated successfully.");
|
||||
}
|
||||
|
||||
public function toggle(Request $request, Business $business, MarketingAutomation $automation)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
$this->ensureAutomationBelongsToBusiness($automation, $business);
|
||||
|
||||
$automation->update([
|
||||
'is_active' => ! $automation->is_active,
|
||||
]);
|
||||
|
||||
$status = $automation->is_active ? 'enabled' : 'disabled';
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->with('success', "Automation \"{$automation->name}\" has been {$status}.");
|
||||
}
|
||||
|
||||
public function runNow(Request $request, Business $business, MarketingAutomation $automation)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
$this->ensureAutomationBelongsToBusiness($automation, $business);
|
||||
|
||||
if (! $automation->is_active) {
|
||||
return redirect()
|
||||
->back()
|
||||
->with('error', 'Cannot run an inactive automation. Enable it first.');
|
||||
}
|
||||
|
||||
// Dispatch the job
|
||||
RunMarketingAutomationJob::dispatch($automation->id);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.automations.runs.index', [$business, $automation])
|
||||
->with('success', "Automation \"{$automation->name}\" has been queued to run.");
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Business $business, MarketingAutomation $automation)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
$this->ensureAutomationBelongsToBusiness($automation, $business);
|
||||
|
||||
$name = $automation->name;
|
||||
$automation->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.marketing.automations.index', $business)
|
||||
->with('success', "Automation \"{$name}\" has been deleted.");
|
||||
}
|
||||
|
||||
protected function authorizeForBusiness(Business $business): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Check user has access to this business
|
||||
if (! $user->businesses->contains($business->id) && ! $user->hasRole('Super Admin')) {
|
||||
abort(403, 'Unauthorized access to this business.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function ensureAutomationBelongsToBusiness(MarketingAutomation $automation, Business $business): void
|
||||
{
|
||||
if ($automation->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingAutomation;
|
||||
use App\Models\Marketing\MarketingAutomationRun;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class MarketingAutomationRunController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business, MarketingAutomation $automation)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
$this->ensureAutomationBelongsToBusiness($automation, $business);
|
||||
|
||||
$runs = MarketingAutomationRun::where('marketing_automation_id', $automation->id)
|
||||
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
||||
->orderBy('started_at', 'desc')
|
||||
->paginate(25);
|
||||
|
||||
return view('seller.marketing.automations.runs.index', compact(
|
||||
'business',
|
||||
'automation',
|
||||
'runs'
|
||||
));
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, MarketingAutomation $automation, MarketingAutomationRun $run)
|
||||
{
|
||||
$this->authorizeForBusiness($business);
|
||||
$this->ensureAutomationBelongsToBusiness($automation, $business);
|
||||
$this->ensureRunBelongsToAutomation($run, $automation);
|
||||
|
||||
return view('seller.marketing.automations.runs.show', compact(
|
||||
'business',
|
||||
'automation',
|
||||
'run'
|
||||
));
|
||||
}
|
||||
|
||||
protected function authorizeForBusiness(Business $business): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->businesses->contains($business->id) && ! $user->hasRole('Super Admin')) {
|
||||
abort(403, 'Unauthorized access to this business.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function ensureAutomationBelongsToBusiness(MarketingAutomation $automation, Business $business): void
|
||||
{
|
||||
if ($automation->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
protected function ensureRunBelongsToAutomation(MarketingAutomationRun $run, MarketingAutomation $automation): void
|
||||
{
|
||||
if ($run->marketing_automation_id !== $automation->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
125
app/Http/Controllers/Seller/Processing/BiomassController.php
Normal file
125
app/Http/Controllers/Seller/Processing/BiomassController.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Processing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Processing\ProcBiomassLot;
|
||||
use App\Models\Processing\ProcVendor;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BiomassController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$query = ProcBiomassLot::forBusiness($business->id)
|
||||
->with('product')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('lot_number', 'like', '%'.$request->search.'%');
|
||||
}
|
||||
|
||||
$biomassLots = $query->paginate(25);
|
||||
|
||||
return view('seller.processing.biomass.index', compact('business', 'biomassLots'));
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$vendors = ProcVendor::forBusiness($business->id)->active()->get();
|
||||
$products = Product::where('business_id', $business->id)->get(); // Biomass product types
|
||||
|
||||
return view('seller.processing.biomass.create', compact('business', 'vendors', 'products'));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'lot_number' => 'required|string|max:100',
|
||||
'product_id' => 'nullable|exists:products,id',
|
||||
'source_type' => 'required|in:internal,external_vendor,internal_business',
|
||||
'source_id' => 'nullable|integer',
|
||||
'wet_weight' => 'required|numeric|min:0',
|
||||
'dry_weight' => 'nullable|numeric|min:0',
|
||||
'moisture_percent' => 'nullable|numeric|min:0|max:100',
|
||||
'thc_percent' => 'nullable|numeric|min:0|max:100',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$validated['business_id'] = $business->id;
|
||||
$validated['status'] = 'available';
|
||||
|
||||
ProcBiomassLot::create($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.processing.biomass.index', $business)
|
||||
->with('success', 'Biomass lot created successfully.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, ProcBiomassLot $biomass)
|
||||
{
|
||||
$this->authorizeForBusiness($biomass, $business);
|
||||
|
||||
$biomass->load(['product', 'extractionRunInputs.extractionRun']);
|
||||
|
||||
return view('seller.processing.biomass.show', compact('business', 'biomass'));
|
||||
}
|
||||
|
||||
public function edit(Request $request, Business $business, ProcBiomassLot $biomass)
|
||||
{
|
||||
$this->authorizeForBusiness($biomass, $business);
|
||||
|
||||
$vendors = ProcVendor::forBusiness($business->id)->active()->get();
|
||||
$products = Product::where('business_id', $business->id)->get();
|
||||
|
||||
return view('seller.processing.biomass.edit', compact('business', 'biomass', 'vendors', 'products'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Business $business, ProcBiomassLot $biomass)
|
||||
{
|
||||
$this->authorizeForBusiness($biomass, $business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'lot_number' => 'required|string|max:100',
|
||||
'product_id' => 'nullable|exists:products,id',
|
||||
'source_type' => 'required|in:internal,external_vendor,internal_business',
|
||||
'source_id' => 'nullable|integer',
|
||||
'wet_weight' => 'required|numeric|min:0',
|
||||
'dry_weight' => 'nullable|numeric|min:0',
|
||||
'moisture_percent' => 'nullable|numeric|min:0|max:100',
|
||||
'thc_percent' => 'nullable|numeric|min:0|max:100',
|
||||
'status' => 'required|in:available,allocated,depleted,quarantined',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$biomass->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.processing.biomass.show', [$business, $biomass])
|
||||
->with('success', 'Biomass lot updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Business $business, ProcBiomassLot $biomass)
|
||||
{
|
||||
$this->authorizeForBusiness($biomass, $business);
|
||||
|
||||
$biomass->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.processing.biomass.index', $business)
|
||||
->with('success', 'Biomass lot deleted successfully.');
|
||||
}
|
||||
|
||||
protected function authorizeForBusiness(ProcBiomassLot $biomass, Business $business): void
|
||||
{
|
||||
if ($biomass->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to this biomass lot.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user