Compare commits
405 Commits
docs/add-f
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05f77e6144 | ||
|
|
b92ba4b86d | ||
|
|
f8f219f00b | ||
|
|
f16dac012d | ||
|
|
f566b83cc6 | ||
|
|
418da7a39e | ||
|
|
3c6fe92811 | ||
|
|
7d3243b67e | ||
|
|
8f6597f428 | ||
|
|
64d38b8b2f | ||
|
|
7aa366eda9 | ||
|
|
d7adaf0cba | ||
|
|
f8b5599b4b | ||
|
|
d6161817ad | ||
|
|
be8b039c23 | ||
|
|
8519c7dd40 | ||
|
|
096bbcc173 | ||
|
|
cdff325a3c | ||
|
|
8eb822ec81 | ||
|
|
ef0eb78d93 | ||
|
|
1dfdb74bc5 | ||
|
|
75352af8b0 | ||
|
|
544d33324a | ||
|
|
235981d908 | ||
|
|
fc1dcb5d77 | ||
|
|
91548b00de | ||
|
|
bcd6426e2e | ||
|
|
22e5be9a63 | ||
|
|
515957f7e7 | ||
|
|
18f98afc95 | ||
|
|
e3781747c1 | ||
|
|
8ef10be240 | ||
|
|
e239e6ece6 | ||
|
|
5e307d25e6 | ||
|
|
5c4ec7fd46 | ||
|
|
c1b487624c | ||
|
|
5d47a8dedf | ||
|
|
b6e89cfac1 | ||
|
|
4f272af661 | ||
|
|
f1820eb47d | ||
|
|
5b78f8db0f | ||
|
|
90f414ddf7 | ||
|
|
61b0977fc7 | ||
|
|
7954804998 | ||
|
|
88fc44cee6 | ||
|
|
e02cb0415a | ||
|
|
0307a7a310 | ||
|
|
991ca95c70 | ||
|
|
3905f86d6a | ||
|
|
ece7dc602d | ||
|
|
b093e088c8 | ||
|
|
22a62ba005 | ||
|
|
57c236dd27 | ||
|
|
97f38985a9 | ||
|
|
c698c621d3 | ||
|
|
e072b01d6a | ||
|
|
076b990573 | ||
|
|
e1f34935e4 | ||
|
|
ba286d830e | ||
|
|
95db76ed09 | ||
|
|
725891b975 | ||
|
|
04b0c7a991 | ||
|
|
9dcaf5bdd7 | ||
|
|
36fac08dd1 | ||
|
|
427d5c905f | ||
|
|
641b6dc74c | ||
|
|
1d0a3d3221 | ||
|
|
fba6cd69ad | ||
|
|
ce5c670bf2 | ||
|
|
578720130b | ||
|
|
12f7ba9949 | ||
|
|
3f8625fc0d | ||
|
|
02270569ed | ||
|
|
df84066b0e | ||
|
|
d035c3ba46 | ||
|
|
6577cd0c0a | ||
|
|
8dfd9076dc | ||
|
|
ff8a2c93b4 | ||
|
|
e98aaa034b | ||
|
|
a892514c3a | ||
|
|
559a1ee2fc | ||
|
|
664c081680 | ||
|
|
6517f2fa44 | ||
|
|
c9b99efbe0 | ||
|
|
8469ff5204 | ||
|
|
9be95461cb | ||
|
|
b24266cdc1 | ||
|
|
b96beddef5 | ||
|
|
1eabb951e5 | ||
|
|
486e6864b6 | ||
|
|
cd25979e13 | ||
|
|
382c1cc29e | ||
|
|
edad5712fc | ||
|
|
91b2002dd6 | ||
|
|
54e52add84 | ||
|
|
6290274719 | ||
|
|
5bbc740962 | ||
|
|
82cac8ebab | ||
|
|
dc668e24d5 | ||
|
|
910be9c14a | ||
|
|
aab2e65903 | ||
|
|
f85be8a676 | ||
|
|
36473e1c49 | ||
|
|
fe0c6b22af | ||
|
|
06e35cb296 | ||
|
|
4b347112c6 | ||
|
|
632ddce08a | ||
|
|
35c603944f | ||
|
|
ea3ed4de0a | ||
|
|
179c9a7818 | ||
|
|
6835a19b39 | ||
|
|
3b9ddd8865 | ||
|
|
d9d8190835 | ||
|
|
8af01a6772 | ||
|
|
e11a934766 | ||
|
|
86c2e0cf1c | ||
|
|
f899e5f8cb | ||
|
|
f2b1ceebe9 | ||
|
|
b0e343f2b5 | ||
|
|
162b742092 | ||
|
|
a1922ee10e | ||
|
|
e28aa402d1 | ||
|
|
609d55d5c9 | ||
|
|
d649c8239f | ||
|
|
86b7d8db4e | ||
|
|
701534dd6b | ||
|
|
f341fc6673 | ||
|
|
103b7a6077 | ||
|
|
5a57fd1e27 | ||
|
|
6f56d21936 | ||
|
|
44cf1423e4 | ||
|
|
ceea43823b | ||
|
|
618d5aeea9 | ||
|
|
9c3e3b1c7b | ||
|
|
b3a5eebd56 | ||
|
|
dc804e8e25 | ||
|
|
20709d201f | ||
|
|
b33e71fecc | ||
|
|
cced67001e | ||
|
|
bc8cb45533 | ||
|
|
a48051f0bb | ||
|
|
84e81272a5 | ||
|
|
435a6b074c | ||
|
|
9a5d89fbdd | ||
|
|
b3ae727c5a | ||
|
|
c004ee3b1e | ||
|
|
41f8bee6a6 | ||
|
|
f53124cd2e | ||
|
|
1d1ac2d520 | ||
|
|
bca2cd5c77 | ||
|
|
ff25196d51 | ||
|
|
58006d7b19 | ||
|
|
4237cf45ab | ||
|
|
5f591bee19 | ||
|
|
c9fa8d7578 | ||
|
|
7c3f5a27a3 | ||
|
|
13d2fa3ac7 | ||
|
|
fab181128a | ||
|
|
fbb1619c38 | ||
|
|
9a9bfeae35 | ||
|
|
c7f3af5f39 | ||
|
|
0db14bda0e | ||
|
|
0ff3b64f80 | ||
|
|
2ca4338e7e | ||
|
|
d905805980 | ||
|
|
2f5cb5c0e7 | ||
|
|
86fef4d021 | ||
|
|
5b95c8b365 | ||
|
|
5c1863218f | ||
|
|
ee30c65c34 | ||
|
|
d10357758d | ||
|
|
59cd1c5a6b | ||
|
|
2b865f2633 | ||
|
|
d7f79c6a5b | ||
|
|
7eb658ef6c | ||
|
|
150ecb9124 | ||
|
|
2fe3e7abd9 | ||
|
|
dc975a4206 | ||
|
|
04668d1b29 | ||
|
|
d13184819f | ||
|
|
f05211c924 | ||
|
|
5cd86ed463 | ||
|
|
7dd4cd314f | ||
|
|
29c95be27b | ||
|
|
b37cb2b5c9 | ||
|
|
a95d875564 | ||
|
|
41e65bf3b0 | ||
|
|
fbac9498fd | ||
|
|
a155999bbb | ||
|
|
9978e1efcc | ||
|
|
6fbcc1a451 | ||
|
|
3fd89291e7 | ||
|
|
e4588ec8b6 | ||
|
|
82bd313d21 | ||
|
|
ada6ede429 | ||
|
|
549bdf0e93 | ||
|
|
b8ed494c41 | ||
|
|
0f843fa0f2 | ||
|
|
01859205f5 | ||
|
|
5e4ce9f21b | ||
|
|
a9f30cdfaa | ||
|
|
09c0d1bbe8 | ||
|
|
13908d0d3a | ||
|
|
43f852b618 | ||
|
|
0df1694dad | ||
|
|
4d0c9698d6 | ||
|
|
0ed49f947c | ||
|
|
aa788e9fe2 | ||
|
|
0e1f145c45 | ||
|
|
b926a627f2 | ||
|
|
e017ddf762 | ||
|
|
6eee8d8c07 | ||
|
|
0b62d8371f | ||
|
|
60a375960f | ||
|
|
8e1162a1c9 | ||
|
|
0d4d57c51f | ||
|
|
ceb0526f0f | ||
|
|
cc2bedff41 | ||
|
|
1cfc8983a9 | ||
|
|
90dd3f415d | ||
|
|
281fc7f5a1 | ||
|
|
d20162c5b2 | ||
|
|
3318880afd | ||
|
|
0a06a02bf6 | ||
|
|
ffe059a4d5 | ||
|
|
59ed05dd53 | ||
|
|
19eee0d36f | ||
|
|
9967e39dc8 | ||
|
|
6a4bd75b33 | ||
|
|
61a680b7e3 | ||
|
|
0f248ca178 | ||
|
|
00782038d3 | ||
|
|
3c1a7da11a | ||
|
|
9833cc592d | ||
|
|
54e8ff474f | ||
|
|
efc61680c9 | ||
|
|
8a72453cc2 | ||
|
|
07c5a1e336 | ||
|
|
d16c1a3746 | ||
|
|
81745fbf70 | ||
|
|
6c3be5221b | ||
|
|
1e6cb75422 | ||
|
|
b4bc8c129f | ||
|
|
86e656a89b | ||
|
|
c7c15fa484 | ||
|
|
1e60212644 | ||
|
|
33607ff982 | ||
|
|
bb34d24e1b | ||
|
|
94e67c5955 | ||
|
|
7606484317 | ||
|
|
e57212437d | ||
|
|
c9b68ba61e | ||
|
|
bd9abe29b9 | ||
|
|
6223dcc024 | ||
|
|
3de733a528 | ||
|
|
eccaedf219 | ||
|
|
a4e465c428 | ||
|
|
b96f5d6d59 | ||
|
|
28d1701904 | ||
|
|
4cb6b87134 | ||
|
|
e3f7181558 | ||
|
|
456b44681c | ||
|
|
e60accf724 | ||
|
|
66db854ebc | ||
|
|
2d02493b24 | ||
|
|
e3c7d14001 | ||
|
|
966d381740 | ||
|
|
1eff01496b | ||
|
|
bf83c4bc63 | ||
|
|
aec4a12af8 | ||
|
|
49ef373cbe | ||
|
|
9a40e1945e | ||
|
|
99e34832a0 | ||
|
|
e1ebf245b2 | ||
|
|
10688606ca | ||
|
|
f36aad8d6d | ||
|
|
f543fe930a | ||
|
|
62be464ebe | ||
|
|
3b245b421f | ||
|
|
8f45d86315 | ||
|
|
629831cdd8 | ||
|
|
3ac21c22ec | ||
|
|
60362f5792 | ||
|
|
078e4f380c | ||
|
|
2457d81061 | ||
|
|
dec35f9eea | ||
|
|
6840f0a583 | ||
|
|
759bbe90b0 | ||
|
|
3a7e49f176 | ||
|
|
ca661b8649 | ||
|
|
430f7efe5c | ||
|
|
d06c66f703 | ||
|
|
0b2a22c5c9 | ||
|
|
33deab99b2 | ||
|
|
5696db0023 | ||
|
|
394e0ba201 | ||
|
|
d8b7230512 | ||
|
|
20b9fa8dc7 | ||
|
|
c5878de5d2 | ||
|
|
85936a643b | ||
|
|
4d50ab2fab | ||
|
|
163168d561 | ||
|
|
afab8bc2c9 | ||
|
|
492890b2d8 | ||
|
|
e907e3d610 | ||
|
|
2db314509f | ||
|
|
46314b16c0 | ||
|
|
ef49a5566d | ||
|
|
fbdd770d69 | ||
|
|
d183cf6ec1 | ||
|
|
d257f5b8a3 | ||
|
|
b73439ae90 | ||
|
|
9c1313171c | ||
|
|
8b379a3653 | ||
|
|
53fe654340 | ||
|
|
1c3f0e1efb | ||
|
|
37cc8994ad | ||
|
|
2dc6119e98 | ||
|
|
56464e0f5b | ||
|
|
a7a0ee9ce8 | ||
|
|
c8538e155c | ||
|
|
37db77cbb2 | ||
|
|
e2f4667818 | ||
|
|
2ca5cb048b | ||
|
|
6426016c2e | ||
|
|
d08d080937 | ||
|
|
8c7beccdc8 | ||
|
|
0584111357 | ||
|
|
87174f80c5 | ||
|
|
bd01908b52 | ||
|
|
af8666bd42 | ||
|
|
4f5faa5d39 | ||
|
|
2831def53a | ||
|
|
a0baf3ad39 | ||
|
|
16e002ccb9 | ||
|
|
bf0dea6ee3 | ||
|
|
602c060a0a | ||
|
|
2c0d1d5658 | ||
|
|
f8d1f9dc91 | ||
|
|
7887a695f7 | ||
|
|
654a76c5db | ||
|
|
a339d8fc75 | ||
|
|
482789ca41 | ||
|
|
28a66fba92 | ||
|
|
8903759335 | ||
|
|
ecade68740 | ||
|
|
64b77477fb | ||
|
|
1e763882c6 | ||
|
|
ddf6d2470b | ||
|
|
e538b45d5b | ||
|
|
b922ab2556 | ||
|
|
9207453164 | ||
|
|
5d17cbccfb | ||
|
|
4d46f29404 | ||
|
|
dd598ccd50 | ||
|
|
6049658ad9 | ||
|
|
96791a7611 | ||
|
|
7bffe6dbf7 | ||
|
|
7eff3f74be | ||
|
|
cc44f47a3f | ||
|
|
c19617244e | ||
|
|
18381bb2fe | ||
|
|
1dcf78621b | ||
|
|
a38906d91e | ||
|
|
603a50931b | ||
|
|
d5ddccc318 | ||
|
|
615d221c0c | ||
|
|
5227def0d8 | ||
|
|
745a41b811 | ||
|
|
4f8bafc6dd | ||
|
|
d56bc5d21a | ||
|
|
3a26392bd0 | ||
|
|
8a23f5438b | ||
|
|
1d837c0bf0 | ||
|
|
d8739a71a5 | ||
|
|
9821984630 | ||
|
|
63f1fb6bf9 | ||
|
|
7a26ae7ac9 | ||
|
|
b4a057b5f7 | ||
|
|
7e2438c44f | ||
|
|
48a80e8e76 | ||
|
|
490ef0ae0a | ||
|
|
5f99fba396 | ||
|
|
84f364de74 | ||
|
|
39c955cdc4 | ||
|
|
e02ca54187 | ||
|
|
ac46ee004b | ||
|
|
17a6eb260d | ||
|
|
5ea80366be | ||
|
|
99aa0cb980 | ||
|
|
3de53a76d0 | ||
|
|
7fa9b6aff8 | ||
|
|
19b86d9f0e | ||
|
|
62c617a8db | ||
|
|
7616c5e7f4 | ||
|
|
0406d13b92 | ||
|
|
d0ad85c943 | ||
|
|
8f41e08bc6 | ||
|
|
2c82099bdd | ||
|
|
dd967ff223 | ||
|
|
569e84562e | ||
|
|
a51398a336 | ||
|
|
6e97798f5b | ||
|
|
25181ec31b | ||
|
|
e8a1a62898 |
@@ -1,35 +0,0 @@
|
|||||||
# Number Input Spinners Removed
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
All number input spinner arrows (up/down buttons) have been globally removed from the application.
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
CSS has been added to both main layout files to hide spinners:
|
|
||||||
|
|
||||||
1. **app.blade.php** (lines 17-31)
|
|
||||||
2. **app-with-sidebar.blade.php** (lines 17-31)
|
|
||||||
|
|
||||||
## CSS Used
|
|
||||||
```css
|
|
||||||
/* Chrome, Safari, Edge, Opera */
|
|
||||||
input[type="number"]::-webkit-outer-spin-button,
|
|
||||||
input[type="number"]::-webkit-inner-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Firefox */
|
|
||||||
input[type="number"] {
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
appearance: textfield;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## User Preference
|
|
||||||
User specifically requested:
|
|
||||||
- Remove up/down arrows on number input boxes
|
|
||||||
- Apply this globally across all pages
|
|
||||||
- Remember this preference for future pages
|
|
||||||
|
|
||||||
## Date
|
|
||||||
2025-11-05
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(test:*)",
|
|
||||||
"Bash(docker exec:*)",
|
|
||||||
"Bash(docker stats:*)",
|
|
||||||
"Bash(docker logs:*)",
|
|
||||||
"Bash(docker-compose down:*)",
|
|
||||||
"Bash(docker-compose up:*)",
|
|
||||||
"Bash(php --version:*)",
|
|
||||||
"Bash(docker-compose build:*)",
|
|
||||||
"Bash(docker-compose restart:*)",
|
|
||||||
"Bash(find:*)",
|
|
||||||
"Bash(docker ps:*)",
|
|
||||||
"Bash(php -l:*)",
|
|
||||||
"Bash(curl:*)",
|
|
||||||
"Bash(cat:*)",
|
|
||||||
"Bash(docker update:*)",
|
|
||||||
"Bash(grep:*)",
|
|
||||||
"Bash(sed:*)",
|
|
||||||
"Bash(php artisan:*)",
|
|
||||||
"Bash(php check_blade.php:*)"
|
|
||||||
],
|
|
||||||
"deny": [],
|
|
||||||
"ask": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
66
.env.example
66
.env.example
@@ -8,6 +8,10 @@ APP_LOCALE=en
|
|||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
|
# Stock Notification Settings
|
||||||
|
# Number of days before stock notification requests expire (default: 30)
|
||||||
|
STOCK_NOTIFICATION_EXPIRATION_DAYS=30
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
APP_MAINTENANCE_DRIVER=file
|
||||||
# APP_MAINTENANCE_STORE=database
|
# APP_MAINTENANCE_STORE=database
|
||||||
|
|
||||||
@@ -34,7 +38,7 @@ SESSION_PATH=/
|
|||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
BROADCAST_CONNECTION=reverb
|
BROADCAST_CONNECTION=reverb
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=minio
|
||||||
QUEUE_CONNECTION=redis
|
QUEUE_CONNECTION=redis
|
||||||
|
|
||||||
# Laravel Reverb (WebSocket Server for Real-Time Broadcasting)
|
# Laravel Reverb (WebSocket Server for Real-Time Broadcasting)
|
||||||
@@ -77,19 +81,42 @@ MAIL_ENCRYPTION=null
|
|||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
# AWS/MinIO S3 Storage Configuration
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
# Local development: Use FILESYSTEM_DISK=public (default)
|
# MinIO/S3 Storage Configuration
|
||||||
# Production: Use FILESYSTEM_DISK=s3 with MinIO credentials below
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
AWS_ACCESS_KEY_ID=
|
# Versioning is enabled in all environments for asset recovery
|
||||||
AWS_SECRET_ACCESS_KEY=
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
|
||||||
AWS_BUCKET=
|
|
||||||
AWS_ENDPOINT=
|
|
||||||
AWS_URL=
|
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
|
||||||
|
|
||||||
# Production MinIO Configuration (example):
|
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||||
# FILESYSTEM_DISK=s3
|
# │ LOCAL DEVELOPMENT (Docker MinIO) │
|
||||||
|
# └─────────────────────────────────────────────────────────────────────┘
|
||||||
|
# Use local MinIO container for development (versioning enabled)
|
||||||
|
# Access MinIO Console: http://localhost:9001 (minioadmin/minioadmin)
|
||||||
|
FILESYSTEM_DISK=minio
|
||||||
|
AWS_ACCESS_KEY_ID=minioadmin
|
||||||
|
AWS_SECRET_ACCESS_KEY=minioadmin
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
AWS_BUCKET=media
|
||||||
|
AWS_ENDPOINT=http://minio:9000
|
||||||
|
AWS_URL=http://localhost:9000/media
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||||
|
|
||||||
|
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
# │ STAGING/DEVELOP (media-dev bucket) │
|
||||||
|
# └─────────────────────────────────────────────────────────────────────┘
|
||||||
|
# FILESYSTEM_DISK=minio
|
||||||
|
# AWS_ACCESS_KEY_ID=<staging-access-key>
|
||||||
|
# AWS_SECRET_ACCESS_KEY=<staging-secret-key>
|
||||||
|
# AWS_DEFAULT_REGION=us-east-1
|
||||||
|
# AWS_BUCKET=media-dev
|
||||||
|
# AWS_ENDPOINT=https://cdn.cannabrands.app
|
||||||
|
# AWS_URL=https://cdn.cannabrands.app/media-dev
|
||||||
|
# AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||||
|
|
||||||
|
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
# │ PRODUCTION (media bucket) │
|
||||||
|
# └─────────────────────────────────────────────────────────────────────┘
|
||||||
|
# FILESYSTEM_DISK=minio
|
||||||
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
|
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
|
||||||
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
|
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
|
||||||
# AWS_DEFAULT_REGION=us-east-1
|
# AWS_DEFAULT_REGION=us-east-1
|
||||||
@@ -99,3 +126,16 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
|||||||
# AWS_USE_PATH_STYLE_ENDPOINT=true
|
# AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
# AI Orchestrator Configuration
|
||||||
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
AI_ENABLED=true
|
||||||
|
AI_LLM_PROVIDER=mock
|
||||||
|
# AI_LLM_PROVIDER=openai
|
||||||
|
# AI_LLM_PROVIDER=anthropic
|
||||||
|
AI_OPENAI_API_KEY=
|
||||||
|
AI_OPENAI_MODEL=gpt-4o
|
||||||
|
AI_ANTHROPIC_API_KEY=
|
||||||
|
AI_ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||||
|
AI_LOG_CHANNEL=ai
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ chmod +x .githooks/*
|
|||||||
|
|
||||||
### `pre-commit` - Laravel Pint Auto-formatter ✅ ENFORCED
|
### `pre-commit` - Laravel Pint Auto-formatter ✅ ENFORCED
|
||||||
**What it does:**
|
**What it does:**
|
||||||
- Runs Laravel Pint on staged files only (`--dirty`)
|
- Runs Laravel Pint on staged PHP files only (not unstaged files)
|
||||||
- Auto-formats code to match team standards
|
- Auto-formats code to match team standards
|
||||||
- Automatically stages formatted files
|
- Automatically re-stages the formatted files
|
||||||
- Fast feedback (runs in seconds)
|
- Fast feedback (runs in seconds)
|
||||||
|
- Safe: Won't format or stage files you haven't explicitly added
|
||||||
|
|
||||||
**When it runs:**
|
**When it runs:**
|
||||||
- Every time you run `git commit`
|
- Every time you run `git commit`
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Laravel Pint Pre-commit Hook
|
# Laravel Pint Pre-commit Hook
|
||||||
# Automatically format code before committing
|
# Automatically format staged PHP files before committing
|
||||||
|
|
||||||
echo "🎨 Running Laravel Pint..."
|
echo "🎨 Running Laravel Pint..."
|
||||||
|
|
||||||
# Run Pint on staged files only
|
# Get only staged PHP files
|
||||||
./vendor/bin/pint --dirty
|
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')
|
||||||
|
|
||||||
# Check if Pint made changes
|
# Exit early if no PHP files are staged
|
||||||
if ! git diff --quiet; then
|
if [ -z "$STAGED_FILES" ]; then
|
||||||
echo "✅ Code formatted! Files have been updated."
|
echo "✅ No PHP files staged"
|
||||||
echo " Changes have been staged automatically."
|
|
||||||
|
|
||||||
# Stage the formatted files
|
|
||||||
git add -u
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo "✅ Code style looks good!"
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Run Pint only on staged files
|
||||||
|
echo "$STAGED_FILES" | xargs ./vendor/bin/pint
|
||||||
|
|
||||||
|
# Check if Pint made changes to any of the staged files
|
||||||
|
CHANGED=false
|
||||||
|
for file in $STAGED_FILES; do
|
||||||
|
if ! git diff --quiet "$file" 2>/dev/null; then
|
||||||
|
CHANGED=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Re-stage the formatted files (only the ones that were already staged)
|
||||||
|
if [ "$CHANGED" = true ]; then
|
||||||
|
echo "✅ Code formatted! Files have been updated."
|
||||||
|
echo " Changes have been staged automatically."
|
||||||
|
echo "$STAGED_FILES" | xargs git add
|
||||||
|
else
|
||||||
|
echo "✅ Code style looks good!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
#
|
#
|
||||||
# Pre-push hook - Runs tests before pushing (supports both Sail and K8s)
|
# Pre-push hook - Optionally run tests before pushing
|
||||||
# Can be skipped with: git push --no-verify
|
# Can be skipped with: git push --no-verify
|
||||||
#
|
#
|
||||||
|
# This is OPTIONAL - CI/CD will run comprehensive tests automatically.
|
||||||
|
# Running tests locally can catch issues faster, but it's not required.
|
||||||
|
#
|
||||||
|
|
||||||
echo "🧪 Running tests before push..."
|
echo "🚀 Preparing to push..."
|
||||||
echo " (Use 'git push --no-verify' to skip)"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Detect which environment is running
|
# Detect which environment is running
|
||||||
SAIL_RUNNING=false
|
SAIL_RUNNING=false
|
||||||
K8S_RUNNING=false
|
K8S_RUNNING=false
|
||||||
|
|
||||||
# Check if Sail is running
|
# Check if Sail is running (use vendor/bin/sail ps which works for all project names)
|
||||||
if docker ps --format '{{.Names}}' | grep -q "sail" 2>/dev/null; then
|
if [ -f ./vendor/bin/sail ] && ./vendor/bin/sail ps 2>/dev/null | grep -q "Up"; then
|
||||||
SAIL_RUNNING=true
|
SAIL_RUNNING=true
|
||||||
echo "📦 Detected Sail environment"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if k8s namespace exists for this worktree
|
# Check if k8s namespace exists for this worktree
|
||||||
@@ -24,41 +25,46 @@ K8S_NS=$(echo "$BRANCH" | sed 's/feature\//feat-/' | sed 's/bugfix\//fix-/' | se
|
|||||||
|
|
||||||
if kubectl get namespace "$K8S_NS" >/dev/null 2>&1; then
|
if kubectl get namespace "$K8S_NS" >/dev/null 2>&1; then
|
||||||
K8S_RUNNING=true
|
K8S_RUNNING=true
|
||||||
echo "☸️ Detected K8s environment (namespace: $K8S_NS)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run tests in appropriate environment
|
# Offer to run tests if environment is available
|
||||||
if [ "$SAIL_RUNNING" = true ]; then
|
if [ "$SAIL_RUNNING" = true ] || [ "$K8S_RUNNING" = true ]; then
|
||||||
./vendor/bin/sail artisan test --parallel
|
echo "💡 Tests will run automatically in CI/CD"
|
||||||
TEST_EXIT_CODE=$?
|
|
||||||
elif [ "$K8S_RUNNING" = true ]; then
|
|
||||||
echo " Running tests in k8s pod..."
|
|
||||||
kubectl -n "$K8S_NS" exec deploy/web -- php artisan test
|
|
||||||
TEST_EXIT_CODE=$?
|
|
||||||
else
|
|
||||||
echo "⚠️ No environment running (Sail or K8s)"
|
|
||||||
echo " Skipping tests - please run tests manually"
|
|
||||||
echo ""
|
echo ""
|
||||||
read -p "Continue push anyway? (y/n) " -n 1 -r
|
read -p "Run tests locally before push? (y/N) " -n 1 -r
|
||||||
echo ""
|
echo ""
|
||||||
if [ ! "$REPLY" = "y" ] && [ ! "$REPLY" = "Y" ]; then
|
echo ""
|
||||||
echo "Push aborted"
|
|
||||||
exit 1
|
if [ "$REPLY" = "y" ] || [ "$REPLY" = "Y" ]; then
|
||||||
|
echo "🧪 Running tests..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$SAIL_RUNNING" = true ]; then
|
||||||
|
./vendor/bin/sail artisan test --parallel
|
||||||
|
TEST_EXIT_CODE=$?
|
||||||
|
elif [ "$K8S_RUNNING" = true ]; then
|
||||||
|
kubectl -n "$K8S_NS" exec deploy/web -- php artisan test
|
||||||
|
TEST_EXIT_CODE=$?
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $TEST_EXIT_CODE -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ Tests failed!"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " 1. Fix the failing tests (recommended)"
|
||||||
|
echo " 2. Push anyway - CI will catch failures: git push --no-verify"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ All tests passed!"
|
||||||
|
echo ""
|
||||||
fi
|
fi
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check test results
|
|
||||||
if [ $TEST_EXIT_CODE -ne 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "❌ Tests failed!"
|
|
||||||
echo ""
|
|
||||||
echo "Options:"
|
|
||||||
echo " 1. Fix the failing tests (recommended)"
|
|
||||||
echo " 2. Push anyway with: git push --no-verify"
|
|
||||||
echo ""
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "⚡ Pushing to remote (CI will run full test suite)..."
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ All tests passed! Pushing..."
|
|
||||||
|
exit 0
|
||||||
|
|||||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -30,6 +30,9 @@ yarn-error.log
|
|||||||
# Node symlink (for ARM-based machines)
|
# Node symlink (for ARM-based machines)
|
||||||
/node
|
/node
|
||||||
|
|
||||||
|
# Git worktrees directory
|
||||||
|
/.worktrees/
|
||||||
|
|
||||||
# Database backups
|
# Database backups
|
||||||
*.gz
|
*.gz
|
||||||
*.sql.gz
|
*.sql.gz
|
||||||
@@ -43,6 +46,9 @@ version.env
|
|||||||
.cannabrands-secrets/
|
.cannabrands-secrets/
|
||||||
reverb-keys*
|
reverb-keys*
|
||||||
|
|
||||||
|
# Local Claude context (DO NOT COMMIT)
|
||||||
|
CLAUDE.local.md
|
||||||
|
|
||||||
# Core dumps and debug files
|
# Core dumps and debug files
|
||||||
core
|
core
|
||||||
core.*
|
core.*
|
||||||
@@ -58,4 +64,20 @@ core.*
|
|||||||
!resources/**/*.png
|
!resources/**/*.png
|
||||||
!resources/**/*.jpg
|
!resources/**/*.jpg
|
||||||
!resources/**/*.jpeg
|
!resources/**/*.jpeg
|
||||||
.claude/settings.local.json
|
# Claude Code settings (personal AI preferences)
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
storage/tmp/*
|
||||||
|
!storage/tmp/.gitignore
|
||||||
|
SESSION_ACTIVE
|
||||||
|
|
||||||
|
# Developer personal notes (keep local, don't commit)
|
||||||
|
/docs/dev-notes/
|
||||||
|
*.dev.md
|
||||||
|
NOTES.md
|
||||||
|
TODO.personal.md
|
||||||
|
SESSION_*
|
||||||
|
|
||||||
|
# AI workflow personal context files
|
||||||
|
CLAUDE.local.md
|
||||||
|
claude.*.md
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ steps:
|
|||||||
- apt-get install -y -qq git zip unzip libicu-dev libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libpq-dev
|
- apt-get install -y -qq git zip unzip libicu-dev libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libpq-dev
|
||||||
- echo "Installing PHP extensions..."
|
- echo "Installing PHP extensions..."
|
||||||
- docker-php-ext-configure gd --with-freetype --with-jpeg
|
- docker-php-ext-configure gd --with-freetype --with-jpeg
|
||||||
- docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip gd
|
- docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip gd pcntl
|
||||||
- echo "Installing Composer..."
|
- echo "Installing Composer..."
|
||||||
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
|
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
|
||||||
- echo "Creating minimal .env for package discovery..."
|
- echo "Creating minimal .env for package discovery..."
|
||||||
@@ -133,6 +133,34 @@ steps:
|
|||||||
- php artisan test --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:
|
||||||
|
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||||
|
environment:
|
||||||
|
APP_ENV: development
|
||||||
|
DB_CONNECTION: pgsql
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_DATABASE: testing
|
||||||
|
DB_USERNAME: testing
|
||||||
|
DB_PASSWORD: testing
|
||||||
|
CACHE_STORE: array
|
||||||
|
SESSION_DRIVER: array
|
||||||
|
QUEUE_CONNECTION: sync
|
||||||
|
commands:
|
||||||
|
- echo "Validating seeders (matches K8s init container)..."
|
||||||
|
- 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!"
|
||||||
|
when:
|
||||||
|
branch: [develop, master]
|
||||||
|
event: push
|
||||||
|
status: success
|
||||||
|
|
||||||
# Build and push Docker image for DEV environment (develop branch)
|
# Build and push Docker image for DEV environment (develop branch)
|
||||||
build-image-dev:
|
build-image-dev:
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
|||||||
@@ -291,6 +291,42 @@ npm run changelog
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## CI/CD Pipeline Stages
|
||||||
|
|
||||||
|
The Woodpecker CI pipeline runs the following stages for every push to `develop` or `master`:
|
||||||
|
|
||||||
|
1. **PHP Lint** - Syntax validation
|
||||||
|
2. **Code Style (Pint)** - Formatting check
|
||||||
|
3. **Tests** - PHPUnit/Pest tests with `APP_ENV=testing`
|
||||||
|
4. **Seeder Validation** - Validates seeders with `APP_ENV=development`
|
||||||
|
5. **Docker Build** - Creates container image
|
||||||
|
6. **Auto-Deploy** - Deploys to dev.cannabrands.app (develop branch only)
|
||||||
|
|
||||||
|
### Why Seeder Validation?
|
||||||
|
|
||||||
|
The dev environment (`dev.cannabrands.app`) runs `migrate:fresh --seed` on every K8s deployment via init container. If seeders have bugs (e.g., undefined functions, missing relationships), the deployment fails and pods crash.
|
||||||
|
|
||||||
|
**The Problem:**
|
||||||
|
- Tests run with `APP_ENV=testing` which **skips DevSeeder**
|
||||||
|
- K8s runs with `APP_ENV=development` which **runs DevSeeder**
|
||||||
|
- Seeder bugs passed CI but crashed in K8s
|
||||||
|
|
||||||
|
**The Solution:**
|
||||||
|
- Add dedicated seeder validation step with `APP_ENV=development`
|
||||||
|
- Runs the exact same command as K8s init container
|
||||||
|
- Catches seeder errors before deployment
|
||||||
|
|
||||||
|
**Time Cost:** ~20-30 seconds added to CI pipeline
|
||||||
|
|
||||||
|
**What It Catches:**
|
||||||
|
- Runtime errors (e.g., `fake()` outside factory context)
|
||||||
|
- Database constraint violations
|
||||||
|
- Missing relationships (foreign key errors)
|
||||||
|
- Invalid enum values
|
||||||
|
- Seeder syntax errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Pre-Commit Checklist
|
## Pre-Commit Checklist
|
||||||
|
|
||||||
Before committing:
|
Before committing:
|
||||||
@@ -300,6 +336,7 @@ Before committing:
|
|||||||
|
|
||||||
Before releasing:
|
Before releasing:
|
||||||
- [ ] All tests green in CI
|
- [ ] All tests green in CI
|
||||||
|
- [ ] **Seeder validation passed in CI**
|
||||||
- [ ] Tested in dev/staging environment
|
- [ ] Tested in dev/staging environment
|
||||||
- [ ] Release notes written
|
- [ ] Release notes written
|
||||||
- [ ] CHANGELOG updated (auto-generated)
|
- [ ] CHANGELOG updated (auto-generated)
|
||||||
|
|||||||
@@ -157,7 +157,7 @@
|
|||||||
* implement Brand model with 14 actual brands ([49d4f11](https://code.cannabrands.app/Cannabrands/hub/commit/49d4f11102f83ce5d2fdf1add9c95f17d28d2005))
|
* implement Brand model with 14 actual brands ([49d4f11](https://code.cannabrands.app/Cannabrands/hub/commit/49d4f11102f83ce5d2fdf1add9c95f17d28d2005))
|
||||||
* implement business management features (contacts, locations, users, profiles) ([38ac09e](https://code.cannabrands.app/Cannabrands/hub/commit/38ac09e9e7def2d4d4533b880eee0dfbdea1df4b))
|
* implement business management features (contacts, locations, users, profiles) ([38ac09e](https://code.cannabrands.app/Cannabrands/hub/commit/38ac09e9e7def2d4d4533b880eee0dfbdea1df4b))
|
||||||
* implement buyer order and invoice management portal (Day 13) ([aaad277](https://code.cannabrands.app/Cannabrands/hub/commit/aaad277a49d80640e7b397d3df22adeda2a8ff7d))
|
* implement buyer order and invoice management portal (Day 13) ([aaad277](https://code.cannabrands.app/Cannabrands/hub/commit/aaad277a49d80640e7b397d3df22adeda2a8ff7d))
|
||||||
* implement buyer-specific Nexus dashboard with LeafLink-style navigation ([9b72a8f](https://code.cannabrands.app/Cannabrands/hub/commit/9b72a8f3ba97924e94eed806cc014af538110ec2))
|
* implement buyer-specific Nexus dashboard with Marketplace Platform-style navigation ([9b72a8f](https://code.cannabrands.app/Cannabrands/hub/commit/9b72a8f3ba97924e94eed806cc014af538110ec2))
|
||||||
* implement buyer/seller routing structure with dual registration options ([5393969](https://code.cannabrands.app/Cannabrands/hub/commit/53939692c01669724372028f8056d9bfdf0bb92a))
|
* implement buyer/seller routing structure with dual registration options ([5393969](https://code.cannabrands.app/Cannabrands/hub/commit/53939692c01669724372028f8056d9bfdf0bb92a))
|
||||||
* implement buyer/seller user type separation in database layer ([4e15b3d](https://code.cannabrands.app/Cannabrands/hub/commit/4e15b3d15cc8fd046dd40d0a6de512ec8b878b84))
|
* implement buyer/seller user type separation in database layer ([4e15b3d](https://code.cannabrands.app/Cannabrands/hub/commit/4e15b3d15cc8fd046dd40d0a6de512ec8b878b84))
|
||||||
* implement CalVer versioning system with sidebar display ([197d102](https://code.cannabrands.app/Cannabrands/hub/commit/197d10269004f758692cacb444a0e9698ec7e7f1))
|
* implement CalVer versioning system with sidebar display ([197d102](https://code.cannabrands.app/Cannabrands/hub/commit/197d10269004f758692cacb444a0e9698ec7e7f1))
|
||||||
|
|||||||
153
CLAUDE.md
153
CLAUDE.md
@@ -1,5 +1,24 @@
|
|||||||
# Claude Code Context
|
# Claude Code Context
|
||||||
|
|
||||||
|
## 📌 IMPORTANT: Check Personal Context Files
|
||||||
|
|
||||||
|
**ALWAYS read `claude.kelly.md` first** - Contains personal preferences and session tracking workflow
|
||||||
|
|
||||||
|
## 📘 Platform Conventions
|
||||||
|
|
||||||
|
**For ALL naming, routing, and architectural conventions, see:**
|
||||||
|
`/docs/platform_naming_and_style_guide.md`
|
||||||
|
|
||||||
|
This guide is the **source of truth** for:
|
||||||
|
- Terminology (no vendor references)
|
||||||
|
- Routing patterns
|
||||||
|
- Model naming
|
||||||
|
- UI copy standards
|
||||||
|
- Commit message rules
|
||||||
|
- Database conventions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🚨 Critical Mistakes You Make
|
## 🚨 Critical Mistakes You Make
|
||||||
|
|
||||||
### 1. Business Isolation (MOST COMMON!)
|
### 1. Business Isolation (MOST COMMON!)
|
||||||
@@ -48,6 +67,79 @@ ALL routes need auth + user type middleware except public pages
|
|||||||
|
|
||||||
**Exception:** Only use inline styles for truly dynamic values from database (e.g., user-uploaded brand colors)
|
**Exception:** Only use inline styles for truly dynamic values from database (e.g., user-uploaded brand colors)
|
||||||
|
|
||||||
|
### 8. Media Storage - MinIO Architecture (CRITICAL!)
|
||||||
|
❌ **NEVER use** `Storage::disk('public')` for brand/product media
|
||||||
|
✅ **ALWAYS use** `Storage` (respects .env FILESYSTEM_DISK=minio)
|
||||||
|
**Why:** All media lives on MinIO (S3-compatible storage), not local disk. Using wrong disk breaks production images.
|
||||||
|
|
||||||
|
**⚠️ BLADE TEMPLATE RULES (CRITICAL):**
|
||||||
|
❌ **NEVER use** `/storage/` prefix in image src attributes
|
||||||
|
❌ **NEVER use** `asset('storage/...')` for media
|
||||||
|
✅ **ALWAYS use** dynamic image routes with model methods
|
||||||
|
|
||||||
|
**Correct Image Display Patterns:**
|
||||||
|
```blade
|
||||||
|
{{-- Product images - use getImageUrl() method --}}
|
||||||
|
<img src="{{ $product->getImageUrl('medium') }}" alt="{{ $product->name }}">
|
||||||
|
<img src="{{ $product->getImageUrl('thumb') }}" alt="{{ $product->name }}">
|
||||||
|
|
||||||
|
{{-- Brand logos - use getLogoUrl() method --}}
|
||||||
|
<img src="{{ $brand->getLogoUrl('medium') }}" alt="{{ $brand->name }}">
|
||||||
|
|
||||||
|
{{-- In Alpine.js - use route() helper --}}
|
||||||
|
<img :src="`{{ url('/images/product/') }}/${product.hashid}/400`">
|
||||||
|
```
|
||||||
|
|
||||||
|
**URL Patterns (for accessing images):**
|
||||||
|
- **Product image:** `/images/product/{product_hashid}/{width?}`
|
||||||
|
- Example: `/images/product/78xd4/400` (400px width)
|
||||||
|
- **Brand logo:** `/images/brand-logo/{brand_hashid}/{width?}`
|
||||||
|
- Example: `/images/brand-logo/75pg7/600` (600px thumbnail)
|
||||||
|
- **Brand banner:** `/images/brand-banner/{brand_hashid}/{width?}`
|
||||||
|
- Example: `/images/brand-banner/75pg7/1344` (1344px banner)
|
||||||
|
|
||||||
|
**Product Image Storage (TWO METHODS):**
|
||||||
|
Products can store images in TWO ways - **always check both**:
|
||||||
|
1. **Direct `image_path` column** - Single image stored directly on product
|
||||||
|
- Access via `$product->getImageUrl()` method
|
||||||
|
- Path stored like: `businesses/cannabrands/brands/thunder-bud/products/TB-AM-AZ1G/images/alien-marker.png`
|
||||||
|
2. **`images()` relation** - Multiple images in `product_images` table
|
||||||
|
- Access via `$product->images` collection
|
||||||
|
- Used for galleries with multiple images
|
||||||
|
|
||||||
|
**When loading product images for display:**
|
||||||
|
```php
|
||||||
|
// Check BOTH methods - direct image_path first, then relation
|
||||||
|
if ($product->image_path) {
|
||||||
|
$imageUrl = $product->getImageUrl('medium');
|
||||||
|
} elseif ($product->images->count() > 0) {
|
||||||
|
$imageUrl = $product->images->first()->url;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Storage Path Requirements (on MinIO):**
|
||||||
|
- **Brand logos/banners:** `businesses/{business_slug}/brands/{brand_slug}/branding/{filename}`
|
||||||
|
- Example: `businesses/cannabrands/brands/thunder-bud/branding/logo.png`
|
||||||
|
- **Product images:** `businesses/{business_slug}/brands/{brand_slug}/products/{product_sku}/images/{filename}`
|
||||||
|
- Example: `businesses/cannabrands/brands/thunder-bud/products/TB-BM-AZ1G/images/black-maple.png`
|
||||||
|
|
||||||
|
**DO NOT:**
|
||||||
|
- Use `/storage/` prefix in Blade templates for ANY media
|
||||||
|
- Use `asset('storage/...')` for ANY media
|
||||||
|
- Use numeric IDs in paths (e.g., `products/14/`)
|
||||||
|
- Use hashids in storage paths
|
||||||
|
- Skip business or brand directories
|
||||||
|
- Use `Storage::disk('public')` anywhere in media code
|
||||||
|
- Assume images are ONLY in `images()` relation - check `image_path` too!
|
||||||
|
|
||||||
|
**See Comments In:**
|
||||||
|
- `app/Models/Brand.php` (line 47) - Brand asset paths
|
||||||
|
- `app/Models/Product.php` (line 108) - Product image paths
|
||||||
|
- `app/Http/Controllers/ImageController.php` (line 10) - Critical storage rules
|
||||||
|
- `docs/architecture/MEDIA_STORAGE.md` - Complete documentation
|
||||||
|
|
||||||
|
**This has caused multiple production outages - review docs before ANY storage changes!**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech Stack by Area
|
## Tech Stack by Area
|
||||||
@@ -70,6 +162,35 @@ Users have `user_type` matching their business type.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Local Development Setup
|
||||||
|
|
||||||
|
**First-time setup or fresh database:**
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan dev:setup --fresh
|
||||||
|
```
|
||||||
|
|
||||||
|
This command:
|
||||||
|
- Runs migrations (use `--fresh` to drop all tables first)
|
||||||
|
- Prompts to seed dev fixtures (users, businesses, brands)
|
||||||
|
- Seeds brand profiles and orchestrator profiles
|
||||||
|
- Displays test credentials when complete
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--fresh` — Drop all tables and re-run migrations
|
||||||
|
- `--skip-seed` — Skip the seeding prompt
|
||||||
|
|
||||||
|
**Test Credentials (seeded by dev:setup):**
|
||||||
|
| Role | Email | Password |
|
||||||
|
|------|-------|----------|
|
||||||
|
| Super Admin | admin@cannabrands.com | password |
|
||||||
|
| Admin | admin@example.com | password |
|
||||||
|
| Seller | seller@example.com | password |
|
||||||
|
| Buyer | buyer@example.com | password |
|
||||||
|
| Cannabrands Owner | cannabrands-owner@example.com | password |
|
||||||
|
| Brand Manager | brand-manager@example.com | password |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Testing & Git
|
## Testing & Git
|
||||||
|
|
||||||
**Before commit:**
|
**Before commit:**
|
||||||
@@ -78,7 +199,12 @@ php artisan test --parallel # REQUIRED
|
|||||||
./vendor/bin/pint # REQUIRED
|
./vendor/bin/pint # REQUIRED
|
||||||
```
|
```
|
||||||
|
|
||||||
**Credentials:** `{buyer,seller,admin}@example.com` / `password`
|
**Commit Messages:**
|
||||||
|
- ❌ **DO NOT** include Claude Code signature/attribution in commit messages
|
||||||
|
- ❌ **DO NOT** add "🤖 Generated with Claude Code" or "Co-Authored-By: Claude"
|
||||||
|
- ✅ Write clean, professional commit messages without AI attribution
|
||||||
|
|
||||||
|
**Credentials:** See "Local Development Setup" section above
|
||||||
|
|
||||||
**Branches:** Never commit to `master`/`develop` directly - use feature branches
|
**Branches:** Never commit to `master`/`develop` directly - use feature branches
|
||||||
|
|
||||||
@@ -104,11 +230,28 @@ Product::where('is_active', true)->get(); // No business_id filter!
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## External Docs (Read When Needed)
|
## Architecture Docs (Read When Needed)
|
||||||
|
|
||||||
- `docs/URL_STRUCTURE.md` - **READ BEFORE** routing changes
|
**🎯 START HERE:**
|
||||||
- `docs/DATABASE.md` - **READ BEFORE** migrations
|
- **`SYSTEM_ARCHITECTURE.md`** - Complete system guide covering ALL architectural patterns, security rules, modules, departments, performance, and development workflow
|
||||||
- `docs/DEVELOPMENT.md` - Local setup
|
|
||||||
|
**Deep Dives (when needed):**
|
||||||
|
- `docs/supplements/departments.md` - Department system, permissions, access control
|
||||||
|
- `docs/supplements/processing.md` - Processing operations (Solventless vs BHO, conversions, wash batches)
|
||||||
|
- `docs/supplements/permissions.md` - RBAC, impersonation, audit logging
|
||||||
|
- `docs/supplements/precognition.md` - Real-time form validation migration
|
||||||
|
- `docs/supplements/analytics.md` - Product tracking, email campaigns
|
||||||
|
- `docs/supplements/batch-system.md` - Batch management and COAs
|
||||||
|
- `docs/supplements/performance.md` - Caching, indexing, N+1 prevention
|
||||||
|
- `docs/supplements/horizon.md` - Queue monitoring and deployment
|
||||||
|
|
||||||
|
**Architecture Details:**
|
||||||
|
- `docs/architecture/URL_STRUCTURE.md` - **READ BEFORE** routing changes
|
||||||
|
- `docs/architecture/DATABASE.md` - **READ BEFORE** migrations
|
||||||
|
- `docs/architecture/API.md` - API endpoints and contracts
|
||||||
|
|
||||||
|
**Other:**
|
||||||
|
- `VERSIONING_AND_AUDITING.md` - Quicksave and Laravel Auditing
|
||||||
- `CONTRIBUTING.md` - Detailed git workflow
|
- `CONTRIBUTING.md` - Detailed git workflow
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
157
CONTRIBUTING.md
157
CONTRIBUTING.md
@@ -239,6 +239,163 @@ git push origin feature/my-feature
|
|||||||
git push --no-verify
|
git push --no-verify
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Keeping Your Feature Branch Up-to-Date
|
||||||
|
|
||||||
|
**Best practice for teams:** Sync your feature branch with `develop` regularly to avoid large merge conflicts.
|
||||||
|
|
||||||
|
#### Daily Start-of-Work Routine
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Get latest changes from develop
|
||||||
|
git checkout develop
|
||||||
|
git pull origin develop
|
||||||
|
|
||||||
|
# 2. Update your feature branch
|
||||||
|
git checkout feature/my-feature
|
||||||
|
git merge develop
|
||||||
|
|
||||||
|
# 3. If there are conflicts (see below), resolve them
|
||||||
|
# 4. Continue working
|
||||||
|
```
|
||||||
|
|
||||||
|
**How often?**
|
||||||
|
- Minimum: Once per day (start of work)
|
||||||
|
- Better: Multiple times per day if develop is active
|
||||||
|
- Always: Before creating your Pull Request
|
||||||
|
|
||||||
|
#### Merge vs Rebase: Which to Use?
|
||||||
|
|
||||||
|
**For teams of 5+ developers, use `merge` (not `rebase`):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout feature/my-feature
|
||||||
|
git merge develop
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why merge over rebase?**
|
||||||
|
- ✅ Safer: Preserves your commit history
|
||||||
|
- ✅ Collaborative: Works when multiple people work on the same feature branch
|
||||||
|
- ✅ Transparent: Shows when you integrated upstream changes
|
||||||
|
- ✅ No force-push: Once you've pushed to origin, merge won't require `--force`
|
||||||
|
|
||||||
|
**When to use rebase:**
|
||||||
|
- ⚠️ Only if you haven't pushed yet
|
||||||
|
- ⚠️ Only if you're the sole developer on the branch
|
||||||
|
- ⚠️ You want a cleaner, linear history
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Only do this if you haven't pushed yet!
|
||||||
|
git checkout feature/my-feature
|
||||||
|
git rebase develop
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never rebase after pushing** - it rewrites history and breaks collaboration.
|
||||||
|
|
||||||
|
#### Handling Merge Conflicts
|
||||||
|
|
||||||
|
When you run `git merge develop` and see conflicts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ git merge develop
|
||||||
|
Auto-merging app/Http/Controllers/OrderController.php
|
||||||
|
CONFLICT (content): Merge conflict in app/Http/Controllers/OrderController.php
|
||||||
|
Automatic merge failed; fix conflicts and then commit the result.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step-by-step resolution:**
|
||||||
|
|
||||||
|
1. **See which files have conflicts:**
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
# Look for "both modified:" files
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Open conflicted files** - look for conflict markers:
|
||||||
|
```php
|
||||||
|
<<<<<<< HEAD
|
||||||
|
// Your code
|
||||||
|
=======
|
||||||
|
// Code from develop
|
||||||
|
>>>>>>> develop
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Resolve conflicts** - edit the file to keep what you need:
|
||||||
|
```php
|
||||||
|
// Choose your code, their code, or combine both
|
||||||
|
// Remove the <<<, ===, >>> markers
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Mark as resolved:**
|
||||||
|
```bash
|
||||||
|
git add app/Http/Controllers/OrderController.php
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Complete the merge:**
|
||||||
|
```bash
|
||||||
|
git commit -m "merge: resolve conflicts with develop"
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Run tests to ensure nothing broke:**
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan test
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Push the merge commit:**
|
||||||
|
```bash
|
||||||
|
git push origin feature/my-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
#### When Conflicts Are Too Complex
|
||||||
|
|
||||||
|
If conflicts are extensive or you're unsure:
|
||||||
|
|
||||||
|
1. **Abort the merge:**
|
||||||
|
```bash
|
||||||
|
git merge --abort
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Ask for help** in #engineering Slack:
|
||||||
|
- "I'm merging develop into feature/X and have conflicts in OrderController"
|
||||||
|
- Someone might have context on the upstream changes
|
||||||
|
|
||||||
|
3. **Pair program the resolution** - screen share with the person who made the conflicting changes
|
||||||
|
|
||||||
|
4. **Alternative: Start fresh** (last resort):
|
||||||
|
```bash
|
||||||
|
# Create new branch from latest develop
|
||||||
|
git checkout develop
|
||||||
|
git pull origin develop
|
||||||
|
git checkout -b feature/my-feature-v2
|
||||||
|
|
||||||
|
# Cherry-pick your commits
|
||||||
|
git cherry-pick <commit-hash>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Example: Multi-Day Feature Work
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Monday morning
|
||||||
|
git checkout develop && git pull origin develop
|
||||||
|
git checkout feature/payment-integration
|
||||||
|
git merge develop # Get latest changes
|
||||||
|
# Work all day, make commits
|
||||||
|
|
||||||
|
# Tuesday morning
|
||||||
|
git checkout develop && git pull origin develop
|
||||||
|
git checkout feature/payment-integration
|
||||||
|
git merge develop # Sync again (someone added auth changes)
|
||||||
|
# Continue working
|
||||||
|
|
||||||
|
# Wednesday
|
||||||
|
git checkout develop && git pull origin develop
|
||||||
|
git checkout feature/payment-integration
|
||||||
|
git merge develop # Final sync before PR
|
||||||
|
git push origin feature/payment-integration
|
||||||
|
# Create Pull Request
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** Small, manageable syncs instead of one huge conflict on PR day.
|
||||||
|
|
||||||
### When to Test Locally
|
### When to Test Locally
|
||||||
|
|
||||||
**Always run tests before pushing if you:**
|
**Always run tests before pushing if you:**
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@@ -34,13 +34,18 @@ COPY public ./public
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ==================== Stage 2: Composer Builder ====================
|
# ==================== Stage 2: Composer Builder ====================
|
||||||
FROM composer:2 AS composer-builder
|
# Pin to PHP 8.4 - composer:2 uses latest PHP which may not be supported by dependencies yet
|
||||||
|
FROM php:8.4-cli-alpine AS composer-builder
|
||||||
|
|
||||||
|
# Install Composer
|
||||||
|
COPY --from=composer:2.8 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install required PHP extensions for Filament
|
# Install required PHP extensions for Filament and Horizon
|
||||||
RUN apk add --no-cache icu-dev \
|
RUN apk add --no-cache icu-dev libpng-dev libjpeg-turbo-dev freetype-dev libzip-dev \
|
||||||
&& docker-php-ext-install intl
|
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||||
|
&& docker-php-ext-install intl gd pcntl zip
|
||||||
|
|
||||||
# Copy composer files
|
# Copy composer files
|
||||||
COPY composer.json composer.lock ./
|
COPY composer.json composer.lock ./
|
||||||
|
|||||||
50
Makefile
50
Makefile
@@ -1,31 +1,26 @@
|
|||||||
.PHONY: help dev dev-down dev-build dev-shell dev-logs dev-vite k-dev k-down k-logs k-shell k-artisan k-composer k-vite k-status prod-build prod-up prod-down prod-logs prod-shell prod-vite prod-test prod-test-build prod-test-up prod-test-down prod-test-logs prod-test-shell prod-test-status prod-test-clean migrate test clean install
|
.PHONY: help dev dev-down dev-build dev-shell dev-logs dev-vite k-setup k-dev k-down k-logs k-shell k-artisan k-composer k-vite k-status prod-build prod-up prod-down prod-logs prod-shell prod-vite prod-test prod-test-build prod-test-up prod-test-down prod-test-logs prod-test-shell prod-test-status prod-test-clean migrate test clean install
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
# ==================== K8s Variables ====================
|
# ==================== K8s Variables ====================
|
||||||
# K3d cluster must be created with dual volume mounts:
|
|
||||||
# k3d cluster create dev \
|
|
||||||
# --api-port 6443 \
|
|
||||||
# --port "80:80@loadbalancer" \
|
|
||||||
# --port "443:443@loadbalancer" \
|
|
||||||
# --volume /Users/jon/projects/cannabrands/cannabrands_new/.worktrees:/worktrees \
|
|
||||||
# --volume /Users/jon/projects/cannabrands/cannabrands_new:/project-root \
|
|
||||||
# --volume k3d-dev-images:/k3d/images
|
|
||||||
|
|
||||||
# Detect if we're in a worktree or project root
|
# Detect if we're in a worktree or project root
|
||||||
GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null)
|
GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null)
|
||||||
IS_WORKTREE := $(shell echo "$(GIT_DIR)" | grep -q ".worktrees" && echo "true" || echo "false")
|
IS_WORKTREE := $(shell echo "$(GIT_DIR)" | grep -q ".worktrees" && echo "true" || echo "false")
|
||||||
|
|
||||||
# Set paths based on location
|
# Find project root (handles both worktree and main repo)
|
||||||
ifeq ($(IS_WORKTREE),true)
|
ifeq ($(IS_WORKTREE),true)
|
||||||
# In a worktree - use worktree-specific path
|
# In a worktree - project root is two levels up
|
||||||
|
PROJECT_ROOT := $(shell cd ../.. && pwd)
|
||||||
WORKTREE_NAME := $(shell basename $(CURDIR))
|
WORKTREE_NAME := $(shell basename $(CURDIR))
|
||||||
K8S_VOLUME_PATH := /worktrees/$(WORKTREE_NAME)
|
K8S_VOLUME_PATH := /worktrees/$(WORKTREE_NAME)
|
||||||
|
HOST_WORKTREE_PATH := $(PROJECT_ROOT)/.worktrees
|
||||||
else
|
else
|
||||||
# In project root - use root path
|
# In project root
|
||||||
|
PROJECT_ROOT := $(shell pwd)
|
||||||
WORKTREE_NAME := root
|
WORKTREE_NAME := root
|
||||||
K8S_VOLUME_PATH := /project-root
|
K8S_VOLUME_PATH := /project-root
|
||||||
|
HOST_WORKTREE_PATH := $(PROJECT_ROOT)/.worktrees
|
||||||
endif
|
endif
|
||||||
|
|
||||||
# Generate namespace from branch name (feat-branch-name)
|
# Generate namespace from branch name (feat-branch-name)
|
||||||
@@ -69,6 +64,28 @@ dev-vite: ## Start Vite dev server (run after 'make dev')
|
|||||||
./vendor/bin/sail npm run dev
|
./vendor/bin/sail npm run dev
|
||||||
|
|
||||||
# ==================== K8s Local Development ====================
|
# ==================== K8s Local Development ====================
|
||||||
|
k-setup: ## One-time setup: Create K3d cluster with auto-detected volume mounts
|
||||||
|
@echo "🔧 Setting up K3d cluster 'dev' with auto-detected paths"
|
||||||
|
@echo " Project Root: $(PROJECT_ROOT)"
|
||||||
|
@echo " Worktrees Path: $(HOST_WORKTREE_PATH)"
|
||||||
|
@echo ""
|
||||||
|
@# Check if cluster already exists
|
||||||
|
@if k3d cluster list | grep -q "^dev "; then \
|
||||||
|
echo "⚠️ Cluster 'dev' already exists!"; \
|
||||||
|
echo " To recreate, run: k3d cluster delete dev && make k-setup"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@# Create cluster with dynamic volume mounts
|
||||||
|
k3d cluster create dev \
|
||||||
|
--api-port 6443 \
|
||||||
|
--port "80:80@loadbalancer" \
|
||||||
|
--port "443:443@loadbalancer" \
|
||||||
|
--volume $(HOST_WORKTREE_PATH):/worktrees \
|
||||||
|
--volume $(PROJECT_ROOT):/project-root
|
||||||
|
@echo ""
|
||||||
|
@echo "✅ K3d cluster created successfully!"
|
||||||
|
@echo " Next step: Run 'make k-dev' to start your environment"
|
||||||
|
|
||||||
k-dev: ## Start k8s local environment (like Sail, but with namespace isolation)
|
k-dev: ## Start k8s local environment (like Sail, but with namespace isolation)
|
||||||
@echo "🚀 Starting k8s environment"
|
@echo "🚀 Starting k8s environment"
|
||||||
@echo " Location: $(if $(filter true,$(IS_WORKTREE)),Worktree ($(WORKTREE_NAME)),Project Root)"
|
@echo " Location: $(if $(filter true,$(IS_WORKTREE)),Worktree ($(WORKTREE_NAME)),Project Root)"
|
||||||
@@ -254,6 +271,13 @@ install: ## Initial project setup
|
|||||||
@echo " 2. Run 'make dev' to start development environment"
|
@echo " 2. Run 'make dev' to start development environment"
|
||||||
@echo " 3. Run 'make migrate' to set up database"
|
@echo " 3. Run 'make migrate' to set up database"
|
||||||
|
|
||||||
|
setup-hooks: ## Configure git hooks for code quality
|
||||||
|
@git config core.hooksPath .githooks
|
||||||
|
@chmod +x .githooks/*
|
||||||
|
@echo "✅ Git hooks configured!"
|
||||||
|
@echo " - pre-commit: Auto-formats code with Laravel Pint"
|
||||||
|
@echo " - pre-push: Optionally runs tests before pushing"
|
||||||
|
|
||||||
mailpit: ## Open Mailpit web UI
|
mailpit: ## Open Mailpit web UI
|
||||||
@open http://localhost:8025 || xdg-open http://localhost:8025 || echo "Open http://localhost:8025 in your browser"
|
@open http://localhost:8025 || xdg-open http://localhost:8025 || echo "Open http://localhost:8025 in your browser"
|
||||||
|
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
# PRODUCT2 MIGRATION INSTRUCTIONS
|
|
||||||
|
|
||||||
## Context
|
|
||||||
We are migrating the OLD seller product page from `../cannabrands-hub-old` to create a new "Product2" page in the current project at `/hub`. This page will be a comprehensive, modernized version of the old seller product edit page.
|
|
||||||
|
|
||||||
## Critical Rules
|
|
||||||
1. **SELLER SIDE ONLY** - Work only with `/s/` routes (seller area)
|
|
||||||
2. **STAY IN BRANCH** - `feature/product-page-migrate` (verify before making changes)
|
|
||||||
3. **ROLLBACK READY** - All database migrations must be fully reversible
|
|
||||||
4. **DO NOT TOUCH BOM** - Leave existing BOM functionality completely as-is (we'll discuss later)
|
|
||||||
5. **SINGLE PAGE LAYOUT** - No tabs, use card-based layout with Nexus components
|
|
||||||
6. **FOLLOW OLD LAYOUT** - Modernize the old product page structure, don't reinvent
|
|
||||||
|
|
||||||
## Old Project Analysis Complete
|
|
||||||
- Old project location: `../cannabrands-hub-old`
|
|
||||||
- Old used Laravel CRM for product management
|
|
||||||
- Comprehensive field analysis done (see below)
|
|
||||||
- Old layout analyzed from vendor views
|
|
||||||
|
|
||||||
## Complete Missing Fields (from migrations analysis)
|
|
||||||
|
|
||||||
### From `products` table:
|
|
||||||
```sql
|
|
||||||
-- Metadata
|
|
||||||
product_line (text, nullable)
|
|
||||||
product_link (text, nullable) -- External URL
|
|
||||||
creatives (text, nullable) -- Marketing assets
|
|
||||||
barcode (string, nullable)
|
|
||||||
brand_display_order (integer, nullable)
|
|
||||||
|
|
||||||
-- Configuration
|
|
||||||
has_varieties (boolean, default: false)
|
|
||||||
license_id (unsignedBigInteger, nullable)
|
|
||||||
sell_multiples (boolean, default: false)
|
|
||||||
fractional_quantities (boolean, default: false)
|
|
||||||
allow_sample (boolean, default: false)
|
|
||||||
isFPR (boolean, default: false)
|
|
||||||
isSellable (boolean, default: false)
|
|
||||||
|
|
||||||
-- Case/Box Packaging
|
|
||||||
isCase (boolean, default: false)
|
|
||||||
cased_qty (integer, default: 0)
|
|
||||||
isBox (boolean, default: false)
|
|
||||||
boxed_qty (integer, default: 0)
|
|
||||||
|
|
||||||
-- Dates
|
|
||||||
launch_date (date, nullable)
|
|
||||||
|
|
||||||
-- Inventory Management
|
|
||||||
inventory_manage_pct (integer, nullable) -- 0-100%
|
|
||||||
min_order_qty (integer, nullable)
|
|
||||||
max_order_qty (integer, nullable)
|
|
||||||
low_stock_threshold (integer, nullable)
|
|
||||||
low_stock_alert_enabled (boolean, default: false)
|
|
||||||
|
|
||||||
-- Strain
|
|
||||||
strain_value (decimal 8,2, nullable)
|
|
||||||
|
|
||||||
-- Arizona Compliance
|
|
||||||
arz_total_weight (decimal 10,3, nullable)
|
|
||||||
arz_usable_mmj (decimal 10,3, nullable)
|
|
||||||
|
|
||||||
-- Descriptions
|
|
||||||
long_description (text, nullable)
|
|
||||||
ingredients (text, nullable)
|
|
||||||
effects (text, nullable)
|
|
||||||
dosage_guidelines (text, nullable)
|
|
||||||
|
|
||||||
-- Visibility
|
|
||||||
show_inventory_to_buyers (boolean, default: false)
|
|
||||||
|
|
||||||
-- Threshold Automation
|
|
||||||
decreasing_qty_threshold (integer, nullable)
|
|
||||||
decreasing_qty_action (string, nullable)
|
|
||||||
increasing_qty_threshold (integer, nullable)
|
|
||||||
increasing_qty_action (string, nullable)
|
|
||||||
|
|
||||||
-- Packaging Reference
|
|
||||||
packaging_id (foreignId, nullable)
|
|
||||||
|
|
||||||
-- Enhanced Status
|
|
||||||
status (enum: available, archived, sample, backorder, internal, unavailable)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Need to create:
|
|
||||||
- `product_packaging` table (id, name, description, is_active, timestamps)
|
|
||||||
|
|
||||||
## Product2 Page Layout (Single Page, No Tabs)
|
|
||||||
|
|
||||||
### Structure:
|
|
||||||
```
|
|
||||||
HEADER (Product name, SKU, status badges, action buttons)
|
|
||||||
|
|
||||||
LEFT SIDEBAR (1/3 width):
|
|
||||||
- Product Images (main + gallery + upload)
|
|
||||||
- Quick Stats Card (cost, wholesale, MSRP, margin)
|
|
||||||
- Audit Info Card (created, modified, by user)
|
|
||||||
|
|
||||||
MAIN CONTENT (2/3 width):
|
|
||||||
Card 1: Basic Information
|
|
||||||
Card 2: Pricing & Units
|
|
||||||
Card 3: Inventory Management
|
|
||||||
Card 4: Cannabis Information
|
|
||||||
Card 5: Product Details & Content
|
|
||||||
Card 6: Advanced Settings
|
|
||||||
Card 7: Compliance & Tracking
|
|
||||||
|
|
||||||
FULL WIDTH (bottom):
|
|
||||||
Card 8: Product Varieties (if has_varieties = true)
|
|
||||||
Card 9: Lab Test Results (link to separate management)
|
|
||||||
Collapsible: Audit History
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cards Detail:
|
|
||||||
|
|
||||||
**Card 1: Basic Information**
|
|
||||||
- Brand (dropdown) *
|
|
||||||
- Product Line (text)
|
|
||||||
- SKU (text) *
|
|
||||||
- Barcode (text)
|
|
||||||
- Product Name (text) *
|
|
||||||
- Type (dropdown) *
|
|
||||||
- Category (text)
|
|
||||||
- Description (textarea)
|
|
||||||
- Active toggle
|
|
||||||
- Featured toggle
|
|
||||||
|
|
||||||
**Card 2: Pricing & Units**
|
|
||||||
- Cost Price, Wholesale, MSRP, Margin (auto-calc)
|
|
||||||
- Price Unit dropdown
|
|
||||||
- Net Weight + Weight Unit
|
|
||||||
- Units Per Case
|
|
||||||
- Checkboxes: Sell in Multiples, Fractional Quantities, Sell as Case, Sell as Box
|
|
||||||
|
|
||||||
**Card 3: Inventory Management**
|
|
||||||
- On Hand, Allocated, Available, Reorder Point (display)
|
|
||||||
- Min/Max Order Qty
|
|
||||||
- Low Stock Threshold + Alert checkbox
|
|
||||||
- Show Inventory to Buyers checkbox
|
|
||||||
- Inventory Management slider (0-100%)
|
|
||||||
- Threshold Automation (decrease/increase triggers)
|
|
||||||
|
|
||||||
**Card 4: Cannabis Information**
|
|
||||||
- THC%, CBD%, THC mg, CBD mg
|
|
||||||
- Strain dropdown (with classification)
|
|
||||||
- Strain Value
|
|
||||||
- Product Packaging dropdown
|
|
||||||
- Ingredients, Effects, Dosing Guidelines (text areas)
|
|
||||||
- Arizona Compliance (Total Weight, Usable MMJ)
|
|
||||||
|
|
||||||
**Card 5: Product Details & Content**
|
|
||||||
- Short Description
|
|
||||||
- Long Description (rich text editor)
|
|
||||||
- Product Link (external URL)
|
|
||||||
- Creatives/Assets
|
|
||||||
|
|
||||||
**Card 6: Advanced Settings**
|
|
||||||
- Enable Sample Requests checkbox
|
|
||||||
- Sellable Product checkbox
|
|
||||||
- Finished Product Ready checkbox
|
|
||||||
- Status dropdown
|
|
||||||
- Display Order (within brand)
|
|
||||||
|
|
||||||
**Card 7: Compliance & Tracking**
|
|
||||||
- Metrc ID
|
|
||||||
- License dropdown
|
|
||||||
- Launch Date, Harvest Date, Package Date, Test Date
|
|
||||||
|
|
||||||
**Card 8: Product Varieties** (conditional)
|
|
||||||
- Table showing child products with name, SKU, prices, stock
|
|
||||||
- Add Variety button
|
|
||||||
|
|
||||||
**Card 9: Lab Test Results**
|
|
||||||
- Summary of latest lab test
|
|
||||||
- Link to full lab management (don't build lab CRUD yet)
|
|
||||||
|
|
||||||
## Tasks to Complete
|
|
||||||
|
|
||||||
### 1. Database Migration (with rollback)
|
|
||||||
- Create migration: `add_product2_fields_to_products_table.php`
|
|
||||||
- Add ALL missing fields listed above
|
|
||||||
- Proper indexes
|
|
||||||
- Full `down()` method for rollback
|
|
||||||
- Create `product_packaging` table migration
|
|
||||||
|
|
||||||
### 2. Routes
|
|
||||||
- File: `routes/seller.php`
|
|
||||||
- Add under existing products routes:
|
|
||||||
- `/{product}/edit2` → Product2 edit page
|
|
||||||
- Keep existing routes intact
|
|
||||||
|
|
||||||
### 3. Controller
|
|
||||||
- Create: `app/Http/Controllers/Seller/Product2Controller.php`
|
|
||||||
- Methods: edit(), update()
|
|
||||||
- Full validation for all new fields
|
|
||||||
- Business isolation checks (CRITICAL - see CLAUDE.md)
|
|
||||||
- Image upload handling
|
|
||||||
|
|
||||||
### 4. Model Updates
|
|
||||||
- Update `app/Models/Product.php` fillable array
|
|
||||||
- Add new relationships if needed (packaging)
|
|
||||||
- Add accessors/mutators as needed
|
|
||||||
|
|
||||||
### 5. Views
|
|
||||||
- Create: `resources/views/seller/products/edit2.blade.php`
|
|
||||||
- Use Nexus card components
|
|
||||||
- Single page layout (no tabs)
|
|
||||||
- Alpine.js for interactivity
|
|
||||||
- Follow structure outlined above
|
|
||||||
- Use existing DaisyUI + Nexus patterns
|
|
||||||
|
|
||||||
### 6. Nexus Components Available
|
|
||||||
From `nexus-html@3.1.0/resources/views/`:
|
|
||||||
- Cards: `card`, `card-body`, `card-title`
|
|
||||||
- Forms: `input`, `select`, `textarea`, `checkbox`, `toggle`, `label`, `fieldset`
|
|
||||||
- Layouts: Grid system with responsive columns
|
|
||||||
- File upload: FilePond integration
|
|
||||||
- Date picker: Flatpickr
|
|
||||||
- Icons: Iconify (lucide set)
|
|
||||||
|
|
||||||
## Key Files from Old Project
|
|
||||||
- Controller: `vendor/venturedrake/laravel-crm/src/Http/Controllers/ProductController.php`
|
|
||||||
- Edit View: `vendor/venturedrake/laravel-crm/resources/views/products/edit.blade.php`
|
|
||||||
- Fields Form: `vendor/venturedrake/laravel-crm/resources/views/products/partials/fields.blade.php` (1400+ lines!)
|
|
||||||
|
|
||||||
## Current Project Files
|
|
||||||
- Routes: `routes/seller.php`
|
|
||||||
- Controller: `app/Http/Controllers/Seller/ProductController.php`
|
|
||||||
- Model: `app/Models/Product.php`
|
|
||||||
- Current Edit: `resources/views/seller/products/edit.blade.php`
|
|
||||||
- Migration: `database/migrations/2025_10_07_172951_create_products_table.php`
|
|
||||||
|
|
||||||
## Important Notes from CLAUDE.md
|
|
||||||
1. **Business Isolation**: ALWAYS scope by business_id BEFORE finding by ID
|
|
||||||
- `Product::whereHas('brand', fn($q) => $q->where('business_id', $business->id))->findOrFail($id)`
|
|
||||||
2. **Route Protection**: Use middleware `['auth', 'verified', 'seller', 'approved']`
|
|
||||||
3. **No Filament**: Use DaisyUI + Blade for seller area
|
|
||||||
4. **Run tests before commit**: `php artisan test --parallel && ./vendor/bin/pint`
|
|
||||||
|
|
||||||
## Git Branch
|
|
||||||
- Current: `feature/product-page-migrate`
|
|
||||||
- DO NOT commit to develop directly
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
1. Verify branch: `git branch` (should show feature/product-page-migrate)
|
|
||||||
2. Create migrations with full rollback capability
|
|
||||||
3. Update Product model
|
|
||||||
4. Create Product2Controller
|
|
||||||
5. Create edit2.blade.php view
|
|
||||||
6. Test thoroughly
|
|
||||||
7. Run Pint + tests
|
|
||||||
8. Commit with clear message
|
|
||||||
|
|
||||||
## Questions to Clarify Before Building
|
|
||||||
- Collapsible cards to reduce clutter? (yes/no)
|
|
||||||
- Should quantity_on_hand be editable in UI? (currently hidden)
|
|
||||||
- Which fields are absolutely required vs nice-to-have?
|
|
||||||
- SQL dump ready for real data analysis?
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Cannabrands B2B Platform
|
# Cannabrands B2B Platform
|
||||||
|
|
||||||
A LeafLink-style cannabis marketplace platform built with Laravel, featuring business onboarding, compliance tracking, and multi-tenant architecture foundation.
|
A comprehensive B2B cannabis marketplace platform built with Laravel, featuring business onboarding, compliance tracking, and multi-tenant architecture.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -579,7 +579,7 @@ See `.env.production.example` for complete configuration template.
|
|||||||
- Follow PSR-12 coding standards
|
- Follow PSR-12 coding standards
|
||||||
- Use Pest for testing new features
|
- Use Pest for testing new features
|
||||||
- Reference `/docs/APP_OVERVIEW.md` for development approach
|
- Reference `/docs/APP_OVERVIEW.md` for development approach
|
||||||
- All features should maintain LeafLink-style compliance focus
|
- All features should maintain strong compliance and regulatory focus
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
193
app/Console/Commands/Ai/AiStatsCommand.php
Normal file
193
app/Console/Commands/Ai/AiStatsCommand.php
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Ai;
|
||||||
|
|
||||||
|
use App\Models\Ai\AiGeneratorState;
|
||||||
|
use App\Models\Ai\AiPromptLog;
|
||||||
|
use App\Models\Ai\AiSuggestion;
|
||||||
|
use App\Models\Business;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Stats Command
|
||||||
|
*
|
||||||
|
* Display AI orchestrator statistics.
|
||||||
|
*/
|
||||||
|
class AiStatsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'ai:stats
|
||||||
|
{--business= : Filter by business ID}
|
||||||
|
{--days=30 : Number of days to analyze}';
|
||||||
|
|
||||||
|
protected $description = 'Display AI orchestrator statistics';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$businessId = $this->option('business');
|
||||||
|
$days = (int) $this->option('days');
|
||||||
|
|
||||||
|
$this->info("AI Orchestrator Statistics (Last {$days} days)");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if ($businessId) {
|
||||||
|
$business = Business::find($businessId);
|
||||||
|
if (! $business) {
|
||||||
|
$this->error("Business not found: {$businessId}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
$this->info("Business: {$business->name}");
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggestion stats
|
||||||
|
$this->displaySuggestionStats($businessId, $days);
|
||||||
|
|
||||||
|
// LLM usage stats
|
||||||
|
$this->displayLlmStats($businessId, $days);
|
||||||
|
|
||||||
|
// Generator performance
|
||||||
|
if ($businessId) {
|
||||||
|
$this->displayGeneratorStats((int) $businessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function displaySuggestionStats(?int $businessId, int $days): void
|
||||||
|
{
|
||||||
|
$this->info('📊 Suggestion Statistics');
|
||||||
|
|
||||||
|
$query = AiSuggestion::where('created_at', '>=', now()->subDays($days));
|
||||||
|
|
||||||
|
if ($businessId) {
|
||||||
|
$query->where('business_id', $businessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$suggestions = $query->get();
|
||||||
|
|
||||||
|
$total = $suggestions->count();
|
||||||
|
$pending = $suggestions->where('status', 'pending')->count();
|
||||||
|
$actioned = $suggestions->where('status', 'actioned')->count();
|
||||||
|
$dismissed = $suggestions->where('status', 'dismissed')->count();
|
||||||
|
$expired = $suggestions->where('status', 'expired')->count();
|
||||||
|
|
||||||
|
$actionRate = $total > 0 ? round(($actioned / $total) * 100, 1) : 0;
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Value'],
|
||||||
|
[
|
||||||
|
['Total Suggestions', number_format($total)],
|
||||||
|
['Pending', number_format($pending)],
|
||||||
|
['Actioned', number_format($actioned)." ({$actionRate}%)"],
|
||||||
|
['Dismissed', number_format($dismissed)],
|
||||||
|
['Expired', number_format($expired)],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// By category
|
||||||
|
$byCategory = $suggestions->groupBy('category')->map->count()->sortDesc();
|
||||||
|
|
||||||
|
if ($byCategory->isNotEmpty()) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('By Category:');
|
||||||
|
$this->table(
|
||||||
|
['Category', 'Count'],
|
||||||
|
$byCategory->map(fn ($count, $cat) => [$cat, $count])->values()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// By priority
|
||||||
|
$byPriority = $suggestions->groupBy('priority')->map->count();
|
||||||
|
|
||||||
|
if ($byPriority->isNotEmpty()) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('By Priority:');
|
||||||
|
$this->table(
|
||||||
|
['Priority', 'Count'],
|
||||||
|
collect(['urgent', 'high', 'normal', 'low'])
|
||||||
|
->map(fn ($p) => [$p, $byPriority[$p] ?? 0])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function displayLlmStats(?int $businessId, int $days): void
|
||||||
|
{
|
||||||
|
$this->info('🤖 LLM Usage Statistics');
|
||||||
|
|
||||||
|
$stats = AiPromptLog::getUsageStats($businessId ?? 0, $days);
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Value'],
|
||||||
|
[
|
||||||
|
['Total Requests', number_format($stats['total_requests'])],
|
||||||
|
['Total Tokens', number_format($stats['total_tokens'])],
|
||||||
|
['Total Cost', '$'.number_format($stats['total_cost'], 4)],
|
||||||
|
['Avg Latency', round($stats['average_latency_ms'] ?? 0).'ms'],
|
||||||
|
['Error Rate', number_format($stats['error_rate'], 1).'%'],
|
||||||
|
['Cache Hit Rate', number_format($stats['cache_hit_rate'], 1).'%'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// By provider
|
||||||
|
if (! empty($stats['by_provider'])) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('By Provider:');
|
||||||
|
$this->table(
|
||||||
|
['Provider', 'Requests', 'Tokens', 'Cost'],
|
||||||
|
collect($stats['by_provider'])->map(fn ($data, $provider) => [
|
||||||
|
$provider,
|
||||||
|
number_format($data['requests']),
|
||||||
|
number_format($data['tokens']),
|
||||||
|
'$'.number_format($data['cost'], 4),
|
||||||
|
])->values()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function displayGeneratorStats(int $businessId): void
|
||||||
|
{
|
||||||
|
$this->info('⚙️ Generator Performance');
|
||||||
|
|
||||||
|
$report = AiGeneratorState::getPerformanceReport($businessId);
|
||||||
|
|
||||||
|
if (empty($report['generators'])) {
|
||||||
|
$this->line('No generator data available.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Generator', 'Context', 'Runs', 'Suggestions', 'Action Rate', 'Grade'],
|
||||||
|
collect($report['generators'])->map(fn ($g) => [
|
||||||
|
$g['type'],
|
||||||
|
$g['context'],
|
||||||
|
number_format($g['total_runs']),
|
||||||
|
number_format($g['total_suggestions']),
|
||||||
|
number_format($g['action_rate'], 1).'%',
|
||||||
|
$g['performance_grade'],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('Summary:');
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Value'],
|
||||||
|
[
|
||||||
|
['Total Generators', $report['summary']['total_generators']],
|
||||||
|
['Enabled', $report['summary']['enabled_generators']],
|
||||||
|
['Total Suggestions', number_format($report['summary']['total_suggestions'])],
|
||||||
|
['Overall Action Rate', number_format($report['summary']['overall_action_rate'], 1).'%'],
|
||||||
|
['Avg Accuracy', $report['summary']['average_accuracy']
|
||||||
|
? number_format($report['summary']['average_accuracy'] * 100, 1).'%'
|
||||||
|
: 'N/A'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
app/Console/Commands/Ai/GenerateBriefingsCommand.php
Normal file
198
app/Console/Commands/Ai/GenerateBriefingsCommand.php
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Ai;
|
||||||
|
|
||||||
|
use App\Jobs\Ai\GenerateBriefingJob;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Briefings Command
|
||||||
|
*
|
||||||
|
* Artisan command to generate AI briefings for users.
|
||||||
|
*/
|
||||||
|
class GenerateBriefingsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'ai:generate-briefings
|
||||||
|
{type=daily : Type of briefing (daily, weekly, monthly)}
|
||||||
|
{--user= : Generate for specific user ID}
|
||||||
|
{--business= : Generate for specific business ID}
|
||||||
|
{--sync : Run synchronously instead of queuing}';
|
||||||
|
|
||||||
|
protected $description = 'Generate AI briefings for users';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$type = $this->argument('type');
|
||||||
|
|
||||||
|
if (! in_array($type, ['daily', 'weekly', 'monthly'])) {
|
||||||
|
$this->error("Invalid briefing type: {$type}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if feature is enabled
|
||||||
|
if (! config('ai_orchestrator.briefings.enabled', true)) {
|
||||||
|
$this->warn('Briefings are disabled in configuration.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! config("ai_orchestrator.briefings.{$type}.enabled", true)) {
|
||||||
|
$this->warn("{$type} briefings are disabled in configuration.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $this->option('user');
|
||||||
|
$businessId = $this->option('business');
|
||||||
|
$sync = $this->option('sync');
|
||||||
|
|
||||||
|
if ($userId) {
|
||||||
|
return $this->generateForUser((int) $userId, $type, $sync);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($businessId) {
|
||||||
|
return $this->generateForBusiness((int) $businessId, $type, $sync);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->generateForAll($type, $sync);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateForUser(int $userId, string $type, bool $sync): int
|
||||||
|
{
|
||||||
|
$user = User::with('business')->find($userId);
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
$this->error("User not found: {$userId}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->business_id) {
|
||||||
|
$this->error('User has no associated business');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Generating {$type} briefing for user: {$user->name}");
|
||||||
|
|
||||||
|
if ($sync) {
|
||||||
|
$this->dispatchSync($userId, $user->business_id, $type);
|
||||||
|
} else {
|
||||||
|
GenerateBriefingJob::dispatch($userId, $user->business_id, $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Done.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateForBusiness(int $businessId, string $type, bool $sync): int
|
||||||
|
{
|
||||||
|
$business = Business::find($businessId);
|
||||||
|
|
||||||
|
if (! $business) {
|
||||||
|
$this->error("Business not found: {$businessId}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get users who should receive briefings (sellers/admins)
|
||||||
|
$users = User::where('business_id', $businessId)
|
||||||
|
->whereIn('user_type', ['seller', 'both'])
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($users->isEmpty()) {
|
||||||
|
$this->warn("No eligible users found for business: {$business->name}");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Generating {$type} briefings for {$users->count()} users in {$business->name}");
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($users->count());
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
if ($sync) {
|
||||||
|
$this->dispatchSync($user->id, $businessId, $type);
|
||||||
|
} else {
|
||||||
|
GenerateBriefingJob::dispatch($user->id, $businessId, $type);
|
||||||
|
}
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Done.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateForAll(string $type, bool $sync): int
|
||||||
|
{
|
||||||
|
// Get all active seller businesses
|
||||||
|
$businesses = Business::where('type', 'seller')
|
||||||
|
->orWhere('type', 'both')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($businesses->isEmpty()) {
|
||||||
|
$this->warn('No eligible businesses found.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalUsers = 0;
|
||||||
|
|
||||||
|
foreach ($businesses as $business) {
|
||||||
|
$userCount = User::where('business_id', $business->id)
|
||||||
|
->whereIn('user_type', ['seller', 'both'])
|
||||||
|
->where('is_active', true)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$totalUsers += $userCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Generating {$type} briefings for {$totalUsers} users across {$businesses->count()} businesses");
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($totalUsers);
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
foreach ($businesses as $business) {
|
||||||
|
$users = User::where('business_id', $business->id)
|
||||||
|
->whereIn('user_type', ['seller', 'both'])
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
if ($sync) {
|
||||||
|
$this->dispatchSync($user->id, $business->id, $type);
|
||||||
|
} else {
|
||||||
|
GenerateBriefingJob::dispatch($user->id, $business->id, $type);
|
||||||
|
}
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Done.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function dispatchSync(int $userId, int $businessId, string $type): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$orchestrator = app(\App\Services\Ai\Contracts\AiOrchestratorContract::class);
|
||||||
|
$orchestrator->generateBriefing($userId, $businessId, $type);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error("Failed for user {$userId}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
app/Console/Commands/Ai/ProcessAiSuggestionsCommand.php
Normal file
128
app/Console/Commands/Ai/ProcessAiSuggestionsCommand.php
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Ai;
|
||||||
|
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Services\Ai\Contracts\AiOrchestratorContract;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process AI Suggestions Command
|
||||||
|
*
|
||||||
|
* Artisan command to manually trigger AI suggestion generation.
|
||||||
|
*/
|
||||||
|
class ProcessAiSuggestionsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'ai:process-suggestions
|
||||||
|
{context : Context type (deal, order, thread, buyer)}
|
||||||
|
{entity : Entity ID to process}
|
||||||
|
{--business= : Business ID (required)}
|
||||||
|
{--no-llm : Disable LLM generation, use rules only}';
|
||||||
|
|
||||||
|
protected $description = 'Manually process AI suggestions for an entity';
|
||||||
|
|
||||||
|
public function handle(AiOrchestratorContract $orchestrator): int
|
||||||
|
{
|
||||||
|
$contextType = $this->argument('context');
|
||||||
|
$entityId = (int) $this->argument('entity');
|
||||||
|
$businessId = (int) $this->option('business');
|
||||||
|
$useLlm = ! $this->option('no-llm');
|
||||||
|
|
||||||
|
if (! $businessId) {
|
||||||
|
$this->error('--business option is required');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$business = Business::find($businessId);
|
||||||
|
if (! $business) {
|
||||||
|
$this->error("Business not found: {$businessId}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$validContexts = ['deal', 'order', 'thread', 'buyer'];
|
||||||
|
if (! in_array($contextType, $validContexts)) {
|
||||||
|
$this->error('Invalid context type. Must be one of: '.implode(', ', $validContexts));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Processing {$contextType} #{$entityId} for business: {$business->name}");
|
||||||
|
$this->info('LLM: '.($useLlm ? 'enabled' : 'disabled'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build context
|
||||||
|
$context = $orchestrator->buildContext($contextType, $entityId, $businessId);
|
||||||
|
|
||||||
|
$this->info('Context built successfully');
|
||||||
|
$this->line(" Type: {$context->type}");
|
||||||
|
$this->line(" Entity ID: {$context->entityId}");
|
||||||
|
|
||||||
|
// Generate suggestions
|
||||||
|
$suggestions = $orchestrator->generateSuggestions($context, [
|
||||||
|
'use_llm' => $useLlm,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->info("Generated {$suggestions->count()} suggestions");
|
||||||
|
|
||||||
|
if ($suggestions->isNotEmpty()) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->table(
|
||||||
|
['Title', 'Type', 'Priority', 'Confidence'],
|
||||||
|
$suggestions->map(fn ($s) => [
|
||||||
|
substr($s->title, 0, 40),
|
||||||
|
$s->type->value,
|
||||||
|
$s->priority->value,
|
||||||
|
number_format($s->confidence * 100, 0).'%',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist suggestions
|
||||||
|
if ($this->confirm('Persist these suggestions?', true)) {
|
||||||
|
foreach ($suggestions as $suggestion) {
|
||||||
|
$orchestrator->persistSuggestion($suggestion);
|
||||||
|
}
|
||||||
|
$this->info('Suggestions persisted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assess risks
|
||||||
|
$risks = $orchestrator->assessRisks($context);
|
||||||
|
|
||||||
|
if ($risks->isNotEmpty()) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Identified {$risks->count()} risks");
|
||||||
|
$this->table(
|
||||||
|
['Title', 'Level', 'Score', 'Impact'],
|
||||||
|
$risks->map(fn ($r) => [
|
||||||
|
substr($r->title, 0, 40),
|
||||||
|
$r->level,
|
||||||
|
number_format($r->score * 100, 0).'%',
|
||||||
|
$r->impactValue ? '$'.number_format($r->impactValue) : '-',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($this->confirm('Persist these risks?', true)) {
|
||||||
|
foreach ($risks as $risk) {
|
||||||
|
$orchestrator->persistRisk($risk);
|
||||||
|
}
|
||||||
|
$this->info('Risks persisted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error("Error: {$e->getMessage()}");
|
||||||
|
|
||||||
|
if ($this->output->isVerbose()) {
|
||||||
|
$this->line($e->getTraceAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/Console/Commands/AutoTuneMissingBrandProfiles.php
Normal file
98
app/Console/Commands/AutoTuneMissingBrandProfiles.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Services\AI\BrandAiProfileGenerator;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class AutoTuneMissingBrandProfiles extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'brand-ai-profiles:auto-tune-missing
|
||||||
|
{--limit=0 : Maximum number of brands to process (0 = all)}
|
||||||
|
{--dry-run : Show what would be processed without actually generating}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Auto-generate AI profiles for brands that don\'t have one';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(BrandAiProfileGenerator $generator): int
|
||||||
|
{
|
||||||
|
$this->info('Finding brands without AI profiles...');
|
||||||
|
|
||||||
|
// Get brands that don't have an AI profile
|
||||||
|
$query = Brand::whereDoesntHave('aiProfile')
|
||||||
|
->whereHas('business', function ($q) {
|
||||||
|
$q->where('status', 'approved');
|
||||||
|
})
|
||||||
|
->orderBy('name');
|
||||||
|
|
||||||
|
$limit = (int) $this->option('limit');
|
||||||
|
if ($limit > 0) {
|
||||||
|
$query->limit($limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
$brands = $query->get();
|
||||||
|
|
||||||
|
if ($brands->isEmpty()) {
|
||||||
|
$this->info('All brands already have AI profiles. Nothing to do.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Found {$brands->count()} brand(s) without AI profiles.");
|
||||||
|
|
||||||
|
if ($this->option('dry-run')) {
|
||||||
|
$this->warn('DRY RUN - No profiles will be generated.');
|
||||||
|
$this->table(
|
||||||
|
['ID', 'Brand Name', 'Business', 'Voice', 'Audience'],
|
||||||
|
$brands->map(fn ($b) => [
|
||||||
|
$b->id,
|
||||||
|
$b->name,
|
||||||
|
$b->business?->name ?? 'N/A',
|
||||||
|
$b->brand_voice ?? 'N/A',
|
||||||
|
$b->brand_audience ?? 'N/A',
|
||||||
|
])->toArray()
|
||||||
|
);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($brands->count());
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
$success = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($brands as $brand) {
|
||||||
|
try {
|
||||||
|
$generator->generateForBrand($brand);
|
||||||
|
$success++;
|
||||||
|
$this->line(" <info>✓</info> {$brand->name}");
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$failed++;
|
||||||
|
$this->line(" <error>✗</error> {$brand->name}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
$this->info("Completed: {$success} profiles generated, {$failed} failed.");
|
||||||
|
|
||||||
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/Console/Commands/CheckMediaFiles.php
Normal file
148
app/Console/Commands/CheckMediaFiles.php
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class CheckMediaFiles extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'media:check {--brands : Check brand images} {--products : Check product images} {--all : Check all media}';
|
||||||
|
|
||||||
|
protected $description = 'Check which brand and product images exist on MinIO storage';
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$checkBrands = $this->option('brands') || $this->option('all');
|
||||||
|
$checkProducts = $this->option('products') || $this->option('all');
|
||||||
|
|
||||||
|
if (! $checkBrands && ! $checkProducts) {
|
||||||
|
$checkBrands = $checkProducts = true; // Default to checking everything
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($checkBrands) {
|
||||||
|
$this->checkBrandImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($checkProducts) {
|
||||||
|
$this->checkProductImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkBrandImages()
|
||||||
|
{
|
||||||
|
$this->info('🔍 Checking brand images...');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$brands = Brand::whereNotNull('logo_path')
|
||||||
|
->orWhereNotNull('banner_path')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$broken = [];
|
||||||
|
$working = [];
|
||||||
|
|
||||||
|
foreach ($brands as $brand) {
|
||||||
|
$logoOk = $brand->logo_path ? Storage::exists($brand->logo_path) : true;
|
||||||
|
$bannerOk = $brand->banner_path ? Storage::exists($brand->banner_path) : true;
|
||||||
|
|
||||||
|
if (! $logoOk || ! $bannerOk) {
|
||||||
|
$status = [];
|
||||||
|
if (! $logoOk) {
|
||||||
|
$status[] = '❌ LOGO: '.$brand->logo_path;
|
||||||
|
}
|
||||||
|
if (! $bannerOk) {
|
||||||
|
$status[] = '❌ BANNER: '.$brand->banner_path;
|
||||||
|
}
|
||||||
|
$broken[] = [
|
||||||
|
'brand' => $brand->name.' (slug: '.$brand->slug.')',
|
||||||
|
'status' => implode(' | ', $status),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$working[] = $brand->name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($broken)) {
|
||||||
|
$this->info('✅ All '.count($working).' brand images exist on MinIO!');
|
||||||
|
} else {
|
||||||
|
$this->error('Found '.count($broken).' brands with missing images:');
|
||||||
|
$this->newLine();
|
||||||
|
foreach ($broken as $b) {
|
||||||
|
$this->line(' '.$b['brand']);
|
||||||
|
$this->line(' '.$b['status']);
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Working: '.count($working).' brands');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkProductImages()
|
||||||
|
{
|
||||||
|
$this->info('🔍 Checking product images...');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$products = Product::whereNotNull('image_path')->get();
|
||||||
|
|
||||||
|
$broken = [];
|
||||||
|
$working = [];
|
||||||
|
$wrongPath = [];
|
||||||
|
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$exists = Storage::exists($product->image_path);
|
||||||
|
|
||||||
|
if (! $exists) {
|
||||||
|
$broken[] = [
|
||||||
|
'product' => $product->name.' (SKU: '.$product->sku.')',
|
||||||
|
'path' => $product->image_path,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$working[] = $product->name;
|
||||||
|
|
||||||
|
// Check if path follows correct pattern
|
||||||
|
$expectedPattern = 'businesses/*/brands/*/products/*/images/*';
|
||||||
|
if (! preg_match('#^businesses/[^/]+/brands/[^/]+/products/[^/]+/images/#', $product->image_path)) {
|
||||||
|
$wrongPath[] = [
|
||||||
|
'product' => $product->name.' (SKU: '.$product->sku.')',
|
||||||
|
'path' => $product->image_path,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($broken)) {
|
||||||
|
$this->info('✅ All '.count($working).' product images exist on MinIO!');
|
||||||
|
} else {
|
||||||
|
$this->error('Found '.count($broken).' products with missing images:');
|
||||||
|
$this->newLine();
|
||||||
|
foreach (array_slice($broken, 0, 10) as $p) {
|
||||||
|
$this->line(' ❌ '.$p['product']);
|
||||||
|
$this->line(' Path: '.$p['path']);
|
||||||
|
}
|
||||||
|
if (count($broken) > 10) {
|
||||||
|
$this->line(' ... and '.(count($broken) - 10).' more');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($wrongPath)) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('⚠️ Found '.count($wrongPath).' products with WRONG path pattern:');
|
||||||
|
$this->newLine();
|
||||||
|
foreach (array_slice($wrongPath, 0, 5) as $p) {
|
||||||
|
$this->line(' '.$p['product']);
|
||||||
|
$this->line(' Current: '.$p['path']);
|
||||||
|
$this->line(' Should be: businesses/{business_slug}/brands/{brand_slug}/products/{sku}/images/');
|
||||||
|
}
|
||||||
|
if (count($wrongPath) > 5) {
|
||||||
|
$this->line(' ... and '.(count($wrongPath) - 5).' more');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
155
app/Console/Commands/CleanupPermissionAuditLogs.php
Normal file
155
app/Console/Commands/CleanupPermissionAuditLogs.php
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\PermissionAuditLog;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class CleanupPermissionAuditLogs extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'permissions:cleanup-audit
|
||||||
|
{--dry-run : Show what would be deleted without actually deleting}
|
||||||
|
{--force : Skip confirmation prompt}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Delete expired permission audit logs (non-critical logs past their expiration date)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$isDryRun = $this->option('dry-run');
|
||||||
|
$isForced = $this->option('force');
|
||||||
|
|
||||||
|
$this->info('🔍 Scanning for expired permission audit logs...');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Find expired logs
|
||||||
|
$expiredLogs = PermissionAuditLog::expired()->get();
|
||||||
|
|
||||||
|
if ($expiredLogs->isEmpty()) {
|
||||||
|
$this->info('✅ No expired audit logs found. Everything is up to date!');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
$totalCount = $expiredLogs->count();
|
||||||
|
$oldestLog = $expiredLogs->sortBy('created_at')->first();
|
||||||
|
$newestLog = $expiredLogs->sortByDesc('created_at')->first();
|
||||||
|
|
||||||
|
// Display summary
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Value'],
|
||||||
|
[
|
||||||
|
['Expired logs found', $totalCount],
|
||||||
|
['Oldest expired log', $oldestLog->created_at->format('Y-m-d H:i:s')],
|
||||||
|
['Newest expired log', $newestLog->created_at->format('Y-m-d H:i:s')],
|
||||||
|
['Date range', $oldestLog->created_at->diffForHumans($newestLog->created_at, true)],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Show sample of logs to be deleted
|
||||||
|
$this->info('📋 Sample of logs to be deleted:');
|
||||||
|
$sampleLogs = $expiredLogs->take(5);
|
||||||
|
|
||||||
|
foreach ($sampleLogs as $log) {
|
||||||
|
$this->line(sprintf(
|
||||||
|
' • [%s] %s - %s (expired %s)',
|
||||||
|
$log->created_at->format('Y-m-d'),
|
||||||
|
$log->action_name,
|
||||||
|
$log->targetUser?->name ?? 'Unknown User',
|
||||||
|
$log->expires_at->diffForHumans()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($totalCount > 5) {
|
||||||
|
$this->line(" ... and {$totalCount} more");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Dry run mode
|
||||||
|
if ($isDryRun) {
|
||||||
|
$this->warn('🧪 DRY RUN MODE - No logs will be deleted');
|
||||||
|
$this->info("Would delete {$totalCount} expired audit logs");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmation prompt (unless forced)
|
||||||
|
if (! $isForced) {
|
||||||
|
$confirmed = $this->confirm(
|
||||||
|
"Are you sure you want to delete {$totalCount} expired audit logs?",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $confirmed) {
|
||||||
|
$this->info('❌ Cleanup cancelled');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform deletion
|
||||||
|
$this->info('🗑️ Deleting expired audit logs...');
|
||||||
|
|
||||||
|
$progressBar = $this->output->createProgressBar($totalCount);
|
||||||
|
$progressBar->start();
|
||||||
|
|
||||||
|
$deletedCount = 0;
|
||||||
|
$errorCount = 0;
|
||||||
|
|
||||||
|
foreach ($expiredLogs as $log) {
|
||||||
|
try {
|
||||||
|
$log->delete();
|
||||||
|
$deletedCount++;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$errorCount++;
|
||||||
|
$this->error("Failed to delete log ID {$log->id}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
// Final summary
|
||||||
|
if ($errorCount === 0) {
|
||||||
|
$this->info("✅ Successfully deleted {$deletedCount} expired audit logs");
|
||||||
|
} else {
|
||||||
|
$this->warn("⚠️ Deleted {$deletedCount} logs with {$errorCount} errors");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show remaining stats
|
||||||
|
$remainingTotal = PermissionAuditLog::count();
|
||||||
|
$remainingCritical = PermissionAuditLog::critical()->count();
|
||||||
|
$remainingNonExpired = $remainingTotal - $remainingCritical;
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('📊 Database statistics after cleanup:');
|
||||||
|
$this->table(
|
||||||
|
['Category', 'Count'],
|
||||||
|
[
|
||||||
|
['Critical logs (kept forever)', $remainingCritical],
|
||||||
|
['Non-critical logs (not yet expired)', $remainingNonExpired],
|
||||||
|
['Total remaining logs', $remainingTotal],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Console/Commands/CleanupTempFiles.php
Normal file
28
app/Console/Commands/CleanupTempFiles.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\MediaStorageService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class CleanupTempFiles extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'media:cleanup-temp';
|
||||||
|
|
||||||
|
protected $description = 'Clean up temporary files older than 24 hours from MinIO storage';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$this->info('🧹 Cleaning up temporary files...');
|
||||||
|
|
||||||
|
$deleted = MediaStorageService::cleanupTempFiles();
|
||||||
|
|
||||||
|
if ($deleted > 0) {
|
||||||
|
$this->info("✅ Deleted {$deleted} temporary file(s)");
|
||||||
|
} else {
|
||||||
|
$this->info('✅ No temporary files to clean up');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Console/Commands/ClearVarieties.php
Normal file
68
app/Console/Commands/ClearVarieties.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ClearVarieties extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'products:clear-varieties
|
||||||
|
{--brand-id= : Limit to a specific brand ID}
|
||||||
|
{--force : Skip confirmation prompt}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Clear all parent_product_id links (undo variety relationships)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$brandId = $this->option('brand-id');
|
||||||
|
$force = $this->option('force');
|
||||||
|
|
||||||
|
$query = Product::query()->whereNotNull('parent_product_id');
|
||||||
|
|
||||||
|
if ($brandId) {
|
||||||
|
$query->where('brand_id', $brandId);
|
||||||
|
$this->info("Filtering to brand_id: {$brandId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = $query->count();
|
||||||
|
|
||||||
|
if ($count === 0) {
|
||||||
|
$this->info('No products have parent_product_id set. Nothing to clear.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope = $brandId ? "brand #{$brandId}" : 'all brands';
|
||||||
|
$this->warn("This will clear parent_product_id for {$count} products in {$scope}.");
|
||||||
|
|
||||||
|
if (! $force && ! $this->confirm('Are you sure you want to continue?')) {
|
||||||
|
$this->info('Operation cancelled.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the update
|
||||||
|
$updated = Product::query()
|
||||||
|
->whereNotNull('parent_product_id')
|
||||||
|
->when($brandId, fn ($q) => $q->where('brand_id', $brandId))
|
||||||
|
->update(['parent_product_id' => null]);
|
||||||
|
|
||||||
|
$this->info("✓ Cleared parent_product_id for {$updated} products.");
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Console/Commands/CreateSystemMenusCommand.php
Normal file
59
app/Console/Commands/CreateSystemMenusCommand.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\Menu;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class CreateSystemMenusCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'menus:create-system {--brand= : Specific brand ID to create menus for}';
|
||||||
|
|
||||||
|
protected $description = 'Create system menus (Available Now, Promotions, Daily Deals, Best Sellers) for all brands';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$brandId = $this->option('brand');
|
||||||
|
|
||||||
|
if ($brandId) {
|
||||||
|
$brands = Brand::where('id', $brandId)->get();
|
||||||
|
if ($brands->isEmpty()) {
|
||||||
|
$this->error("Brand with ID {$brandId} not found.");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$brands = Brand::all();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Creating system menus for '.count($brands).' brand(s)...');
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar(count($brands));
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($brands as $brand) {
|
||||||
|
$menus = Menu::createSystemMenusForBrand($brand);
|
||||||
|
|
||||||
|
foreach ($menus as $menu) {
|
||||||
|
if ($menu->wasRecentlyCreated) {
|
||||||
|
$created++;
|
||||||
|
} else {
|
||||||
|
$skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
$this->info("Done! Created {$created} new system menus, {$skipped} already existed.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Company;
|
use App\Models\Business;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use App\Models\OrderItem;
|
use App\Models\OrderItem;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
@@ -40,19 +40,21 @@ class CreateTestInvoiceForApproval extends Command
|
|||||||
|
|
||||||
$this->info("✓ Using buyer: {$buyer->name} ({$buyer->email})");
|
$this->info("✓ Using buyer: {$buyer->name} ({$buyer->email})");
|
||||||
|
|
||||||
// Get any company
|
// Get any business
|
||||||
$company = Company::first();
|
$business = Business::first();
|
||||||
|
|
||||||
if (! $company) {
|
if (! $business) {
|
||||||
$this->error('No company found. Please seed database first.');
|
$this->error('No business found. Please seed database first.');
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("✓ Company: {$company->name}");
|
$this->info("✓ Business: {$business->name}");
|
||||||
|
|
||||||
// Get some products
|
// Get some products that have inventory
|
||||||
$products = Product::where('quantity_on_hand', '>', 10)->where('is_active', true)->take(5)->get();
|
$products = Product::whereHas('inventoryItems', function ($q) {
|
||||||
|
$q->where('quantity_on_hand', '>', 10);
|
||||||
|
})->where('is_active', true)->take(5)->get();
|
||||||
if ($products->isEmpty()) {
|
if ($products->isEmpty()) {
|
||||||
$this->error('No products found. Please seed products first.');
|
$this->error('No products found. Please seed products first.');
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ class CreateTestInvoiceForApproval extends Command
|
|||||||
$this->info("✓ Found {$products->count()} products for order");
|
$this->info("✓ Found {$products->count()} products for order");
|
||||||
|
|
||||||
// Create order
|
// Create order
|
||||||
$order = $this->createOrder($buyer, $company);
|
$order = $this->createOrder($buyer, $business);
|
||||||
$this->info("✓ Created order: {$order->order_number}");
|
$this->info("✓ Created order: {$order->order_number}");
|
||||||
|
|
||||||
// Add items to order
|
// Add items to order
|
||||||
@@ -125,11 +127,11 @@ class CreateTestInvoiceForApproval extends Command
|
|||||||
/**
|
/**
|
||||||
* Create a test order.
|
* Create a test order.
|
||||||
*/
|
*/
|
||||||
protected function createOrder(User $buyer, Company $company): Order
|
protected function createOrder(User $buyer, Business $business): Order
|
||||||
{
|
{
|
||||||
return Order::create([
|
return Order::create([
|
||||||
'order_number' => 'ORD-TEST-'.strtoupper(substr(md5(time()), 0, 10)),
|
'order_number' => 'ORD-TEST-'.strtoupper(substr(md5(time()), 0, 10)),
|
||||||
'company_id' => $company->id,
|
'business_id' => $business->id,
|
||||||
'user_id' => $buyer->id,
|
'user_id' => $buyer->id,
|
||||||
'subtotal' => 0, // Will be calculated
|
'subtotal' => 0, // Will be calculated
|
||||||
'tax' => 0,
|
'tax' => 0,
|
||||||
|
|||||||
75
app/Console/Commands/DevSetup.php
Normal file
75
app/Console/Commands/DevSetup.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class DevSetup extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'dev:setup
|
||||||
|
{--fresh : Drop all tables and re-run migrations}
|
||||||
|
{--skip-seed : Skip seeding dev fixtures}';
|
||||||
|
|
||||||
|
protected $description = 'Set up local development environment with migrations and dev fixtures';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if (app()->environment('production')) {
|
||||||
|
$this->error('This command cannot be run in production!');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Setting up development environment...');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
if ($this->option('fresh')) {
|
||||||
|
$this->warn('Dropping all tables and re-running migrations...');
|
||||||
|
$this->call('migrate:fresh');
|
||||||
|
} else {
|
||||||
|
$this->info('Running migrations...');
|
||||||
|
$this->call('migrate');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Seed dev fixtures
|
||||||
|
if (! $this->option('skip-seed')) {
|
||||||
|
if ($this->confirm('Seed development fixtures (users, businesses, brands)?', true)) {
|
||||||
|
$this->info('Seeding development fixtures...');
|
||||||
|
$this->call('db:seed', ['--class' => 'ProductionSyncSeeder']);
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Seeding dev suites and plans...');
|
||||||
|
$this->call('db:seed', ['--class' => 'DevSuitesSeeder']);
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Seeding brand profiles...');
|
||||||
|
$this->call('db:seed', ['--class' => 'BrandProfilesSeeder']);
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Seeding orchestrator profiles...');
|
||||||
|
$this->call('orchestrator:seed-brand-profiles', ['--force' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Development setup complete!');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Credential', 'Email', 'Password'],
|
||||||
|
[
|
||||||
|
['Super Admin', 'admin@cannabrands.com', 'password'],
|
||||||
|
['Admin', 'admin@example.com', 'password'],
|
||||||
|
['Seller', 'seller@example.com', 'password'],
|
||||||
|
['Buyer', 'buyer@example.com', 'password'],
|
||||||
|
['Cannabrands Owner', 'cannabrands-owner@example.com', 'password'],
|
||||||
|
['Brand Manager', 'brand-manager@example.com', 'password'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/Console/Commands/ExploreRemoteDatabase.php
Normal file
91
app/Console/Commands/ExploreRemoteDatabase.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ExploreRemoteDatabase extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'explore:remote-db {query?}';
|
||||||
|
|
||||||
|
protected $description = 'Explore the remote MySQL database';
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
// Configure remote MySQL connection
|
||||||
|
config(['database.connections.remote_mysql' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'host' => 'sql1.creationshop.net',
|
||||||
|
'port' => '3306',
|
||||||
|
'database' => 'hub_cannabrands',
|
||||||
|
'username' => 'claude',
|
||||||
|
'password' => 'claude',
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
'collation' => 'utf8mb4_unicode_ci',
|
||||||
|
'prefix' => '',
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
]]);
|
||||||
|
|
||||||
|
$this->info('✓ Connected to remote MySQL database');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Show brands table structure
|
||||||
|
$this->info('=== BRANDS TABLE STRUCTURE ===');
|
||||||
|
$columns = DB::connection('remote_mysql')->select('DESCRIBE brands');
|
||||||
|
foreach ($columns as $column) {
|
||||||
|
$this->line(" {$column->Field} ({$column->Type})");
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Show first 5 brands
|
||||||
|
$this->info('=== BRANDS ===');
|
||||||
|
$brands = DB::connection('remote_mysql')->table('brands')->limit(5)->get();
|
||||||
|
foreach ($brands as $brand) {
|
||||||
|
$this->line(json_encode($brand, JSON_PRETTY_PRINT));
|
||||||
|
$this->line('---');
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Show products table structure
|
||||||
|
$this->info('=== PRODUCTS TABLE ===');
|
||||||
|
$this->line('Sample products with SKU codes:');
|
||||||
|
$products = DB::connection('remote_mysql')
|
||||||
|
->table('products')
|
||||||
|
->select('id', 'brand_id', 'name', 'code', 'barcode', 'wholesale_price', 'cost', 'quantity')
|
||||||
|
->where('active', 1)
|
||||||
|
->whereNotNull('code')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$this->line(json_encode($product, JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Show orders table structure
|
||||||
|
$this->info('=== ORDERS & ORDER_PRODUCTS ===');
|
||||||
|
$orderSample = DB::connection('remote_mysql')
|
||||||
|
->table('order_products')
|
||||||
|
->join('orders', 'orders.id', '=', 'order_products.order_id')
|
||||||
|
->join('products', 'products.id', '=', 'order_products.product_id')
|
||||||
|
->select(
|
||||||
|
'orders.id as order_id',
|
||||||
|
'orders.created_at',
|
||||||
|
'products.code as sku',
|
||||||
|
'products.name',
|
||||||
|
'order_products.quantity',
|
||||||
|
'order_products.price',
|
||||||
|
'order_products.subtotal'
|
||||||
|
)
|
||||||
|
->limit(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($orderSample as $order) {
|
||||||
|
$this->line(json_encode($order, JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
278
app/Console/Commands/ExportBusinessConfigSeeder.php
Normal file
278
app/Console/Commands/ExportBusinessConfigSeeder.php
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Business;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports current business configurations to a seeder file.
|
||||||
|
*
|
||||||
|
* This captures the current admin settings for all businesses:
|
||||||
|
* - Suite assignments (business_suite pivot)
|
||||||
|
* - Enterprise plan status
|
||||||
|
* - Module flags
|
||||||
|
* - Usage limits
|
||||||
|
*
|
||||||
|
* The generated seeder is IDEMPOTENT - it updates existing records
|
||||||
|
* without deleting data.
|
||||||
|
*/
|
||||||
|
class ExportBusinessConfigSeeder extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'export:business-config-seeder
|
||||||
|
{--output= : Output file path (default: database/seeders/BusinessConfigSeeder.php)}
|
||||||
|
{--business= : Export only specific business by slug}';
|
||||||
|
|
||||||
|
protected $description = 'Export current business configurations to a seeder file';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration fields to export.
|
||||||
|
*/
|
||||||
|
private array $configFields = [
|
||||||
|
// Enterprise plan
|
||||||
|
'is_enterprise_plan',
|
||||||
|
|
||||||
|
// Legacy module flags
|
||||||
|
'has_marketing',
|
||||||
|
'has_analytics',
|
||||||
|
'has_inventory',
|
||||||
|
'has_manufacturing',
|
||||||
|
'has_processing',
|
||||||
|
'has_compliance',
|
||||||
|
'has_crm',
|
||||||
|
'has_buyer_intelligence',
|
||||||
|
'copilot_enabled',
|
||||||
|
'has_conversations',
|
||||||
|
|
||||||
|
// Legacy suite flags (kept for compatibility)
|
||||||
|
'has_sales_suite',
|
||||||
|
'has_processing_suite',
|
||||||
|
'has_manufacturing_suite',
|
||||||
|
'has_delivery_suite',
|
||||||
|
'has_management_suite',
|
||||||
|
'has_enterprise_suite',
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
'use_suite_navigation',
|
||||||
|
|
||||||
|
// Usage limits
|
||||||
|
'sales_suite_brand_limit',
|
||||||
|
'sales_suite_sku_limit_per_brand',
|
||||||
|
'sales_suite_menu_limit_per_brand',
|
||||||
|
'sales_suite_message_limit_per_brand',
|
||||||
|
'sales_suite_ai_credits_per_brand',
|
||||||
|
'sales_suite_contact_limit_per_brand',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$outputPath = $this->option('output') ?? database_path('seeders/BusinessConfigSeeder.php');
|
||||||
|
$specificBusiness = $this->option('business');
|
||||||
|
|
||||||
|
$this->info('Exporting business configurations...');
|
||||||
|
|
||||||
|
// Get businesses to export
|
||||||
|
$query = Business::with('suites')->orderBy('name');
|
||||||
|
if ($specificBusiness) {
|
||||||
|
$query->where('slug', $specificBusiness);
|
||||||
|
}
|
||||||
|
$businesses = $query->get();
|
||||||
|
|
||||||
|
if ($businesses->isEmpty()) {
|
||||||
|
$this->error('No businesses found to export.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Found {$businesses->count()} businesses to export.");
|
||||||
|
|
||||||
|
// Build the seeder content
|
||||||
|
$seederContent = $this->generateSeederContent($businesses);
|
||||||
|
|
||||||
|
// Write to file
|
||||||
|
File::put($outputPath, $seederContent);
|
||||||
|
|
||||||
|
$this->info("Seeder exported to: {$outputPath}");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Show summary
|
||||||
|
$this->table(
|
||||||
|
['Business', 'Type', 'Enterprise', 'Suites'],
|
||||||
|
$businesses->map(fn ($b) => [
|
||||||
|
$b->name,
|
||||||
|
$b->type,
|
||||||
|
$b->is_enterprise_plan ? 'Yes' : 'No',
|
||||||
|
$b->suites->pluck('key')->implode(', ') ?: '-',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateSeederContent($businesses): string
|
||||||
|
{
|
||||||
|
$timestamp = now()->format('Y-m-d H:i:s');
|
||||||
|
$configs = [];
|
||||||
|
|
||||||
|
foreach ($businesses as $business) {
|
||||||
|
$config = [
|
||||||
|
'slug' => $business->slug,
|
||||||
|
'name' => $business->name,
|
||||||
|
'suites' => $business->suites->pluck('key')->toArray(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add only non-null/non-default config values
|
||||||
|
foreach ($this->configFields as $field) {
|
||||||
|
$value = $business->{$field};
|
||||||
|
if ($value !== null && $value !== false && $value !== 0) {
|
||||||
|
$config[$field] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$configs[] = $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
$configsPhp = $this->arrayToPhp($configs, 2);
|
||||||
|
|
||||||
|
return <<<PHP
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Suite;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BusinessConfigSeeder - Sets business configurations and suite assignments.
|
||||||
|
*
|
||||||
|
* Auto-generated on: {$timestamp}
|
||||||
|
* Generated by: php artisan export:business-config-seeder
|
||||||
|
*
|
||||||
|
* This seeder is IDEMPOTENT - it updates existing records without deleting data.
|
||||||
|
* Run with: php artisan db:seed --class=BusinessConfigSeeder
|
||||||
|
*/
|
||||||
|
class BusinessConfigSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Business configurations exported from admin settings.
|
||||||
|
*/
|
||||||
|
private array \$configs = {$configsPhp};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
\$this->command->info('Applying business configurations...');
|
||||||
|
|
||||||
|
// Cache suite IDs by key for efficiency
|
||||||
|
\$suiteIds = Suite::pluck('id', 'key')->toArray();
|
||||||
|
|
||||||
|
\$updated = 0;
|
||||||
|
\$skipped = 0;
|
||||||
|
|
||||||
|
foreach (\$this->configs as \$config) {
|
||||||
|
\$business = Business::where('slug', \$config['slug'])->first();
|
||||||
|
|
||||||
|
if (! \$business) {
|
||||||
|
\$this->command->warn(" Skipping: {\$config['name']} (slug: {\$config['slug']}) - not found");
|
||||||
|
\$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract suites from config
|
||||||
|
\$suites = \$config['suites'] ?? [];
|
||||||
|
unset(\$config['suites'], \$config['slug'], \$config['name']);
|
||||||
|
|
||||||
|
// Update business config fields
|
||||||
|
\$business->update(\$config);
|
||||||
|
|
||||||
|
// Sync suite assignments (without detaching extras)
|
||||||
|
\$suiteIdsToSync = collect(\$suites)
|
||||||
|
->map(fn (\$key) => \$suiteIds[\$key] ?? null)
|
||||||
|
->filter()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
if (! empty(\$suiteIdsToSync)) {
|
||||||
|
\$business->suites()->syncWithoutDetaching(\$suiteIdsToSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
\$updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
\$this->command->info(" Updated: {\$updated} businesses");
|
||||||
|
if (\$skipped > 0) {
|
||||||
|
\$this->command->warn(" Skipped: {\$skipped} businesses (not found)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PHP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PHP array to formatted PHP code string.
|
||||||
|
*/
|
||||||
|
private function arrayToPhp(array $array, int $indent = 1): string
|
||||||
|
{
|
||||||
|
$spaces = str_repeat(' ', $indent);
|
||||||
|
$closingSpaces = str_repeat(' ', $indent - 1);
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($array as $key => $value) {
|
||||||
|
$keyStr = is_int($key) ? '' : "'{$key}' => ";
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
if (empty($value)) {
|
||||||
|
// Empty array
|
||||||
|
$valueStr = '[]';
|
||||||
|
} elseif ($this->isSequentialArray($value) && $this->isSimpleArray($value)) {
|
||||||
|
// Simple sequential array - inline
|
||||||
|
$valueStr = '['.implode(', ', array_map(fn ($v) => $this->valueToPhp($v), $value)).']';
|
||||||
|
} else {
|
||||||
|
// Complex array - multiline
|
||||||
|
$valueStr = $this->arrayToPhp($value, $indent + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$valueStr = $this->valueToPhp($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = $spaces.$keyStr.$valueStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "[\n".implode(",\n", $items).",\n{$closingSpaces}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function valueToPhp($value): string
|
||||||
|
{
|
||||||
|
if (is_null($value)) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
if (is_int($value) || is_float($value)) {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "'".addslashes($value)."'";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSequentialArray(array $array): bool
|
||||||
|
{
|
||||||
|
return array_keys($array) === range(0, count($array) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSimpleArray(array $array): bool
|
||||||
|
{
|
||||||
|
foreach ($array as $value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Console/Commands/GenerateInventoryItemHashids.php
Normal file
54
app/Console/Commands/GenerateInventoryItemHashids.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class GenerateInventoryItemHashids extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'inventory:generate-hashids';
|
||||||
|
|
||||||
|
protected $description = 'Generate hashids for inventory items, movements, and alerts that don\'t have them';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
// Process InventoryItems
|
||||||
|
$this->processModel(InventoryItem::class, 'inventory items');
|
||||||
|
|
||||||
|
// Process InventoryMovements
|
||||||
|
$this->processModel(\App\Models\InventoryMovement::class, 'inventory movements');
|
||||||
|
|
||||||
|
// Process InventoryAlerts
|
||||||
|
$this->processModel(\App\Models\InventoryAlert::class, 'inventory alerts');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function processModel(string $modelClass, string $label): void
|
||||||
|
{
|
||||||
|
$records = $modelClass::whereNull('hashid')->get();
|
||||||
|
|
||||||
|
if ($records->isEmpty()) {
|
||||||
|
$this->info("✓ All {$label} already have hashids!");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Found {$records->count()} {$label} without hashids. Generating...");
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($records->count());
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$record->hashid = $record->generateHashid();
|
||||||
|
$record->saveQuietly(); // Don't trigger observers/events
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("✅ Generated hashids for {$records->count()} {$label}!");
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
635
app/Console/Commands/GenerateMarketingOrchestratorTasks.php
Normal file
635
app/Console/Commands/GenerateMarketingOrchestratorTasks.php
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\AutomationRunLog;
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\MenuViewEvent;
|
||||||
|
use App\Models\OrchestratorMarketingConfig;
|
||||||
|
use App\Models\OrchestratorTask;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\SendMenuLog;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketing Orchestrator - "Head of Marketing" automated playbooks.
|
||||||
|
*
|
||||||
|
* Generates actionable tasks for marketing teams based on engagement signals:
|
||||||
|
* - Campaign blast candidates (high-engagement customers)
|
||||||
|
* - Segment refinement suggestions
|
||||||
|
* - Launch announcements for new brands/SKUs
|
||||||
|
* - Holiday campaign opportunities
|
||||||
|
* - New SKU feature suggestions
|
||||||
|
* - Nurture sequence recommendations
|
||||||
|
*/
|
||||||
|
class GenerateMarketingOrchestratorTasks extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'orchestrator:generate-marketing-tasks
|
||||||
|
{--business= : Limit to specific business ID}
|
||||||
|
{--playbook= : Run only specific playbook}
|
||||||
|
{--dry-run : Show what would be created without creating}';
|
||||||
|
|
||||||
|
protected $description = 'Generate Marketing Orchestrator tasks from automated playbooks (Head of Marketing)';
|
||||||
|
|
||||||
|
private int $tasksCreated = 0;
|
||||||
|
|
||||||
|
private bool $dryRun = false;
|
||||||
|
|
||||||
|
private OrchestratorMarketingConfig $config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-brand task counter for throttling.
|
||||||
|
*/
|
||||||
|
private array $brandTaskCount = [];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
AutomationRunLog::recordStart(AutomationRunLog::CMD_GENERATE_MARKETING_TASKS);
|
||||||
|
|
||||||
|
$this->dryRun = $this->option('dry-run');
|
||||||
|
$specificBusinessId = $this->option('business');
|
||||||
|
$specificPlaybook = $this->option('playbook');
|
||||||
|
|
||||||
|
$this->info('📣 Marketing Orchestrator - Generating tasks...');
|
||||||
|
if ($this->dryRun) {
|
||||||
|
$this->warn(' (DRY RUN - no tasks will be created)');
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Load global config
|
||||||
|
try {
|
||||||
|
$this->config = OrchestratorMarketingConfig::getGlobal();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->config = new OrchestratorMarketingConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" Throttle: max {$this->config->getMaxTasksPerBrandPerRun()} tasks/brand/run");
|
||||||
|
$this->line(" Cooldown: {$this->config->getCooldownDays()} days between touches");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Get seller businesses
|
||||||
|
$businessQuery = Business::query()->where('type', '!=', 'buyer');
|
||||||
|
|
||||||
|
if ($specificBusinessId) {
|
||||||
|
$businessQuery->where('id', $specificBusinessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$businesses = $businessQuery->get();
|
||||||
|
|
||||||
|
foreach ($businesses as $business) {
|
||||||
|
$this->brandTaskCount = [];
|
||||||
|
$this->line("📊 Processing: {$business->name}");
|
||||||
|
|
||||||
|
// Run playbooks based on filter or all
|
||||||
|
if (! $specificPlaybook || $specificPlaybook === 'campaign-blast') {
|
||||||
|
$count = $this->playbook1CampaignBlastCandidates($business);
|
||||||
|
$this->line(" ├─ Playbook 1 (Campaign Blast): {$count} tasks");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $specificPlaybook || $specificPlaybook === 'segment-refinement') {
|
||||||
|
$count = $this->playbook2SegmentRefinement($business);
|
||||||
|
$this->line(" ├─ Playbook 2 (Segment Refinement): {$count} tasks");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $specificPlaybook || $specificPlaybook === 'launch-announcement') {
|
||||||
|
$count = $this->playbook3LaunchAnnouncement($business);
|
||||||
|
$this->line(" ├─ Playbook 3 (Launch Announcement): {$count} tasks");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $specificPlaybook || $specificPlaybook === 'holiday-campaign') {
|
||||||
|
$count = $this->playbook4HolidayCampaign($business);
|
||||||
|
$this->line(" ├─ Playbook 4 (Holiday Campaign): {$count} tasks");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $specificPlaybook || $specificPlaybook === 'new-sku-feature') {
|
||||||
|
$count = $this->playbook5NewSkuFeature($business);
|
||||||
|
$this->line(" ├─ Playbook 5 (New SKU Feature): {$count} tasks");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $specificPlaybook || $specificPlaybook === 'nurture-sequence') {
|
||||||
|
$count = $this->playbook6NurtureSequence($business);
|
||||||
|
$this->line(" └─ Playbook 6 (Nurture Sequence): {$count} tasks");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("✅ Complete! Total marketing tasks created: {$this->tasksCreated}");
|
||||||
|
|
||||||
|
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_GENERATE_MARKETING_TASKS, [
|
||||||
|
'tasks_created' => $this->tasksCreated,
|
||||||
|
'businesses_processed' => $businesses->count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Playbook 1: Campaign Blast Candidates
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find high-engagement customers who haven't received marketing in a while.
|
||||||
|
*/
|
||||||
|
private function playbook1CampaignBlastCandidates(Business $business): int
|
||||||
|
{
|
||||||
|
if (! $this->config->isCampaignBlastEnabled()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$settings = $this->config->getCampaignBlastSettings();
|
||||||
|
$brands = $business->brands;
|
||||||
|
|
||||||
|
foreach ($brands as $brand) {
|
||||||
|
if (! $this->canCreateTaskForBrand($brand->id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find customers with high engagement (menu views + orders)
|
||||||
|
$engagedCustomers = $this->getEngagedCustomers($brand, $settings['min_engagement_score']);
|
||||||
|
|
||||||
|
foreach ($engagedCustomers as $customer) {
|
||||||
|
// Check if already has pending marketing task
|
||||||
|
if (OrchestratorTask::existsPending(
|
||||||
|
$business->id,
|
||||||
|
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE,
|
||||||
|
$customer->id
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cooldown - no marketing sends in X days
|
||||||
|
$lastMarketingSend = SendMenuLog::where('business_id', $business->id)
|
||||||
|
->where('customer_id', $customer->id)
|
||||||
|
->whereNotNull('orchestrator_task_id')
|
||||||
|
->where('sent_at', '>=', now()->subDays($settings['days_since_last_send']))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($lastMarketingSend) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createTask([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'brand_id' => $brand->id,
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'type' => OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE,
|
||||||
|
'owner_role' => OrchestratorTask::ROLE_MARKETING,
|
||||||
|
'status' => OrchestratorTask::STATUS_PENDING,
|
||||||
|
'priority' => OrchestratorTask::PRIORITY_NORMAL,
|
||||||
|
'due_at' => now()->addDays(3),
|
||||||
|
'payload' => [
|
||||||
|
'customer_name' => $customer->name,
|
||||||
|
'brand_name' => $brand->name,
|
||||||
|
'engagement_score' => $customer->engagement_score ?? 0,
|
||||||
|
'reason' => "High-engagement customer ({$customer->name}) ready for campaign blast for {$brand->name}",
|
||||||
|
'suggested_action' => 'Include in next email campaign or menu blast',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count++;
|
||||||
|
$this->recordTaskForBrand($brand->id);
|
||||||
|
|
||||||
|
if ($count >= $settings['max_tasks_per_run']) {
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Playbook 2: Segment Refinement
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find brands with many customers but no defined segments.
|
||||||
|
*/
|
||||||
|
private function playbook2SegmentRefinement(Business $business): int
|
||||||
|
{
|
||||||
|
if (! $this->config->isSegmentRefinementEnabled()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$settings = $this->config->getSegmentRefinementSettings();
|
||||||
|
$brands = $business->brands;
|
||||||
|
|
||||||
|
foreach ($brands as $brand) {
|
||||||
|
// Check if already has pending segment task
|
||||||
|
if (OrchestratorTask::where('business_id', $business->id)
|
||||||
|
->where('brand_id', $brand->id)
|
||||||
|
->where('type', OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT)
|
||||||
|
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||||
|
->exists()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count unique customers who have engaged with this brand
|
||||||
|
$customerCount = MenuViewEvent::where('brand_id', $brand->id)
|
||||||
|
->whereNotNull('customer_id')
|
||||||
|
->distinct('customer_id')
|
||||||
|
->count('customer_id');
|
||||||
|
|
||||||
|
if ($customerCount < $settings['min_customers']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createTask([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'brand_id' => $brand->id,
|
||||||
|
'type' => OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT,
|
||||||
|
'owner_role' => OrchestratorTask::ROLE_MARKETING,
|
||||||
|
'status' => OrchestratorTask::STATUS_PENDING,
|
||||||
|
'priority' => OrchestratorTask::PRIORITY_LOW,
|
||||||
|
'due_at' => now()->addWeek(),
|
||||||
|
'payload' => [
|
||||||
|
'brand_name' => $brand->name,
|
||||||
|
'customer_count' => $customerCount,
|
||||||
|
'reason' => "{$brand->name} has {$customerCount} engaged customers - consider creating marketing segments",
|
||||||
|
'suggested_action' => 'Define customer segments (VIP, Regular, New) for targeted campaigns',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Playbook 3: Launch Announcement
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest launch campaigns for new brands.
|
||||||
|
*/
|
||||||
|
private function playbook3LaunchAnnouncement(Business $business): int
|
||||||
|
{
|
||||||
|
if (! $this->config->isLaunchAnnouncementEnabled()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$settings = $this->config->getLaunchAnnouncementSettings();
|
||||||
|
|
||||||
|
// Find brands created recently
|
||||||
|
$newBrands = $business->brands()
|
||||||
|
->where('created_at', '>=', now()->subDays($settings['days_new']))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($newBrands as $brand) {
|
||||||
|
// Check if already has pending launch task
|
||||||
|
if (OrchestratorTask::where('business_id', $business->id)
|
||||||
|
->where('brand_id', $brand->id)
|
||||||
|
->where('type', OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT)
|
||||||
|
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||||
|
->exists()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createTask([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'brand_id' => $brand->id,
|
||||||
|
'type' => OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT,
|
||||||
|
'owner_role' => OrchestratorTask::ROLE_MARKETING,
|
||||||
|
'status' => OrchestratorTask::STATUS_PENDING,
|
||||||
|
'priority' => OrchestratorTask::PRIORITY_HIGH,
|
||||||
|
'due_at' => now()->addDays(2),
|
||||||
|
'payload' => [
|
||||||
|
'brand_name' => $brand->name,
|
||||||
|
'brand_created_at' => $brand->created_at->toDateString(),
|
||||||
|
'days_since_launch' => now()->diffInDays($brand->created_at),
|
||||||
|
'reason' => "New brand '{$brand->name}' launched - create announcement campaign",
|
||||||
|
'suggested_action' => 'Send launch email to customer list, update website banners',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Playbook 4: Holiday Campaign
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest holiday campaigns based on upcoming holidays.
|
||||||
|
*/
|
||||||
|
private function playbook4HolidayCampaign(Business $business): int
|
||||||
|
{
|
||||||
|
if (! $this->config->isHolidayCampaignEnabled()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$settings = $this->config->getHolidayCampaignSettings();
|
||||||
|
|
||||||
|
// Define holidays relevant to the cannabis industry
|
||||||
|
$holidays = $this->getUpcomingHolidays($settings['days_before']);
|
||||||
|
|
||||||
|
if (empty($holidays)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($holidays as $holiday) {
|
||||||
|
// Check if already has pending holiday task for this business
|
||||||
|
$existingTask = OrchestratorTask::where('business_id', $business->id)
|
||||||
|
->where('type', OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN)
|
||||||
|
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||||
|
->whereJsonContains('payload->holiday_name', $holiday['name'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($existingTask) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createTask([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'type' => OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN,
|
||||||
|
'owner_role' => OrchestratorTask::ROLE_MARKETING,
|
||||||
|
'status' => OrchestratorTask::STATUS_PENDING,
|
||||||
|
'priority' => OrchestratorTask::PRIORITY_HIGH,
|
||||||
|
'due_at' => $holiday['date']->copy()->subDays(7),
|
||||||
|
'payload' => [
|
||||||
|
'holiday_name' => $holiday['name'],
|
||||||
|
'holiday_date' => $holiday['date']->toDateString(),
|
||||||
|
'days_until' => now()->diffInDays($holiday['date']),
|
||||||
|
'reason' => "{$holiday['name']} is in {$holiday['days_until']} days - prepare campaign",
|
||||||
|
'suggested_action' => $holiday['suggested_action'] ?? 'Create themed email campaign and promotions',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Playbook 5: New SKU Feature
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest featuring new SKUs in marketing materials.
|
||||||
|
*/
|
||||||
|
private function playbook5NewSkuFeature(Business $business): int
|
||||||
|
{
|
||||||
|
if (! $this->config->isNewSkuFeatureEnabled()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$settings = $this->config->getNewSkuFeatureSettings();
|
||||||
|
$brands = $business->brands;
|
||||||
|
|
||||||
|
foreach ($brands as $brand) {
|
||||||
|
// Find new products for this brand
|
||||||
|
$newProducts = Product::where('brand_id', $brand->id)
|
||||||
|
->where('created_at', '>=', now()->subDays($settings['days_new']))
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($newProducts->count() < $settings['min_products']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already has pending new SKU task
|
||||||
|
if (OrchestratorTask::where('business_id', $business->id)
|
||||||
|
->where('brand_id', $brand->id)
|
||||||
|
->where('type', OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE)
|
||||||
|
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||||
|
->exists()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$productNames = $newProducts->pluck('name')->take(5)->toArray();
|
||||||
|
|
||||||
|
$this->createTask([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'brand_id' => $brand->id,
|
||||||
|
'type' => OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE,
|
||||||
|
'owner_role' => OrchestratorTask::ROLE_MARKETING,
|
||||||
|
'status' => OrchestratorTask::STATUS_PENDING,
|
||||||
|
'priority' => OrchestratorTask::PRIORITY_NORMAL,
|
||||||
|
'due_at' => now()->addDays(5),
|
||||||
|
'payload' => [
|
||||||
|
'brand_name' => $brand->name,
|
||||||
|
'new_product_count' => $newProducts->count(),
|
||||||
|
'product_names' => $productNames,
|
||||||
|
'reason' => "{$brand->name} has {$newProducts->count()} new products to feature",
|
||||||
|
'suggested_action' => 'Create "New Arrivals" email or update product showcase',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Playbook 6: Nurture Sequence
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest nurture sequences for new customers.
|
||||||
|
*/
|
||||||
|
private function playbook6NurtureSequence(Business $business): int
|
||||||
|
{
|
||||||
|
if (! $this->config->isNurtureSequenceEnabled()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$settings = $this->config->getNurtureSequenceSettings();
|
||||||
|
|
||||||
|
if (! Schema::hasTable('orders')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$brandIds = $business->brands()->pluck('id');
|
||||||
|
if ($brandIds->isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find customers with first order in date range and limited orders
|
||||||
|
$nurtureCandiates = DB::table('orders')
|
||||||
|
->join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||||
|
->join('products', 'order_items.product_id', '=', 'products.id')
|
||||||
|
->whereIn('products.brand_id', $brandIds)
|
||||||
|
->select('orders.business_id as customer_id')
|
||||||
|
->groupBy('orders.business_id')
|
||||||
|
->havingRaw('COUNT(DISTINCT orders.id) <= ?', [$settings['max_orders']])
|
||||||
|
->havingRaw('MIN(orders.created_at) <= ?', [now()->subDays($settings['days_since_first_order'])])
|
||||||
|
->limit(20)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($nurtureCandiates as $candidate) {
|
||||||
|
// Check if already has pending nurture task
|
||||||
|
if (OrchestratorTask::existsPending(
|
||||||
|
$business->id,
|
||||||
|
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE,
|
||||||
|
$candidate->customer_id
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer = Business::find($candidate->customer_id);
|
||||||
|
if (! $customer) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createTask([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'type' => OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE,
|
||||||
|
'owner_role' => OrchestratorTask::ROLE_MARKETING,
|
||||||
|
'status' => OrchestratorTask::STATUS_PENDING,
|
||||||
|
'priority' => OrchestratorTask::PRIORITY_LOW,
|
||||||
|
'due_at' => now()->addWeek(),
|
||||||
|
'payload' => [
|
||||||
|
'customer_name' => $customer->name,
|
||||||
|
'reason' => "New customer '{$customer->name}' ready for nurture sequence",
|
||||||
|
'suggested_action' => 'Add to welcome email series or educational content drip',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get engaged customers for a brand.
|
||||||
|
*/
|
||||||
|
private function getEngagedCustomers(Brand $brand, int $minScore): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
// Get customers who have viewed menus multiple times
|
||||||
|
return MenuViewEvent::where('brand_id', $brand->id)
|
||||||
|
->whereNotNull('customer_id')
|
||||||
|
->select('customer_id', DB::raw('COUNT(*) as view_count'))
|
||||||
|
->groupBy('customer_id')
|
||||||
|
->havingRaw('COUNT(*) >= ?', [3])
|
||||||
|
->orderByDesc('view_count')
|
||||||
|
->limit(50)
|
||||||
|
->get()
|
||||||
|
->map(function ($item) {
|
||||||
|
$customer = Business::find($item->customer_id);
|
||||||
|
if ($customer) {
|
||||||
|
$customer->engagement_score = min(100, $item->view_count * 10);
|
||||||
|
|
||||||
|
return $customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
->filter()
|
||||||
|
->filter(fn ($c) => ($c->engagement_score ?? 0) >= $minScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upcoming holidays.
|
||||||
|
*/
|
||||||
|
private function getUpcomingHolidays(int $daysAhead): array
|
||||||
|
{
|
||||||
|
$holidays = [
|
||||||
|
['name' => '4/20', 'date' => now()->setMonth(4)->setDay(20), 'suggested_action' => 'Major cannabis holiday - plan big promotional campaign'],
|
||||||
|
['name' => 'Green Wednesday', 'date' => $this->getGreenWednesday(), 'suggested_action' => 'Day before Thanksgiving - high sales day'],
|
||||||
|
['name' => 'Black Friday', 'date' => $this->getBlackFriday(), 'suggested_action' => 'Major sales event - prepare deals and promotions'],
|
||||||
|
['name' => '7/10 (Dab Day)', 'date' => now()->setMonth(7)->setDay(10), 'suggested_action' => 'Concentrate holiday - feature extracts and dabs'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$upcoming = [];
|
||||||
|
foreach ($holidays as $holiday) {
|
||||||
|
$date = $holiday['date'];
|
||||||
|
|
||||||
|
// If date is in past this year, check next year
|
||||||
|
if ($date->isPast()) {
|
||||||
|
$date = $date->addYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
$daysUntil = now()->diffInDays($date, false);
|
||||||
|
|
||||||
|
if ($daysUntil > 0 && $daysUntil <= $daysAhead) {
|
||||||
|
$holiday['date'] = $date;
|
||||||
|
$holiday['days_until'] = $daysUntil;
|
||||||
|
$upcoming[] = $holiday;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $upcoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getGreenWednesday(): \Carbon\Carbon
|
||||||
|
{
|
||||||
|
// Wednesday before Thanksgiving (4th Thursday of November)
|
||||||
|
$november = now()->setMonth(11)->startOfMonth();
|
||||||
|
$thanksgiving = $november->copy()->nthOfMonth(4, \Carbon\Carbon::THURSDAY);
|
||||||
|
|
||||||
|
return $thanksgiving->copy()->subDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getBlackFriday(): \Carbon\Carbon
|
||||||
|
{
|
||||||
|
$november = now()->setMonth(11)->startOfMonth();
|
||||||
|
$thanksgiving = $november->copy()->nthOfMonth(4, \Carbon\Carbon::THURSDAY);
|
||||||
|
|
||||||
|
return $thanksgiving->copy()->addDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we can create a task for this brand (throttling).
|
||||||
|
*/
|
||||||
|
private function canCreateTaskForBrand(int $brandId): bool
|
||||||
|
{
|
||||||
|
$currentCount = $this->brandTaskCount[$brandId] ?? 0;
|
||||||
|
|
||||||
|
return $currentCount < $this->config->getMaxTasksPerBrandPerRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record that we created a task for this brand.
|
||||||
|
*/
|
||||||
|
private function recordTaskForBrand(int $brandId): void
|
||||||
|
{
|
||||||
|
if (! isset($this->brandTaskCount[$brandId])) {
|
||||||
|
$this->brandTaskCount[$brandId] = 0;
|
||||||
|
}
|
||||||
|
$this->brandTaskCount[$brandId]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a marketing task.
|
||||||
|
*/
|
||||||
|
private function createTask(array $taskData): ?OrchestratorTask
|
||||||
|
{
|
||||||
|
// Ensure marketing role
|
||||||
|
$taskData['owner_role'] = OrchestratorTask::ROLE_MARKETING;
|
||||||
|
|
||||||
|
// Marketing tasks are admin-only (not visible to seller reps)
|
||||||
|
$taskData['visible_to_reps'] = false;
|
||||||
|
$taskData['approval_state'] = OrchestratorTask::APPROVAL_AUTO;
|
||||||
|
|
||||||
|
if ($this->dryRun) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tasksCreated++;
|
||||||
|
|
||||||
|
return OrchestratorTask::create($taskData);
|
||||||
|
}
|
||||||
|
}
|
||||||
228
app/Console/Commands/GenerateMenuFollowups.php
Normal file
228
app/Console/Commands/GenerateMenuFollowups.php
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\MenuViewEvent;
|
||||||
|
use App\Models\OrchestratorTask;
|
||||||
|
use App\Models\SendMenuLog;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class GenerateMenuFollowups extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'orchestrator:generate-menu-followups
|
||||||
|
{--days-no-view=3 : Days after send with no view to trigger followup}
|
||||||
|
{--days-viewed-no-order=3 : Days after view with no order to trigger followup}
|
||||||
|
{--business= : Limit to specific business ID}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Generate Orchestrator followup tasks for menu sends without views or orders';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$daysNoView = (int) $this->option('days-no-view');
|
||||||
|
$daysViewedNoOrder = (int) $this->option('days-viewed-no-order');
|
||||||
|
$specificBusinessId = $this->option('business');
|
||||||
|
|
||||||
|
$this->info('Generating menu followup tasks...');
|
||||||
|
$this->info(" - No view threshold: {$daysNoView} days");
|
||||||
|
$this->info(" - Viewed no order threshold: {$daysViewedNoOrder} days");
|
||||||
|
|
||||||
|
$businessQuery = Business::query()
|
||||||
|
->where('type', '!=', 'buyer'); // Only process seller businesses
|
||||||
|
|
||||||
|
if ($specificBusinessId) {
|
||||||
|
$businessQuery->where('id', $specificBusinessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$businesses = $businessQuery->get();
|
||||||
|
$totalNoView = 0;
|
||||||
|
$totalViewedNoOrder = 0;
|
||||||
|
|
||||||
|
foreach ($businesses as $business) {
|
||||||
|
$this->line("Processing business: {$business->name} (ID: {$business->id})");
|
||||||
|
|
||||||
|
// Case A: No view after send
|
||||||
|
$noViewCount = $this->generateNoViewFollowups($business, $daysNoView);
|
||||||
|
$totalNoView += $noViewCount;
|
||||||
|
|
||||||
|
// Case B: Viewed but no order
|
||||||
|
$viewedNoOrderCount = $this->generateViewedNoOrderFollowups($business, $daysViewedNoOrder);
|
||||||
|
$totalViewedNoOrder += $viewedNoOrderCount;
|
||||||
|
|
||||||
|
$this->line(" - Created {$noViewCount} no-view followups");
|
||||||
|
$this->line(" - Created {$viewedNoOrderCount} viewed-no-order followups");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Complete! Total tasks created:');
|
||||||
|
$this->info(" - No view followups: {$totalNoView}");
|
||||||
|
$this->info(" - Viewed no order followups: {$totalViewedNoOrder}");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate followup tasks for menus sent but never viewed.
|
||||||
|
*/
|
||||||
|
protected function generateNoViewFollowups(Business $business, int $daysThreshold): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
// Find SendMenuLog rows where:
|
||||||
|
// - sent_at is {daysThreshold} to {daysThreshold + 2} days ago (window)
|
||||||
|
// - There is no MenuViewEvent for the same business_id, menu_id, customer_id after sent_at
|
||||||
|
// - There is no existing OrchestratorTask of type menu_followup_no_view in pending status
|
||||||
|
$cutoffStart = now()->subDays($daysThreshold + 2);
|
||||||
|
$cutoffEnd = now()->subDays($daysThreshold);
|
||||||
|
|
||||||
|
$sendLogs = SendMenuLog::query()
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->whereBetween('sent_at', [$cutoffStart, $cutoffEnd])
|
||||||
|
->whereNotNull('customer_id')
|
||||||
|
->whereNotNull('menu_id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($sendLogs as $log) {
|
||||||
|
// Check if there's been a view after the send
|
||||||
|
$hasView = MenuViewEvent::hasViewAfter(
|
||||||
|
$business->id,
|
||||||
|
$log->menu_id,
|
||||||
|
$log->customer_id,
|
||||||
|
$log->sent_at
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($hasView) {
|
||||||
|
continue; // Menu was viewed, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if task already exists
|
||||||
|
$existingTask = OrchestratorTask::query()
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->where('customer_id', $log->customer_id)
|
||||||
|
->where('menu_id', $log->menu_id)
|
||||||
|
->where('type', OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW)
|
||||||
|
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($existingTask) {
|
||||||
|
continue; // Task already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the followup task
|
||||||
|
OrchestratorTask::create([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'brand_id' => $log->brand_id,
|
||||||
|
'menu_id' => $log->menu_id,
|
||||||
|
'customer_id' => $log->customer_id,
|
||||||
|
'type' => OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW,
|
||||||
|
'status' => OrchestratorTask::STATUS_PENDING,
|
||||||
|
'due_at' => now(),
|
||||||
|
'payload' => [
|
||||||
|
'send_menu_log_id' => $log->id,
|
||||||
|
'recipient_name' => $log->meta['recipient_name'] ?? 'Unknown',
|
||||||
|
'recipient_type' => $log->recipient_type,
|
||||||
|
'recipient_id' => $log->recipient_id,
|
||||||
|
'channel' => $log->channel,
|
||||||
|
'original_sent_at' => $log->sent_at->toIso8601String(),
|
||||||
|
'suggested_message' => 'Hi! Just checking if you had a chance to look at the menu I sent over. Let me know if you have any questions!',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate followup tasks for menus viewed but no order placed.
|
||||||
|
*/
|
||||||
|
protected function generateViewedNoOrderFollowups(Business $business, int $daysThreshold): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
// Find MenuViewEvent rows where:
|
||||||
|
// - viewed_at is {daysThreshold} to {daysThreshold + 2} days ago
|
||||||
|
// - There was a SendMenuLog for that menu/customer earlier
|
||||||
|
// - There is no order yet (simplified: we just check if task exists)
|
||||||
|
// - There is no existing OrchestratorTask of type menu_followup_viewed_no_order in pending status
|
||||||
|
$cutoffStart = now()->subDays($daysThreshold + 2);
|
||||||
|
$cutoffEnd = now()->subDays($daysThreshold);
|
||||||
|
|
||||||
|
$viewEvents = MenuViewEvent::query()
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->whereBetween('viewed_at', [$cutoffStart, $cutoffEnd])
|
||||||
|
->whereNotNull('customer_id')
|
||||||
|
->whereNotNull('menu_id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($viewEvents as $viewEvent) {
|
||||||
|
// Check if there was a SendMenuLog for this menu/customer
|
||||||
|
$sendLog = SendMenuLog::query()
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->where('menu_id', $viewEvent->menu_id)
|
||||||
|
->where('customer_id', $viewEvent->customer_id)
|
||||||
|
->where('sent_at', '<', $viewEvent->viewed_at)
|
||||||
|
->orderByDesc('sent_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $sendLog) {
|
||||||
|
continue; // No prior send, this was a direct view
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if task already exists
|
||||||
|
$existingTask = OrchestratorTask::query()
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->where('customer_id', $viewEvent->customer_id)
|
||||||
|
->where('menu_id', $viewEvent->menu_id)
|
||||||
|
->where('type', OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER)
|
||||||
|
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($existingTask) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For V1.3, we skip order checking (would need to hook into orders table)
|
||||||
|
// In future: check Order::where('business_id', $customer_id)->where('created_at', '>', $viewEvent->viewed_at)->exists()
|
||||||
|
|
||||||
|
// Create the followup task
|
||||||
|
OrchestratorTask::create([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'brand_id' => $viewEvent->brand_id,
|
||||||
|
'menu_id' => $viewEvent->menu_id,
|
||||||
|
'customer_id' => $viewEvent->customer_id,
|
||||||
|
'type' => OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER,
|
||||||
|
'status' => OrchestratorTask::STATUS_PENDING,
|
||||||
|
'due_at' => now(),
|
||||||
|
'payload' => [
|
||||||
|
'send_menu_log_id' => $sendLog->id,
|
||||||
|
'recipient_name' => $sendLog->meta['recipient_name'] ?? 'Unknown',
|
||||||
|
'recipient_type' => $sendLog->recipient_type,
|
||||||
|
'recipient_id' => $sendLog->recipient_id,
|
||||||
|
'channel' => $sendLog->channel,
|
||||||
|
'original_sent_at' => $sendLog->sent_at->toIso8601String(),
|
||||||
|
'viewed_at' => $viewEvent->viewed_at->toIso8601String(),
|
||||||
|
'suggested_message' => "I saw you checked out the menu I sent over. Is there anything specific you're looking for? Happy to help!",
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
1554
app/Console/Commands/GenerateSalesOrchestratorTasks.php
Normal file
1554
app/Console/Commands/GenerateSalesOrchestratorTasks.php
Normal file
File diff suppressed because it is too large
Load Diff
464
app/Console/Commands/ImportAlohaSales.php
Normal file
464
app/Console/Commands/ImportAlohaSales.php
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\OrderItem;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ImportAlohaSales extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'import:aloha-sales {--dry-run : Show what would be imported without actually importing} {--force : Overwrite existing orders} {--skip-existing : Skip orders that already exist} {--limit= : Limit number of invoices to import}';
|
||||||
|
|
||||||
|
protected $description = 'Import Aloha TymeMachine sales history (invoices and customers) from remote MySQL';
|
||||||
|
|
||||||
|
private $mysqli;
|
||||||
|
|
||||||
|
private $stats = [
|
||||||
|
'total_invoices' => 0,
|
||||||
|
'imported_invoices' => 0,
|
||||||
|
'skipped_invoices' => 0,
|
||||||
|
'failed_invoices' => 0,
|
||||||
|
'customers_created' => 0,
|
||||||
|
'total_items' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
private $customerCache = [];
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$force = $this->option('force');
|
||||||
|
$skipExisting = $this->option('skip-existing');
|
||||||
|
$limit = $this->option('limit');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('🔍 DRY RUN MODE - No data will be imported');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('🚀 Starting Aloha TymeMachine Sales Import');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Connect to remote MySQL
|
||||||
|
$this->info('📡 Connecting to remote MySQL database...');
|
||||||
|
$this->mysqli = new \mysqli('sql1.creationshop.net', 'claude', 'claude', 'hub_cannabrands');
|
||||||
|
|
||||||
|
if ($this->mysqli->connect_error) {
|
||||||
|
$this->error('Failed to connect: '.$this->mysqli->connect_error);
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$this->info('✓ Connected to remote MySQL');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Get all invoices with Aloha TymeMachine products (brand_id = 11)
|
||||||
|
$this->info('📦 Fetching invoices with Aloha TymeMachine products...');
|
||||||
|
$query = '
|
||||||
|
SELECT DISTINCT i.id
|
||||||
|
FROM invoices i
|
||||||
|
INNER JOIN invoice_lines il ON i.id = il.invoice_id
|
||||||
|
INNER JOIN products p ON il.product_id = p.id
|
||||||
|
WHERE p.brand_id = 11
|
||||||
|
AND i.deleted_at IS NULL
|
||||||
|
ORDER BY i.id
|
||||||
|
';
|
||||||
|
if ($limit) {
|
||||||
|
$query .= ' LIMIT '.(int) $limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->mysqli->query($query);
|
||||||
|
$invoiceIds = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$invoiceIds[] = $row['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->stats['total_invoices'] = count($invoiceIds);
|
||||||
|
$this->info("Found {$this->stats['total_invoices']} invoices with Aloha TymeMachine products");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if (! $dryRun && ! $force && ! $skipExisting) {
|
||||||
|
if (! $this->confirm('This will import all invoices and customers. Continue?', true)) {
|
||||||
|
$this->warn('Import cancelled');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import each invoice
|
||||||
|
$progressBar = $this->output->createProgressBar($this->stats['total_invoices']);
|
||||||
|
$progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
|
||||||
|
$progressBar->setMessage('Starting...');
|
||||||
|
|
||||||
|
foreach ($invoiceIds as $invoiceId) {
|
||||||
|
$progressBar->setMessage("Invoice #{$invoiceId}");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->importInvoice($invoiceId, $dryRun, $force, $skipExisting);
|
||||||
|
|
||||||
|
if ($result === 'imported') {
|
||||||
|
$this->stats['imported_invoices']++;
|
||||||
|
} elseif ($result === 'skipped') {
|
||||||
|
$this->stats['skipped_invoices']++;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->stats['failed_invoices']++;
|
||||||
|
$progressBar->clear();
|
||||||
|
$this->error("Failed to import invoice #{$invoiceId}: {$e->getMessage()}");
|
||||||
|
$progressBar->display();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
// Show summary
|
||||||
|
$this->info('📊 Import Summary:');
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Count'],
|
||||||
|
[
|
||||||
|
['Total Invoices', $this->stats['total_invoices']],
|
||||||
|
['✓ Imported', $this->stats['imported_invoices']],
|
||||||
|
['⊘ Skipped', $this->stats['skipped_invoices']],
|
||||||
|
['✗ Failed', $this->stats['failed_invoices']],
|
||||||
|
['Customers Created', $this->stats['customers_created']],
|
||||||
|
['Order Items Created', $this->stats['total_items']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->mysqli->close();
|
||||||
|
|
||||||
|
return $this->stats['failed_invoices'] > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function importInvoice(int $invoiceId, bool $dryRun, bool $force, bool $skipExisting): string
|
||||||
|
{
|
||||||
|
// Fetch invoice from remote
|
||||||
|
$result = $this->mysqli->query("SELECT * FROM invoices WHERE id = {$invoiceId}");
|
||||||
|
$remote = $result->fetch_assoc();
|
||||||
|
|
||||||
|
if (! $remote) {
|
||||||
|
throw new \Exception("Invoice #{$invoiceId} not found in remote database");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
if (Order::where('id', $invoiceId)->exists()) {
|
||||||
|
if ($skipExisting) {
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $force && ! $dryRun) {
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun && $force) {
|
||||||
|
// Force delete existing order and items (hard delete, not soft delete)
|
||||||
|
DB::table('order_items')->where('order_id', $invoiceId)->delete();
|
||||||
|
Order::where('id', $invoiceId)->forceDelete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
return 'imported';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create customer business
|
||||||
|
$customer = $this->findOrCreateCustomer($remote['organisation_id'], $dryRun);
|
||||||
|
|
||||||
|
if (! $customer) {
|
||||||
|
throw new \Exception("Failed to create customer for organisation #{$remote['organisation_id']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Cannabrands business (seller)
|
||||||
|
$seller = Business::where('slug', 'cannabrands')->first();
|
||||||
|
if (! $seller) {
|
||||||
|
throw new \Exception('Cannabrands business not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first user for this business to assign as order creator
|
||||||
|
$user = $customer->users()->first();
|
||||||
|
if (! $user) {
|
||||||
|
throw new \Exception("No user found for customer business #{$customer->id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get invoice lines
|
||||||
|
$linesResult = $this->mysqli->query("
|
||||||
|
SELECT il.*, p.brand_id
|
||||||
|
FROM invoice_lines il
|
||||||
|
INNER JOIN products p ON il.product_id = p.id
|
||||||
|
WHERE il.invoice_id = {$invoiceId}
|
||||||
|
AND il.deleted_at IS NULL
|
||||||
|
");
|
||||||
|
|
||||||
|
$invoiceLines = [];
|
||||||
|
while ($line = $linesResult->fetch_assoc()) {
|
||||||
|
$invoiceLines[] = $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create order
|
||||||
|
$order = new Order;
|
||||||
|
$order->id = $invoiceId;
|
||||||
|
$order->business_id = $customer->id; // Buyer business
|
||||||
|
$order->user_id = $user->id; // User who placed the order
|
||||||
|
$order->order_number = $remote['invoice_id'] ?? "ALOHA-{$invoiceId}";
|
||||||
|
$order->status = $this->mapStatus($remote['status']);
|
||||||
|
$order->subtotal = ($remote['subtotal'] ?? 0) / 100; // Convert cents to dollars
|
||||||
|
$order->tax = ($remote['tax'] ?? 0) / 100;
|
||||||
|
$order->total = ($remote['total'] ?? 0) / 100;
|
||||||
|
$order->notes = $this->sanitizeUtf8($remote['comments']);
|
||||||
|
$order->payment_terms = $this->sanitizeUtf8($remote['terms']);
|
||||||
|
$order->delivery_method = 'pickup'; // Default
|
||||||
|
$order->timestamps = false;
|
||||||
|
$order->created_at = $remote['created_at'];
|
||||||
|
$order->updated_at = $remote['updated_at'];
|
||||||
|
$order->save();
|
||||||
|
|
||||||
|
// Create order items
|
||||||
|
$itemCount = 0;
|
||||||
|
foreach ($invoiceLines as $line) {
|
||||||
|
// Find the product locally - map by remote product_id
|
||||||
|
// Note: The remote product_id may not match the local product_id
|
||||||
|
// We need to find the local product by SKU (code from remote)
|
||||||
|
$remoteProduct = $this->mysqli->query("SELECT code, name FROM products WHERE id = {$line['product_id']}")->fetch_assoc();
|
||||||
|
|
||||||
|
if (! $remoteProduct) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find local product by SKU and ensure it's Aloha brand
|
||||||
|
$localBrand = Brand::where('name', 'Aloha TymeMachine')->first();
|
||||||
|
if (! $localBrand) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$product = Product::where('sku', $remoteProduct['code'])
|
||||||
|
->where('brand_id', $localBrand->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $product) {
|
||||||
|
continue; // Skip products not imported
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate line_total (amount + tax)
|
||||||
|
$amount = (($line['amount'] ?? 0) / 100);
|
||||||
|
$tax = (($line['tax_amount'] ?? 0) / 100);
|
||||||
|
$lineTotal = $amount + $tax;
|
||||||
|
|
||||||
|
$orderItem = new OrderItem;
|
||||||
|
$orderItem->order_id = $order->id;
|
||||||
|
$orderItem->product_id = $product->id; // Use local product ID
|
||||||
|
$orderItem->quantity = (int) ($line['quantity'] ?? 1); // Cast to integer
|
||||||
|
$orderItem->unit_price = $line['price'] ?? 0;
|
||||||
|
$orderItem->line_total = $lineTotal;
|
||||||
|
|
||||||
|
// Product snapshot fields
|
||||||
|
$orderItem->product_name = $product->name;
|
||||||
|
$orderItem->product_sku = $product->sku;
|
||||||
|
$orderItem->brand_name = $product->brand->name ?? 'Aloha TymeMachine';
|
||||||
|
|
||||||
|
$orderItem->timestamps = false;
|
||||||
|
$orderItem->created_at = $line['created_at'];
|
||||||
|
$orderItem->updated_at = $line['updated_at'];
|
||||||
|
$orderItem->save();
|
||||||
|
|
||||||
|
$itemCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->stats['total_items'] += $itemCount;
|
||||||
|
|
||||||
|
return 'imported';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findOrCreateCustomer(int $organisationId, bool $dryRun): ?Business
|
||||||
|
{
|
||||||
|
// Check cache first
|
||||||
|
if (isset($this->customerCache[$organisationId])) {
|
||||||
|
return $this->customerCache[$organisationId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already imported
|
||||||
|
$mapping = DB::table('remote_customer_mappings')
|
||||||
|
->where('remote_organisation_id', $organisationId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($mapping) {
|
||||||
|
$business = Business::find($mapping->business_id);
|
||||||
|
if ($business) {
|
||||||
|
// Ensure business has at least one user
|
||||||
|
if ($business->users()->count() == 0) {
|
||||||
|
$this->createUserForBusiness($business);
|
||||||
|
}
|
||||||
|
$this->customerCache[$organisationId] = $business;
|
||||||
|
|
||||||
|
return $business;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from remote
|
||||||
|
$result = $this->mysqli->query("SELECT * FROM companies WHERE id = {$organisationId}");
|
||||||
|
$remote = $result->fetch_assoc();
|
||||||
|
|
||||||
|
if (! $remote) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
return new Business(['name' => $remote['name']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if business already exists by slug
|
||||||
|
$slug = Str::slug($remote['name']);
|
||||||
|
$business = Business::where('slug', $slug)->first();
|
||||||
|
|
||||||
|
if ($business) {
|
||||||
|
// Business already exists, create mapping and return it
|
||||||
|
// Ensure it has a user
|
||||||
|
if ($business->users()->count() == 0) {
|
||||||
|
$this->createUserForBusiness($business);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mapping if it doesn't exist
|
||||||
|
$existingMapping = DB::table('remote_customer_mappings')
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->where('remote_organisation_id', $organisationId)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $existingMapping) {
|
||||||
|
DB::table('remote_customer_mappings')->insert([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'remote_organisation_id' => $organisationId,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->customerCache[$organisationId] = $business;
|
||||||
|
|
||||||
|
return $business;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new business
|
||||||
|
$business = new Business;
|
||||||
|
$business->name = $this->sanitizeUtf8($remote['name']);
|
||||||
|
$business->slug = Str::slug($remote['name']);
|
||||||
|
$business->type = 'buyer';
|
||||||
|
$business->status = 'approved';
|
||||||
|
$business->is_active = true;
|
||||||
|
$business->onboarding_completed = true;
|
||||||
|
$business->tax_rate = 0;
|
||||||
|
$business->tax_exempt = false;
|
||||||
|
$business->has_analytics = false;
|
||||||
|
$business->has_marketing = false;
|
||||||
|
$business->has_manufacturing = false;
|
||||||
|
$business->has_processing = false;
|
||||||
|
|
||||||
|
// Map address if available
|
||||||
|
if (! empty($remote['address'])) {
|
||||||
|
$business->physical_address = $this->sanitizeUtf8($remote['address']);
|
||||||
|
}
|
||||||
|
if (! empty($remote['city'])) {
|
||||||
|
$business->physical_city = $this->sanitizeUtf8($remote['city']);
|
||||||
|
}
|
||||||
|
if (! empty($remote['state'])) {
|
||||||
|
$business->physical_state = $this->sanitizeUtf8($remote['state']);
|
||||||
|
}
|
||||||
|
if (! empty($remote['zipcode'])) {
|
||||||
|
$business->physical_zipcode = $this->sanitizeUtf8($remote['zipcode']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$business->save();
|
||||||
|
|
||||||
|
// Create a default user for this business
|
||||||
|
$this->createUserForBusiness($business);
|
||||||
|
|
||||||
|
// Create mapping
|
||||||
|
DB::table('remote_customer_mappings')->insert([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'remote_organisation_id' => $organisationId,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->stats['customers_created']++;
|
||||||
|
$this->customerCache[$organisationId] = $business;
|
||||||
|
|
||||||
|
return $business;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapStatus(?string $remoteStatus): string
|
||||||
|
{
|
||||||
|
// Map remote invoice status to local order status
|
||||||
|
// Valid local statuses: new, buyer_modified, seller_modified, accepted, in_progress,
|
||||||
|
// ready_for_invoice, awaiting_invoice_approval, ready_for_manifest, ready_for_delivery,
|
||||||
|
// delivered, cancelled, rejected
|
||||||
|
$statusMap = [
|
||||||
|
'draft' => 'new', // Order just created
|
||||||
|
'sent' => 'accepted', // Order sent to customer, accepted
|
||||||
|
'paid' => 'delivered', // Payment received, order completed
|
||||||
|
'partial' => 'in_progress', // Partially paid/fulfilled
|
||||||
|
'overdue' => 'accepted', // Still active but overdue
|
||||||
|
];
|
||||||
|
|
||||||
|
return $statusMap[$remoteStatus] ?? 'new';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a default user for a business
|
||||||
|
*/
|
||||||
|
private function createUserForBusiness(Business $business): User
|
||||||
|
{
|
||||||
|
$user = new User;
|
||||||
|
$user->first_name = 'System';
|
||||||
|
$user->last_name = 'User';
|
||||||
|
$user->email = 'system+'.$business->slug.'@imported.local';
|
||||||
|
$user->password = Hash::make(Str::random(32)); // Random password
|
||||||
|
$user->user_type = 'buyer';
|
||||||
|
$user->email_verified_at = now();
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
// Attach user to business
|
||||||
|
$user->businesses()->attach($business->id);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize text from MySQL (Windows-1252 encoding) to proper UTF-8
|
||||||
|
*/
|
||||||
|
private function sanitizeUtf8(?string $text): ?string
|
||||||
|
{
|
||||||
|
if (! $text) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, try to detect the encoding
|
||||||
|
$encoding = mb_detect_encoding($text, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true);
|
||||||
|
|
||||||
|
// If already UTF-8 and valid, return as-is
|
||||||
|
if ($encoding === 'UTF-8' && mb_check_encoding($text, 'UTF-8')) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to convert from Windows-1252 to UTF-8
|
||||||
|
$converted = @iconv('Windows-1252', 'UTF-8//TRANSLIT//IGNORE', $text);
|
||||||
|
|
||||||
|
// If iconv fails, fall back to mb_convert_encoding
|
||||||
|
if ($converted === false) {
|
||||||
|
$converted = mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final cleanup: remove any remaining invalid UTF-8 sequences
|
||||||
|
$converted = mb_convert_encoding($converted, 'UTF-8', 'UTF-8');
|
||||||
|
|
||||||
|
return $converted;
|
||||||
|
}
|
||||||
|
}
|
||||||
289
app/Console/Commands/ImportBrandFromMySQL.php
Normal file
289
app/Console/Commands/ImportBrandFromMySQL.php
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Brand;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Intervention\Image\Drivers\Gd\Driver;
|
||||||
|
use Intervention\Image\ImageManager;
|
||||||
|
|
||||||
|
class ImportBrandFromMySQL extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'brand:import-from-mysql {remoteName? : Remote brand name} {localName? : Local brand name (if different)}';
|
||||||
|
|
||||||
|
protected $description = 'Import brand data and images from remote MySQL database';
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$remoteBrandName = $this->argument('remoteName') ?? 'Canna';
|
||||||
|
$localBrandName = $this->argument('localName') ?? $remoteBrandName;
|
||||||
|
|
||||||
|
$this->info('Connecting to remote MySQL database...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Connect to remote MySQL with latin1 charset (Windows-1252)
|
||||||
|
$pdo = new \PDO(
|
||||||
|
'mysql:host=sql1.creationshop.net;dbname=hub_cannabrands;charset=latin1',
|
||||||
|
'claude',
|
||||||
|
'claude'
|
||||||
|
);
|
||||||
|
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
$this->info('Connected successfully!');
|
||||||
|
|
||||||
|
// Fetch brand data from MySQL
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT brand_id, name, tagline, short_desc, `desc`, url,
|
||||||
|
image, banner, address, unit_number, city, state, zip, phone,
|
||||||
|
public, fb, insta, twitter, youtube
|
||||||
|
FROM brands
|
||||||
|
WHERE name = :name
|
||||||
|
');
|
||||||
|
$stmt->execute(['name' => $remoteBrandName]);
|
||||||
|
$remoteBrand = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (! $remoteBrand) {
|
||||||
|
$this->error("Brand '{$remoteBrandName}' not found in remote database");
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Found remote brand: {$remoteBrand['name']}");
|
||||||
|
|
||||||
|
// Find local brand by name
|
||||||
|
$localBrand = Brand::where('name', $localBrandName)->first();
|
||||||
|
|
||||||
|
if (! $localBrand) {
|
||||||
|
$this->error("Brand '{$localBrandName}' not found in local database");
|
||||||
|
$this->info('Available brands: '.Brand::pluck('name')->implode(', '));
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Found local brand: {$localBrand->name} (ID: {$localBrand->id})");
|
||||||
|
|
||||||
|
// Create brands directory if it doesn't exist
|
||||||
|
if (! Storage::disk('public')->exists('brands')) {
|
||||||
|
Storage::disk('public')->makeDirectory('brands');
|
||||||
|
$this->info('Created brands directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Intervention Image
|
||||||
|
$manager = new ImageManager(new Driver);
|
||||||
|
|
||||||
|
// Process logo image with thumbnails (save as PNG for transparency support)
|
||||||
|
if ($remoteBrand['image']) {
|
||||||
|
$logoPath = "brands/{$localBrand->slug}-logo.png";
|
||||||
|
|
||||||
|
// Read and process the original image
|
||||||
|
$originalImage = $manager->read($remoteBrand['image']);
|
||||||
|
|
||||||
|
// Try to remove white background by making white pixels transparent
|
||||||
|
// Sample corners to detect if background is white
|
||||||
|
$width = $originalImage->width();
|
||||||
|
$height = $originalImage->height();
|
||||||
|
|
||||||
|
// Use GD to manipulate pixels
|
||||||
|
$gdImage = imagecreatefromstring($remoteBrand['image']);
|
||||||
|
if ($gdImage !== false) {
|
||||||
|
// Enable alpha blending
|
||||||
|
imagealphablending($gdImage, false);
|
||||||
|
imagesavealpha($gdImage, true);
|
||||||
|
|
||||||
|
// Make white and near-white pixels transparent
|
||||||
|
for ($x = 0; $x < imagesx($gdImage); $x++) {
|
||||||
|
for ($y = 0; $y < imagesy($gdImage); $y++) {
|
||||||
|
$rgb = imagecolorat($gdImage, $x, $y);
|
||||||
|
$colors = imagecolorsforindex($gdImage, $rgb);
|
||||||
|
|
||||||
|
// If pixel is white or very close to white (RGB > 245)
|
||||||
|
if ($colors['red'] > 245 && $colors['green'] > 245 && $colors['blue'] > 245) {
|
||||||
|
$transparent = imagecolorallocatealpha($gdImage, 255, 255, 255, 127);
|
||||||
|
imagesetpixel($gdImage, $x, $y, $transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save as PNG
|
||||||
|
ob_start();
|
||||||
|
imagepng($gdImage);
|
||||||
|
$processedData = ob_get_clean();
|
||||||
|
imagedestroy($gdImage);
|
||||||
|
|
||||||
|
Storage::disk('public')->put($logoPath, $processedData);
|
||||||
|
$originalImage = $manager->read($processedData);
|
||||||
|
} else {
|
||||||
|
// Fallback: save original as PNG
|
||||||
|
Storage::disk('public')->put($logoPath, $originalImage->toPng());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate thumbnails optimized for retina displays (PNG for transparency)
|
||||||
|
// Thumbnail (160x160) for list views (2x retina at 80px)
|
||||||
|
$thumbRetina = clone $originalImage;
|
||||||
|
$thumbRetina->scale(width: 160);
|
||||||
|
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-thumb.png", $thumbRetina->toPng());
|
||||||
|
|
||||||
|
// Medium (600x600) for product cards (2x retina at 300px)
|
||||||
|
$mediumRetina = clone $originalImage;
|
||||||
|
$mediumRetina->scale(width: 600);
|
||||||
|
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-medium.png", $mediumRetina->toPng());
|
||||||
|
|
||||||
|
// Large (1600x1600) for detail views
|
||||||
|
$largeRetina = clone $originalImage;
|
||||||
|
$largeRetina->scale(width: 1600);
|
||||||
|
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-large.png", $largeRetina->toPng());
|
||||||
|
|
||||||
|
$localBrand->logo_path = $logoPath;
|
||||||
|
$this->info("✓ Saved logo + thumbnails: {$logoPath} (".strlen($remoteBrand['image']).' bytes)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process banner image with thumbnails
|
||||||
|
if ($remoteBrand['banner']) {
|
||||||
|
$bannerPath = "brands/{$localBrand->slug}-banner.jpg";
|
||||||
|
|
||||||
|
// Save original
|
||||||
|
Storage::disk('public')->put($bannerPath, $remoteBrand['banner']);
|
||||||
|
|
||||||
|
// Generate banner thumbnails if banner is large enough
|
||||||
|
if (strlen($remoteBrand['banner']) > 1000) {
|
||||||
|
$image = $manager->read($remoteBrand['banner']);
|
||||||
|
|
||||||
|
// Medium banner (1344px wide) for retina displays at 672px
|
||||||
|
$mediumBanner = clone $image;
|
||||||
|
$mediumBanner->scale(width: 1344);
|
||||||
|
Storage::disk('public')->put("brands/{$localBrand->slug}-banner-medium.jpg", $mediumBanner->toJpeg(quality: 92));
|
||||||
|
|
||||||
|
// Large banner (2560px wide) for full-width hero sections
|
||||||
|
$largeBanner = clone $image;
|
||||||
|
$largeBanner->scale(width: 2560);
|
||||||
|
Storage::disk('public')->put("brands/{$localBrand->slug}-banner-large.jpg", $largeBanner->toJpeg(quality: 92));
|
||||||
|
}
|
||||||
|
|
||||||
|
$localBrand->banner_path = $bannerPath;
|
||||||
|
$this->info("✓ Saved banner + thumbnails: {$bannerPath} (".strlen($remoteBrand['banner']).' bytes)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to sanitize text (convert Windows-1252 to UTF-8)
|
||||||
|
$sanitize = function ($text) {
|
||||||
|
if (! $text) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, convert from Windows-1252/ISO-8859-1 to UTF-8
|
||||||
|
$text = mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
|
||||||
|
|
||||||
|
// Replace common Windows-1252 special characters with standard equivalents
|
||||||
|
$replacements = [
|
||||||
|
"\xE2\x80\x98" => "'", // Left single quote
|
||||||
|
"\xE2\x80\x99" => "'", // Right single quote (apostrophe)
|
||||||
|
"\xE2\x80\x9C" => '"', // Left double quote
|
||||||
|
"\xE2\x80\x9D" => '"', // Right double quote
|
||||||
|
"\xE2\x80\x93" => '-', // En dash
|
||||||
|
"\xE2\x80\x94" => '-', // Em dash
|
||||||
|
"\xE2\x80\x26" => '...', // Ellipsis
|
||||||
|
];
|
||||||
|
|
||||||
|
$text = str_replace(array_keys($replacements), array_values($replacements), $text);
|
||||||
|
|
||||||
|
return trim($text);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update other brand fields
|
||||||
|
$updates = [];
|
||||||
|
|
||||||
|
if ($remoteBrand['tagline']) {
|
||||||
|
$localBrand->tagline = $sanitize($remoteBrand['tagline']);
|
||||||
|
$updates[] = 'tagline';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteBrand['short_desc']) {
|
||||||
|
$localBrand->description = $sanitize($remoteBrand['short_desc']);
|
||||||
|
$updates[] = 'description';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteBrand['desc']) {
|
||||||
|
$localBrand->long_description = $sanitize($remoteBrand['desc']);
|
||||||
|
$updates[] = 'long_description';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteBrand['url']) {
|
||||||
|
$localBrand->website_url = $remoteBrand['url'];
|
||||||
|
$updates[] = 'website_url';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address fields
|
||||||
|
if ($remoteBrand['address']) {
|
||||||
|
$localBrand->address = $remoteBrand['address'];
|
||||||
|
$updates[] = 'address';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteBrand['unit_number']) {
|
||||||
|
$localBrand->unit_number = $remoteBrand['unit_number'];
|
||||||
|
$updates[] = 'unit_number';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteBrand['city']) {
|
||||||
|
$localBrand->city = $remoteBrand['city'];
|
||||||
|
$updates[] = 'city';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteBrand['state']) {
|
||||||
|
$localBrand->state = $remoteBrand['state'];
|
||||||
|
$updates[] = 'state';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteBrand['zip']) {
|
||||||
|
$localBrand->zip_code = $remoteBrand['zip'];
|
||||||
|
$updates[] = 'zip_code';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteBrand['phone']) {
|
||||||
|
$localBrand->phone = $remoteBrand['phone'];
|
||||||
|
$updates[] = 'phone';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Social media
|
||||||
|
if ($remoteBrand['fb']) {
|
||||||
|
$localBrand->facebook_url = 'https://facebook.com/'.$remoteBrand['fb'];
|
||||||
|
$updates[] = 'facebook_url';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteBrand['insta']) {
|
||||||
|
$localBrand->instagram_handle = $remoteBrand['insta'];
|
||||||
|
$updates[] = 'instagram_handle';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteBrand['twitter']) {
|
||||||
|
$localBrand->twitter_handle = $remoteBrand['twitter'];
|
||||||
|
$updates[] = 'twitter_handle';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteBrand['youtube']) {
|
||||||
|
$localBrand->youtube_url = $remoteBrand['youtube'];
|
||||||
|
$updates[] = 'youtube_url';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility
|
||||||
|
$localBrand->is_public = (bool) $remoteBrand['public'];
|
||||||
|
$updates[] = 'is_public';
|
||||||
|
|
||||||
|
// Save the brand
|
||||||
|
$localBrand->save();
|
||||||
|
|
||||||
|
$this->info("\n✓ Successfully imported brand data!");
|
||||||
|
$this->info('Updated fields: '.implode(', ', $updates));
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('View the brand at:');
|
||||||
|
$this->line("http://localhost/s/cannabrands/brands/{$localBrand->hashid}/edit");
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error('Error: '.$e->getMessage());
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
144
app/Console/Commands/ImportProductsFromRemote.php
Normal file
144
app/Console/Commands/ImportProductsFromRemote.php
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ImportProductsFromRemote extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'import:products-from-remote {--business=cannabrands}';
|
||||||
|
|
||||||
|
protected $description = 'Import products and SKUs from remote MySQL database';
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
// Configure remote MySQL connection
|
||||||
|
config(['database.connections.remote_mysql' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'host' => 'sql1.creationshop.net',
|
||||||
|
'port' => '3306',
|
||||||
|
'database' => 'hub_cannabrands',
|
||||||
|
'username' => 'claude',
|
||||||
|
'password' => 'claude',
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
'collation' => 'utf8mb4_unicode_ci',
|
||||||
|
'prefix' => '',
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
]]);
|
||||||
|
|
||||||
|
$this->info('🔗 Connected to remote MySQL database');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Get or create the local business
|
||||||
|
$businessSlug = $this->option('business');
|
||||||
|
$localBusiness = Business::where('slug', $businessSlug)->first();
|
||||||
|
|
||||||
|
if (! $localBusiness) {
|
||||||
|
$this->error("Business with slug '{$businessSlug}' not found in local database.");
|
||||||
|
$this->info('Available businesses:');
|
||||||
|
Business::all()->each(fn ($b) => $this->line(" - {$b->slug}"));
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("📦 Importing products for: {$localBusiness->name}");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Get all brands from remote database
|
||||||
|
$remoteBrands = DB::connection('remote_mysql')
|
||||||
|
->table('brands')
|
||||||
|
->whereNotNull('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->info("Found {$remoteBrands->count()} brands in remote database");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$brandMap = [];
|
||||||
|
$importedBrands = 0;
|
||||||
|
$importedProducts = 0;
|
||||||
|
|
||||||
|
foreach ($remoteBrands as $remoteBrand) {
|
||||||
|
// Create or update brand in local database
|
||||||
|
$localBrand = Brand::updateOrCreate(
|
||||||
|
[
|
||||||
|
'business_id' => $localBusiness->id,
|
||||||
|
'name' => $remoteBrand->name,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => Str::slug($remoteBrand->name),
|
||||||
|
'tagline' => $remoteBrand->tagline,
|
||||||
|
'description' => $remoteBrand->desc ?? $remoteBrand->short_desc,
|
||||||
|
'website_url' => $remoteBrand->url ? 'https://'.ltrim($remoteBrand->url, 'https://') : null,
|
||||||
|
'is_public' => (bool) $remoteBrand->public,
|
||||||
|
'is_active' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$brandMap[$remoteBrand->brand_id] = $localBrand->id;
|
||||||
|
$importedBrands++;
|
||||||
|
|
||||||
|
$this->line(" ✓ Brand: {$localBrand->name}");
|
||||||
|
|
||||||
|
// Get products for this brand
|
||||||
|
$remoteProducts = DB::connection('remote_mysql')
|
||||||
|
->table('products')
|
||||||
|
->where('brand_id', $remoteBrand->brand_id)
|
||||||
|
->where('active', 1)
|
||||||
|
->whereNotNull('code')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($remoteProducts as $remoteProduct) {
|
||||||
|
try {
|
||||||
|
// Create or update product (skip strain_id foreign key for now)
|
||||||
|
Product::updateOrCreate(
|
||||||
|
[
|
||||||
|
'brand_id' => $localBrand->id,
|
||||||
|
'sku' => $remoteProduct->code,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => $remoteProduct->name,
|
||||||
|
'description' => $remoteProduct->description,
|
||||||
|
'price' => $remoteProduct->wholesale_price ?? 0,
|
||||||
|
'cost' => $remoteProduct->cost ?? 0,
|
||||||
|
'is_active' => (bool) $remoteProduct->active,
|
||||||
|
'unit_id' => null, // Units will need to be mapped separately
|
||||||
|
'strain_id' => null, // Strains will need to be imported separately
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$importedProducts++;
|
||||||
|
} catch (\Illuminate\Database\UniqueConstraintViolationException $e) {
|
||||||
|
// Skip products with slug conflicts (already exist for different brand)
|
||||||
|
$this->warn(" ⚠ Skipped '{$remoteProduct->name}' (slug conflict)");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteProducts->count() > 0) {
|
||||||
|
$this->line(" → Imported {$remoteProducts->count()} products");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('✅ Import Complete!');
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Count'],
|
||||||
|
[
|
||||||
|
['Brands Imported', $importedBrands],
|
||||||
|
['Products Imported', $importedProducts],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('📊 You can now view real SKU data in the brand stats dashboard!');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
474
app/Console/Commands/ImportThunderBudBulk.php
Normal file
474
app/Console/Commands/ImportThunderBudBulk.php
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\ProductImage;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ImportThunderBudBulk extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'import:thunderbud-bulk {--dry-run : Show what would be imported without actually importing} {--force : Overwrite existing products} {--skip-existing : Skip products that already exist} {--limit= : Limit number of products to import}';
|
||||||
|
|
||||||
|
protected $description = 'Import all Thunder Bud products from remote MySQL database';
|
||||||
|
|
||||||
|
private $mysqli;
|
||||||
|
|
||||||
|
private $stats = [
|
||||||
|
'total' => 0,
|
||||||
|
'imported' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
private $productLineCache = [];
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$force = $this->option('force');
|
||||||
|
$skipExisting = $this->option('skip-existing');
|
||||||
|
$limit = $this->option('limit');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('🔍 DRY RUN MODE - No data will be imported');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('🚀 Starting Thunder Bud Bulk Product Import');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Connect to remote MySQL
|
||||||
|
$this->info('📡 Connecting to remote MySQL database...');
|
||||||
|
$this->mysqli = new \mysqli('sql1.creationshop.net', 'claude', 'claude', 'hub_cannabrands');
|
||||||
|
|
||||||
|
if ($this->mysqli->connect_error) {
|
||||||
|
$this->error('Failed to connect: '.$this->mysqli->connect_error);
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$this->info('✓ Connected to remote MySQL');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Get all Thunder Bud products
|
||||||
|
$this->info('📦 Fetching Thunder Bud products (brand_id = 6)...');
|
||||||
|
// Order by parent_product_id so parent products (NULL) are imported first
|
||||||
|
$query = 'SELECT id FROM products WHERE brand_id = 6 ORDER BY parent_product_id IS NULL DESC, parent_product_id, id';
|
||||||
|
if ($limit) {
|
||||||
|
$query .= ' LIMIT '.(int) $limit;
|
||||||
|
}
|
||||||
|
$result = $this->mysqli->query($query);
|
||||||
|
|
||||||
|
$productIds = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$productIds[] = $row['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->stats['total'] = count($productIds);
|
||||||
|
$this->info("Found {$this->stats['total']} products to import");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if (! $dryRun && ! $force && ! $skipExisting) {
|
||||||
|
if (! $this->confirm('This will import all products. Continue?', true)) {
|
||||||
|
$this->warn('Import cancelled');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import each product
|
||||||
|
$progressBar = $this->output->createProgressBar($this->stats['total']);
|
||||||
|
$progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
|
||||||
|
$progressBar->setMessage('Starting...');
|
||||||
|
|
||||||
|
foreach ($productIds as $productId) {
|
||||||
|
$progressBar->setMessage("Product #{$productId}");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->importProduct($productId, $dryRun, $force, $skipExisting);
|
||||||
|
|
||||||
|
if ($result === 'imported') {
|
||||||
|
$this->stats['imported']++;
|
||||||
|
} elseif ($result === 'skipped') {
|
||||||
|
$this->stats['skipped']++;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->stats['failed']++;
|
||||||
|
$progressBar->clear();
|
||||||
|
$this->error("Failed to import product #{$productId}: {$e->getMessage()}");
|
||||||
|
$progressBar->display();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
// Show summary
|
||||||
|
$this->info('📊 Import Summary:');
|
||||||
|
$this->table(
|
||||||
|
['Status', 'Count'],
|
||||||
|
[
|
||||||
|
['Total Products', $this->stats['total']],
|
||||||
|
['✓ Imported', $this->stats['imported']],
|
||||||
|
['⊘ Skipped', $this->stats['skipped']],
|
||||||
|
['✗ Failed', $this->stats['failed']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->mysqli->close();
|
||||||
|
|
||||||
|
return $this->stats['failed'] > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function importProduct(int $productId, bool $dryRun, bool $force, bool $skipExisting): string
|
||||||
|
{
|
||||||
|
// Fetch product from remote
|
||||||
|
$result = $this->mysqli->query("SELECT * FROM products WHERE id = {$productId}");
|
||||||
|
$remote = $result->fetch_assoc();
|
||||||
|
|
||||||
|
if (! $remote) {
|
||||||
|
throw new \Exception("Product #{$productId} not found in remote database");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this product is a variety (has a parent)
|
||||||
|
$isVariety = ! empty($remote['parent_product_id']);
|
||||||
|
$parentProductId = $remote['parent_product_id'];
|
||||||
|
|
||||||
|
if ($isVariety) {
|
||||||
|
// Check if parent product exists locally
|
||||||
|
$parentProduct = Product::find($parentProductId);
|
||||||
|
if (! $parentProduct) {
|
||||||
|
// Parent not imported yet - skip this variety for now
|
||||||
|
// It will be imported in a second pass or when parent is imported
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
if (Product::where('id', $productId)->exists()) {
|
||||||
|
if ($skipExisting) {
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $force && ! $dryRun) {
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun && $force) {
|
||||||
|
// Store existing hashid to preserve it
|
||||||
|
$existingHashid = Product::where('id', $productId)->value('hashid');
|
||||||
|
|
||||||
|
// Force delete product and related records (hard delete)
|
||||||
|
DB::table('product_images')->where('product_id', $productId)->delete();
|
||||||
|
Product::where('id', $productId)->forceDelete();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$existingHashid = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
return 'imported';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get category mapping
|
||||||
|
$categoryMapping = $this->getCategoryMapping($remote['product_category_id']);
|
||||||
|
|
||||||
|
// Get descriptions from both tables with UTF-8 sanitization
|
||||||
|
$description = $this->sanitizeUtf8(ltrim($remote['description'] ?? '', '? '));
|
||||||
|
|
||||||
|
// Parse out "Thunder Bud {Name}: {tagline}" format to extract just the tagline
|
||||||
|
// Example: "Thunder Bud Violet Meadows: Floral calm, sweet vibes" → "Floral calm, sweet vibes"
|
||||||
|
if ($description && preg_match('/^Thunder Bud .+?:\s*(.+)$/s', $description, $matches)) {
|
||||||
|
$description = trim($matches[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get long description from product_extras
|
||||||
|
$longDescription = null;
|
||||||
|
$extrasResult = $this->mysqli->query("SELECT long_description FROM product_extras WHERE product_id = {$productId}");
|
||||||
|
if ($extrasResult && $extra = $extrasResult->fetch_assoc()) {
|
||||||
|
$longDescription = $this->sanitizeUtf8($extra['long_description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unit from remote units table
|
||||||
|
$remoteUnit = null;
|
||||||
|
if ($remote['unit']) {
|
||||||
|
$unitResult = $this->mysqli->query("SELECT unit FROM units WHERE id = {$remote['unit']}");
|
||||||
|
if ($unitResult && $unitRow = $unitResult->fetch_assoc()) {
|
||||||
|
$remoteUnit = $unitRow['unit'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map remote unit abbreviation to local
|
||||||
|
$unitAbbr = null;
|
||||||
|
if ($remoteUnit) {
|
||||||
|
$remoteToLocalUnit = [
|
||||||
|
'GM' => 'g',
|
||||||
|
'EA' => 'ea',
|
||||||
|
'OZ' => 'oz',
|
||||||
|
'LB' => 'lb',
|
||||||
|
];
|
||||||
|
$unitAbbr = $remoteToLocalUnit[$remoteUnit] ?? strtolower($remoteUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and save image BLOB
|
||||||
|
$imagePath = null;
|
||||||
|
if ($remote['product_image']) {
|
||||||
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$mimeType = $finfo->buffer($remote['product_image']);
|
||||||
|
$extension = match ($mimeType) {
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/gif' => 'gif',
|
||||||
|
default => 'jpg'
|
||||||
|
};
|
||||||
|
|
||||||
|
$slug = Str::slug($remote['name']);
|
||||||
|
$imagePath = "businesses/cannabrands/products/{$productId}/{$slug}.{$extension}";
|
||||||
|
Storage::put($imagePath, $remote['product_image']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get brand
|
||||||
|
$brand = Brand::find(6); // Thunder Bud
|
||||||
|
|
||||||
|
// Map type to unit if not set
|
||||||
|
if (! $unitAbbr) {
|
||||||
|
$unitMapping = [
|
||||||
|
'pre_roll' => 'ea',
|
||||||
|
'flower' => 'g',
|
||||||
|
'concentrate' => 'g',
|
||||||
|
];
|
||||||
|
$type = $categoryMapping['type'];
|
||||||
|
$unitAbbr = $unitMapping[$type] ?? 'ea';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create product line from child category name
|
||||||
|
$productLineName = $categoryMapping['child_category_name'];
|
||||||
|
$productLine = $this->findOrCreateProductLine($brand->business_id, $productLineName);
|
||||||
|
|
||||||
|
// Find unit
|
||||||
|
$unit = DB::table('units')->where('abbreviation', $unitAbbr)->first();
|
||||||
|
|
||||||
|
// Check for varieties
|
||||||
|
$varietiesResult = $this->mysqli->query("SELECT COUNT(*) as count FROM products WHERE parent_product_id = {$productId} AND deleted_at IS NULL");
|
||||||
|
$varietiesCount = $varietiesResult->fetch_assoc()['count'];
|
||||||
|
$hasVarieties = $varietiesCount > 0;
|
||||||
|
|
||||||
|
// Create product
|
||||||
|
$product = new Product;
|
||||||
|
$product->id = $productId;
|
||||||
|
$product->brand_id = 6; // Thunder Bud local brand
|
||||||
|
$product->name = $this->sanitizeUtf8($remote['name']);
|
||||||
|
|
||||||
|
// Handle slug - varieties need unique slugs
|
||||||
|
$baseSlug = Str::slug($remote['name']);
|
||||||
|
if ($isVariety) {
|
||||||
|
// Append product ID to make variety slug unique
|
||||||
|
$product->slug = $baseSlug.'-'.$productId;
|
||||||
|
} else {
|
||||||
|
$product->slug = $baseSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SKU - varieties need unique SKUs
|
||||||
|
$baseSku = $this->sanitizeUtf8($remote['code']) ?? 'TB-'.Str::upper(Str::random(6));
|
||||||
|
if ($isVariety) {
|
||||||
|
// Append product ID to make variety SKU unique
|
||||||
|
$product->sku = $baseSku.'-'.$productId;
|
||||||
|
$product->parent_product_id = $parentProductId;
|
||||||
|
} else {
|
||||||
|
$product->sku = $baseSku;
|
||||||
|
}
|
||||||
|
|
||||||
|
$product->description = $description;
|
||||||
|
$product->long_description = $longDescription;
|
||||||
|
$product->type = $categoryMapping['type'];
|
||||||
|
$product->subcategory = $categoryMapping['parent_category_name'];
|
||||||
|
$product->status = $remote['active'] ? 'available' : 'unavailable';
|
||||||
|
$product->is_active = (bool) $remote['active'];
|
||||||
|
$product->wholesale_price = $remote['wholesale_price'] ?? 0;
|
||||||
|
$product->image_path = $imagePath;
|
||||||
|
$product->product_link = $this->sanitizeUtf8($remote['product_link']);
|
||||||
|
$product->creatives = $this->sanitizeUtf8($remote['creatives']);
|
||||||
|
$product->brand_display_order = (int) $remote['brand_display_order'];
|
||||||
|
$product->product_line_id = $productLine->id ?? null;
|
||||||
|
$product->unit_id = $unit->id ?? null;
|
||||||
|
$product->has_varieties = $hasVarieties;
|
||||||
|
|
||||||
|
// Set defaults for required fields
|
||||||
|
$product->is_featured = false;
|
||||||
|
$product->is_assembly = false;
|
||||||
|
$product->is_raw_material = false;
|
||||||
|
$product->price_unit = 'unit';
|
||||||
|
$product->weight_unit = 'g';
|
||||||
|
$product->sort_order = 0;
|
||||||
|
$product->sell_multiples = false;
|
||||||
|
$product->fractional_quantities = false;
|
||||||
|
$product->allow_sample = false;
|
||||||
|
$product->is_fpr = false;
|
||||||
|
$product->is_sellable = true;
|
||||||
|
$product->is_case = false;
|
||||||
|
$product->cased_qty = 0;
|
||||||
|
$product->is_box = false;
|
||||||
|
$product->boxed_qty = 0;
|
||||||
|
$product->show_inventory_to_buyers = true;
|
||||||
|
$product->sync_bamboo = false;
|
||||||
|
|
||||||
|
$product->timestamps = false;
|
||||||
|
$product->created_at = $remote['created_at'];
|
||||||
|
$product->updated_at = $remote['updated_at'];
|
||||||
|
$product->save();
|
||||||
|
|
||||||
|
// Restore existing hashid to preserve URLs
|
||||||
|
if ($existingHashid) {
|
||||||
|
$product->hashid = $existingHashid;
|
||||||
|
$product->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update parent product if this is a variety
|
||||||
|
if ($isVariety && isset($parentProduct)) {
|
||||||
|
if (! $parentProduct->has_varieties) {
|
||||||
|
$parentProduct->has_varieties = true;
|
||||||
|
$parentProduct->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ProductImage record
|
||||||
|
if ($imagePath) {
|
||||||
|
$productImage = new ProductImage;
|
||||||
|
$productImage->product_id = $product->id;
|
||||||
|
$productImage->path = $imagePath;
|
||||||
|
$productImage->type = 'image';
|
||||||
|
$productImage->is_primary = true;
|
||||||
|
$productImage->sort_order = 0;
|
||||||
|
$productImage->order = 0;
|
||||||
|
$productImage->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'imported';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findOrCreateProductLine(int $businessId, string $name): ?\stdClass
|
||||||
|
{
|
||||||
|
// Check cache first
|
||||||
|
$cacheKey = "{$businessId}:{$name}";
|
||||||
|
if (isset($this->productLineCache[$cacheKey])) {
|
||||||
|
return $this->productLineCache[$cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create
|
||||||
|
$productLine = DB::table('product_lines')
|
||||||
|
->where('business_id', $businessId)
|
||||||
|
->where('name', $name)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $productLine) {
|
||||||
|
$productLineId = DB::table('product_lines')->insertGetId([
|
||||||
|
'business_id' => $businessId,
|
||||||
|
'name' => $name,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
$productLine = (object) ['id' => $productLineId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache it
|
||||||
|
$this->productLineCache[$cacheKey] = $productLine;
|
||||||
|
|
||||||
|
return $productLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCategoryMapping(?int $categoryId): array
|
||||||
|
{
|
||||||
|
if (! $categoryId) {
|
||||||
|
return [
|
||||||
|
'type' => 'pre_roll',
|
||||||
|
'category_name' => 'Unknown',
|
||||||
|
'parent_category_name' => 'Unknown',
|
||||||
|
'child_category_name' => 'Unknown',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch category
|
||||||
|
$result = $this->mysqli->query("SELECT * FROM product_categories WHERE id = {$categoryId}");
|
||||||
|
$category = $result->fetch_assoc();
|
||||||
|
|
||||||
|
if (! $category) {
|
||||||
|
return [
|
||||||
|
'type' => 'pre_roll',
|
||||||
|
'category_name' => 'Unknown',
|
||||||
|
'parent_category_name' => 'Unknown',
|
||||||
|
'child_category_name' => 'Unknown',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$childCategoryName = $category['name'];
|
||||||
|
$parentCategoryName = $category['name']; // Default to same if no parent
|
||||||
|
|
||||||
|
if ($category['parent_id']) {
|
||||||
|
$parentResult = $this->mysqli->query("SELECT name FROM product_categories WHERE id = {$category['parent_id']}");
|
||||||
|
$parent = $parentResult->fetch_assoc();
|
||||||
|
if ($parent) {
|
||||||
|
$parentCategoryName = $parent['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map parent category to type
|
||||||
|
$categoryToType = [
|
||||||
|
'Pre-Rolls' => 'pre_roll',
|
||||||
|
'Flower' => 'flower',
|
||||||
|
'Concentrates' => 'concentrate',
|
||||||
|
'Edibles' => 'edible',
|
||||||
|
];
|
||||||
|
|
||||||
|
$type = $categoryToType[$parentCategoryName] ?? 'pre_roll';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => $type,
|
||||||
|
'category_name' => $category['parent_id'] ? "{$parentCategoryName} / {$childCategoryName}" : $childCategoryName,
|
||||||
|
'parent_category_name' => $parentCategoryName,
|
||||||
|
'child_category_name' => $childCategoryName,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize text from MySQL (Windows-1252 encoding) to proper UTF-8
|
||||||
|
* Uses iconv for automatic conversion of all Windows-1252 characters
|
||||||
|
*/
|
||||||
|
private function sanitizeUtf8(?string $text): ?string
|
||||||
|
{
|
||||||
|
if (! $text) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, try to detect the encoding
|
||||||
|
$encoding = mb_detect_encoding($text, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true);
|
||||||
|
|
||||||
|
// If already UTF-8 and valid, just clean up corrupted emoji placeholders
|
||||||
|
if ($encoding === 'UTF-8' && mb_check_encoding($text, 'UTF-8')) {
|
||||||
|
return str_replace('??', '', $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to convert from Windows-1252 to UTF-8
|
||||||
|
// Use //TRANSLIT to transliterate unsupported characters
|
||||||
|
// Use //IGNORE to skip characters that can't be converted
|
||||||
|
$converted = @iconv('Windows-1252', 'UTF-8//TRANSLIT//IGNORE', $text);
|
||||||
|
|
||||||
|
// If iconv fails, fall back to mb_convert_encoding
|
||||||
|
if ($converted === false) {
|
||||||
|
$converted = mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final cleanup: remove any remaining invalid UTF-8 sequences
|
||||||
|
$converted = mb_convert_encoding($converted, 'UTF-8', 'UTF-8');
|
||||||
|
|
||||||
|
// Remove corrupted emoji placeholders (literal "??" characters from source data)
|
||||||
|
$converted = str_replace('??', '', $converted);
|
||||||
|
|
||||||
|
return $converted;
|
||||||
|
}
|
||||||
|
}
|
||||||
564
app/Console/Commands/ImportThunderBudProduct.php
Normal file
564
app/Console/Commands/ImportThunderBudProduct.php
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\OrderItem;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\ProductImage;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ImportThunderBudProduct extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'import:thunderbud-product {--dry-run : Show what would be imported without actually importing} {--regenerate-hashid : Generate new hashid instead of preserving existing one}';
|
||||||
|
|
||||||
|
protected $description = 'Import Thunder Bud Product #44 (Cap Junky) from remote MySQL with full sales history (Option B)';
|
||||||
|
|
||||||
|
private $mysqli;
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('🔍 DRY RUN MODE - No data will be imported');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('🚀 Starting Thunder Bud Product Import (Option B: Full Chain)');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Connect to remote MySQL
|
||||||
|
$this->info('📡 Connecting to remote MySQL database...');
|
||||||
|
$this->mysqli = new \mysqli('sql1.creationshop.net', 'claude', 'claude', 'hub_cannabrands');
|
||||||
|
|
||||||
|
if ($this->mysqli->connect_error) {
|
||||||
|
$this->error('Failed to connect: '.$this->mysqli->connect_error);
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$this->info('✓ Connected to remote MySQL');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Step 1: Import Product
|
||||||
|
$this->info('📦 Step 1: Importing Product #44 (Cap Junky)...');
|
||||||
|
$product = $this->importProduct($dryRun);
|
||||||
|
if (! $product) {
|
||||||
|
$this->error('Failed to import product');
|
||||||
|
$this->mysqli->close();
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Step 2: Import Customer
|
||||||
|
$this->info('👥 Step 2: Importing Customer #61 (Story)...');
|
||||||
|
$customer = $this->importCustomer($dryRun);
|
||||||
|
if (! $customer) {
|
||||||
|
$this->error('Failed to import customer');
|
||||||
|
$this->mysqli->close();
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Step 3: Import Order
|
||||||
|
$this->info('📋 Step 3: Importing Invoice #293 as Order...');
|
||||||
|
$order = $this->importOrder($customer, $product, $dryRun);
|
||||||
|
if (! $order) {
|
||||||
|
$this->error('Failed to import order');
|
||||||
|
$this->mysqli->close();
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->mysqli->close();
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
$this->info('✅ Import completed successfully!');
|
||||||
|
$this->newLine();
|
||||||
|
$this->table(
|
||||||
|
['Item', 'Status', 'Details'],
|
||||||
|
[
|
||||||
|
['Product', '✓', $product ? "ID: {$product->id} - {$product->name}" : 'N/A'],
|
||||||
|
['Image', '✓', $product && $product->image_path ? $product->image_path : 'N/A'],
|
||||||
|
['Customer', '✓', $customer ? "ID: {$customer->id} - {$customer->name}" : 'N/A'],
|
||||||
|
['Order', '✓', $order ? "ID: {$order->id} - {$order->order_number}" : 'N/A'],
|
||||||
|
['Order Items', '✓', $order ? $order->items()->count().' line items' : 'N/A'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('🔗 Verification URLs:');
|
||||||
|
$business = $product->brand->business;
|
||||||
|
$this->line('Product: '.route('seller.business.products.edit', [$business->slug, $product]));
|
||||||
|
$this->line('Order: '.route('seller.business.orders.show', [$business->slug, $order]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function importProduct($dryRun): ?Product
|
||||||
|
{
|
||||||
|
// Fetch product from remote
|
||||||
|
$result = $this->mysqli->query('SELECT * FROM products WHERE id = 44');
|
||||||
|
$remote = $result->fetch_assoc();
|
||||||
|
|
||||||
|
if (! $remote) {
|
||||||
|
$this->error('Product #44 not found in remote database');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get category mapping
|
||||||
|
$categoryMapping = $this->getCategoryMapping($remote['product_category_id']);
|
||||||
|
|
||||||
|
// Check for varieties (child products)
|
||||||
|
$varietiesResult = $this->mysqli->query('SELECT COUNT(*) as count FROM products WHERE parent_product_id = 44 AND deleted_at IS NULL');
|
||||||
|
$varietiesCount = $varietiesResult->fetch_assoc()['count'];
|
||||||
|
$hasVarieties = $varietiesCount > 0;
|
||||||
|
|
||||||
|
// Get descriptions from both tables
|
||||||
|
$description = ltrim($remote['description'], '? '); // Short description
|
||||||
|
|
||||||
|
// Get long description from product_extras
|
||||||
|
$longDescription = null;
|
||||||
|
$extrasResult = $this->mysqli->query('SELECT long_description FROM product_extras WHERE product_id = 44');
|
||||||
|
if ($extrasResult && $extra = $extrasResult->fetch_assoc()) {
|
||||||
|
$longDescription = $extra['long_description'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unit from remote units table
|
||||||
|
$remoteUnit = null;
|
||||||
|
if ($remote['unit']) {
|
||||||
|
$unitResult = $this->mysqli->query("SELECT unit FROM units WHERE id = {$remote['unit']}");
|
||||||
|
if ($unitResult && $unitRow = $unitResult->fetch_assoc()) {
|
||||||
|
$remoteUnit = $unitRow['unit'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map remote unit abbreviation to local
|
||||||
|
$unitAbbr = null;
|
||||||
|
if ($remoteUnit) {
|
||||||
|
// Map common remote abbreviations to local
|
||||||
|
$remoteToLocalUnit = [
|
||||||
|
'GM' => 'g',
|
||||||
|
'EA' => 'ea',
|
||||||
|
'OZ' => 'oz',
|
||||||
|
'LB' => 'lb',
|
||||||
|
];
|
||||||
|
$unitAbbr = $remoteToLocalUnit[$remoteUnit] ?? strtolower($remoteUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview the data
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('📦 Product Preview:');
|
||||||
|
$this->table(
|
||||||
|
['Field', 'Value'],
|
||||||
|
[
|
||||||
|
['ID', $remote['id']],
|
||||||
|
['Name', $remote['name']],
|
||||||
|
['SKU', $remote['code']],
|
||||||
|
['Remote Category', $categoryMapping['category_name']],
|
||||||
|
[' → Type (mapped)', $categoryMapping['type']],
|
||||||
|
[' → Subcategory', $categoryMapping['parent_category_name']],
|
||||||
|
[' → Product Line', $categoryMapping['child_category_name']],
|
||||||
|
['Remote Unit', $remoteUnit ?? 'NULL'],
|
||||||
|
[' → Unit (mapped)', $unitAbbr ?? 'NULL'],
|
||||||
|
['Description (short)', substr($description ?? '', 0, 60).'...'],
|
||||||
|
['Long Description', $longDescription ? substr($longDescription, 0, 60).'...' : 'NULL'],
|
||||||
|
['Price', '$'.$remote['wholesale_price']],
|
||||||
|
['Has Image', $remote['product_image'] ? 'Yes ('.strlen($remote['product_image']).' bytes)' : 'No'],
|
||||||
|
['Has Varieties', $hasVarieties ? "Yes ($varietiesCount)" : 'No'],
|
||||||
|
['Active', $remote['active'] ? 'Yes' : 'No'],
|
||||||
|
['Brand Display Order', $remote['brand_display_order'] ?? 'NULL'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
$existingHashid = null;
|
||||||
|
if (Product::where('id', 44)->exists()) {
|
||||||
|
if (! $this->confirm('Product #44 already exists locally. Delete and re-import?', false)) {
|
||||||
|
$this->warn('Skipping product import');
|
||||||
|
|
||||||
|
return Product::find(44);
|
||||||
|
}
|
||||||
|
if (! $dryRun) {
|
||||||
|
// Store existing hashid to preserve it
|
||||||
|
$existingHashid = Product::where('id', 44)->value('hashid');
|
||||||
|
// Delete product and related records
|
||||||
|
DB::table('product_images')->where('product_id', 44)->delete();
|
||||||
|
Product::where('id', 44)->delete();
|
||||||
|
$this->info('✓ Deleted existing product');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('[DRY RUN] Would import this product');
|
||||||
|
|
||||||
|
return new Product(['id' => 44, 'name' => $remote['name']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm import
|
||||||
|
if (! $this->confirm('Import this product?', true)) {
|
||||||
|
$this->warn('Import cancelled');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and save image BLOB
|
||||||
|
$imagePath = null;
|
||||||
|
if ($remote['product_image']) {
|
||||||
|
$this->line(' Extracting image BLOB ('.strlen($remote['product_image']).' bytes)...');
|
||||||
|
|
||||||
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$mimeType = $finfo->buffer($remote['product_image']);
|
||||||
|
$extension = match ($mimeType) {
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/gif' => 'gif',
|
||||||
|
default => 'jpg'
|
||||||
|
};
|
||||||
|
|
||||||
|
$imagePath = 'businesses/cannabrands/products/44/cap-junky.'.$extension;
|
||||||
|
Storage::put($imagePath, $remote['product_image']);
|
||||||
|
$this->info(" ✓ Saved image to: {$imagePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get brand to find business for product line lookup
|
||||||
|
$brand = Brand::find(6); // Thunder Bud
|
||||||
|
|
||||||
|
// Map type to unit
|
||||||
|
$unitMapping = [
|
||||||
|
'pre_roll' => 'ea',
|
||||||
|
'flower' => 'g',
|
||||||
|
'concentrate' => 'g',
|
||||||
|
];
|
||||||
|
|
||||||
|
$type = $categoryMapping['type'];
|
||||||
|
$unitAbbr = $unitMapping[$type] ?? 'ea';
|
||||||
|
|
||||||
|
// Find or create product line from child category name
|
||||||
|
// Child category (e.g., "Non-Infused") becomes the product line
|
||||||
|
$productLineName = $categoryMapping['child_category_name'];
|
||||||
|
$productLine = DB::table('product_lines')
|
||||||
|
->where('business_id', $brand->business_id)
|
||||||
|
->where('name', $productLineName)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $productLine) {
|
||||||
|
// Create new product line
|
||||||
|
$productLineId = DB::table('product_lines')->insertGetId([
|
||||||
|
'business_id' => $brand->business_id,
|
||||||
|
'name' => $productLineName,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
$this->info(" ✓ Created new product line: {$productLineName} (ID: {$productLineId})");
|
||||||
|
$productLine = (object) ['id' => $productLineId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find unit
|
||||||
|
$unit = DB::table('units')->where('abbreviation', $unitAbbr)->first();
|
||||||
|
|
||||||
|
// Create product
|
||||||
|
$product = new Product;
|
||||||
|
$product->id = 44; // Preserve remote ID
|
||||||
|
$product->brand_id = 6; // Thunder Bud local brand
|
||||||
|
$product->name = $remote['name'];
|
||||||
|
$product->slug = Str::slug($remote['name']);
|
||||||
|
$product->sku = $remote['code'] ?? 'TB-CJ-AZ1G';
|
||||||
|
$product->description = $description; // Short description
|
||||||
|
$product->long_description = $longDescription; // Long description from product_extras
|
||||||
|
$product->type = $type; // Mapped from category
|
||||||
|
$product->status = $remote['active'] ? 'available' : 'unavailable';
|
||||||
|
$product->is_active = (bool) $remote['active'];
|
||||||
|
$product->wholesale_price = $remote['wholesale_price'];
|
||||||
|
$product->image_path = $imagePath;
|
||||||
|
$product->product_link = $remote['product_link'];
|
||||||
|
$product->creatives = $remote['creatives'];
|
||||||
|
$product->brand_display_order = (int) $remote['brand_display_order'];
|
||||||
|
$product->product_line_id = $productLine->id ?? null;
|
||||||
|
$product->unit_id = $unit->id ?? null;
|
||||||
|
$product->subcategory = $categoryMapping['parent_category_name']; // e.g., "Pre-Rolls"
|
||||||
|
|
||||||
|
// Set defaults for required fields
|
||||||
|
$product->is_featured = false;
|
||||||
|
$product->is_assembly = false;
|
||||||
|
$product->is_raw_material = false;
|
||||||
|
$product->price_unit = 'unit';
|
||||||
|
$product->weight_unit = 'g';
|
||||||
|
$product->sort_order = 0;
|
||||||
|
$product->has_varieties = $hasVarieties; // From variety check
|
||||||
|
$product->sell_multiples = false;
|
||||||
|
$product->fractional_quantities = false;
|
||||||
|
$product->allow_sample = false;
|
||||||
|
$product->is_fpr = false;
|
||||||
|
$product->is_sellable = true;
|
||||||
|
$product->is_case = false;
|
||||||
|
$product->cased_qty = 0;
|
||||||
|
$product->is_box = false;
|
||||||
|
$product->boxed_qty = 0;
|
||||||
|
$product->show_inventory_to_buyers = true;
|
||||||
|
$product->sync_bamboo = false;
|
||||||
|
|
||||||
|
$product->timestamps = false;
|
||||||
|
$product->created_at = $remote['created_at'];
|
||||||
|
$product->updated_at = $remote['updated_at'];
|
||||||
|
$product->save();
|
||||||
|
|
||||||
|
// Restore existing hashid to preserve URLs (unless --regenerate-hashid flag is set)
|
||||||
|
if ($existingHashid && ! $this->option('regenerate-hashid')) {
|
||||||
|
$product->hashid = $existingHashid;
|
||||||
|
$product->save();
|
||||||
|
$this->info(" ✓ Preserved existing hashid: {$existingHashid}");
|
||||||
|
} elseif ($existingHashid && $this->option('regenerate-hashid')) {
|
||||||
|
$this->info(" ✓ Generated new hashid: {$product->hashid}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ProductImage record for the listing page
|
||||||
|
if ($imagePath) {
|
||||||
|
$productImage = new ProductImage;
|
||||||
|
$productImage->product_id = $product->id;
|
||||||
|
$productImage->path = $imagePath;
|
||||||
|
$productImage->type = 'image';
|
||||||
|
$productImage->is_primary = true;
|
||||||
|
$productImage->sort_order = 0;
|
||||||
|
$productImage->order = 0;
|
||||||
|
$productImage->save();
|
||||||
|
$this->info(' ✓ Created ProductImage record');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("✓ Created product: {$product->name} (ID: {$product->id})");
|
||||||
|
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function importCustomer($dryRun): ?Business
|
||||||
|
{
|
||||||
|
// Fetch company from remote
|
||||||
|
$result = $this->mysqli->query('SELECT * FROM companies WHERE id = 61');
|
||||||
|
$remote = $result->fetch_assoc();
|
||||||
|
|
||||||
|
if (! $remote) {
|
||||||
|
$this->error('Company #61 not found in remote database');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line("Found: {$remote['name']}");
|
||||||
|
|
||||||
|
// Check if mapping already exists
|
||||||
|
$mapping = DB::table('remote_customer_mappings')->where('remote_company_id', 61)->first();
|
||||||
|
if ($mapping) {
|
||||||
|
$existing = Business::find($mapping->business_id);
|
||||||
|
if ($existing) {
|
||||||
|
$this->warn(" Customer already imported as Business #{$existing->id}");
|
||||||
|
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->info('[DRY RUN] Would import customer: '.$remote['name']);
|
||||||
|
|
||||||
|
return new Business(['name' => $remote['name']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create business
|
||||||
|
$business = new Business;
|
||||||
|
$business->name = $remote['name'];
|
||||||
|
$business->slug = Str::slug($remote['name']);
|
||||||
|
$business->type = 'buyer';
|
||||||
|
$business->status = 'approved';
|
||||||
|
$business->is_active = true;
|
||||||
|
$business->onboarding_completed = true;
|
||||||
|
$business->tax_rate = 0;
|
||||||
|
$business->tax_exempt = false;
|
||||||
|
$business->has_analytics = false;
|
||||||
|
$business->has_marketing = false;
|
||||||
|
$business->has_manufacturing = false;
|
||||||
|
$business->has_processing = false;
|
||||||
|
|
||||||
|
// Map address fields if available
|
||||||
|
if (isset($remote['address'])) {
|
||||||
|
$business->physical_address = $remote['address'];
|
||||||
|
}
|
||||||
|
if (isset($remote['city'])) {
|
||||||
|
$business->physical_city = $remote['city'];
|
||||||
|
}
|
||||||
|
if (isset($remote['state'])) {
|
||||||
|
$business->physical_state = $remote['state'];
|
||||||
|
}
|
||||||
|
if (isset($remote['zipcode'])) {
|
||||||
|
$business->physical_zipcode = $remote['zipcode'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$business->save();
|
||||||
|
|
||||||
|
// Create remote customer mapping
|
||||||
|
DB::table('remote_customer_mappings')->insert([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'remote_company_id' => 61,
|
||||||
|
'remote_organisation_id' => 5, // From invoice data
|
||||||
|
'remote_person_id' => 13, // From invoice data
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->info("✓ Created business: {$business->name} (ID: {$business->id})");
|
||||||
|
$this->info(' ✓ Created remote_customer_mappings record');
|
||||||
|
|
||||||
|
return $business;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function importOrder($customer, $product, $dryRun): ?Order
|
||||||
|
{
|
||||||
|
// Fetch invoice from remote
|
||||||
|
$result = $this->mysqli->query('SELECT * FROM invoices WHERE id = 293');
|
||||||
|
$remote = $result->fetch_assoc();
|
||||||
|
|
||||||
|
if (! $remote) {
|
||||||
|
$this->error('Invoice #293 not found in remote database');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line("Found: Invoice #{$remote['invoice_id']} - Issue Date: {$remote['issue_date']}");
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
if (Order::where('id', 293)->exists()) {
|
||||||
|
if ($this->confirm('Order #293 already exists locally. Delete and re-import?', false)) {
|
||||||
|
if (! $dryRun) {
|
||||||
|
Order::where('id', 293)->delete();
|
||||||
|
$this->info('✓ Deleted existing order');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->warn('Skipping order import');
|
||||||
|
|
||||||
|
return Order::find(293);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->info('[DRY RUN] Would import invoice #'.$remote['invoice_id'].' as order');
|
||||||
|
|
||||||
|
return new Order(['id' => 293, 'order_number' => 'IMPORT-293']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create order
|
||||||
|
$order = new Order;
|
||||||
|
$order->id = 293; // Preserve remote ID
|
||||||
|
$order->order_number = 'IMPORT-'.$remote['invoice_id'];
|
||||||
|
$order->business_id = $customer->id;
|
||||||
|
$order->remote_organisation_id = $remote['organisation_id'];
|
||||||
|
$order->subtotal = $remote['subtotal'] / 100; // Convert cents to decimal
|
||||||
|
$order->tax = $remote['tax'] / 100;
|
||||||
|
$order->total = $remote['total'] / 100;
|
||||||
|
$order->status = 'invoiced'; // Imported from invoices table
|
||||||
|
$order->workorder_status = 0;
|
||||||
|
$order->created_by = 'seller'; // Orders were created by sellers (invoices)
|
||||||
|
$order->surcharge = 0.00;
|
||||||
|
|
||||||
|
$order->timestamps = false;
|
||||||
|
$order->created_at = $remote['issue_date'];
|
||||||
|
$order->updated_at = $remote['updated_at'];
|
||||||
|
$order->save();
|
||||||
|
|
||||||
|
$this->info("✓ Created order: {$order->order_number} (ID: {$order->id})");
|
||||||
|
|
||||||
|
// Import invoice lines
|
||||||
|
$linesResult = $this->mysqli->query('SELECT * FROM invoice_lines WHERE invoice_id = 293 AND product_id = 44');
|
||||||
|
$lineCount = 0;
|
||||||
|
while ($line = $linesResult->fetch_assoc()) {
|
||||||
|
$orderItem = new OrderItem;
|
||||||
|
$orderItem->order_id = $order->id;
|
||||||
|
$orderItem->product_id = $product->id;
|
||||||
|
$orderItem->quantity = (int) $line['quantity'];
|
||||||
|
$orderItem->unit_price = $line['price']; // Already in decimal format
|
||||||
|
$orderItem->line_total = $line['amount'] / 100; // Convert cents to decimal
|
||||||
|
|
||||||
|
// Denormalized product fields (required)
|
||||||
|
$orderItem->product_name = $product->name;
|
||||||
|
$orderItem->product_sku = $product->sku;
|
||||||
|
$orderItem->brand_name = $product->brand->name;
|
||||||
|
|
||||||
|
// Note: tax is stored at order level, not line level
|
||||||
|
|
||||||
|
$orderItem->timestamps = false;
|
||||||
|
$orderItem->created_at = $remote['created_at'];
|
||||||
|
$orderItem->updated_at = $remote['updated_at'];
|
||||||
|
$orderItem->save();
|
||||||
|
|
||||||
|
$lineCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(" ✓ Created {$lineCount} order line item(s)");
|
||||||
|
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCategoryMapping(?int $categoryId): array
|
||||||
|
{
|
||||||
|
if (! $categoryId) {
|
||||||
|
return [
|
||||||
|
'type' => 'flower', // default
|
||||||
|
'category_name' => 'Uncategorized',
|
||||||
|
'parent_category_name' => 'Uncategorized',
|
||||||
|
'child_category_name' => 'Uncategorized',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get category from remote
|
||||||
|
$result = $this->mysqli->query("SELECT id, name, parent_id FROM product_categories WHERE id = {$categoryId}");
|
||||||
|
$category = $result->fetch_assoc();
|
||||||
|
|
||||||
|
if (! $category) {
|
||||||
|
return [
|
||||||
|
'type' => 'flower',
|
||||||
|
'category_name' => 'Unknown Category',
|
||||||
|
'parent_category_name' => 'Unknown Category',
|
||||||
|
'child_category_name' => 'Unknown Category',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$childCategoryName = $category['name'];
|
||||||
|
$parentCategoryName = $category['name']; // Default to same if no parent
|
||||||
|
|
||||||
|
// If has parent, get parent category name
|
||||||
|
if ($category['parent_id']) {
|
||||||
|
$parentResult = $this->mysqli->query("SELECT name FROM product_categories WHERE id = {$category['parent_id']}");
|
||||||
|
$parent = $parentResult->fetch_assoc();
|
||||||
|
if ($parent) {
|
||||||
|
$parentCategoryName = $parent['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map parent category name to local type
|
||||||
|
$typeMap = [
|
||||||
|
'Pre-Rolls' => 'pre_roll',
|
||||||
|
'Flower' => 'flower',
|
||||||
|
'Concentrates' => 'concentrate',
|
||||||
|
'Edibles' => 'edible',
|
||||||
|
'Vapes' => 'vape',
|
||||||
|
'Topicals' => 'topical',
|
||||||
|
'Tinctures' => 'tincture',
|
||||||
|
'Accessories' => 'accessory',
|
||||||
|
];
|
||||||
|
|
||||||
|
$type = $typeMap[$parentCategoryName] ?? 'flower';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => $type,
|
||||||
|
'category_name' => $category['parent_id'] ? "{$parentCategoryName} / {$childCategoryName}" : $childCategoryName,
|
||||||
|
'parent_category_name' => $parentCategoryName,
|
||||||
|
'child_category_name' => $childCategoryName,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
446
app/Console/Commands/ImportThunderBudSales.php
Normal file
446
app/Console/Commands/ImportThunderBudSales.php
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\OrderItem;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ImportThunderBudSales extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'import:thunderbud-sales {--dry-run : Show what would be imported without actually importing} {--force : Overwrite existing orders} {--skip-existing : Skip orders that already exist} {--limit= : Limit number of invoices to import}';
|
||||||
|
|
||||||
|
protected $description = 'Import Thunder Bud sales history (invoices and customers) from remote MySQL';
|
||||||
|
|
||||||
|
private $mysqli;
|
||||||
|
|
||||||
|
private $stats = [
|
||||||
|
'total_invoices' => 0,
|
||||||
|
'imported_invoices' => 0,
|
||||||
|
'skipped_invoices' => 0,
|
||||||
|
'failed_invoices' => 0,
|
||||||
|
'customers_created' => 0,
|
||||||
|
'total_items' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
private $customerCache = [];
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$force = $this->option('force');
|
||||||
|
$skipExisting = $this->option('skip-existing');
|
||||||
|
$limit = $this->option('limit');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('🔍 DRY RUN MODE - No data will be imported');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('🚀 Starting Thunder Bud Sales Import');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Connect to remote MySQL
|
||||||
|
$this->info('📡 Connecting to remote MySQL database...');
|
||||||
|
$this->mysqli = new \mysqli('sql1.creationshop.net', 'claude', 'claude', 'hub_cannabrands');
|
||||||
|
|
||||||
|
if ($this->mysqli->connect_error) {
|
||||||
|
$this->error('Failed to connect: '.$this->mysqli->connect_error);
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$this->info('✓ Connected to remote MySQL');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Get all invoices with Thunder Bud products
|
||||||
|
$this->info('📦 Fetching invoices with Thunder Bud products...');
|
||||||
|
$query = '
|
||||||
|
SELECT DISTINCT i.id
|
||||||
|
FROM invoices i
|
||||||
|
INNER JOIN invoice_lines il ON i.id = il.invoice_id
|
||||||
|
INNER JOIN products p ON il.product_id = p.id
|
||||||
|
WHERE p.brand_id = 6
|
||||||
|
AND i.deleted_at IS NULL
|
||||||
|
ORDER BY i.id
|
||||||
|
';
|
||||||
|
if ($limit) {
|
||||||
|
$query .= ' LIMIT '.(int) $limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->mysqli->query($query);
|
||||||
|
$invoiceIds = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$invoiceIds[] = $row['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->stats['total_invoices'] = count($invoiceIds);
|
||||||
|
$this->info("Found {$this->stats['total_invoices']} invoices with Thunder Bud products");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if (! $dryRun && ! $force && ! $skipExisting) {
|
||||||
|
if (! $this->confirm('This will import all invoices and customers. Continue?', true)) {
|
||||||
|
$this->warn('Import cancelled');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import each invoice
|
||||||
|
$progressBar = $this->output->createProgressBar($this->stats['total_invoices']);
|
||||||
|
$progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
|
||||||
|
$progressBar->setMessage('Starting...');
|
||||||
|
|
||||||
|
foreach ($invoiceIds as $invoiceId) {
|
||||||
|
$progressBar->setMessage("Invoice #{$invoiceId}");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->importInvoice($invoiceId, $dryRun, $force, $skipExisting);
|
||||||
|
|
||||||
|
if ($result === 'imported') {
|
||||||
|
$this->stats['imported_invoices']++;
|
||||||
|
} elseif ($result === 'skipped') {
|
||||||
|
$this->stats['skipped_invoices']++;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->stats['failed_invoices']++;
|
||||||
|
$progressBar->clear();
|
||||||
|
$this->error("Failed to import invoice #{$invoiceId}: {$e->getMessage()}");
|
||||||
|
$progressBar->display();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
// Show summary
|
||||||
|
$this->info('📊 Import Summary:');
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Count'],
|
||||||
|
[
|
||||||
|
['Total Invoices', $this->stats['total_invoices']],
|
||||||
|
['✓ Imported', $this->stats['imported_invoices']],
|
||||||
|
['⊘ Skipped', $this->stats['skipped_invoices']],
|
||||||
|
['✗ Failed', $this->stats['failed_invoices']],
|
||||||
|
['Customers Created', $this->stats['customers_created']],
|
||||||
|
['Order Items Created', $this->stats['total_items']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->mysqli->close();
|
||||||
|
|
||||||
|
return $this->stats['failed_invoices'] > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function importInvoice(int $invoiceId, bool $dryRun, bool $force, bool $skipExisting): string
|
||||||
|
{
|
||||||
|
// Fetch invoice from remote
|
||||||
|
$result = $this->mysqli->query("SELECT * FROM invoices WHERE id = {$invoiceId}");
|
||||||
|
$remote = $result->fetch_assoc();
|
||||||
|
|
||||||
|
if (! $remote) {
|
||||||
|
throw new \Exception("Invoice #{$invoiceId} not found in remote database");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
if (Order::where('id', $invoiceId)->exists()) {
|
||||||
|
if ($skipExisting) {
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $force && ! $dryRun) {
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun && $force) {
|
||||||
|
// Force delete existing order and items (hard delete, not soft delete)
|
||||||
|
DB::table('order_items')->where('order_id', $invoiceId)->delete();
|
||||||
|
Order::where('id', $invoiceId)->forceDelete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
return 'imported';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create customer business
|
||||||
|
$customer = $this->findOrCreateCustomer($remote['organisation_id'], $dryRun);
|
||||||
|
|
||||||
|
if (! $customer) {
|
||||||
|
throw new \Exception("Failed to create customer for organisation #{$remote['organisation_id']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Thunder Bud business (seller)
|
||||||
|
$seller = Business::where('slug', 'cannabrands')->first();
|
||||||
|
if (! $seller) {
|
||||||
|
throw new \Exception('Thunder Bud/Cannabrands business not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first user for this business to assign as order creator
|
||||||
|
$user = $customer->users()->first();
|
||||||
|
if (! $user) {
|
||||||
|
throw new \Exception("No user found for customer business #{$customer->id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get invoice lines
|
||||||
|
$linesResult = $this->mysqli->query("
|
||||||
|
SELECT il.*, p.brand_id
|
||||||
|
FROM invoice_lines il
|
||||||
|
INNER JOIN products p ON il.product_id = p.id
|
||||||
|
WHERE il.invoice_id = {$invoiceId}
|
||||||
|
AND il.deleted_at IS NULL
|
||||||
|
");
|
||||||
|
|
||||||
|
$invoiceLines = [];
|
||||||
|
while ($line = $linesResult->fetch_assoc()) {
|
||||||
|
$invoiceLines[] = $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create order
|
||||||
|
$order = new Order;
|
||||||
|
$order->id = $invoiceId;
|
||||||
|
$order->business_id = $customer->id; // Buyer business
|
||||||
|
$order->user_id = $user->id; // User who placed the order
|
||||||
|
$order->order_number = $remote['invoice_id'] ?? "TB-{$invoiceId}";
|
||||||
|
$order->status = $this->mapStatus($remote['status']);
|
||||||
|
$order->subtotal = ($remote['subtotal'] ?? 0) / 100; // Convert cents to dollars
|
||||||
|
$order->tax = ($remote['tax'] ?? 0) / 100;
|
||||||
|
$order->total = ($remote['total'] ?? 0) / 100;
|
||||||
|
$order->notes = $this->sanitizeUtf8($remote['comments']);
|
||||||
|
$order->payment_terms = $this->sanitizeUtf8($remote['terms']);
|
||||||
|
$order->delivery_method = 'pickup'; // Default
|
||||||
|
$order->timestamps = false;
|
||||||
|
$order->created_at = $remote['created_at'];
|
||||||
|
$order->updated_at = $remote['updated_at'];
|
||||||
|
$order->save();
|
||||||
|
|
||||||
|
// Create order items
|
||||||
|
$itemCount = 0;
|
||||||
|
foreach ($invoiceLines as $line) {
|
||||||
|
// Only import items for Thunder Bud products that exist locally
|
||||||
|
$product = Product::find($line['product_id']);
|
||||||
|
if (! $product || $product->brand_id != 6) {
|
||||||
|
continue; // Skip non-Thunder Bud products or products not imported
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate line_total (amount + tax)
|
||||||
|
$amount = (($line['amount'] ?? 0) / 100);
|
||||||
|
$tax = (($line['tax_amount'] ?? 0) / 100);
|
||||||
|
$lineTotal = $amount + $tax;
|
||||||
|
|
||||||
|
$orderItem = new OrderItem;
|
||||||
|
$orderItem->order_id = $order->id;
|
||||||
|
$orderItem->product_id = $line['product_id'];
|
||||||
|
$orderItem->quantity = (int) ($line['quantity'] ?? 1); // Cast to integer
|
||||||
|
$orderItem->unit_price = $line['price'] ?? 0;
|
||||||
|
$orderItem->line_total = $lineTotal;
|
||||||
|
|
||||||
|
// Product snapshot fields
|
||||||
|
$orderItem->product_name = $product->name;
|
||||||
|
$orderItem->product_sku = $product->sku;
|
||||||
|
$orderItem->brand_name = $product->brand->name ?? 'Thunder Bud';
|
||||||
|
|
||||||
|
$orderItem->timestamps = false;
|
||||||
|
$orderItem->created_at = $line['created_at'];
|
||||||
|
$orderItem->updated_at = $line['updated_at'];
|
||||||
|
$orderItem->save();
|
||||||
|
|
||||||
|
$itemCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->stats['total_items'] += $itemCount;
|
||||||
|
|
||||||
|
return 'imported';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findOrCreateCustomer(int $organisationId, bool $dryRun): ?Business
|
||||||
|
{
|
||||||
|
// Check cache first
|
||||||
|
if (isset($this->customerCache[$organisationId])) {
|
||||||
|
return $this->customerCache[$organisationId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already imported
|
||||||
|
$mapping = DB::table('remote_customer_mappings')
|
||||||
|
->where('remote_organisation_id', $organisationId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($mapping) {
|
||||||
|
$business = Business::find($mapping->business_id);
|
||||||
|
if ($business) {
|
||||||
|
// Ensure business has at least one user
|
||||||
|
if ($business->users()->count() == 0) {
|
||||||
|
$this->createUserForBusiness($business);
|
||||||
|
}
|
||||||
|
$this->customerCache[$organisationId] = $business;
|
||||||
|
|
||||||
|
return $business;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from remote
|
||||||
|
$result = $this->mysqli->query("SELECT * FROM companies WHERE id = {$organisationId}");
|
||||||
|
$remote = $result->fetch_assoc();
|
||||||
|
|
||||||
|
if (! $remote) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
return new Business(['name' => $remote['name']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if business already exists by slug
|
||||||
|
$slug = Str::slug($remote['name']);
|
||||||
|
$business = Business::where('slug', $slug)->first();
|
||||||
|
|
||||||
|
if ($business) {
|
||||||
|
// Business already exists, create mapping and return it
|
||||||
|
// Ensure it has a user
|
||||||
|
if ($business->users()->count() == 0) {
|
||||||
|
$this->createUserForBusiness($business);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mapping if it doesn't exist
|
||||||
|
$existingMapping = DB::table('remote_customer_mappings')
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->where('remote_organisation_id', $organisationId)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $existingMapping) {
|
||||||
|
DB::table('remote_customer_mappings')->insert([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'remote_organisation_id' => $organisationId,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->customerCache[$organisationId] = $business;
|
||||||
|
|
||||||
|
return $business;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new business
|
||||||
|
$business = new Business;
|
||||||
|
$business->name = $this->sanitizeUtf8($remote['name']);
|
||||||
|
$business->slug = Str::slug($remote['name']);
|
||||||
|
$business->type = 'buyer';
|
||||||
|
$business->status = 'approved';
|
||||||
|
$business->is_active = true;
|
||||||
|
$business->onboarding_completed = true;
|
||||||
|
$business->tax_rate = 0;
|
||||||
|
$business->tax_exempt = false;
|
||||||
|
$business->has_analytics = false;
|
||||||
|
$business->has_marketing = false;
|
||||||
|
$business->has_manufacturing = false;
|
||||||
|
$business->has_processing = false;
|
||||||
|
|
||||||
|
// Map address if available
|
||||||
|
if (! empty($remote['address'])) {
|
||||||
|
$business->physical_address = $this->sanitizeUtf8($remote['address']);
|
||||||
|
}
|
||||||
|
if (! empty($remote['city'])) {
|
||||||
|
$business->physical_city = $this->sanitizeUtf8($remote['city']);
|
||||||
|
}
|
||||||
|
if (! empty($remote['state'])) {
|
||||||
|
$business->physical_state = $this->sanitizeUtf8($remote['state']);
|
||||||
|
}
|
||||||
|
if (! empty($remote['zipcode'])) {
|
||||||
|
$business->physical_zipcode = $this->sanitizeUtf8($remote['zipcode']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$business->save();
|
||||||
|
|
||||||
|
// Create a default user for this business
|
||||||
|
$this->createUserForBusiness($business);
|
||||||
|
|
||||||
|
// Create mapping
|
||||||
|
DB::table('remote_customer_mappings')->insert([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'remote_organisation_id' => $organisationId,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->stats['customers_created']++;
|
||||||
|
$this->customerCache[$organisationId] = $business;
|
||||||
|
|
||||||
|
return $business;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapStatus(?string $remoteStatus): string
|
||||||
|
{
|
||||||
|
// Map remote invoice status to local order status
|
||||||
|
// Valid local statuses: new, buyer_modified, seller_modified, accepted, in_progress,
|
||||||
|
// ready_for_invoice, awaiting_invoice_approval, ready_for_manifest, ready_for_delivery,
|
||||||
|
// delivered, cancelled, rejected
|
||||||
|
$statusMap = [
|
||||||
|
'draft' => 'new', // Order just created
|
||||||
|
'sent' => 'accepted', // Order sent to customer, accepted
|
||||||
|
'paid' => 'delivered', // Payment received, order completed
|
||||||
|
'partial' => 'in_progress', // Partially paid/fulfilled
|
||||||
|
'overdue' => 'accepted', // Still active but overdue
|
||||||
|
];
|
||||||
|
|
||||||
|
return $statusMap[$remoteStatus] ?? 'new';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a default user for a business
|
||||||
|
*/
|
||||||
|
private function createUserForBusiness(Business $business): User
|
||||||
|
{
|
||||||
|
$user = new User;
|
||||||
|
$user->first_name = 'System';
|
||||||
|
$user->last_name = 'User';
|
||||||
|
$user->email = 'system+'.$business->slug.'@imported.local';
|
||||||
|
$user->password = Hash::make(Str::random(32)); // Random password
|
||||||
|
$user->user_type = 'buyer';
|
||||||
|
$user->email_verified_at = now();
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
// Attach user to business
|
||||||
|
$user->businesses()->attach($business->id);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize text from MySQL (Windows-1252 encoding) to proper UTF-8
|
||||||
|
*/
|
||||||
|
private function sanitizeUtf8(?string $text): ?string
|
||||||
|
{
|
||||||
|
if (! $text) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, try to detect the encoding
|
||||||
|
$encoding = mb_detect_encoding($text, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true);
|
||||||
|
|
||||||
|
// If already UTF-8 and valid, return as-is
|
||||||
|
if ($encoding === 'UTF-8' && mb_check_encoding($text, 'UTF-8')) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to convert from Windows-1252 to UTF-8
|
||||||
|
$converted = @iconv('Windows-1252', 'UTF-8//TRANSLIT//IGNORE', $text);
|
||||||
|
|
||||||
|
// If iconv fails, fall back to mb_convert_encoding
|
||||||
|
if ($converted === false) {
|
||||||
|
$converted = mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final cleanup: remove any remaining invalid UTF-8 sequences
|
||||||
|
$converted = mb_convert_encoding($converted, 'UTF-8', 'UTF-8');
|
||||||
|
|
||||||
|
return $converted;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Intelligence;
|
||||||
|
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Services\Intelligence\BrandPlacementSignalsService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute Brand Placement Signals
|
||||||
|
*
|
||||||
|
* Nightly job to compute brand coverage and sales opportunities
|
||||||
|
* for internal sales intelligence.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan intelligence:compute-brand-placement # All sellers
|
||||||
|
* php artisan intelligence:compute-brand-placement --seller=4 # Specific seller
|
||||||
|
*/
|
||||||
|
class ComputeBrandPlacementSignals extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'intelligence:compute-brand-placement
|
||||||
|
{--seller= : Specific seller business ID to compute}
|
||||||
|
{--dry-run : Show what would be computed without saving}';
|
||||||
|
|
||||||
|
protected $description = 'Compute brand placement signals for sales intelligence';
|
||||||
|
|
||||||
|
public function handle(BrandPlacementSignalsService $service): int
|
||||||
|
{
|
||||||
|
$this->info('🧠 Computing Brand Placement Signals...');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$sellerId = $this->option('seller');
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('DRY RUN MODE - No data will be saved');
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get seller(s) to process
|
||||||
|
if ($sellerId) {
|
||||||
|
$sellers = Business::where('id', $sellerId)
|
||||||
|
->whereIn('type', ['seller', 'both'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($sellers->isEmpty()) {
|
||||||
|
$this->error("Seller business #{$sellerId} not found or not a seller type.");
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get all seller businesses with brands
|
||||||
|
$sellers = Business::whereIn('type', ['seller', 'both'])
|
||||||
|
->whereHas('brands')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sellers->isEmpty()) {
|
||||||
|
$this->warn('No seller businesses found with brands.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Processing {$sellers->count()} seller(s)...");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$totalCoverage = 0;
|
||||||
|
$totalOpportunities = 0;
|
||||||
|
|
||||||
|
$progressBar = $this->output->createProgressBar($sellers->count());
|
||||||
|
$progressBar->start();
|
||||||
|
|
||||||
|
foreach ($sellers as $seller) {
|
||||||
|
if ($dryRun) {
|
||||||
|
$brandCount = $seller->brands()->where('is_active', true)->count();
|
||||||
|
$this->line(" Would process: {$seller->name} ({$brandCount} brands)");
|
||||||
|
} else {
|
||||||
|
$result = $service->computeForSeller($seller->id);
|
||||||
|
$totalCoverage += $result['coverage_updated'];
|
||||||
|
$totalOpportunities += $result['opportunities_created'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$this->info('✅ Computation complete!');
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Count'],
|
||||||
|
[
|
||||||
|
['Sellers Processed', $sellers->count()],
|
||||||
|
['Store Coverage Records', $totalCoverage],
|
||||||
|
['Opportunities Identified', $totalOpportunities],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
143
app/Console/Commands/LinkVarietiesFromSku.php
Normal file
143
app/Console/Commands/LinkVarietiesFromSku.php
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class LinkVarietiesFromSku extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'products:link-varieties-from-sku
|
||||||
|
{--dry-run : Preview changes without writing to the database}
|
||||||
|
{--brand-id= : Limit to a specific brand ID}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Link variety products to their parent based on SKU pattern (e.g., AZ3G → AZ1G)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$brandId = $this->option('brand-id');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->info('🔍 DRY RUN MODE - No changes will be made');
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = Product::query()
|
||||||
|
->whereNotNull('sku')
|
||||||
|
->whereNull('parent_product_id'); // Only process unlinked products
|
||||||
|
|
||||||
|
if ($brandId) {
|
||||||
|
$query->where('brand_id', $brandId);
|
||||||
|
$this->info("Filtering to brand_id: {$brandId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$linked = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$noParent = 0;
|
||||||
|
|
||||||
|
$this->info('Scanning products for variety patterns...');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Process in chunks for memory efficiency
|
||||||
|
$query->chunk(100, function ($products) use ($dryRun, &$linked, &$skipped, &$noParent) {
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$result = $this->processProduct($product, $dryRun);
|
||||||
|
|
||||||
|
match ($result) {
|
||||||
|
'linked' => $linked++,
|
||||||
|
'skipped' => $skipped++,
|
||||||
|
'no_parent' => $noParent++,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
$this->info('Summary:');
|
||||||
|
$this->info(" ✓ Linked: {$linked}");
|
||||||
|
$this->info(" ○ Skipped (not variety pattern): {$skipped}");
|
||||||
|
$this->info(" ✗ No parent found: {$noParent}");
|
||||||
|
|
||||||
|
if ($dryRun && $linked > 0) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('Run without --dry-run to apply these changes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single product to determine if it's a variety and link it.
|
||||||
|
*/
|
||||||
|
private function processProduct(Product $product, bool $dryRun): string
|
||||||
|
{
|
||||||
|
$sku = $product->sku;
|
||||||
|
$parts = explode('-', $sku);
|
||||||
|
|
||||||
|
// Guard: need at least 3 parts (e.g., TB-BM-AZ3G)
|
||||||
|
if (count($parts) < 3) {
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if third segment matches pattern like AZ1G, AZ3G, AZ5G
|
||||||
|
if (! preg_match('/^([A-Z]+)(\d+)G$/', $parts[2], $matches)) {
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $matches[1]; // e.g., 'AZ'
|
||||||
|
$qty = (int) $matches[2]; // e.g., 1, 3, 5
|
||||||
|
|
||||||
|
// If qty === 1, this is a parent, not a variety
|
||||||
|
if ($qty === 1) {
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a variety candidate (qty > 1)
|
||||||
|
// Build the parent's third segment and base SKU
|
||||||
|
$parentThird = $state.'1G'; // e.g., 'AZ1G'
|
||||||
|
$parentPrefix = "{$parts[0]}-{$parts[1]}-{$parentThird}"; // e.g., 'TB-BM-AZ1G'
|
||||||
|
|
||||||
|
// Look up parent within the same brand
|
||||||
|
$parent = Product::where('brand_id', $product->brand_id)
|
||||||
|
->where(function ($q) use ($parentPrefix) {
|
||||||
|
$q->where('sku', $parentPrefix)
|
||||||
|
->orWhere('sku', 'like', $parentPrefix.'-%');
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $parent) {
|
||||||
|
$this->warn(" ✗ No parent found for #{$product->id} ({$sku})");
|
||||||
|
|
||||||
|
return 'no_parent';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link the variety to its parent
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line(" → Would link #{$product->id} (<fg=cyan>{$sku}</>) → parent #{$parent->id} (<fg=green>{$parent->sku}</>)");
|
||||||
|
} else {
|
||||||
|
DB::transaction(function () use ($product, $parent) {
|
||||||
|
$product->parent_product_id = $parent->id;
|
||||||
|
$product->save();
|
||||||
|
});
|
||||||
|
$this->line(" ✓ Linked #{$product->id} (<fg=cyan>{$sku}</>) → parent #{$parent->id} (<fg=green>{$parent->sku}</>)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'linked';
|
||||||
|
}
|
||||||
|
}
|
||||||
356
app/Console/Commands/ListFeatureRoutes.php
Normal file
356
app/Console/Commands/ListFeatureRoutes.php
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug command to list seller-side feature routes and check legacy menu coverage.
|
||||||
|
*
|
||||||
|
* This helps developers identify implemented features that may not appear
|
||||||
|
* in the current navigation menu, ensuring nothing is forgotten when
|
||||||
|
* building suite-based menus.
|
||||||
|
*
|
||||||
|
* @see docs/architecture/SUITES_AND_PRICING_MODEL.md
|
||||||
|
*/
|
||||||
|
class ListFeatureRoutes extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'debug:list-feature-routes
|
||||||
|
{--show-routes : Show all routes for each feature}
|
||||||
|
{--json : Output as JSON}';
|
||||||
|
|
||||||
|
protected $description = 'List seller-side feature domains and check if they appear in the legacy menu';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy menu feature references.
|
||||||
|
* These are patterns/keywords that appear in the legacy seller-sidebar.blade.php
|
||||||
|
* Used to determine if a feature domain is represented in the current menu.
|
||||||
|
*/
|
||||||
|
protected array $legacyMenuFeatures = [
|
||||||
|
'dashboard' => ['seller.business.dashboard', 'seller.dashboard'],
|
||||||
|
'brands' => ['seller.business.brands'],
|
||||||
|
'analytics' => ['seller.business.dashboard.analytics', 'analytics'],
|
||||||
|
'orchestrator' => ['seller.business.orchestrator'],
|
||||||
|
'conversations' => ['seller.business.messaging', 'conversations'],
|
||||||
|
'contacts' => ['seller.business.contacts'],
|
||||||
|
'sales' => ['seller.business.dashboard.sales'],
|
||||||
|
'customers' => ['seller.business.customers'],
|
||||||
|
'orders' => ['seller.business.orders'],
|
||||||
|
'invoices' => ['seller.business.invoices'],
|
||||||
|
'backorders' => ['seller.business.backorders'],
|
||||||
|
'promotions' => ['seller.business.promotions'],
|
||||||
|
'products' => ['seller.business.products'],
|
||||||
|
'components' => ['seller.business.components'],
|
||||||
|
'inventory' => ['seller.business.inventory'],
|
||||||
|
'processing' => ['seller.business.processing'],
|
||||||
|
'manufacturing' => ['seller.business.manufacturing'],
|
||||||
|
'fleet' => ['seller.business.fleet'],
|
||||||
|
'marketing' => ['seller.business.marketing'],
|
||||||
|
'crm' => ['seller.business.crm'],
|
||||||
|
'settings' => ['seller.business.settings'],
|
||||||
|
'reports' => ['seller.business.processing.wash-reports'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature grouping rules based on route name patterns.
|
||||||
|
* Maps route name segments to feature keys.
|
||||||
|
*/
|
||||||
|
protected array $featurePatterns = [
|
||||||
|
'dashboard' => ['dashboard'],
|
||||||
|
'brands' => ['brands'],
|
||||||
|
'products' => ['products'],
|
||||||
|
'components' => ['components'],
|
||||||
|
'inventory' => ['inventory.items', 'inventory.movements', 'inventory.alerts', 'inventory.dashboard'],
|
||||||
|
'orders' => ['orders'],
|
||||||
|
'invoices' => ['invoices'],
|
||||||
|
'backorders' => ['backorders'],
|
||||||
|
'customers' => ['customers'],
|
||||||
|
'contacts' => ['contacts'],
|
||||||
|
'conversations' => ['messaging'],
|
||||||
|
'promotions' => ['promotions'],
|
||||||
|
'menus' => ['menus'],
|
||||||
|
'orchestrator' => ['orchestrator'],
|
||||||
|
'copilot' => ['copilot'],
|
||||||
|
'processing' => ['processing', 'batches'],
|
||||||
|
'manufacturing' => ['manufacturing'],
|
||||||
|
'fleet' => ['fleet'],
|
||||||
|
'marketing' => ['marketing'],
|
||||||
|
'crm' => ['crm'],
|
||||||
|
'settings' => ['settings'],
|
||||||
|
'compliance' => ['compliance'],
|
||||||
|
'bulk-actions' => ['bulk-actions', 'bulk'],
|
||||||
|
'api' => ['api'],
|
||||||
|
'webhooks' => ['webhooks'],
|
||||||
|
'integrations' => ['integrations'],
|
||||||
|
'onboarding' => ['onboarding'],
|
||||||
|
'impersonate' => ['impersonate'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$this->info('Scanning seller-side routes...');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Collect all seller routes
|
||||||
|
$sellerRoutes = $this->collectSellerRoutes();
|
||||||
|
|
||||||
|
// Group by feature
|
||||||
|
$features = $this->groupByFeature($sellerRoutes);
|
||||||
|
|
||||||
|
// Check legacy menu coverage
|
||||||
|
$results = $this->analyzeFeatures($features);
|
||||||
|
|
||||||
|
// Output results
|
||||||
|
if ($this->option('json')) {
|
||||||
|
$this->line(json_encode($results, JSON_PRETTY_PRINT));
|
||||||
|
} else {
|
||||||
|
$this->outputTable($results);
|
||||||
|
|
||||||
|
if ($this->option('show-routes')) {
|
||||||
|
$this->outputDetailedRoutes($features);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->outputSummary($results);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all routes that belong to the seller area.
|
||||||
|
*/
|
||||||
|
protected function collectSellerRoutes(): array
|
||||||
|
{
|
||||||
|
$routes = [];
|
||||||
|
|
||||||
|
foreach (Route::getRoutes() as $route) {
|
||||||
|
$name = $route->getName();
|
||||||
|
$uri = $route->uri();
|
||||||
|
|
||||||
|
// Filter to seller routes by URI prefix or route name
|
||||||
|
$isSellerRoute = Str::startsWith($uri, 's/')
|
||||||
|
|| Str::startsWith($uri, 's/{')
|
||||||
|
|| Str::startsWith($name ?? '', 'seller.');
|
||||||
|
|
||||||
|
if (! $isSellerRoute) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip Livewire and internal routes
|
||||||
|
if (Str::contains($uri, ['livewire', '__clockwork'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$routes[] = [
|
||||||
|
'name' => $name,
|
||||||
|
'uri' => $uri,
|
||||||
|
'methods' => implode('|', $route->methods()),
|
||||||
|
'controller' => $route->getActionName(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group routes by inferred feature key.
|
||||||
|
*/
|
||||||
|
protected function groupByFeature(array $routes): array
|
||||||
|
{
|
||||||
|
$features = [];
|
||||||
|
|
||||||
|
foreach ($routes as $route) {
|
||||||
|
$featureKey = $this->inferFeatureKey($route);
|
||||||
|
|
||||||
|
if (! isset($features[$featureKey])) {
|
||||||
|
$features[$featureKey] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$features[$featureKey][] = $route;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by feature name
|
||||||
|
ksort($features);
|
||||||
|
|
||||||
|
return $features;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer the feature key from a route.
|
||||||
|
*/
|
||||||
|
protected function inferFeatureKey(array $route): string
|
||||||
|
{
|
||||||
|
$name = $route['name'] ?? '';
|
||||||
|
$uri = $route['uri'];
|
||||||
|
|
||||||
|
// Try to match against known feature patterns
|
||||||
|
foreach ($this->featurePatterns as $feature => $patterns) {
|
||||||
|
foreach ($patterns as $pattern) {
|
||||||
|
if (Str::contains($name, $pattern) || Str::contains($uri, $pattern)) {
|
||||||
|
return $feature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to extracting from route name
|
||||||
|
// seller.business.{feature}.action -> extract {feature}
|
||||||
|
if (preg_match('/^seller\.business\.([a-z-]+)/', $name, $matches)) {
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract from URI: s/{business}/{feature}/...
|
||||||
|
if (preg_match('/^s\/\{[^}]+\}\/([a-z-]+)/', $uri, $matches)) {
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract from URI without business param: s/{feature}/...
|
||||||
|
if (preg_match('/^s\/([a-z-]+)/', $uri, $matches)) {
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze features and check legacy menu coverage.
|
||||||
|
*/
|
||||||
|
protected function analyzeFeatures(array $features): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($features as $featureKey => $routes) {
|
||||||
|
$inLegacyMenu = $this->isInLegacyMenu($featureKey, $routes);
|
||||||
|
|
||||||
|
// Get example routes (up to 3)
|
||||||
|
$examples = array_slice(
|
||||||
|
array_map(fn ($r) => $r['name'] ?: $r['uri'], $routes),
|
||||||
|
0,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'feature' => $featureKey,
|
||||||
|
'route_count' => count($routes),
|
||||||
|
'in_legacy_menu' => $inLegacyMenu,
|
||||||
|
'examples' => $examples,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: features NOT in legacy menu first (to highlight gaps)
|
||||||
|
usort($results, function ($a, $b) {
|
||||||
|
if ($a['in_legacy_menu'] !== $b['in_legacy_menu']) {
|
||||||
|
return $a['in_legacy_menu'] ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strcmp($a['feature'], $b['feature']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a feature appears in the legacy menu.
|
||||||
|
*/
|
||||||
|
protected function isInLegacyMenu(string $featureKey, array $routes): bool
|
||||||
|
{
|
||||||
|
// Check direct feature mapping
|
||||||
|
if (isset($this->legacyMenuFeatures[$featureKey])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any route matches known legacy menu patterns
|
||||||
|
foreach ($routes as $route) {
|
||||||
|
$routeName = $route['name'] ?? '';
|
||||||
|
foreach ($this->legacyMenuFeatures as $patterns) {
|
||||||
|
foreach ($patterns as $pattern) {
|
||||||
|
if (Str::startsWith($routeName, $pattern)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output results as a table.
|
||||||
|
*/
|
||||||
|
protected function outputTable(array $results): void
|
||||||
|
{
|
||||||
|
$tableData = [];
|
||||||
|
|
||||||
|
foreach ($results as $result) {
|
||||||
|
$tableData[] = [
|
||||||
|
$result['feature'],
|
||||||
|
$result['route_count'],
|
||||||
|
$result['in_legacy_menu'] ? '<fg=green>YES</>' : '<fg=yellow>NO</>',
|
||||||
|
Str::limit(implode(', ', $result['examples']), 60),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Feature', 'Routes', 'In Menu?', 'Example Routes'],
|
||||||
|
$tableData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output detailed routes for each feature.
|
||||||
|
*/
|
||||||
|
protected function outputDetailedRoutes(array $features): void
|
||||||
|
{
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Detailed Routes by Feature:');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
foreach ($features as $feature => $routes) {
|
||||||
|
$this->line("<fg=cyan>[$feature]</> (".count($routes).' routes)');
|
||||||
|
|
||||||
|
foreach ($routes as $route) {
|
||||||
|
$methods = $route['methods'];
|
||||||
|
$name = $route['name'] ?: '(unnamed)';
|
||||||
|
$uri = $route['uri'];
|
||||||
|
|
||||||
|
$this->line(" <fg=gray>{$methods}</> {$uri}");
|
||||||
|
$this->line(" <fg=gray>→ {$name}</>");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output summary statistics.
|
||||||
|
*/
|
||||||
|
protected function outputSummary(array $results): void
|
||||||
|
{
|
||||||
|
$totalFeatures = count($results);
|
||||||
|
$inMenu = count(array_filter($results, fn ($r) => $r['in_legacy_menu']));
|
||||||
|
$notInMenu = $totalFeatures - $inMenu;
|
||||||
|
$totalRoutes = array_sum(array_column($results, 'route_count'));
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Summary:');
|
||||||
|
$this->line(" Total seller routes: <fg=white>{$totalRoutes}</>");
|
||||||
|
$this->line(" Feature domains: <fg=white>{$totalFeatures}</>");
|
||||||
|
$this->line(" In legacy menu: <fg=green>{$inMenu}</>");
|
||||||
|
$this->line(" Not in legacy menu: <fg=yellow>{$notInMenu}</>");
|
||||||
|
|
||||||
|
if ($notInMenu > 0) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('Features not in legacy menu may need to be added to suite menus:');
|
||||||
|
foreach ($results as $result) {
|
||||||
|
if (! $result['in_legacy_menu']) {
|
||||||
|
$this->line(" - <fg=yellow>{$result['feature']}</> ({$result['route_count']} routes)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('<fg=gray>Tip: Use --show-routes to see all routes per feature</>');
|
||||||
|
$this->line('<fg=gray>Tip: Use --json for machine-readable output</>');
|
||||||
|
}
|
||||||
|
}
|
||||||
216
app/Console/Commands/MigrateFlagsToSuites.php
Normal file
216
app/Console/Commands/MigrateFlagsToSuites.php
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Suite;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates the legacy has_* flags on businesses to the new suites pivot table.
|
||||||
|
*
|
||||||
|
* This command maps the old feature flags (has_analytics, has_manufacturing, etc.)
|
||||||
|
* to suite assignments in the business_suite pivot table.
|
||||||
|
*/
|
||||||
|
class MigrateFlagsToSuites extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'migrate:flags-to-suites
|
||||||
|
{--dry-run : Run without making changes}
|
||||||
|
{--force : Force migration without confirmation}';
|
||||||
|
|
||||||
|
protected $description = 'Migrate legacy has_* flags to business_suite pivot table';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map feature flags to suite keys.
|
||||||
|
*
|
||||||
|
* Some feature flags map to specific suites, others are deprecated or
|
||||||
|
* will be handled by the new granular permissions system.
|
||||||
|
*/
|
||||||
|
private const FLAG_TO_SUITE_MAP = [
|
||||||
|
// Feature flags that map directly to suites
|
||||||
|
'has_manufacturing' => 'manufacturing',
|
||||||
|
'has_processing' => 'processing',
|
||||||
|
'has_marketing' => 'marketing',
|
||||||
|
'has_compliance' => 'compliance',
|
||||||
|
'has_inventory' => 'inventory',
|
||||||
|
'has_accounting' => 'finance', // accounting maps to finance suite
|
||||||
|
|
||||||
|
// Feature flags that are part of the Sales suite
|
||||||
|
'has_analytics' => 'sales',
|
||||||
|
'has_crm' => 'sales',
|
||||||
|
'has_assemblies' => 'inventory', // assemblies is part of inventory
|
||||||
|
'has_conversations' => 'inbox', // conversations maps to inbox suite
|
||||||
|
'has_buyer_intelligence' => 'sales',
|
||||||
|
|
||||||
|
// Legacy suite flags (already named as suites)
|
||||||
|
'has_sales_suite' => 'sales',
|
||||||
|
'has_processing_suite' => 'processing',
|
||||||
|
'has_manufacturing_suite' => 'manufacturing',
|
||||||
|
'has_delivery_suite' => 'distribution',
|
||||||
|
'has_management_suite' => 'management',
|
||||||
|
'has_enterprise_suite' => 'enterprise',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$isDryRun = $this->option('dry-run');
|
||||||
|
$force = $this->option('force');
|
||||||
|
|
||||||
|
$this->info('Starting has_* flags to suites migration...');
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
$this->warn('DRY RUN MODE - No changes will be made');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $force && ! $isDryRun) {
|
||||||
|
if (! $this->confirm('This will migrate has_* flags to the business_suite pivot table. Continue?')) {
|
||||||
|
$this->info('Migration cancelled.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure suites exist
|
||||||
|
$suites = Suite::all()->keyBy('key');
|
||||||
|
if ($suites->isEmpty()) {
|
||||||
|
$this->error('No suites found. Please run: php artisan db:seed --class=SuitesSeeder');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Found '.$suites->count().' suites in database');
|
||||||
|
|
||||||
|
// Get all businesses with any has_* flags enabled
|
||||||
|
$businesses = Business::query()
|
||||||
|
->where(function ($query) {
|
||||||
|
foreach (array_keys(self::FLAG_TO_SUITE_MAP) as $flag) {
|
||||||
|
$query->orWhere($flag, true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->info("Found {$businesses->count()} businesses with enabled flags");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'total_businesses' => 0,
|
||||||
|
'total_suite_assignments' => 0,
|
||||||
|
'skipped_existing' => 0,
|
||||||
|
'errors' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$progressBar = $this->output->createProgressBar($businesses->count());
|
||||||
|
$progressBar->start();
|
||||||
|
|
||||||
|
foreach ($businesses as $business) {
|
||||||
|
$stats['total_businesses']++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$suitesToAssign = $this->determineSuitesForBusiness($business, $suites);
|
||||||
|
|
||||||
|
if (empty($suitesToAssign)) {
|
||||||
|
$progressBar->advance();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($suitesToAssign as $suiteKey) {
|
||||||
|
$suite = $suites->get($suiteKey);
|
||||||
|
if (! $suite) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn(" Suite '{$suiteKey}' not found for business {$business->name}");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already assigned
|
||||||
|
$existingAssignment = DB::table('business_suite')
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->where('suite_id', $suite->id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($existingAssignment) {
|
||||||
|
$stats['skipped_existing']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $isDryRun) {
|
||||||
|
DB::table('business_suite')->insert([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'suite_id' => $suite->id,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats['total_suite_assignments']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$stats['errors']++;
|
||||||
|
$this->newLine();
|
||||||
|
$this->error(" Error processing {$business->name}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
// Display stats
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Count'],
|
||||||
|
[
|
||||||
|
['Total Businesses Processed', $stats['total_businesses']],
|
||||||
|
['Suite Assignments Created', $isDryRun ? "{$stats['total_suite_assignments']} (would create)" : $stats['total_suite_assignments']],
|
||||||
|
['Skipped (Already Assigned)', $stats['skipped_existing']],
|
||||||
|
['Errors', $stats['errors']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->info('Migration completed!');
|
||||||
|
|
||||||
|
if (! $isDryRun) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Note: The has_* flags remain on the businesses for backwards compatibility.');
|
||||||
|
$this->info('They can be deprecated once all code uses the suites system.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which suites a business should have based on its flags.
|
||||||
|
*/
|
||||||
|
private function determineSuitesForBusiness(Business $business, $suites): array
|
||||||
|
{
|
||||||
|
$assignedSuites = [];
|
||||||
|
|
||||||
|
foreach (self::FLAG_TO_SUITE_MAP as $flag => $suiteKey) {
|
||||||
|
// Check if the business has this flag enabled
|
||||||
|
if ($business->getAttribute($flag)) {
|
||||||
|
// Don't duplicate suite assignments
|
||||||
|
if (! in_array($suiteKey, $assignedSuites)) {
|
||||||
|
$assignedSuites[] = $suiteKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enterprise suite gets all suites
|
||||||
|
if (in_array('enterprise', $assignedSuites)) {
|
||||||
|
// Add all non-internal suites
|
||||||
|
foreach ($suites as $suite) {
|
||||||
|
if (! $suite->is_internal && ! in_array($suite->key, $assignedSuites)) {
|
||||||
|
$assignedSuites[] = $suite->key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $assignedSuites;
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/Console/Commands/MigrateImagesToMinIO.php
Normal file
162
app/Console/Commands/MigrateImagesToMinIO.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\Business;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class MigrateImagesToMinIO extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'media:migrate-to-minio {--dry-run : Show what would be migrated without actually doing it}';
|
||||||
|
|
||||||
|
protected $description = 'Migrate existing brand images from storage/app/public to MinIO with proper hierarchy';
|
||||||
|
|
||||||
|
protected int $migratedLogos = 0;
|
||||||
|
|
||||||
|
protected int $migratedBanners = 0;
|
||||||
|
|
||||||
|
protected int $errors = 0;
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('🔍 DRY RUN MODE - No files will be moved or database records updated');
|
||||||
|
$this->newLine();
|
||||||
|
} else {
|
||||||
|
$this->info('🚀 Starting image migration to MinIO...');
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all brands with images
|
||||||
|
$brands = Brand::with('business')
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereNotNull('logo_path')
|
||||||
|
->orWhereNotNull('banner_path');
|
||||||
|
})
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($brands->isEmpty()) {
|
||||||
|
$this->info('✅ No brands with images found. Nothing to migrate.');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Found {$brands->count()} brands with images");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$progressBar = $this->output->createProgressBar($brands->count());
|
||||||
|
$progressBar->start();
|
||||||
|
|
||||||
|
foreach ($brands as $brand) {
|
||||||
|
$this->migrateBrandImages($brand, $dryRun);
|
||||||
|
$progressBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
$this->info('✅ Migration Complete!');
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Count'],
|
||||||
|
[
|
||||||
|
['Logos Migrated', $this->migratedLogos],
|
||||||
|
['Banners Migrated', $this->migratedBanners],
|
||||||
|
['Errors', $this->errors],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $dryRun && $this->errors === 0) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('🎉 All images successfully migrated to MinIO!');
|
||||||
|
$this->info('📂 Check MinIO console: http://localhost:9001');
|
||||||
|
$this->info('🗑️ You can now safely delete storage/app/public/brands/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function migrateBrandImages(Brand $brand, bool $dryRun): void
|
||||||
|
{
|
||||||
|
$business = $brand->business;
|
||||||
|
|
||||||
|
// Migrate logo
|
||||||
|
if ($brand->logo_path) {
|
||||||
|
$this->migrateImage(
|
||||||
|
$brand,
|
||||||
|
$business,
|
||||||
|
$brand->logo_path,
|
||||||
|
'logo',
|
||||||
|
$dryRun
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate banner
|
||||||
|
if ($brand->banner_path) {
|
||||||
|
$this->migrateImage(
|
||||||
|
$brand,
|
||||||
|
$business,
|
||||||
|
$brand->banner_path,
|
||||||
|
'banner',
|
||||||
|
$dryRun
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function migrateImage(
|
||||||
|
Brand $brand,
|
||||||
|
Business $business,
|
||||||
|
string $oldPath,
|
||||||
|
string $type,
|
||||||
|
bool $dryRun
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
// Check if file exists in old location
|
||||||
|
$oldDisk = Storage::disk('public');
|
||||||
|
if (! $oldDisk->exists($oldPath)) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn(" ⚠️ File not found: {$oldPath} (skipping)");
|
||||||
|
$this->errors++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine file extension
|
||||||
|
$extension = pathinfo($oldPath, PATHINFO_EXTENSION);
|
||||||
|
|
||||||
|
// Build new path using our hierarchy
|
||||||
|
$newPath = "businesses/{$business->slug}/brands/{$brand->slug}/branding/{$type}.{$extension}";
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line(' 📋 Would migrate:');
|
||||||
|
$this->line(" From: {$oldPath}");
|
||||||
|
$this->line(" To: {$newPath}");
|
||||||
|
} else {
|
||||||
|
// Get file contents
|
||||||
|
$fileContents = $oldDisk->get($oldPath);
|
||||||
|
|
||||||
|
// Upload to MinIO using our new hierarchy
|
||||||
|
$minioDisk = Storage::disk('minio');
|
||||||
|
$minioDisk->put($newPath, $fileContents);
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
if ($type === 'logo') {
|
||||||
|
$brand->update(['logo_path' => $newPath]);
|
||||||
|
$this->migratedLogos++;
|
||||||
|
} else {
|
||||||
|
$brand->update(['banner_path' => $newPath]);
|
||||||
|
$this->migratedBanners++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->error(" ❌ Error migrating {$type} for {$brand->name}: ".$e->getMessage());
|
||||||
|
$this->errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
app/Console/Commands/MigrateProductImagePaths.php
Normal file
126
app/Console/Commands/MigrateProductImagePaths.php
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class MigrateProductImagePaths extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'media:migrate-product-images {--dry-run : Show what would be migrated without making changes}';
|
||||||
|
|
||||||
|
protected $description = 'Migrate product images from old path (products/{id}/) to correct path (brands/{brand}/products/{sku}/images/)';
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('🔍 DRY RUN MODE - No changes will be made');
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('🚀 Starting product image migration...');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Get all products with image_path
|
||||||
|
$products = Product::whereNotNull('image_path')
|
||||||
|
->with('brand.business')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->info("Found {$products->count()} products with images");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'total' => $products->count(),
|
||||||
|
'migrated' => 0,
|
||||||
|
'skipped_correct_path' => 0,
|
||||||
|
'skipped_missing' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$progressBar = $this->output->createProgressBar($products->count());
|
||||||
|
$progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
|
||||||
|
$progressBar->setMessage('Starting...');
|
||||||
|
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$progressBar->setMessage("Product #{$product->id}: {$product->name}");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if already using correct path pattern
|
||||||
|
if (preg_match('#^businesses/[^/]+/brands/[^/]+/products/[^/]+/images/#', $product->image_path)) {
|
||||||
|
$stats['skipped_correct_path']++;
|
||||||
|
$progressBar->advance();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if old file exists
|
||||||
|
if (! Storage::exists($product->image_path)) {
|
||||||
|
$stats['skipped_missing']++;
|
||||||
|
$progressBar->clear();
|
||||||
|
$this->warn(" ⚠️ Product #{$product->id} - Image missing at: {$product->image_path}");
|
||||||
|
$progressBar->display();
|
||||||
|
$progressBar->advance();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new path
|
||||||
|
$filename = basename($product->image_path);
|
||||||
|
$businessSlug = $product->brand->business->slug ?? 'unknown';
|
||||||
|
$brandSlug = $product->brand->slug ?? 'unknown';
|
||||||
|
$productSku = $product->sku;
|
||||||
|
|
||||||
|
$newPath = "businesses/{$businessSlug}/brands/{$brandSlug}/products/{$productSku}/images/{$filename}";
|
||||||
|
$oldPath = $product->image_path;
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
// Copy file to new location on MinIO
|
||||||
|
$contents = Storage::get($oldPath);
|
||||||
|
Storage::put($newPath, $contents);
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
$product->image_path = $newPath;
|
||||||
|
$product->save();
|
||||||
|
|
||||||
|
// Delete old file
|
||||||
|
Storage::delete($oldPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats['migrated']++;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$stats['failed']++;
|
||||||
|
$progressBar->clear();
|
||||||
|
$this->error(" ✗ Failed to migrate product #{$product->id}: {$e->getMessage()}");
|
||||||
|
$progressBar->display();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
// Show summary
|
||||||
|
$this->info('📊 Migration Summary:');
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Count'],
|
||||||
|
[
|
||||||
|
['Total Products', $stats['total']],
|
||||||
|
['✓ Migrated', $stats['migrated']],
|
||||||
|
['→ Already Correct Path', $stats['skipped_correct_path']],
|
||||||
|
['⊘ Missing Files', $stats['skipped_missing']],
|
||||||
|
['✗ Failed', $stats['failed']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('This was a dry run. Run without --dry-run to actually migrate the images.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats['failed'] > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
304
app/Console/Commands/OrchestratorAnalyzeTiming.php
Normal file
304
app/Console/Commands/OrchestratorAnalyzeTiming.php
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\AutomationRunLog;
|
||||||
|
use App\Models\MenuViewEvent;
|
||||||
|
use App\Models\OrchestratorTask;
|
||||||
|
use App\Models\OrchestratorTimingInsight;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\SendMenuLog;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OrchestratorAnalyzeTiming - Analyzes timing patterns for optimal send windows.
|
||||||
|
*
|
||||||
|
* Computes engagement metrics by hour-of-day and playbook type,
|
||||||
|
* storing results in orchestrator_timing_insights for visualization.
|
||||||
|
*/
|
||||||
|
class OrchestratorAnalyzeTiming extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'orchestrator:analyze-timing
|
||||||
|
{--days=90 : Number of days back to analyze}
|
||||||
|
{--business= : Analyze for specific business ID}
|
||||||
|
{--dry-run : Show what would be computed without saving}';
|
||||||
|
|
||||||
|
protected $description = 'Analyze menu send timing patterns to identify optimal engagement windows';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
AutomationRunLog::recordStart(AutomationRunLog::CMD_ANALYZE_TIMING);
|
||||||
|
|
||||||
|
$days = (int) $this->option('days');
|
||||||
|
$businessId = $this->option('business');
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
$this->info("Analyzing timing patterns from last {$days} days...");
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('DRY RUN - No data will be saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$startDate = now()->subDays($days);
|
||||||
|
|
||||||
|
// Get all send menu logs with outcomes
|
||||||
|
$query = SendMenuLog::query()
|
||||||
|
->whereNotNull('sent_at')
|
||||||
|
->where('sent_at', '>=', $startDate)
|
||||||
|
->whereNotNull('outcome_checked_at');
|
||||||
|
|
||||||
|
if ($businessId) {
|
||||||
|
$query->where('business_id', $businessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$logs = $query->get();
|
||||||
|
|
||||||
|
$this->info("Found {$logs->count()} send logs with outcomes to analyze.");
|
||||||
|
|
||||||
|
if ($logs->isEmpty()) {
|
||||||
|
$this->warn('No data to analyze. Run orchestrator:evaluate-outcomes first.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by hour-of-day
|
||||||
|
$byHour = $logs->groupBy(fn ($log) => $log->sent_at->hour);
|
||||||
|
|
||||||
|
// Also compute by playbook type if orchestrator_task_id is present
|
||||||
|
$byPlaybook = $this->groupByPlaybook($logs);
|
||||||
|
|
||||||
|
// Compute global insights (all playbooks)
|
||||||
|
$this->computeInsights(
|
||||||
|
$byHour,
|
||||||
|
null, // business_id = null for global
|
||||||
|
'all',
|
||||||
|
$dryRun
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compute per-playbook insights
|
||||||
|
foreach ($byPlaybook as $playbookType => $playbookLogs) {
|
||||||
|
$byHourForPlaybook = $playbookLogs->groupBy(fn ($log) => $log->sent_at->hour);
|
||||||
|
$this->computeInsights(
|
||||||
|
$byHourForPlaybook,
|
||||||
|
null,
|
||||||
|
$playbookType,
|
||||||
|
$dryRun
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If specific business, also compute business-specific insights
|
||||||
|
if ($businessId) {
|
||||||
|
$this->info("Computing insights for business ID {$businessId}...");
|
||||||
|
$this->computeInsights(
|
||||||
|
$byHour,
|
||||||
|
(int) $businessId,
|
||||||
|
'all',
|
||||||
|
$dryRun
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($byPlaybook as $playbookType => $playbookLogs) {
|
||||||
|
$byHourForPlaybook = $playbookLogs->groupBy(fn ($log) => $log->sent_at->hour);
|
||||||
|
$this->computeInsights(
|
||||||
|
$byHourForPlaybook,
|
||||||
|
(int) $businessId,
|
||||||
|
$playbookType,
|
||||||
|
$dryRun
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Timing analysis complete!');
|
||||||
|
|
||||||
|
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_ANALYZE_TIMING, [
|
||||||
|
'logs_analyzed' => $logs->count(),
|
||||||
|
'days_analyzed' => $days,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group logs by playbook type (via orchestrator_task_id).
|
||||||
|
*/
|
||||||
|
private function groupByPlaybook($logs): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
$logsWithTask = $logs->whereNotNull('orchestrator_task_id');
|
||||||
|
|
||||||
|
if ($logsWithTask->isEmpty()) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskIds = $logsWithTask->pluck('orchestrator_task_id')->unique();
|
||||||
|
$tasks = OrchestratorTask::whereIn('id', $taskIds)->pluck('type', 'id');
|
||||||
|
|
||||||
|
return $logsWithTask->groupBy(function ($log) use ($tasks) {
|
||||||
|
return $tasks[$log->orchestrator_task_id] ?? 'unknown';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute and store insights for a grouping.
|
||||||
|
*/
|
||||||
|
private function computeInsights(
|
||||||
|
\Illuminate\Support\Collection $byHour,
|
||||||
|
?int $businessId,
|
||||||
|
string $playbookType,
|
||||||
|
bool $dryRun
|
||||||
|
): void {
|
||||||
|
$insights = [];
|
||||||
|
|
||||||
|
for ($hour = 0; $hour < 24; $hour++) {
|
||||||
|
$hourLogs = $byHour->get($hour, collect());
|
||||||
|
|
||||||
|
if ($hourLogs->isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = $hourLogs->count();
|
||||||
|
$views = $hourLogs->where('resulted_in_view', true)->count();
|
||||||
|
$orders = $hourLogs->where('resulted_in_order', true)->count();
|
||||||
|
|
||||||
|
// Calculate average time to view (for logs that resulted in view)
|
||||||
|
$viewedLogs = $hourLogs->where('resulted_in_view', true);
|
||||||
|
$avgHoursToView = null;
|
||||||
|
|
||||||
|
if ($viewedLogs->isNotEmpty()) {
|
||||||
|
// Get matching menu view events
|
||||||
|
$avgHoursToView = $this->calculateAvgHoursToView($viewedLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate average time to order
|
||||||
|
$orderedLogs = $hourLogs->where('resulted_in_order', true);
|
||||||
|
$avgHoursToOrder = null;
|
||||||
|
|
||||||
|
if ($orderedLogs->isNotEmpty()) {
|
||||||
|
$avgHoursToOrder = $this->calculateAvgHoursToOrder($orderedLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
$viewRate = $total > 0 ? round(($views / $total) * 100, 2) : 0;
|
||||||
|
$orderRate = $total > 0 ? round(($orders / $total) * 100, 2) : 0;
|
||||||
|
|
||||||
|
$insights[$hour] = [
|
||||||
|
'business_id' => $businessId,
|
||||||
|
'playbook_type' => $playbookType,
|
||||||
|
'hour_of_day' => $hour,
|
||||||
|
'avg_view_rate' => $viewRate,
|
||||||
|
'avg_order_rate' => $orderRate,
|
||||||
|
'avg_hours_to_view' => $avgHoursToView,
|
||||||
|
'avg_hours_to_order' => $avgHoursToOrder,
|
||||||
|
'sample_size' => $total,
|
||||||
|
'computed_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
OrchestratorTimingInsight::updateOrCreate(
|
||||||
|
[
|
||||||
|
'business_id' => $businessId,
|
||||||
|
'playbook_type' => $playbookType,
|
||||||
|
'hour_of_day' => $hour,
|
||||||
|
'day_of_week' => null, // Future: add day-of-week breakdowns
|
||||||
|
],
|
||||||
|
$insights[$hour]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display summary
|
||||||
|
$this->displaySummary($insights, $businessId, $playbookType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate average hours between send and first view.
|
||||||
|
*/
|
||||||
|
private function calculateAvgHoursToView($logs): ?float
|
||||||
|
{
|
||||||
|
$totalHours = 0;
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach ($logs as $log) {
|
||||||
|
// Find first view after send
|
||||||
|
$firstView = MenuViewEvent::where('menu_id', $log->menu_id)
|
||||||
|
->where('customer_id', $log->customer_id)
|
||||||
|
->where('viewed_at', '>', $log->sent_at)
|
||||||
|
->orderBy('viewed_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($firstView) {
|
||||||
|
$hours = $log->sent_at->diffInHours($firstView->viewed_at, true);
|
||||||
|
$totalHours += $hours;
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count > 0 ? round($totalHours / $count, 2) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate average hours between send and order.
|
||||||
|
*/
|
||||||
|
private function calculateAvgHoursToOrder($logs): ?float
|
||||||
|
{
|
||||||
|
$totalHours = 0;
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach ($logs as $log) {
|
||||||
|
// Find first order after send (within 7 days)
|
||||||
|
$order = Order::where('business_id', $log->customer_id)
|
||||||
|
->where('created_at', '>', $log->sent_at)
|
||||||
|
->where('created_at', '<=', $log->sent_at->copy()->addDays(7))
|
||||||
|
->orderBy('created_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($order) {
|
||||||
|
$hours = $log->sent_at->diffInHours($order->created_at, true);
|
||||||
|
$totalHours += $hours;
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count > 0 ? round($totalHours / $count, 2) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display timing summary.
|
||||||
|
*/
|
||||||
|
private function displaySummary(array $insights, ?int $businessId, string $playbookType): void
|
||||||
|
{
|
||||||
|
if (empty($insights)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope = $businessId ? "Business #{$businessId}" : 'Global';
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("=== {$scope} / {$playbookType} ===");
|
||||||
|
|
||||||
|
// Find top 3 hours by order rate
|
||||||
|
$sorted = collect($insights)->sortByDesc('avg_order_rate')->take(3);
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Hour', 'View Rate', 'Order Rate', 'Avg Hrs to View', 'Sample Size'],
|
||||||
|
$sorted->map(fn ($row) => [
|
||||||
|
$this->formatHour($row['hour_of_day']),
|
||||||
|
$row['avg_view_rate'].'%',
|
||||||
|
$row['avg_order_rate'].'%',
|
||||||
|
$row['avg_hours_to_view'] ?? '-',
|
||||||
|
$row['sample_size'],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatHour(int $hour): string
|
||||||
|
{
|
||||||
|
if ($hour === 0) {
|
||||||
|
return '12 AM';
|
||||||
|
}
|
||||||
|
if ($hour < 12) {
|
||||||
|
return $hour.' AM';
|
||||||
|
}
|
||||||
|
if ($hour === 12) {
|
||||||
|
return '12 PM';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($hour - 12).' PM';
|
||||||
|
}
|
||||||
|
}
|
||||||
235
app/Console/Commands/OrchestratorCheckHorizon.php
Normal file
235
app/Console/Commands/OrchestratorCheckHorizon.php
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\AutomationRunLog;
|
||||||
|
use App\Models\SystemAlert;
|
||||||
|
use App\Notifications\OrchestratorCriticalAlert;
|
||||||
|
use App\Services\OrchestratorGovernanceService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
|
||||||
|
class OrchestratorCheckHorizon extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'orchestrator:check-horizon
|
||||||
|
{--attempt-restart : Attempt to restart Horizon if down}
|
||||||
|
{--notify : Send notifications for critical issues}';
|
||||||
|
|
||||||
|
protected $description = 'Check Horizon and queue health status';
|
||||||
|
|
||||||
|
private OrchestratorGovernanceService $governance;
|
||||||
|
|
||||||
|
private int $consecutiveFailures = 0;
|
||||||
|
|
||||||
|
public function __construct(OrchestratorGovernanceService $governance)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
$this->governance = $governance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
AutomationRunLog::recordStart(AutomationRunLog::CMD_CHECK_HORIZON);
|
||||||
|
|
||||||
|
$this->info('Checking Horizon and queue health...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$health = $this->governance->checkHorizonHealth();
|
||||||
|
|
||||||
|
$this->displayHealth($health);
|
||||||
|
|
||||||
|
// Process alerts
|
||||||
|
$alertsCreated = 0;
|
||||||
|
foreach ($health['alerts'] as $alertData) {
|
||||||
|
$alert = SystemAlert::createAlert(
|
||||||
|
$alertData['type'],
|
||||||
|
$alertData['severity'],
|
||||||
|
SystemAlert::SOURCE_HORIZON,
|
||||||
|
$alertData['title'],
|
||||||
|
$alertData['message'],
|
||||||
|
$alertData['context'] ?? [],
|
||||||
|
30 // Dedupe window: 30 minutes
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($alert) {
|
||||||
|
$alertsCreated++;
|
||||||
|
|
||||||
|
// Send notification for critical alerts
|
||||||
|
if ($this->option('notify') && $alert->isCritical()) {
|
||||||
|
$this->sendCriticalNotification($alert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle restart if needed
|
||||||
|
if ($health['status'] === 'critical' && $this->option('attempt-restart')) {
|
||||||
|
$this->attemptRestart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-resolve alerts if health is now good
|
||||||
|
if ($health['status'] === 'healthy') {
|
||||||
|
$resolved = SystemAlert::autoResolve(
|
||||||
|
SystemAlert::TYPE_HORIZON_DOWN,
|
||||||
|
SystemAlert::SOURCE_HORIZON,
|
||||||
|
'Auto-resolved: Horizon is healthy'
|
||||||
|
);
|
||||||
|
|
||||||
|
$resolved += SystemAlert::autoResolve(
|
||||||
|
SystemAlert::TYPE_HORIZON_DEGRADED,
|
||||||
|
SystemAlert::SOURCE_HORIZON,
|
||||||
|
'Auto-resolved: Horizon is healthy'
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($resolved > 0) {
|
||||||
|
$this->info("Auto-resolved {$resolved} previous alerts.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_CHECK_HORIZON, [
|
||||||
|
'status' => $health['status'],
|
||||||
|
'alerts_created' => $alertsCreated,
|
||||||
|
'checks' => array_keys($health['checks']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $health['status'] === 'critical' ? self::FAILURE : self::SUCCESS;
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
AutomationRunLog::recordFailure(AutomationRunLog::CMD_CHECK_HORIZON, $e->getMessage());
|
||||||
|
|
||||||
|
$this->error('Health check failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
// Create alert for check failure
|
||||||
|
SystemAlert::critical(
|
||||||
|
SystemAlert::TYPE_HORIZON_DOWN,
|
||||||
|
SystemAlert::SOURCE_HORIZON,
|
||||||
|
'Horizon health check failed',
|
||||||
|
'Unable to check Horizon health: '.$e->getMessage()
|
||||||
|
);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function displayHealth(array $health): void
|
||||||
|
{
|
||||||
|
$statusColor = match ($health['status']) {
|
||||||
|
'healthy' => 'green',
|
||||||
|
'warning' => 'yellow',
|
||||||
|
'critical' => 'red',
|
||||||
|
default => 'white',
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->line("Overall Status: <fg={$statusColor}>{$health['status']}</>");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Check', 'Status', 'Details'],
|
||||||
|
collect($health['checks'])->map(function ($check, $name) {
|
||||||
|
$status = $check['status'] ?? 'unknown';
|
||||||
|
$statusColor = match ($status) {
|
||||||
|
'healthy' => 'green',
|
||||||
|
'warning' => 'yellow',
|
||||||
|
'critical' => 'red',
|
||||||
|
default => 'white',
|
||||||
|
};
|
||||||
|
|
||||||
|
$details = collect($check)
|
||||||
|
->except('status', 'error')
|
||||||
|
->map(fn ($v, $k) => "{$k}: {$v}")
|
||||||
|
->implode(', ');
|
||||||
|
|
||||||
|
if (isset($check['error'])) {
|
||||||
|
$details = "Error: {$check['error']}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
$name,
|
||||||
|
"<fg={$statusColor}>{$status}</>",
|
||||||
|
$details ?: '-',
|
||||||
|
];
|
||||||
|
})->toArray()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! empty($health['alerts'])) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('Alerts:');
|
||||||
|
foreach ($health['alerts'] as $alert) {
|
||||||
|
$icon = $alert['severity'] === 'critical' ? '!!!' : '!';
|
||||||
|
$this->line(" [{$icon}] {$alert['title']}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attemptRestart(): void
|
||||||
|
{
|
||||||
|
$this->warn('Attempting to restart Horizon...');
|
||||||
|
|
||||||
|
// Check consecutive failure count
|
||||||
|
$failureKey = 'horizon_restart_failures';
|
||||||
|
$this->consecutiveFailures = (int) Cache::get($failureKey, 0);
|
||||||
|
|
||||||
|
if ($this->consecutiveFailures >= 2) {
|
||||||
|
$this->error('Restart failed twice. Escalating critical alert.');
|
||||||
|
|
||||||
|
SystemAlert::critical(
|
||||||
|
SystemAlert::TYPE_HORIZON_DOWN,
|
||||||
|
SystemAlert::SOURCE_HORIZON,
|
||||||
|
'Horizon restart failed multiple times',
|
||||||
|
'Horizon has failed to restart after 2 attempts. Manual intervention required.',
|
||||||
|
['consecutive_failures' => $this->consecutiveFailures]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset counter
|
||||||
|
Cache::forget($failureKey);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to terminate and restart via Artisan
|
||||||
|
Artisan::call('horizon:terminate');
|
||||||
|
$this->info('Horizon terminate signal sent.');
|
||||||
|
|
||||||
|
// The actual restart would be handled by supervisor/docker
|
||||||
|
// We just send the terminate signal here
|
||||||
|
|
||||||
|
// Wait a moment and check again
|
||||||
|
sleep(5);
|
||||||
|
|
||||||
|
$health = $this->governance->checkHorizonHealth();
|
||||||
|
|
||||||
|
if ($health['status'] === 'healthy') {
|
||||||
|
$this->info('Horizon restarted successfully.');
|
||||||
|
Cache::forget($failureKey);
|
||||||
|
} else {
|
||||||
|
Cache::put($failureKey, $this->consecutiveFailures + 1, now()->addHours(1));
|
||||||
|
$this->warn('Horizon still unhealthy after restart attempt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Cache::put($failureKey, $this->consecutiveFailures + 1, now()->addHours(1));
|
||||||
|
$this->error('Restart attempt failed: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendCriticalNotification(SystemAlert $alert): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Get admin users to notify
|
||||||
|
$admins = \App\Models\User::where('user_type', 'admin')
|
||||||
|
->orWhere('user_type', 'superadmin')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($admins->isNotEmpty()) {
|
||||||
|
Notification::send($admins, new OrchestratorCriticalAlert($alert));
|
||||||
|
$alert->markNotificationSent();
|
||||||
|
$this->info('Critical alert notification sent.');
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->warn('Failed to send notification: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
255
app/Console/Commands/OrchestratorEvaluateOutcomes.php
Normal file
255
app/Console/Commands/OrchestratorEvaluateOutcomes.php
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\AutomationRunLog;
|
||||||
|
use App\Models\MenuViewEvent;
|
||||||
|
use App\Models\OrchestratorMessageVariantStat;
|
||||||
|
use App\Models\OrchestratorTask;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\SendMenuLog;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OrchestratorEvaluateOutcomes - Evaluates outcomes of menu sends.
|
||||||
|
*
|
||||||
|
* This command runs periodically to determine if menu sends resulted in:
|
||||||
|
* - Menu views (buyer viewed the menu after send)
|
||||||
|
* - Orders (buyer placed an order after send)
|
||||||
|
*
|
||||||
|
* The outcomes are stored on both SendMenuLog and OrchestratorTask for analytics.
|
||||||
|
*/
|
||||||
|
class OrchestratorEvaluateOutcomes extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'orchestrator:evaluate-outcomes
|
||||||
|
{--days=30 : Number of days back to evaluate}
|
||||||
|
{--dry-run : Show what would be updated without making changes}';
|
||||||
|
|
||||||
|
protected $description = 'Evaluate outcomes (views/orders) for menu sends and orchestrator tasks';
|
||||||
|
|
||||||
|
protected int $viewsFound = 0;
|
||||||
|
|
||||||
|
protected int $ordersFound = 0;
|
||||||
|
|
||||||
|
protected int $logsUpdated = 0;
|
||||||
|
|
||||||
|
protected int $tasksUpdated = 0;
|
||||||
|
|
||||||
|
protected int $variantStatsRecorded = 0;
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
AutomationRunLog::recordStart(AutomationRunLog::CMD_EVALUATE_OUTCOMES);
|
||||||
|
|
||||||
|
$days = (int) $this->option('days');
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
$this->info("Evaluating outcomes for send_menu_logs from the last {$days} days...");
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('DRY RUN - No changes will be made.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get send menu logs that haven't been evaluated yet
|
||||||
|
$logs = SendMenuLog::query()
|
||||||
|
->whereNull('outcome_checked_at')
|
||||||
|
->where('sent_at', '>=', now()->subDays($days))
|
||||||
|
->with(['brand', 'menu', 'customer'])
|
||||||
|
->orderBy('sent_at', 'asc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->info("Found {$logs->count()} logs to evaluate.");
|
||||||
|
|
||||||
|
$progressBar = $this->output->createProgressBar($logs->count());
|
||||||
|
$progressBar->start();
|
||||||
|
|
||||||
|
foreach ($logs as $log) {
|
||||||
|
$this->evaluateSendMenuLog($log, $dryRun);
|
||||||
|
$progressBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
$this->info('Evaluation complete:');
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Count'],
|
||||||
|
[
|
||||||
|
['Logs evaluated', $logs->count()],
|
||||||
|
['Views found', $this->viewsFound],
|
||||||
|
['Orders found', $this->ordersFound],
|
||||||
|
['Send logs updated', $this->logsUpdated],
|
||||||
|
['Orchestrator tasks updated', $this->tasksUpdated],
|
||||||
|
['Variant stats recorded', $this->variantStatsRecorded],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_EVALUATE_OUTCOMES, [
|
||||||
|
'logs_evaluated' => $logs->count(),
|
||||||
|
'views_found' => $this->viewsFound,
|
||||||
|
'orders_found' => $this->ordersFound,
|
||||||
|
'tasks_updated' => $this->tasksUpdated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a single SendMenuLog for outcomes.
|
||||||
|
*/
|
||||||
|
protected function evaluateSendMenuLog(SendMenuLog $log, bool $dryRun): void
|
||||||
|
{
|
||||||
|
$resultedInView = false;
|
||||||
|
$resultedInOrder = false;
|
||||||
|
|
||||||
|
// Look for menu view events after the send
|
||||||
|
$viewExists = MenuViewEvent::query()
|
||||||
|
->where('business_id', $log->brand?->business_id)
|
||||||
|
->where('customer_id', $log->customer_id)
|
||||||
|
->where('menu_id', $log->menu_id)
|
||||||
|
->where('viewed_at', '>', $log->sent_at)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($viewExists) {
|
||||||
|
$resultedInView = true;
|
||||||
|
$this->viewsFound++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for orders after the send (if Order table has the columns we need)
|
||||||
|
if (Schema::hasColumn('orders', 'business_id')) {
|
||||||
|
$orderExists = Order::query()
|
||||||
|
->where('business_id', $log->customer_id) // buyer's business
|
||||||
|
->whereHas('items.product.brand', function ($q) use ($log) {
|
||||||
|
$q->where('business_id', $log->brand?->business_id);
|
||||||
|
})
|
||||||
|
->where('created_at', '>', $log->sent_at)
|
||||||
|
->where('created_at', '<=', $log->sent_at->copy()->addDays(7)) // Within 7 days
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($orderExists) {
|
||||||
|
$resultedInOrder = true;
|
||||||
|
$this->ordersFound++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update SendMenuLog
|
||||||
|
if (! $dryRun) {
|
||||||
|
$log->update([
|
||||||
|
'resulted_in_view' => $resultedInView,
|
||||||
|
'resulted_in_order' => $resultedInOrder,
|
||||||
|
'outcome_checked_at' => now(),
|
||||||
|
]);
|
||||||
|
$this->logsUpdated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update related OrchestratorTask if one exists
|
||||||
|
$this->updateRelatedTask($log, $resultedInView, $resultedInOrder, $dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the orchestrator task associated with this send, if any.
|
||||||
|
*/
|
||||||
|
protected function updateRelatedTask(SendMenuLog $log, bool $resultedInView, bool $resultedInOrder, bool $dryRun): void
|
||||||
|
{
|
||||||
|
// Check if outcome columns exist on orchestrator_tasks
|
||||||
|
if (! Schema::hasColumn('orchestrator_tasks', 'resulted_in_view')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find tasks that reference this send_menu_log_id in their payload
|
||||||
|
$tasks = OrchestratorTask::query()
|
||||||
|
->where('business_id', $log->brand?->business_id)
|
||||||
|
->where('customer_id', $log->customer_id)
|
||||||
|
->whereJsonContains('payload->send_menu_log_id', $log->id)
|
||||||
|
->whereNull('outcome_checked_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($tasks->isEmpty()) {
|
||||||
|
// Also try finding by menu_id and rough time match
|
||||||
|
$tasks = OrchestratorTask::query()
|
||||||
|
->where('business_id', $log->brand?->business_id)
|
||||||
|
->where('customer_id', $log->customer_id)
|
||||||
|
->whereJsonContains('payload->menu_id', $log->menu_id)
|
||||||
|
->where('status', OrchestratorTask::STATUS_COMPLETED)
|
||||||
|
->where('completed_at', '>=', $log->sent_at->copy()->subMinutes(30))
|
||||||
|
->where('completed_at', '<=', $log->sent_at->copy()->addMinutes(30))
|
||||||
|
->whereNull('outcome_checked_at')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
if (! $dryRun) {
|
||||||
|
$task->update([
|
||||||
|
'resulted_in_view' => $resultedInView,
|
||||||
|
'resulted_in_order' => $resultedInOrder,
|
||||||
|
'outcome_checked_at' => now(),
|
||||||
|
]);
|
||||||
|
$this->tasksUpdated++;
|
||||||
|
|
||||||
|
// Track A/B variant stats if this task used a variant
|
||||||
|
$this->recordVariantStats($task, $resultedInView, $resultedInOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record A/B variant statistics for a task.
|
||||||
|
*
|
||||||
|
* Increments view and/or order counts for the message variant used by this task.
|
||||||
|
* Send count is recorded separately when tasks are completed (via SendMenuLog tracking).
|
||||||
|
*/
|
||||||
|
protected function recordVariantStats(OrchestratorTask $task, bool $resultedInView, bool $resultedInOrder): void
|
||||||
|
{
|
||||||
|
// Check if variant stats table exists
|
||||||
|
if (! Schema::hasTable('orchestrator_message_variant_stats')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $task->payload ?? [];
|
||||||
|
$variantKey = $payload['message_variant_key'] ?? null;
|
||||||
|
|
||||||
|
if (! $variantKey) {
|
||||||
|
return; // Task didn't use a variant
|
||||||
|
}
|
||||||
|
|
||||||
|
$businessId = $task->business_id;
|
||||||
|
$brandId = $task->brand_id;
|
||||||
|
$playbookType = $task->type;
|
||||||
|
|
||||||
|
// Record view stat
|
||||||
|
if ($resultedInView) {
|
||||||
|
OrchestratorMessageVariantStat::incrementView(
|
||||||
|
$businessId,
|
||||||
|
$brandId,
|
||||||
|
$playbookType,
|
||||||
|
$variantKey
|
||||||
|
);
|
||||||
|
$this->variantStatsRecorded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record order stat
|
||||||
|
if ($resultedInOrder) {
|
||||||
|
OrchestratorMessageVariantStat::incrementOrder(
|
||||||
|
$businessId,
|
||||||
|
$brandId,
|
||||||
|
$playbookType,
|
||||||
|
$variantKey
|
||||||
|
);
|
||||||
|
$this->variantStatsRecorded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record send stat (task was completed = message was sent)
|
||||||
|
// Only count once per task, check if this task was just now having its outcome evaluated
|
||||||
|
if ($task->status === OrchestratorTask::STATUS_COMPLETED) {
|
||||||
|
OrchestratorMessageVariantStat::incrementSend(
|
||||||
|
$businessId,
|
||||||
|
$brandId,
|
||||||
|
$playbookType,
|
||||||
|
$variantKey
|
||||||
|
);
|
||||||
|
$this->variantStatsRecorded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
app/Console/Commands/OrchestratorEvaluatePlaybooks.php
Normal file
149
app/Console/Commands/OrchestratorEvaluatePlaybooks.php
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\AutomationRunLog;
|
||||||
|
use App\Models\OrchestratorPlaybookStatus;
|
||||||
|
use App\Models\OrchestratorTask;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OrchestratorEvaluatePlaybooks - Evaluates playbook performance and auto-quarantines.
|
||||||
|
*
|
||||||
|
* Analyzes 30-day rolling metrics for each playbook and quarantines those
|
||||||
|
* that fall below performance thresholds. This prevents misbehaving playbooks
|
||||||
|
* from generating poor suggestions.
|
||||||
|
*/
|
||||||
|
class OrchestratorEvaluatePlaybooks extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'orchestrator:evaluate-playbooks
|
||||||
|
{--dry-run : Show what would happen without making changes}
|
||||||
|
{--business= : Evaluate for specific business ID}
|
||||||
|
{--auto-quarantine : Actually quarantine underperformers (default: report only)}';
|
||||||
|
|
||||||
|
protected $description = 'Evaluate playbook performance and auto-quarantine underperformers';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
AutomationRunLog::recordStart(AutomationRunLog::CMD_EVALUATE_PLAYBOOKS);
|
||||||
|
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$businessId = $this->option('business');
|
||||||
|
$autoQuarantine = $this->option('auto-quarantine');
|
||||||
|
|
||||||
|
$this->info('Evaluating playbook performance...');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('DRY RUN - No changes will be made.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$playbooks = OrchestratorPlaybookStatus::getAllPlaybookTypes();
|
||||||
|
$thirtyDaysAgo = now()->subDays(30);
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($playbooks as $playbookType) {
|
||||||
|
$this->line("Analyzing: {$playbookType}");
|
||||||
|
|
||||||
|
// Get task metrics
|
||||||
|
$query = OrchestratorTask::forType($playbookType)
|
||||||
|
->where('created_at', '>=', $thirtyDaysAgo);
|
||||||
|
|
||||||
|
if ($businessId) {
|
||||||
|
$query->forBusiness((int) $businessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$created = $query->count();
|
||||||
|
$completed = (clone $query)->completed()->count();
|
||||||
|
$dismissed = (clone $query)->dismissed()->count();
|
||||||
|
$resultedInView = (clone $query)->where('resulted_in_view', true)->count();
|
||||||
|
$resultedInOrder = (clone $query)->where('resulted_in_order', true)->count();
|
||||||
|
|
||||||
|
// Calculate rates
|
||||||
|
$resolved = $completed + $dismissed;
|
||||||
|
$viewRate = $resolved > 0 ? round(($resultedInView / $resolved) * 100, 2) : 0;
|
||||||
|
$orderRate = $resolved > 0 ? round(($resultedInOrder / $resolved) * 100, 2) : 0;
|
||||||
|
$dismissalRate = $resolved > 0 ? round(($dismissed / $resolved) * 100, 2) : 0;
|
||||||
|
|
||||||
|
// Get or create status record
|
||||||
|
$status = OrchestratorPlaybookStatus::getOrCreate($playbookType, $businessId ? (int) $businessId : null);
|
||||||
|
|
||||||
|
// Update metrics
|
||||||
|
if (! $dryRun) {
|
||||||
|
$status->updateMetrics([
|
||||||
|
'tasks_created_30d' => $created,
|
||||||
|
'tasks_completed_30d' => $completed,
|
||||||
|
'tasks_dismissed_30d' => $dismissed,
|
||||||
|
'resulted_in_view_30d' => $resultedInView,
|
||||||
|
'resulted_in_order_30d' => $resultedInOrder,
|
||||||
|
'view_rate_30d' => $viewRate,
|
||||||
|
'order_rate_30d' => $orderRate,
|
||||||
|
'dismissal_rate_30d' => $dismissalRate,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check quarantine conditions
|
||||||
|
$shouldQuarantine = $status->shouldQuarantine();
|
||||||
|
$healthStatus = $status->getHealthStatus();
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'playbook' => $this->formatPlaybookName($playbookType),
|
||||||
|
'status' => $status->status,
|
||||||
|
'created' => $created,
|
||||||
|
'completed' => $completed,
|
||||||
|
'dismissed' => $dismissed,
|
||||||
|
'view_rate' => $viewRate.'%',
|
||||||
|
'order_rate' => $orderRate.'%',
|
||||||
|
'dismissal' => $dismissalRate.'%',
|
||||||
|
'health' => $healthStatus,
|
||||||
|
'action' => $shouldQuarantine ? 'QUARANTINE' : '-',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Auto-quarantine if enabled
|
||||||
|
if ($shouldQuarantine && $autoQuarantine && ! $dryRun && $status->isActive()) {
|
||||||
|
$status->quarantine($shouldQuarantine);
|
||||||
|
$this->error(" QUARANTINED: {$shouldQuarantine}");
|
||||||
|
} elseif ($shouldQuarantine) {
|
||||||
|
$this->warn(" Would quarantine: {$shouldQuarantine}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display results table
|
||||||
|
$this->newLine();
|
||||||
|
$this->table(
|
||||||
|
['Playbook', 'Status', 'Created', 'Done', 'Dismissed', 'View%', 'Order%', 'Dismiss%', 'Health', 'Action'],
|
||||||
|
$results
|
||||||
|
);
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
$quarantined = collect($results)->where('action', 'QUARANTINE')->count();
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Evaluation complete. {$quarantined} playbook(s) flagged for quarantine.");
|
||||||
|
|
||||||
|
if ($quarantined > 0 && ! $autoQuarantine) {
|
||||||
|
$this->warn('Run with --auto-quarantine to actually quarantine underperformers.');
|
||||||
|
}
|
||||||
|
|
||||||
|
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_EVALUATE_PLAYBOOKS, [
|
||||||
|
'playbooks_evaluated' => count($playbooks),
|
||||||
|
'quarantined' => $quarantined,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatPlaybookName(string $type): string
|
||||||
|
{
|
||||||
|
return match ($type) {
|
||||||
|
OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW => 'No View',
|
||||||
|
OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER => 'Viewed No Order',
|
||||||
|
OrchestratorTask::TYPE_REACTIVATION_NO_ORDER_30D => 'Reactivation',
|
||||||
|
OrchestratorTask::TYPE_PROMOTION_BROADCAST_SUGGESTION => 'New Menu',
|
||||||
|
OrchestratorTask::TYPE_HIGH_INTENT_BUYER => 'High Intent',
|
||||||
|
OrchestratorTask::TYPE_VIP_BUYER => 'VIP Buyer',
|
||||||
|
OrchestratorTask::TYPE_GHOSTED_BUYER_RESCUE => 'Ghosted',
|
||||||
|
OrchestratorTask::TYPE_AT_RISK_ACCOUNT => 'At-Risk',
|
||||||
|
default => $type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
413
app/Console/Commands/OrchestratorSelfAudit.php
Normal file
413
app/Console/Commands/OrchestratorSelfAudit.php
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\AutomationRunLog;
|
||||||
|
use App\Models\BrandOrchestratorProfile;
|
||||||
|
use App\Models\OrchestratorMessageVariant;
|
||||||
|
use App\Models\OrchestratorTask;
|
||||||
|
use App\Models\SystemAlert;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class OrchestratorSelfAudit extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'orchestrator:self-audit
|
||||||
|
{--fix : Attempt to fix issues where possible}
|
||||||
|
{--days=30 : Days to look back for stale tasks}';
|
||||||
|
|
||||||
|
protected $description = 'Audit orchestrator data for integrity issues';
|
||||||
|
|
||||||
|
private array $issues = [];
|
||||||
|
|
||||||
|
private array $fixes = [];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
AutomationRunLog::recordStart(AutomationRunLog::CMD_SELF_AUDIT);
|
||||||
|
|
||||||
|
$this->info('Running orchestrator self-audit...');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$days = (int) $this->option('days');
|
||||||
|
$shouldFix = $this->option('fix');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Run all audit checks
|
||||||
|
$this->auditStaleTasks($days, $shouldFix);
|
||||||
|
$this->auditMissingPayloadFields($shouldFix);
|
||||||
|
$this->auditImpossibleStates($shouldFix);
|
||||||
|
$this->auditBrandProfiles();
|
||||||
|
$this->auditMessageVariants();
|
||||||
|
$this->auditMissingOutcomes($days);
|
||||||
|
$this->auditOrphanedRecords();
|
||||||
|
|
||||||
|
// Display results
|
||||||
|
$this->displayResults();
|
||||||
|
|
||||||
|
// Create system alerts for significant issues
|
||||||
|
$this->createAlertsForIssues();
|
||||||
|
|
||||||
|
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_SELF_AUDIT, [
|
||||||
|
'issues_found' => count($this->issues),
|
||||||
|
'fixes_applied' => count($this->fixes),
|
||||||
|
'days_audited' => $days,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return count($this->issues) > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
AutomationRunLog::recordFailure(AutomationRunLog::CMD_SELF_AUDIT, $e->getMessage());
|
||||||
|
|
||||||
|
$this->error('Self-audit failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditStaleTasks(int $days, bool $fix): void
|
||||||
|
{
|
||||||
|
$this->info('Checking for stale uncompleted tasks...');
|
||||||
|
|
||||||
|
$staleCount = OrchestratorTask::where('status', OrchestratorTask::STATUS_PENDING)
|
||||||
|
->where('created_at', '<', now()->subDays($days))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($staleCount > 0) {
|
||||||
|
$this->issues[] = [
|
||||||
|
'type' => 'stale_tasks',
|
||||||
|
'severity' => $staleCount > 100 ? 'warning' : 'info',
|
||||||
|
'message' => "{$staleCount} tasks pending for more than {$days} days",
|
||||||
|
'count' => $staleCount,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($fix && $staleCount > 0) {
|
||||||
|
// Auto-dismiss very old tasks (older than 60 days)
|
||||||
|
$veryOld = OrchestratorTask::where('status', OrchestratorTask::STATUS_PENDING)
|
||||||
|
->where('created_at', '<', now()->subDays(60))
|
||||||
|
->update([
|
||||||
|
'status' => OrchestratorTask::STATUS_DISMISSED,
|
||||||
|
'dismissed_reason' => 'Auto-dismissed by self-audit (>60 days old)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($veryOld > 0) {
|
||||||
|
$this->fixes[] = "Auto-dismissed {$veryOld} tasks older than 60 days";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" Found {$staleCount} stale tasks");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditMissingPayloadFields(bool $fix): void
|
||||||
|
{
|
||||||
|
$this->info('Checking for tasks with missing payload fields...');
|
||||||
|
|
||||||
|
// Tasks should have suggested_message in payload
|
||||||
|
$missingMessage = OrchestratorTask::whereNull('payload')
|
||||||
|
->orWhereRaw("payload::text = '{}'")
|
||||||
|
->orWhereRaw("payload::text = 'null'")
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($missingMessage > 0) {
|
||||||
|
$this->issues[] = [
|
||||||
|
'type' => 'missing_payload',
|
||||||
|
'severity' => 'info',
|
||||||
|
'message' => "{$missingMessage} tasks have empty or null payload",
|
||||||
|
'count' => $missingMessage,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" Found {$missingMessage} tasks with missing payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditImpossibleStates(bool $fix): void
|
||||||
|
{
|
||||||
|
$this->info('Checking for impossible task states...');
|
||||||
|
|
||||||
|
$issues = 0;
|
||||||
|
|
||||||
|
// Approved but not visible to reps
|
||||||
|
if (Schema::hasColumn('orchestrator_tasks', 'approval_state')) {
|
||||||
|
$approvedInvisible = OrchestratorTask::where('approval_state', 'approved')
|
||||||
|
->where('visible_to_reps', false)
|
||||||
|
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($approvedInvisible > 0) {
|
||||||
|
$issues += $approvedInvisible;
|
||||||
|
$this->issues[] = [
|
||||||
|
'type' => 'impossible_state',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'message' => "{$approvedInvisible} tasks are approved but not visible to reps",
|
||||||
|
'count' => $approvedInvisible,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($fix) {
|
||||||
|
$fixed = OrchestratorTask::where('approval_state', 'approved')
|
||||||
|
->where('visible_to_reps', false)
|
||||||
|
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||||
|
->update(['visible_to_reps' => true]);
|
||||||
|
|
||||||
|
if ($fixed > 0) {
|
||||||
|
$this->fixes[] = "Fixed {$fixed} approved tasks to be visible";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocked but still visible
|
||||||
|
$blockedVisible = OrchestratorTask::where('approval_state', 'blocked')
|
||||||
|
->where('visible_to_reps', true)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($blockedVisible > 0) {
|
||||||
|
$issues += $blockedVisible;
|
||||||
|
$this->issues[] = [
|
||||||
|
'type' => 'impossible_state',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'message' => "{$blockedVisible} tasks are blocked but still visible to reps",
|
||||||
|
'count' => $blockedVisible,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($fix) {
|
||||||
|
$fixed = OrchestratorTask::where('approval_state', 'blocked')
|
||||||
|
->where('visible_to_reps', true)
|
||||||
|
->update(['visible_to_reps' => false]);
|
||||||
|
|
||||||
|
if ($fixed > 0) {
|
||||||
|
$this->fixes[] = "Fixed {$fixed} blocked tasks to be invisible";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completed but no completed_at
|
||||||
|
$completedNoDate = OrchestratorTask::where('status', OrchestratorTask::STATUS_COMPLETED)
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($completedNoDate > 0) {
|
||||||
|
$issues += $completedNoDate;
|
||||||
|
$this->issues[] = [
|
||||||
|
'type' => 'impossible_state',
|
||||||
|
'severity' => 'info',
|
||||||
|
'message' => "{$completedNoDate} completed tasks have no completed_at timestamp",
|
||||||
|
'count' => $completedNoDate,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($fix) {
|
||||||
|
$fixed = OrchestratorTask::where('status', OrchestratorTask::STATUS_COMPLETED)
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->update(['completed_at' => DB::raw('updated_at')]);
|
||||||
|
|
||||||
|
if ($fixed > 0) {
|
||||||
|
$this->fixes[] = "Set completed_at for {$fixed} completed tasks";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" Found {$issues} impossible state issues");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditBrandProfiles(): void
|
||||||
|
{
|
||||||
|
$this->info('Checking brand orchestrator profiles...');
|
||||||
|
|
||||||
|
if (! Schema::hasTable('brand_orchestrator_profiles')) {
|
||||||
|
$this->line(' Brand profiles table not found (skipping)');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for profiles with invalid brand_id
|
||||||
|
$orphanedProfiles = BrandOrchestratorProfile::whereDoesntHave('brand')->count();
|
||||||
|
|
||||||
|
if ($orphanedProfiles > 0) {
|
||||||
|
$this->issues[] = [
|
||||||
|
'type' => 'orphaned_profiles',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'message' => "{$orphanedProfiles} brand profiles reference non-existent brands",
|
||||||
|
'count' => $orphanedProfiles,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate brand profiles
|
||||||
|
$duplicates = DB::table('brand_orchestrator_profiles')
|
||||||
|
->select('brand_id', DB::raw('COUNT(*) as count'))
|
||||||
|
->groupBy('brand_id')
|
||||||
|
->having('count', '>', 1)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($duplicates > 0) {
|
||||||
|
$this->issues[] = [
|
||||||
|
'type' => 'duplicate_profiles',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'message' => "{$duplicates} brands have duplicate orchestrator profiles",
|
||||||
|
'count' => $duplicates,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" Found {$orphanedProfiles} orphaned, {$duplicates} duplicate profiles");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditMessageVariants(): void
|
||||||
|
{
|
||||||
|
$this->info('Checking message variants...');
|
||||||
|
|
||||||
|
if (! Schema::hasTable('orchestrator_message_variants')) {
|
||||||
|
$this->line(' Message variants table not found (skipping)');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for variants with invalid business_id
|
||||||
|
$orphanedVariants = OrchestratorMessageVariant::whereDoesntHave('business')->count();
|
||||||
|
|
||||||
|
if ($orphanedVariants > 0) {
|
||||||
|
$this->issues[] = [
|
||||||
|
'type' => 'orphaned_variants',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'message' => "{$orphanedVariants} message variants reference non-existent businesses",
|
||||||
|
'count' => $orphanedVariants,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for variants with empty body
|
||||||
|
$emptyBody = OrchestratorMessageVariant::where(function ($q) {
|
||||||
|
$q->whereNull('body')
|
||||||
|
->orWhere('body', '');
|
||||||
|
})->count();
|
||||||
|
|
||||||
|
if ($emptyBody > 0) {
|
||||||
|
$this->issues[] = [
|
||||||
|
'type' => 'empty_variants',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'message' => "{$emptyBody} message variants have empty body text",
|
||||||
|
'count' => $emptyBody,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" Found {$orphanedVariants} orphaned, {$emptyBody} empty variants");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditMissingOutcomes(int $days): void
|
||||||
|
{
|
||||||
|
$this->info('Checking for tasks missing outcome evaluation...');
|
||||||
|
|
||||||
|
if (! Schema::hasColumn('orchestrator_tasks', 'outcome_checked_at')) {
|
||||||
|
$this->line(' Outcome columns not found (skipping)');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks completed more than 7 days ago but never checked for outcomes
|
||||||
|
$missingOutcomes = OrchestratorTask::where('status', OrchestratorTask::STATUS_COMPLETED)
|
||||||
|
->whereNull('outcome_checked_at')
|
||||||
|
->where('completed_at', '<', now()->subDays(7))
|
||||||
|
->where('completed_at', '>=', now()->subDays($days))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($missingOutcomes > 0) {
|
||||||
|
$this->issues[] = [
|
||||||
|
'type' => 'missing_outcomes',
|
||||||
|
'severity' => 'info',
|
||||||
|
'message' => "{$missingOutcomes} completed tasks never had outcomes evaluated",
|
||||||
|
'count' => $missingOutcomes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" Found {$missingOutcomes} tasks missing outcome evaluation");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditOrphanedRecords(): void
|
||||||
|
{
|
||||||
|
$this->info('Checking for orphaned records...');
|
||||||
|
|
||||||
|
// Tasks referencing non-existent businesses
|
||||||
|
$orphanedByBusiness = OrchestratorTask::whereDoesntHave('business')->count();
|
||||||
|
|
||||||
|
if ($orphanedByBusiness > 0) {
|
||||||
|
$this->issues[] = [
|
||||||
|
'type' => 'orphaned_tasks',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'message' => "{$orphanedByBusiness} tasks reference non-existent businesses",
|
||||||
|
'count' => $orphanedByBusiness,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks referencing non-existent customers
|
||||||
|
$orphanedByCustomer = OrchestratorTask::whereNotNull('customer_id')
|
||||||
|
->whereDoesntHave('customer')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($orphanedByCustomer > 0) {
|
||||||
|
$this->issues[] = [
|
||||||
|
'type' => 'orphaned_tasks',
|
||||||
|
'severity' => 'info',
|
||||||
|
'message' => "{$orphanedByCustomer} tasks reference non-existent customers",
|
||||||
|
'count' => $orphanedByCustomer,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" Found {$orphanedByBusiness} orphaned by business, {$orphanedByCustomer} by customer");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function displayResults(): void
|
||||||
|
{
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if (empty($this->issues)) {
|
||||||
|
$this->info('No issues found. Data integrity looks good!');
|
||||||
|
} else {
|
||||||
|
$this->warn('Issues Found:');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Type', 'Severity', 'Count', 'Message'],
|
||||||
|
collect($this->issues)->map(fn ($issue) => [
|
||||||
|
$issue['type'],
|
||||||
|
$issue['severity'],
|
||||||
|
$issue['count'] ?? '-',
|
||||||
|
$issue['message'],
|
||||||
|
])->toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($this->fixes)) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Fixes Applied:');
|
||||||
|
foreach ($this->fixes as $fix) {
|
||||||
|
$this->line(" - {$fix}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('Summary: '.count($this->issues).' issues found, '.count($this->fixes).' fixes applied');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAlertsForIssues(): void
|
||||||
|
{
|
||||||
|
// Only create alerts for significant issues
|
||||||
|
$significantIssues = collect($this->issues)
|
||||||
|
->filter(fn ($issue) => $issue['severity'] === 'warning' || ($issue['count'] ?? 0) > 50);
|
||||||
|
|
||||||
|
if ($significantIssues->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = $significantIssues
|
||||||
|
->map(fn ($issue) => $issue['message'])
|
||||||
|
->implode('; ');
|
||||||
|
|
||||||
|
SystemAlert::createAlert(
|
||||||
|
SystemAlert::TYPE_AUDIT_ISSUE,
|
||||||
|
SystemAlert::SEVERITY_WARNING,
|
||||||
|
SystemAlert::SOURCE_SELF_AUDIT,
|
||||||
|
'Self-audit found '.count($this->issues).' issues',
|
||||||
|
$summary,
|
||||||
|
['issues' => $this->issues, 'fixes' => $this->fixes],
|
||||||
|
1440 // Dedupe: 24 hours
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
app/Console/Commands/OrchestratorSendDailyReport.php
Normal file
120
app/Console/Commands/OrchestratorSendDailyReport.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Mail\DailySalesOpsReport;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
class OrchestratorSendDailyReport extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'orchestrator:send-daily-report
|
||||||
|
{--to= : Email address to send to (overrides default)}
|
||||||
|
{--preview : Display report in console instead of sending}';
|
||||||
|
|
||||||
|
protected $description = 'Send the daily sales ops report email';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$this->info('Preparing daily sales ops report...');
|
||||||
|
|
||||||
|
$report = new DailySalesOpsReport;
|
||||||
|
|
||||||
|
if ($this->option('preview')) {
|
||||||
|
$this->displayPreview($report);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$recipients = $this->getRecipients();
|
||||||
|
|
||||||
|
if (empty($recipients)) {
|
||||||
|
$this->warn('No recipients configured. Use --to option or configure admin emails.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Sending report to: '.implode(', ', $recipients));
|
||||||
|
|
||||||
|
try {
|
||||||
|
Mail::to($recipients)->send($report);
|
||||||
|
|
||||||
|
$this->info('Daily report sent successfully.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error('Failed to send report: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getRecipients(): array
|
||||||
|
{
|
||||||
|
// Check for override
|
||||||
|
if ($to = $this->option('to')) {
|
||||||
|
return [$to];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get admin/superadmin users
|
||||||
|
$admins = User::where('user_type', 'admin')
|
||||||
|
->orWhere('user_type', 'superadmin')
|
||||||
|
->whereNotNull('email')
|
||||||
|
->pluck('email')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// Could also check BusinessMailSettings for specific ops email
|
||||||
|
// $opsEmail = config('orchestrator.daily_report_email');
|
||||||
|
// if ($opsEmail) {
|
||||||
|
// $admins[] = $opsEmail;
|
||||||
|
// }
|
||||||
|
|
||||||
|
return array_unique($admins);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function displayPreview(DailySalesOpsReport $report): void
|
||||||
|
{
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('=== Daily Sales Ops Report Preview ===');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$summary = $report->summary;
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Value'],
|
||||||
|
[
|
||||||
|
['Tasks Created (24h)', $summary['tasks_24h']['created']],
|
||||||
|
['Completed', $summary['tasks_24h']['completed']],
|
||||||
|
['Dismissed', $summary['tasks_24h']['dismissed']],
|
||||||
|
['Pending', $summary['tasks_24h']['pending']],
|
||||||
|
['High-Priority Pending', $summary['tasks_24h']['high_priority_pending']],
|
||||||
|
['Awaiting Approval', $summary['approvals']['pending_count']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Alerts:');
|
||||||
|
$this->table(
|
||||||
|
['Severity', 'Count'],
|
||||||
|
[
|
||||||
|
['Critical', $summary['alerts']['critical']],
|
||||||
|
['Warning', $summary['alerts']['warning']],
|
||||||
|
['Info', $summary['alerts']['info']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Queue Health: '.$summary['queue_health']['status']);
|
||||||
|
$this->info('Automation Health: '.$summary['automation_health']['overall_status']);
|
||||||
|
|
||||||
|
if (! empty($summary['alerts']['items'])) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('Alert Details:');
|
||||||
|
foreach (array_slice($summary['alerts']['items'], 0, 5) as $alert) {
|
||||||
|
$this->line(" [{$alert['severity']}] {$alert['title']}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
204
app/Console/Commands/OrchestratorWatchdog.php
Normal file
204
app/Console/Commands/OrchestratorWatchdog.php
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\AutomationRunLog;
|
||||||
|
use App\Models\SystemAlert;
|
||||||
|
use App\Notifications\OrchestratorCriticalAlert;
|
||||||
|
use App\Services\OrchestratorGovernanceService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
|
||||||
|
class OrchestratorWatchdog extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'orchestrator:watchdog
|
||||||
|
{--notify : Send notifications for critical issues}';
|
||||||
|
|
||||||
|
protected $description = 'Watch automation schedules and alert on stale processes';
|
||||||
|
|
||||||
|
private OrchestratorGovernanceService $governance;
|
||||||
|
|
||||||
|
public function __construct(OrchestratorGovernanceService $governance)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
$this->governance = $governance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
AutomationRunLog::recordStart(AutomationRunLog::CMD_WATCHDOG);
|
||||||
|
|
||||||
|
$this->info('Running orchestrator watchdog checks...');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$issues = [];
|
||||||
|
$alertsCreated = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check all automation schedules
|
||||||
|
$statuses = AutomationRunLog::getAllStatuses();
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Automation', 'Last Run', 'Status', 'Health'],
|
||||||
|
collect($statuses)->map(function ($status) {
|
||||||
|
$lastRun = $status['last_run_at']
|
||||||
|
? $status['last_run_at']->diffForHumans()
|
||||||
|
: 'Never';
|
||||||
|
|
||||||
|
$statusColor = match ($status['last_status']) {
|
||||||
|
AutomationRunLog::STATUS_SUCCESS => 'green',
|
||||||
|
AutomationRunLog::STATUS_FAILED => 'red',
|
||||||
|
AutomationRunLog::STATUS_RUNNING => 'yellow',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
|
||||||
|
$healthColor = $status['health'] === 'healthy' ? 'green' : 'red';
|
||||||
|
|
||||||
|
return [
|
||||||
|
$status['command'],
|
||||||
|
$lastRun,
|
||||||
|
"<fg={$statusColor}>{$status['last_status']}</>",
|
||||||
|
"<fg={$healthColor}>{$status['health']}</>",
|
||||||
|
];
|
||||||
|
})->toArray()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create alerts for unhealthy automations
|
||||||
|
foreach ($statuses as $command => $status) {
|
||||||
|
if ($status['health'] === 'unhealthy') {
|
||||||
|
$issues[] = $command;
|
||||||
|
|
||||||
|
$alert = $this->createStaleAlert($command, $status);
|
||||||
|
if ($alert) {
|
||||||
|
$alertsCreated++;
|
||||||
|
|
||||||
|
if ($this->option('notify') && $status['is_failing']) {
|
||||||
|
$this->sendNotification($alert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Auto-resolve previous alerts for this command
|
||||||
|
SystemAlert::autoResolve(
|
||||||
|
SystemAlert::TYPE_AUTOMATION_STALE,
|
||||||
|
SystemAlert::SOURCE_WATCHDOG,
|
||||||
|
"Auto-resolved: {$command} is running"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run governance checks
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Running governance checks...');
|
||||||
|
|
||||||
|
$governanceAlerts = $this->governance->runAllChecks();
|
||||||
|
|
||||||
|
foreach ($governanceAlerts as $alertData) {
|
||||||
|
$alert = SystemAlert::createAlert(
|
||||||
|
$alertData['type'],
|
||||||
|
$alertData['severity'],
|
||||||
|
SystemAlert::SOURCE_GOVERNANCE,
|
||||||
|
$alertData['title'],
|
||||||
|
$alertData['message'],
|
||||||
|
$alertData['context'] ?? [],
|
||||||
|
60 // Dedupe: 1 hour
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($alert) {
|
||||||
|
$alertsCreated++;
|
||||||
|
$this->line(" [!] {$alertData['title']}");
|
||||||
|
|
||||||
|
if ($this->option('notify') && $alert->isCritical()) {
|
||||||
|
$this->sendNotification($alert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
$this->newLine();
|
||||||
|
$unhealthyCount = count($issues);
|
||||||
|
$governanceIssues = count($governanceAlerts);
|
||||||
|
|
||||||
|
if ($unhealthyCount === 0 && $governanceIssues === 0) {
|
||||||
|
$this->info('All systems healthy.');
|
||||||
|
} else {
|
||||||
|
$this->warn("Issues found: {$unhealthyCount} stale automations, {$governanceIssues} governance alerts");
|
||||||
|
}
|
||||||
|
|
||||||
|
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_WATCHDOG, [
|
||||||
|
'unhealthy_automations' => $unhealthyCount,
|
||||||
|
'governance_alerts' => $governanceIssues,
|
||||||
|
'alerts_created' => $alertsCreated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $unhealthyCount > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
AutomationRunLog::recordFailure(AutomationRunLog::CMD_WATCHDOG, $e->getMessage());
|
||||||
|
|
||||||
|
$this->error('Watchdog failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createStaleAlert(string $command, array $status): ?SystemAlert
|
||||||
|
{
|
||||||
|
$label = $this->getCommandLabel($command);
|
||||||
|
$severity = $status['is_failing']
|
||||||
|
? SystemAlert::SEVERITY_CRITICAL
|
||||||
|
: SystemAlert::SEVERITY_WARNING;
|
||||||
|
|
||||||
|
$message = $status['is_stale']
|
||||||
|
? "{$label} has not run in over {$status['expected_interval_minutes']} minutes."
|
||||||
|
: "{$label} is failing. Last error: ".($status['last_error'] ?? 'Unknown');
|
||||||
|
|
||||||
|
return SystemAlert::createAlert(
|
||||||
|
SystemAlert::TYPE_AUTOMATION_STALE,
|
||||||
|
$severity,
|
||||||
|
SystemAlert::SOURCE_WATCHDOG,
|
||||||
|
"{$label} is unhealthy",
|
||||||
|
$message,
|
||||||
|
[
|
||||||
|
'command' => $command,
|
||||||
|
'last_run_at' => $status['last_run_at']?->toIso8601String(),
|
||||||
|
'last_status' => $status['last_status'],
|
||||||
|
'consecutive_failures' => $status['consecutive_failures'],
|
||||||
|
'expected_interval' => $status['expected_interval_minutes'],
|
||||||
|
],
|
||||||
|
30 // Dedupe: 30 minutes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCommandLabel(string $command): string
|
||||||
|
{
|
||||||
|
return match ($command) {
|
||||||
|
AutomationRunLog::CMD_GENERATE_SALES_TASKS => 'Sales Task Generation',
|
||||||
|
AutomationRunLog::CMD_GENERATE_MARKETING_TASKS => 'Marketing Task Generation',
|
||||||
|
AutomationRunLog::CMD_EVALUATE_OUTCOMES => 'Outcome Evaluation',
|
||||||
|
AutomationRunLog::CMD_ANALYZE_TIMING => 'Timing Analysis',
|
||||||
|
AutomationRunLog::CMD_EVALUATE_PLAYBOOKS => 'Playbook Evaluation',
|
||||||
|
AutomationRunLog::CMD_CHECK_HORIZON => 'Horizon Health Check',
|
||||||
|
AutomationRunLog::CMD_WATCHDOG => 'Watchdog',
|
||||||
|
AutomationRunLog::CMD_SELF_AUDIT => 'Self Audit',
|
||||||
|
AutomationRunLog::CMD_BUYER_SCORING => 'Buyer Scoring',
|
||||||
|
default => $command,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendNotification(SystemAlert $alert): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$admins = \App\Models\User::where('user_type', 'admin')
|
||||||
|
->orWhere('user_type', 'superadmin')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($admins->isNotEmpty()) {
|
||||||
|
Notification::send($admins, new OrchestratorCriticalAlert($alert));
|
||||||
|
$alert->markNotificationSent();
|
||||||
|
$this->info('Notification sent for: '.$alert->title);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->warn('Failed to send notification: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
207
app/Console/Commands/PruneAudits.php
Normal file
207
app/Console/Commands/PruneAudits.php
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\AuditPruningSettings;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class PruneAudits extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'audits:prune {--business= : Prune audits for specific business ID} {--dry-run : Show what would be deleted without actually deleting}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Prune old audit logs based on configured retention policies';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$businessId = $this->option('business');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('🔍 DRY RUN MODE - No audits will be deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get businesses with pruning enabled
|
||||||
|
$query = AuditPruningSettings::where('enabled', true);
|
||||||
|
|
||||||
|
if ($businessId) {
|
||||||
|
$query->where('business_id', $businessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = $query->get();
|
||||||
|
|
||||||
|
if ($settings->isEmpty()) {
|
||||||
|
$this->info('No businesses have audit pruning enabled.');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalDeleted = 0;
|
||||||
|
|
||||||
|
foreach ($settings as $setting) {
|
||||||
|
$business = $setting->business;
|
||||||
|
$businessName = $business ? $business->name : 'Global';
|
||||||
|
|
||||||
|
$this->info("Processing: {$businessName}");
|
||||||
|
$this->line(" Strategy: {$setting->strategy}");
|
||||||
|
|
||||||
|
$deleted = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch ($setting->strategy) {
|
||||||
|
case 'revisions':
|
||||||
|
$deleted = $this->pruneByRevisions($setting, $dryRun);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'time':
|
||||||
|
$deleted = $this->pruneByTime($setting, $dryRun);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'hybrid':
|
||||||
|
$deleted = $this->pruneHybrid($setting, $dryRun);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$setting->update([
|
||||||
|
'last_pruned_at' => now(),
|
||||||
|
'last_pruned_count' => $deleted,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" Deleted: {$deleted} audits");
|
||||||
|
$totalDeleted += $deleted;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error(" Error: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("✓ Pruning complete! Total deleted: {$totalDeleted}");
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune audits keeping only last N revisions per record
|
||||||
|
*/
|
||||||
|
protected function pruneByRevisions(AuditPruningSettings $setting, bool $dryRun = false): int
|
||||||
|
{
|
||||||
|
$keepRevisions = $setting->keep_revisions;
|
||||||
|
$businessId = $setting->business_id;
|
||||||
|
|
||||||
|
// Get all unique auditable records
|
||||||
|
$auditableRecords = DB::table('audits')
|
||||||
|
->select('auditable_type', 'auditable_id')
|
||||||
|
->distinct()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$totalDeleted = 0;
|
||||||
|
|
||||||
|
foreach ($auditableRecords as $record) {
|
||||||
|
// Get IDs of audits to keep (last N revisions)
|
||||||
|
$keepIds = DB::table('audits')
|
||||||
|
->where('auditable_type', $record->auditable_type)
|
||||||
|
->where('auditable_id', $record->auditable_id)
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->limit($keepRevisions)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
// Delete older audits
|
||||||
|
$query = DB::table('audits')
|
||||||
|
->where('auditable_type', $record->auditable_type)
|
||||||
|
->where('auditable_id', $record->auditable_id)
|
||||||
|
->whereNotIn('id', $keepIds);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$totalDeleted += $query->count();
|
||||||
|
} else {
|
||||||
|
$totalDeleted += $query->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totalDeleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune audits older than N days
|
||||||
|
*/
|
||||||
|
protected function pruneByTime(AuditPruningSettings $setting, bool $dryRun = false): int
|
||||||
|
{
|
||||||
|
$keepDays = $setting->keep_days;
|
||||||
|
$cutoffDate = now()->subDays($keepDays);
|
||||||
|
|
||||||
|
$query = DB::table('audits')
|
||||||
|
->where('created_at', '<', $cutoffDate);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
return $query->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hybrid: Keep last N revisions OR audits from last M days (union, whichever keeps more)
|
||||||
|
*/
|
||||||
|
protected function pruneHybrid(AuditPruningSettings $setting, bool $dryRun = false): int
|
||||||
|
{
|
||||||
|
$keepRevisions = $setting->keep_revisions;
|
||||||
|
$keepDays = $setting->keep_days;
|
||||||
|
$cutoffDate = now()->subDays($keepDays);
|
||||||
|
|
||||||
|
// Get all unique auditable records
|
||||||
|
$auditableRecords = DB::table('audits')
|
||||||
|
->select('auditable_type', 'auditable_id')
|
||||||
|
->distinct()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$totalDeleted = 0;
|
||||||
|
|
||||||
|
foreach ($auditableRecords as $record) {
|
||||||
|
// Get IDs to keep from both strategies
|
||||||
|
$keepByRevision = DB::table('audits')
|
||||||
|
->where('auditable_type', $record->auditable_type)
|
||||||
|
->where('auditable_id', $record->auditable_id)
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->limit($keepRevisions)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$keepByTime = DB::table('audits')
|
||||||
|
->where('auditable_type', $record->auditable_type)
|
||||||
|
->where('auditable_id', $record->auditable_id)
|
||||||
|
->where('created_at', '>=', $cutoffDate)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
// Union of both (keep if matches either rule)
|
||||||
|
$keepIds = $keepByRevision->merge($keepByTime)->unique();
|
||||||
|
|
||||||
|
// Delete everything NOT in keep list
|
||||||
|
$query = DB::table('audits')
|
||||||
|
->where('auditable_type', $record->auditable_type)
|
||||||
|
->where('auditable_id', $record->auditable_id)
|
||||||
|
->whereNotIn('id', $keepIds);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$totalDeleted += $query->count();
|
||||||
|
} else {
|
||||||
|
$totalDeleted += $query->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totalDeleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Console/Commands/ResetProductImagePaths.php
Normal file
43
app/Console/Commands/ResetProductImagePaths.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class ResetProductImagePaths extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'media:reset-product-paths';
|
||||||
|
|
||||||
|
protected $description = 'Reset product image paths back to old format for re-migration';
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$products = Product::whereNotNull('image_path')->get();
|
||||||
|
|
||||||
|
$this->info("Resetting {$products->count()} product image paths...");
|
||||||
|
$progressBar = $this->output->createProgressBar($products->count());
|
||||||
|
|
||||||
|
$reset = 0;
|
||||||
|
foreach ($products as $product) {
|
||||||
|
if (preg_match('#/images/(.+)$#', $product->image_path, $matches)) {
|
||||||
|
$filename = $matches[1];
|
||||||
|
$oldPath = 'businesses/cannabrands/products/'.$product->id.'/'.$filename;
|
||||||
|
|
||||||
|
if (Storage::exists($oldPath)) {
|
||||||
|
$product->image_path = $oldPath;
|
||||||
|
$product->save();
|
||||||
|
$reset++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$progressBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("✓ Reset {$reset} products to old paths");
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
159
app/Console/Commands/RestoreBrandMenus.php
Normal file
159
app/Console/Commands/RestoreBrandMenus.php
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Menu;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class RestoreBrandMenus extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'restore:brand-menus
|
||||||
|
{--business= : Business slug or ID to restore menus for}
|
||||||
|
{--dry-run : Preview what would be created without saving}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Restore the 3 default menus for all brands under a business (idempotent)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three default menus to create per brand.
|
||||||
|
*/
|
||||||
|
protected array $defaultMenus = [
|
||||||
|
[
|
||||||
|
'slug' => 'default',
|
||||||
|
'name' => 'Default Menu',
|
||||||
|
'description' => 'The default product menu for this brand',
|
||||||
|
'type' => 'catalog',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'promotions',
|
||||||
|
'name' => 'On Sale',
|
||||||
|
'description' => 'Products currently on promotion',
|
||||||
|
'type' => 'promotional',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'new-drops',
|
||||||
|
'name' => 'New & Featured',
|
||||||
|
'description' => 'New arrivals and featured products',
|
||||||
|
'type' => 'featured',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$businessSlugOrId = $this->option('business');
|
||||||
|
$isDryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
if (! $businessSlugOrId) {
|
||||||
|
$this->error('Please specify a business with --business=<slug or id>');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve business by slug first, then by ID if numeric
|
||||||
|
$business = Business::where('slug', $businessSlugOrId)->first();
|
||||||
|
if (! $business && is_numeric($businessSlugOrId)) {
|
||||||
|
$business = Business::find($businessSlugOrId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $business) {
|
||||||
|
$this->error("Business not found: {$businessSlugOrId}");
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Restoring brand menus for business: {$business->name} (ID: {$business->id})");
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
$this->warn('DRY RUN - no changes will be made');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Load all brands for this business
|
||||||
|
$brands = Brand::where('business_id', $business->id)->get();
|
||||||
|
|
||||||
|
if ($brands->isEmpty()) {
|
||||||
|
$this->warn('No brands found for this business.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Found {$brands->count()} brand(s)");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($brands as $brand) {
|
||||||
|
$this->line("Brand: {$brand->name} (ID: {$brand->id})");
|
||||||
|
$this->line(str_repeat('─', 50));
|
||||||
|
|
||||||
|
foreach ($this->defaultMenus as $menuData) {
|
||||||
|
$exists = Menu::where('business_id', $business->id)
|
||||||
|
->where('brand_id', $brand->id)
|
||||||
|
->where('slug', $menuData['slug'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$this->line(" ⏭ {$menuData['name']} ({$menuData['slug']}) - already exists");
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
$this->line(" ✓ {$menuData['name']} ({$menuData['slug']}) - would create");
|
||||||
|
$created++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu::create([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'brand_id' => $brand->id,
|
||||||
|
'slug' => $menuData['slug'],
|
||||||
|
'name' => $menuData['name'],
|
||||||
|
'description' => $menuData['description'],
|
||||||
|
'type' => $menuData['type'],
|
||||||
|
'is_system' => false,
|
||||||
|
'status' => 'active',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'position' => array_search($menuData, $this->defaultMenus) + 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->line(" ✓ {$menuData['name']} ({$menuData['slug']}) - CREATED");
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Summary');
|
||||||
|
$this->line(str_repeat('═', 50));
|
||||||
|
$this->line(" Created: {$created} menu(s)");
|
||||||
|
$this->line(" Skipped: {$skipped} (already exist)");
|
||||||
|
$this->line(str_repeat('─', 50));
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
$this->warn('This was a dry run. Run without --dry-run to apply changes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
652
app/Console/Commands/SeedBaselinePromos.php
Normal file
652
app/Console/Commands/SeedBaselinePromos.php
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\PromoRecommendation;
|
||||||
|
use App\Services\Promo\PromoCalculator;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class SeedBaselinePromos extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'promos:seed-baseline
|
||||||
|
{--brand=* : Specific brand IDs to seed}
|
||||||
|
{--business= : Business ID or slug to seed all brands for}
|
||||||
|
{--dry-run : Preview recommendations without saving}
|
||||||
|
{--force : Clear existing pending recommendations first}
|
||||||
|
{--types=* : Limit to specific types: edlp, percent_off, bogo, bxgy, bundle}';
|
||||||
|
|
||||||
|
protected $description = 'Generate baseline promotion recommendations for products (draft-only, not auto-activated)';
|
||||||
|
|
||||||
|
protected PromoCalculator $promoCalculator;
|
||||||
|
|
||||||
|
protected int $created = 0;
|
||||||
|
|
||||||
|
protected int $skipped = 0;
|
||||||
|
|
||||||
|
protected int $duplicates = 0;
|
||||||
|
|
||||||
|
protected array $priorityCounts = ['high' => 0, 'medium' => 0, 'low' => 0];
|
||||||
|
|
||||||
|
protected bool $isDryRun = false;
|
||||||
|
|
||||||
|
// Recommendation expiration (days)
|
||||||
|
protected const EXPIRES_AFTER_DAYS = 30;
|
||||||
|
|
||||||
|
public function handle(PromoCalculator $promoCalculator): int
|
||||||
|
{
|
||||||
|
// Check if promo_recommendations table exists
|
||||||
|
if (! \Schema::hasTable('promo_recommendations')) {
|
||||||
|
$this->error('The promo_recommendations table does not exist. Please run migrations first.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->promoCalculator = $promoCalculator;
|
||||||
|
$this->isDryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
$brandIds = $this->option('brand');
|
||||||
|
$businessOption = $this->option('business');
|
||||||
|
$types = $this->option('types') ?: ['edlp', 'percent_off', 'bogo', 'bxgy', 'bundle'];
|
||||||
|
|
||||||
|
// Get brands to process
|
||||||
|
$brands = $this->getBrandsToProcess($brandIds, $businessOption);
|
||||||
|
|
||||||
|
if ($brands === null) {
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($brands->isEmpty()) {
|
||||||
|
$this->info('No active brands found.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force mode: clear existing pending recommendations
|
||||||
|
if ($this->option('force') && ! $this->isDryRun) {
|
||||||
|
$this->clearPendingRecommendations($brandIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'%s baseline promos for %d brand(s)...',
|
||||||
|
$this->isDryRun ? 'Previewing' : 'Seeding',
|
||||||
|
$brands->count()
|
||||||
|
));
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Log start of seeding
|
||||||
|
if (! $this->isDryRun) {
|
||||||
|
Log::info('Promo Engine V3: Starting baseline seed', [
|
||||||
|
'brands_count' => $brands->count(),
|
||||||
|
'types' => $types,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($brands as $brand) {
|
||||||
|
$this->processBrand($brand, $types);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->printSummary();
|
||||||
|
|
||||||
|
// Log completion
|
||||||
|
if (! $this->isDryRun) {
|
||||||
|
Log::info('Promo Engine V3: Baseline seed complete', [
|
||||||
|
'created' => $this->created,
|
||||||
|
'skipped' => $this->skipped,
|
||||||
|
'duplicates' => $this->duplicates,
|
||||||
|
'priority_breakdown' => $this->priorityCounts,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get brands to process based on options.
|
||||||
|
*/
|
||||||
|
protected function getBrandsToProcess(?array $brandIds, ?string $businessOption)
|
||||||
|
{
|
||||||
|
// If specific brand IDs provided
|
||||||
|
if (! empty($brandIds)) {
|
||||||
|
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||||
|
if ($brands->isEmpty()) {
|
||||||
|
$this->error('No brands found with the provided IDs.');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $brands;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If business option provided (ID or slug)
|
||||||
|
if ($businessOption) {
|
||||||
|
$business = is_numeric($businessOption)
|
||||||
|
? Business::find($businessOption)
|
||||||
|
: Business::where('slug', $businessOption)->first();
|
||||||
|
|
||||||
|
if (! $business) {
|
||||||
|
$this->error("Business not found: {$businessOption}");
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Filtering to business: {$business->name}");
|
||||||
|
|
||||||
|
return Brand::where('business_id', $business->id)
|
||||||
|
->active()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: all active brands
|
||||||
|
return Brand::active()->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function processBrand(Brand $brand, array $types): void
|
||||||
|
{
|
||||||
|
$this->info("Brand: {$brand->name} (ID: {$brand->id})");
|
||||||
|
$this->line(str_repeat('─', 50));
|
||||||
|
|
||||||
|
$products = Product::where('brand_id', $brand->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($products->isEmpty()) {
|
||||||
|
$this->warn(' No active products found.');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(" Analyzing {$products->count()} active products...");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$this->processProduct($product, $brand, $types);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function processProduct(Product $product, Brand $brand, array $types): void
|
||||||
|
{
|
||||||
|
// Validate product has required pricing
|
||||||
|
if (! $this->hasValidPricing($product)) {
|
||||||
|
$this->line(" <fg=yellow>✗</> {$product->name}: missing pricing data");
|
||||||
|
$this->skipped++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentMargins = $this->promoCalculator->getCurrentMargins($product);
|
||||||
|
|
||||||
|
// Calculate inventory metrics for priority
|
||||||
|
$metrics = $this->calculateProductMetrics($product);
|
||||||
|
|
||||||
|
foreach ($types as $type) {
|
||||||
|
$this->generateRecommendation($product, $brand, $type, $currentMargins, $metrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateRecommendation(
|
||||||
|
Product $product,
|
||||||
|
Brand $brand,
|
||||||
|
string $type,
|
||||||
|
array $currentMargins,
|
||||||
|
array $metrics
|
||||||
|
): void {
|
||||||
|
// Check for existing pending recommendation
|
||||||
|
if ($this->hasPendingRecommendation($product, $type)) {
|
||||||
|
$this->duplicates++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$recommendation = match ($type) {
|
||||||
|
'edlp' => $this->generateEdlpRecommendation($product, $currentMargins, $metrics),
|
||||||
|
'percent_off' => $this->generatePercentOffRecommendation($product, $currentMargins, $metrics),
|
||||||
|
'bogo' => $this->generateBogoRecommendation($product, $currentMargins, $metrics),
|
||||||
|
'bxgy' => $this->generateBxgyRecommendation($product, $currentMargins, $metrics),
|
||||||
|
'bundle' => $this->generateBundleRecommendation($product, $currentMargins, $metrics),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (! $recommendation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add common fields
|
||||||
|
$recommendation['business_id'] = $brand->business_id;
|
||||||
|
$recommendation['brand_id'] = $brand->id;
|
||||||
|
$recommendation['product_id'] = $product->id;
|
||||||
|
$recommendation['status'] = 'pending';
|
||||||
|
$recommendation['expires_at'] = Carbon::now()->addDays(self::EXPIRES_AFTER_DAYS);
|
||||||
|
|
||||||
|
// Output
|
||||||
|
$this->outputRecommendation($product, $recommendation);
|
||||||
|
|
||||||
|
// Save if not dry run
|
||||||
|
if (! $this->isDryRun) {
|
||||||
|
PromoRecommendation::create($recommendation);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->created++;
|
||||||
|
$this->priorityCounts[$recommendation['priority']]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateEdlpRecommendation(
|
||||||
|
Product $product,
|
||||||
|
array $currentMargins,
|
||||||
|
array $metrics
|
||||||
|
): ?array {
|
||||||
|
$minSafePrice = $this->promoCalculator->minSafeEdlpPrice($product);
|
||||||
|
$currentMsrp = (float) $product->msrp;
|
||||||
|
|
||||||
|
if ($minSafePrice <= 0 || $minSafePrice >= $currentMsrp) {
|
||||||
|
$this->skipped++;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggest 10% above the minimum safe price (conservative)
|
||||||
|
$suggestedPrice = round($minSafePrice * 1.10, 2);
|
||||||
|
|
||||||
|
// Ensure suggested price is still a discount
|
||||||
|
if ($suggestedPrice >= $currentMsrp) {
|
||||||
|
$suggestedPrice = round(($minSafePrice + $currentMsrp) / 2, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the suggestion
|
||||||
|
$result = $this->promoCalculator->checkEdlp($product, $suggestedPrice);
|
||||||
|
if (! $result->approved) {
|
||||||
|
$this->skipped++;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$discountPercent = round((1 - $suggestedPrice / $currentMsrp) * 100, 1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'recommendation_type' => 'edlp',
|
||||||
|
'parameters' => [
|
||||||
|
'suggested_value' => $suggestedPrice,
|
||||||
|
'min_safe_value' => $minSafePrice,
|
||||||
|
'current_msrp' => $currentMsrp,
|
||||||
|
'discount_percent' => $discountPercent,
|
||||||
|
],
|
||||||
|
'estimated_company_margin' => $result->companyMarginPercent(),
|
||||||
|
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
|
||||||
|
'priority' => $this->calculatePriority($metrics),
|
||||||
|
'priority_reason' => $this->getPriorityReason($metrics),
|
||||||
|
'confidence' => $this->calculateConfidence($currentMargins, $result),
|
||||||
|
'velocity_score' => $metrics['velocity_score'],
|
||||||
|
'days_of_supply' => $metrics['days_of_supply'],
|
||||||
|
'units_sold_30d' => $metrics['units_sold_30d'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generatePercentOffRecommendation(
|
||||||
|
Product $product,
|
||||||
|
array $currentMargins,
|
||||||
|
array $metrics
|
||||||
|
): ?array {
|
||||||
|
$maxSafePercent = $this->promoCalculator->maxSafePercentOff($product);
|
||||||
|
|
||||||
|
if ($maxSafePercent <= 5) {
|
||||||
|
$this->skipped++;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggest 75% of max safe (conservative)
|
||||||
|
$suggestedPercent = round($maxSafePercent * 0.75, 0);
|
||||||
|
|
||||||
|
// Minimum 5% to be meaningful
|
||||||
|
if ($suggestedPercent < 5) {
|
||||||
|
$suggestedPercent = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
$result = $this->promoCalculator->checkPercentOff($product, $suggestedPercent);
|
||||||
|
if (! $result->approved) {
|
||||||
|
$this->skipped++;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'recommendation_type' => 'percent_off',
|
||||||
|
'parameters' => [
|
||||||
|
'suggested_value' => $suggestedPercent,
|
||||||
|
'max_safe_value' => $maxSafePercent,
|
||||||
|
],
|
||||||
|
'estimated_company_margin' => $result->companyMarginPercent(),
|
||||||
|
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
|
||||||
|
'priority' => $this->calculatePriority($metrics),
|
||||||
|
'priority_reason' => $this->getPriorityReason($metrics),
|
||||||
|
'confidence' => $this->calculateConfidence($currentMargins, $result),
|
||||||
|
'velocity_score' => $metrics['velocity_score'],
|
||||||
|
'days_of_supply' => $metrics['days_of_supply'],
|
||||||
|
'units_sold_30d' => $metrics['units_sold_30d'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateBogoRecommendation(
|
||||||
|
Product $product,
|
||||||
|
array $currentMargins,
|
||||||
|
array $metrics
|
||||||
|
): ?array {
|
||||||
|
// Standard BOGO: Buy 1 Get 1 Free
|
||||||
|
$result = $this->promoCalculator->checkBogo($product, 1, 1, 100);
|
||||||
|
|
||||||
|
if (! $result->approved) {
|
||||||
|
$this->skipped++;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'recommendation_type' => 'bogo',
|
||||||
|
'parameters' => [
|
||||||
|
'suggested_value' => null,
|
||||||
|
'buy_qty' => 1,
|
||||||
|
'get_qty' => 1,
|
||||||
|
'get_discount_percent' => 100,
|
||||||
|
],
|
||||||
|
'estimated_company_margin' => $result->companyMarginPercent(),
|
||||||
|
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
|
||||||
|
'priority' => $this->calculatePriority($metrics),
|
||||||
|
'priority_reason' => $this->getPriorityReason($metrics),
|
||||||
|
'confidence' => $this->calculateConfidence($currentMargins, $result),
|
||||||
|
'velocity_score' => $metrics['velocity_score'],
|
||||||
|
'days_of_supply' => $metrics['days_of_supply'],
|
||||||
|
'units_sold_30d' => $metrics['units_sold_30d'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateBxgyRecommendation(
|
||||||
|
Product $product,
|
||||||
|
array $currentMargins,
|
||||||
|
array $metrics
|
||||||
|
): ?array {
|
||||||
|
// Try Buy 2 Get 1 Free first (more sustainable than BOGO)
|
||||||
|
$result = $this->promoCalculator->checkBogo($product, 2, 1, 100);
|
||||||
|
|
||||||
|
if (! $result->approved) {
|
||||||
|
// Try Buy 3 Get 1 Free
|
||||||
|
$result = $this->promoCalculator->checkBogo($product, 3, 1, 100);
|
||||||
|
if (! $result->approved) {
|
||||||
|
$this->skipped++;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$buyQty = 3;
|
||||||
|
} else {
|
||||||
|
$buyQty = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'recommendation_type' => 'bxgy',
|
||||||
|
'parameters' => [
|
||||||
|
'suggested_value' => null,
|
||||||
|
'buy_qty' => $buyQty,
|
||||||
|
'get_qty' => 1,
|
||||||
|
'get_discount_percent' => 100,
|
||||||
|
],
|
||||||
|
'estimated_company_margin' => $result->companyMarginPercent(),
|
||||||
|
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
|
||||||
|
'priority' => $this->calculatePriority($metrics),
|
||||||
|
'priority_reason' => $this->getPriorityReason($metrics),
|
||||||
|
'confidence' => $this->calculateConfidence($currentMargins, $result),
|
||||||
|
'velocity_score' => $metrics['velocity_score'],
|
||||||
|
'days_of_supply' => $metrics['days_of_supply'],
|
||||||
|
'units_sold_30d' => $metrics['units_sold_30d'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateBundleRecommendation(
|
||||||
|
Product $product,
|
||||||
|
array $currentMargins,
|
||||||
|
array $metrics
|
||||||
|
): ?array {
|
||||||
|
// Create a 3-pack bundle with ~10% discount
|
||||||
|
$packSize = 3;
|
||||||
|
$regularTotal = (float) $product->wholesale_price * $packSize;
|
||||||
|
$suggestedPrice = round($regularTotal * 0.90, 2);
|
||||||
|
|
||||||
|
// Create a collection with quantity attribute for bundle validation
|
||||||
|
$bundleProducts = collect([
|
||||||
|
(clone $product)->setAttribute('bundle_quantity', $packSize),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->promoCalculator->checkBundle($bundleProducts, $suggestedPrice);
|
||||||
|
|
||||||
|
if (! $result->approved) {
|
||||||
|
$this->skipped++;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$minSafe = $this->promoCalculator->minSafeBundlePrice($bundleProducts);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'recommendation_type' => 'bundle',
|
||||||
|
'parameters' => [
|
||||||
|
'suggested_value' => $suggestedPrice,
|
||||||
|
'min_safe_value' => $minSafe,
|
||||||
|
'pack_size' => $packSize,
|
||||||
|
'individual_price' => (float) $product->wholesale_price,
|
||||||
|
'regular_total' => $regularTotal,
|
||||||
|
],
|
||||||
|
'estimated_company_margin' => $result->companyMarginPercent(),
|
||||||
|
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
|
||||||
|
'priority' => $this->calculatePriority($metrics),
|
||||||
|
'priority_reason' => $this->getPriorityReason($metrics),
|
||||||
|
'confidence' => $this->calculateConfidence($currentMargins, $result),
|
||||||
|
'velocity_score' => $metrics['velocity_score'],
|
||||||
|
'days_of_supply' => $metrics['days_of_supply'],
|
||||||
|
'units_sold_30d' => $metrics['units_sold_30d'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function hasValidPricing(Product $product): bool
|
||||||
|
{
|
||||||
|
return $product->cost_per_unit > 0
|
||||||
|
&& $product->wholesale_price > 0
|
||||||
|
&& $product->msrp_price > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function hasPendingRecommendation(Product $product, string $type): bool
|
||||||
|
{
|
||||||
|
return PromoRecommendation::where('product_id', $product->id)
|
||||||
|
->where('recommendation_type', $type)
|
||||||
|
->pending()
|
||||||
|
->notExpired()
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function calculateProductMetrics(Product $product): array
|
||||||
|
{
|
||||||
|
// Calculate velocity score based on available data
|
||||||
|
$availableQty = $product->available_quantity ?? 0;
|
||||||
|
$unitsSold30d = 0; // Would come from order history if tracked
|
||||||
|
|
||||||
|
// Simple days of supply calculation
|
||||||
|
$daysOfSupply = null;
|
||||||
|
if ($unitsSold30d > 0 && $availableQty > 0) {
|
||||||
|
$dailyVelocity = $unitsSold30d / 30;
|
||||||
|
$daysOfSupply = (int) ($availableQty / $dailyVelocity);
|
||||||
|
} elseif ($availableQty > 0) {
|
||||||
|
// No sales data - assume slow mover
|
||||||
|
$daysOfSupply = 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine velocity score
|
||||||
|
$velocityScore = match (true) {
|
||||||
|
$unitsSold30d >= 50 => 'fast',
|
||||||
|
$unitsSold30d >= 20 => 'medium',
|
||||||
|
$unitsSold30d >= 5 => 'slow',
|
||||||
|
default => 'stale',
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'velocity_score' => $velocityScore,
|
||||||
|
'days_of_supply' => $daysOfSupply,
|
||||||
|
'units_sold_30d' => $unitsSold30d,
|
||||||
|
'available_quantity' => $availableQty,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function calculatePriority(array $metrics): string
|
||||||
|
{
|
||||||
|
$daysOfSupply = $metrics['days_of_supply'];
|
||||||
|
$velocityScore = $metrics['velocity_score'];
|
||||||
|
|
||||||
|
// High priority: excess inventory or stale products
|
||||||
|
if ($daysOfSupply && $daysOfSupply > 90) {
|
||||||
|
return 'high';
|
||||||
|
}
|
||||||
|
if ($velocityScore === 'stale') {
|
||||||
|
return 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medium priority: building inventory or slow movers
|
||||||
|
if ($daysOfSupply && $daysOfSupply > 45) {
|
||||||
|
return 'medium';
|
||||||
|
}
|
||||||
|
if ($velocityScore === 'slow') {
|
||||||
|
return 'medium';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low priority: healthy inventory
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getPriorityReason(array $metrics): string
|
||||||
|
{
|
||||||
|
$daysOfSupply = $metrics['days_of_supply'];
|
||||||
|
$velocityScore = $metrics['velocity_score'];
|
||||||
|
|
||||||
|
if ($daysOfSupply && $daysOfSupply > 90) {
|
||||||
|
return "High inventory ({$daysOfSupply} days of supply)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($velocityScore === 'stale') {
|
||||||
|
return 'Stale inventory, no recent sales';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($velocityScore === 'slow') {
|
||||||
|
return 'Slow-moving product';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($daysOfSupply && $daysOfSupply > 45) {
|
||||||
|
return "Building inventory ({$daysOfSupply} days of supply)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Standard recommendation';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function calculateConfidence(array $currentMargins, $result): float
|
||||||
|
{
|
||||||
|
// Base confidence on data quality and margin headroom
|
||||||
|
$confidence = 0.50;
|
||||||
|
|
||||||
|
// Boost for complete pricing data
|
||||||
|
if ($currentMargins['valid']) {
|
||||||
|
$confidence += 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost for healthy margin headroom
|
||||||
|
$minMargin = min($result->companyMarginPercent(), $result->dispensaryMarginPercent());
|
||||||
|
if ($minMargin >= 60) {
|
||||||
|
$confidence += 0.20;
|
||||||
|
} elseif ($minMargin >= 55) {
|
||||||
|
$confidence += 0.10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap at 0.95
|
||||||
|
return min(0.95, round($confidence, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function outputRecommendation(Product $product, array $recommendation): void
|
||||||
|
{
|
||||||
|
$type = $recommendation['recommendation_type'];
|
||||||
|
$params = $recommendation['parameters'];
|
||||||
|
$margin = min(
|
||||||
|
$recommendation['estimated_company_margin'],
|
||||||
|
$recommendation['estimated_dispensary_margin']
|
||||||
|
);
|
||||||
|
|
||||||
|
$description = match ($type) {
|
||||||
|
'edlp' => sprintf(
|
||||||
|
'$%.2f → $%.2f',
|
||||||
|
$params['current_msrp'],
|
||||||
|
$params['suggested_value']
|
||||||
|
),
|
||||||
|
'percent_off' => sprintf('%.0f%% off (max: %.0f%%)', $params['suggested_value'], $params['max_safe_value']),
|
||||||
|
'bogo' => 'B1G1 Free',
|
||||||
|
'bxgy' => sprintf('B%dG%d Free', $params['buy_qty'], $params['get_qty']),
|
||||||
|
'bundle' => sprintf('%d-pack $%.2f', $params['pack_size'], $params['suggested_value']),
|
||||||
|
default => $type,
|
||||||
|
};
|
||||||
|
|
||||||
|
$priority = $recommendation['priority'];
|
||||||
|
$priorityColor = match ($priority) {
|
||||||
|
'high' => 'red',
|
||||||
|
'medium' => 'yellow',
|
||||||
|
default => 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->line(sprintf(
|
||||||
|
' <fg=green>✓</> %-25s [%s] %s <fg=%s>(margin: %.0f%%, %s)</>',
|
||||||
|
substr($product->name, 0, 25),
|
||||||
|
strtoupper($type),
|
||||||
|
$description,
|
||||||
|
$priorityColor,
|
||||||
|
$margin,
|
||||||
|
$priority
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function clearPendingRecommendations(?array $brandIds): void
|
||||||
|
{
|
||||||
|
$query = PromoRecommendation::pending();
|
||||||
|
|
||||||
|
if (! empty($brandIds)) {
|
||||||
|
$query->whereIn('brand_id', $brandIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = $query->count();
|
||||||
|
$query->delete();
|
||||||
|
|
||||||
|
$this->warn("Cleared {$count} existing pending recommendations.");
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function printSummary(): void
|
||||||
|
{
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Summary');
|
||||||
|
$this->line(str_repeat('═', 50));
|
||||||
|
|
||||||
|
$label = $this->isDryRun ? 'Would create' : 'Created';
|
||||||
|
$this->line(sprintf(' %-20s %d recommendations', "{$label}:", $this->created));
|
||||||
|
$this->line(sprintf(' %-20s %d (insufficient margin)', 'Skipped:', $this->skipped));
|
||||||
|
$this->line(sprintf(' %-20s %d (already pending)', 'Duplicates:', $this->duplicates));
|
||||||
|
|
||||||
|
$this->line(str_repeat('─', 50));
|
||||||
|
$this->info('Priority breakdown:');
|
||||||
|
$this->line(sprintf(' <fg=red>High:</> %d', $this->priorityCounts['high']));
|
||||||
|
$this->line(sprintf(' <fg=yellow>Medium:</> %d', $this->priorityCounts['medium']));
|
||||||
|
$this->line(sprintf(' <fg=green>Low:</> %d', $this->priorityCounts['low']));
|
||||||
|
|
||||||
|
if ($this->isDryRun) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('This was a dry run. No recommendations were saved.');
|
||||||
|
$this->info('Run without --dry-run to save recommendations.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
404
app/Console/Commands/SeedBrandOrchestratorProfiles.php
Normal file
404
app/Console/Commands/SeedBrandOrchestratorProfiles.php
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\BrandOrchestratorProfile;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed/Update BrandOrchestratorProfile records with brand-specific behavior presets.
|
||||||
|
*
|
||||||
|
* This command is IDEMPOTENT - safe to run multiple times.
|
||||||
|
* It uses updateOrCreate to either create new profiles or update existing ones.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan orchestrator:seed-brand-profiles
|
||||||
|
* php artisan orchestrator:seed-brand-profiles --force (skip confirmation)
|
||||||
|
*/
|
||||||
|
class SeedBrandOrchestratorProfiles extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'orchestrator:seed-brand-profiles
|
||||||
|
{--force : Skip confirmation prompt}';
|
||||||
|
|
||||||
|
protected $description = 'Seed or update BrandOrchestratorProfile records with brand-specific configurations';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$this->info('');
|
||||||
|
$this->info('╔══════════════════════════════════════════════════════════════╗');
|
||||||
|
$this->info('║ Brand Orchestrator Profile Seeder ║');
|
||||||
|
$this->info('╚══════════════════════════════════════════════════════════════╝');
|
||||||
|
$this->info('');
|
||||||
|
|
||||||
|
if (! $this->option('force') && ! $this->confirm('This will create/update BrandOrchestratorProfile records. Continue?')) {
|
||||||
|
$this->warn('Aborted.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$profiles = $this->getBrandProfiles();
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
$updated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($profiles as $brandName => $config) {
|
||||||
|
$brand = Brand::where('name', $brandName)->first();
|
||||||
|
|
||||||
|
if (! $brand) {
|
||||||
|
$this->warn(" ⚠ Brand not found: '{$brandName}' - skipping");
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$business = $brand->business;
|
||||||
|
if (! $business) {
|
||||||
|
$this->warn(" ⚠ Brand '{$brandName}' has no business - skipping");
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if profile exists
|
||||||
|
$existingProfile = BrandOrchestratorProfile::where('brand_id', $brand->id)
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// Prepare data for update/create
|
||||||
|
$data = [
|
||||||
|
'behavior_profile' => $config['behavior_profile'],
|
||||||
|
'max_tasks_per_customer_per_run' => $config['max_tasks_per_customer_per_run'] ?? null,
|
||||||
|
'cooldown_hours' => $config['cooldown_hours'] ?? null,
|
||||||
|
'max_pending_per_customer' => $config['max_pending_per_customer'] ?? null,
|
||||||
|
'auto_approval_high_intent' => $config['auto_approval_high_intent'] ?? null,
|
||||||
|
'auto_approval_vip' => $config['auto_approval_vip'] ?? null,
|
||||||
|
'auto_approval_ghosted' => $config['auto_approval_ghosted'] ?? null,
|
||||||
|
'auto_approval_at_risk' => $config['auto_approval_at_risk'] ?? null,
|
||||||
|
'auto_approval_menu_followup_no_view' => $config['auto_approval_menu_followup_no_view'] ?? null,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => $config['auto_approval_menu_followup_viewed_no_order'] ?? null,
|
||||||
|
'auto_approval_reactivation' => $config['auto_approval_reactivation'] ?? null,
|
||||||
|
'auto_approval_new_menu' => $config['auto_approval_new_menu'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
BrandOrchestratorProfile::updateOrCreate(
|
||||||
|
[
|
||||||
|
'brand_id' => $brand->id,
|
||||||
|
'business_id' => $business->id,
|
||||||
|
],
|
||||||
|
$data
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existingProfile) {
|
||||||
|
$this->line(" ✓ <fg=yellow>Updated</> {$brandName} ({$config['behavior_profile']})");
|
||||||
|
$updated++;
|
||||||
|
} else {
|
||||||
|
$this->line(" ✓ <fg=green>Created</> {$brandName} ({$config['behavior_profile']})");
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('');
|
||||||
|
$this->info('══════════════════════════════════════════════════════════════');
|
||||||
|
$this->info(" Created: {$created} | Updated: {$updated} | Skipped: {$skipped}");
|
||||||
|
$this->info('══════════════════════════════════════════════════════════════');
|
||||||
|
$this->info('');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get brand-specific orchestrator profile configurations.
|
||||||
|
*
|
||||||
|
* Profile types:
|
||||||
|
* - aggressive: Lower cooldowns, higher task caps, more auto-approval
|
||||||
|
* - balanced: Uses global settings, light customization
|
||||||
|
* - conservative: Higher cooldowns, lower task caps, more manager review
|
||||||
|
*/
|
||||||
|
private function getBrandProfiles(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 1. THUNDER BUD - Value, high-volume prerolls
|
||||||
|
// "Volume mover" - assertive about followups, promos, reactivations
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'Thunder Bud' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_AGGRESSIVE,
|
||||||
|
'max_tasks_per_customer_per_run' => 4,
|
||||||
|
'cooldown_hours' => 24,
|
||||||
|
'max_pending_per_customer' => 5,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => true,
|
||||||
|
'auto_approval_at_risk' => true,
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => true,
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 2. DOOBZ - Hash-infused prerolls, premium but still pushy
|
||||||
|
// Strong push on menu followups and reactivation, slightly less
|
||||||
|
// spammy than Thunder Bud on the same buyer
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'Doobz' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_AGGRESSIVE,
|
||||||
|
'max_tasks_per_customer_per_run' => 3,
|
||||||
|
'cooldown_hours' => 36,
|
||||||
|
'max_pending_per_customer' => 4,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => true,
|
||||||
|
'auto_approval_at_risk' => true,
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => true,
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 3. TWISTIES - Rosin jam infused prerolls, craft-leaning
|
||||||
|
// Proactive with engaged buyers and menus, but keep ghosted
|
||||||
|
// accounts from getting hammered
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'Twisties' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||||
|
'max_tasks_per_customer_per_run' => null, // use global
|
||||||
|
'cooldown_hours' => null,
|
||||||
|
'max_pending_per_customer' => null,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => false, // Keep ghosted from getting hammered
|
||||||
|
'auto_approval_at_risk' => true,
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => true,
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 4. HIGH EXPECTATIONS - Hash holes, top-shelf, limited
|
||||||
|
// Prestige line - still follow up, but prefer rep review on
|
||||||
|
// at-risk and reactivation flows
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'High Expectations' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_CONSERVATIVE,
|
||||||
|
'max_tasks_per_customer_per_run' => 2,
|
||||||
|
'cooldown_hours' => 72,
|
||||||
|
'max_pending_per_customer' => 3,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => false,
|
||||||
|
'auto_approval_at_risk' => false, // Rep review
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => false, // Rep review
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 5. PROPER COCK - Premium solventless concentrates
|
||||||
|
// Similar to High Expectations: careful, high-touch, more
|
||||||
|
// manual control from reps
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'Proper Cock' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_CONSERVATIVE,
|
||||||
|
'max_tasks_per_customer_per_run' => 2,
|
||||||
|
'cooldown_hours' => 72,
|
||||||
|
'max_pending_per_customer' => 3,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => false,
|
||||||
|
'auto_approval_at_risk' => false,
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => false,
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 6. HASH FACTORY - Artisan hash & rosin, craft brand
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'Hash Factory' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||||
|
'max_tasks_per_customer_per_run' => 3,
|
||||||
|
'cooldown_hours' => 48,
|
||||||
|
'max_pending_per_customer' => 4,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => false,
|
||||||
|
'auto_approval_at_risk' => true,
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => true,
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 7. JUST VAPE - Hash rosin carts / disposables, needs growth
|
||||||
|
// Should behave similar to Thunder Bud on orchestration
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'Just Vape' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_AGGRESSIVE,
|
||||||
|
'max_tasks_per_customer_per_run' => 4,
|
||||||
|
'cooldown_hours' => 24,
|
||||||
|
'max_pending_per_customer' => 5,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => true,
|
||||||
|
'auto_approval_at_risk' => true,
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => true,
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 8. CANNA RSO - RSO / medical-leaning product line
|
||||||
|
// Less aggressive, more thoughtful outreach
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'Canna RSO' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_CONSERVATIVE,
|
||||||
|
'max_tasks_per_customer_per_run' => 2,
|
||||||
|
'cooldown_hours' => 72,
|
||||||
|
'max_pending_per_customer' => 3,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => false,
|
||||||
|
'auto_approval_at_risk' => false,
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => false,
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 9. OUTLAW CANNABIS - Balanced with full auto-approval
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'Outlaw Cannabis' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||||
|
'max_tasks_per_customer_per_run' => 3,
|
||||||
|
'cooldown_hours' => 48,
|
||||||
|
'max_pending_per_customer' => 4,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => true,
|
||||||
|
'auto_approval_at_risk' => true,
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => true,
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 10. ALOHA TYMEMACHINE - Balanced with full auto-approval
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'Aloha TymeMachine' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||||
|
'max_tasks_per_customer_per_run' => 3,
|
||||||
|
'cooldown_hours' => 48,
|
||||||
|
'max_pending_per_customer' => 4,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => true,
|
||||||
|
'auto_approval_at_risk' => true,
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => true,
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 11. DOINKS - Aggressive with full auto-approval
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'Doinks' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_AGGRESSIVE,
|
||||||
|
'max_tasks_per_customer_per_run' => 4,
|
||||||
|
'cooldown_hours' => 24,
|
||||||
|
'max_pending_per_customer' => 5,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => true,
|
||||||
|
'auto_approval_at_risk' => true,
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => true,
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 12. NUVATA - Balanced, use global throttling
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'Nuvata' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||||
|
'max_tasks_per_customer_per_run' => null,
|
||||||
|
'cooldown_hours' => null,
|
||||||
|
'max_pending_per_customer' => null,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => true,
|
||||||
|
'auto_approval_at_risk' => true,
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => true,
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 13. DAIRY2DANK - Balanced, use global throttling
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'Dairy2Dank' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||||
|
'max_tasks_per_customer_per_run' => null,
|
||||||
|
'cooldown_hours' => null,
|
||||||
|
'max_pending_per_customer' => null,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => true,
|
||||||
|
'auto_approval_at_risk' => true,
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => true,
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 14. BLITZD - Balanced, use global throttling
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'Blitzd' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||||
|
'max_tasks_per_customer_per_run' => null,
|
||||||
|
'cooldown_hours' => null,
|
||||||
|
'max_pending_per_customer' => null,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => true,
|
||||||
|
'auto_approval_at_risk' => true,
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => true,
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 15. WHITE LABEL CANNA - Balanced, use global throttling
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
'White Label Canna' => [
|
||||||
|
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||||
|
'max_tasks_per_customer_per_run' => null,
|
||||||
|
'cooldown_hours' => null,
|
||||||
|
'max_pending_per_customer' => null,
|
||||||
|
'auto_approval_high_intent' => true,
|
||||||
|
'auto_approval_vip' => true,
|
||||||
|
'auto_approval_ghosted' => true,
|
||||||
|
'auto_approval_at_risk' => true,
|
||||||
|
'auto_approval_menu_followup_no_view' => true,
|
||||||
|
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||||
|
'auto_approval_reactivation' => true,
|
||||||
|
'auto_approval_new_menu' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
158
app/Console/Commands/SeedCoaData.php
Normal file
158
app/Console/Commands/SeedCoaData.php
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Batch;
|
||||||
|
use App\Models\BatchCoaFile;
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class SeedCoaData extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'seed:coa-data';
|
||||||
|
|
||||||
|
protected $description = 'Add COA files to existing batches for testing';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$this->info('Seeding COA data for testing...');
|
||||||
|
|
||||||
|
// Get all active products with batches
|
||||||
|
$products = Product::with('batches')
|
||||||
|
->where('is_active', true)
|
||||||
|
->whereHas('batches')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($products->isEmpty()) {
|
||||||
|
$this->warn('No products with batches found. Run the main seeder first.');
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Found {$products->count()} products with batches");
|
||||||
|
|
||||||
|
$coaCount = 0;
|
||||||
|
|
||||||
|
foreach ($products as $product) {
|
||||||
|
foreach ($product->batches as $batch) {
|
||||||
|
// Skip if batch already has COAs
|
||||||
|
if ($batch->coaFiles()->exists()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 1-2 COA files per batch
|
||||||
|
$numCoas = rand(1, 2);
|
||||||
|
|
||||||
|
for ($i = 1; $i <= $numCoas; $i++) {
|
||||||
|
$isPrimary = ($i === 1);
|
||||||
|
|
||||||
|
// Create a dummy PDF file
|
||||||
|
$fileName = "COA-{$batch->batch_number}-{$i}.pdf";
|
||||||
|
$filePath = "businesses/{$product->brand->business->uuid}/batches/{$batch->id}/coas/{$fileName}";
|
||||||
|
|
||||||
|
// Create dummy PDF content (just for testing)
|
||||||
|
$pdfContent = $this->generateDummyPdf($batch, $product);
|
||||||
|
Storage::disk('local')->put($filePath, $pdfContent);
|
||||||
|
|
||||||
|
// Create COA file record
|
||||||
|
BatchCoaFile::create([
|
||||||
|
'batch_id' => $batch->id,
|
||||||
|
'file_name' => $fileName,
|
||||||
|
'file_path' => $filePath,
|
||||||
|
'file_size' => strlen($pdfContent),
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
'is_primary' => $isPrimary,
|
||||||
|
'display_order' => $i,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$coaCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" Added {$numCoas} COA(s) for batch {$batch->batch_number}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("✓ Created {$coaCount} COA files");
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateDummyPdf(Batch $batch, Product $product): string
|
||||||
|
{
|
||||||
|
// Generate a simple text-based "PDF" for testing
|
||||||
|
// In a real system, you'd use a PDF library
|
||||||
|
return "%PDF-1.4
|
||||||
|
1 0 obj
|
||||||
|
<<
|
||||||
|
/Type /Catalog
|
||||||
|
/Pages 2 0 R
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<<
|
||||||
|
/Type /Pages
|
||||||
|
/Kids [3 0 R]
|
||||||
|
/Count 1
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<<
|
||||||
|
/Type /Page
|
||||||
|
/Parent 2 0 R
|
||||||
|
/Resources <<
|
||||||
|
/Font <<
|
||||||
|
/F1 <<
|
||||||
|
/Type /Font
|
||||||
|
/Subtype /Type1
|
||||||
|
/BaseFont /Helvetica
|
||||||
|
>>
|
||||||
|
>>
|
||||||
|
>>
|
||||||
|
/MediaBox [0 0 612 792]
|
||||||
|
/Contents 4 0 R
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
4 0 obj
|
||||||
|
<<
|
||||||
|
/Length 250
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
BT
|
||||||
|
/F1 12 Tf
|
||||||
|
50 700 Td
|
||||||
|
(CERTIFICATE OF ANALYSIS) Tj
|
||||||
|
0 -30 Td
|
||||||
|
(Batch Number: {$batch->batch_number}) Tj
|
||||||
|
0 -20 Td
|
||||||
|
(Product: {$product->name}) Tj
|
||||||
|
0 -20 Td
|
||||||
|
(Test Date: ".now()->format('Y-m-d').') Tj
|
||||||
|
0 -30 Td
|
||||||
|
(THC: 25.5%) Tj
|
||||||
|
0 -20 Td
|
||||||
|
(CBD: 0.8%) Tj
|
||||||
|
0 -20 Td
|
||||||
|
(Status: PASSED) Tj
|
||||||
|
ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 5
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000009 00000 n
|
||||||
|
0000000058 00000 n
|
||||||
|
0000000115 00000 n
|
||||||
|
0000000317 00000 n
|
||||||
|
trailer
|
||||||
|
<<
|
||||||
|
/Size 5
|
||||||
|
/Root 1 0 R
|
||||||
|
>>
|
||||||
|
startxref
|
||||||
|
619
|
||||||
|
%%EOF';
|
||||||
|
}
|
||||||
|
}
|
||||||
225
app/Console/Commands/SeedTestOrders.php
Normal file
225
app/Console/Commands/SeedTestOrders.php
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Batch;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Location;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\OrderItem;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class SeedTestOrders extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'seed:test-orders {--clean : Delete existing test orders first}';
|
||||||
|
|
||||||
|
protected $description = 'Create test orders at various statuses for testing the order flow';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if ($this->option('clean')) {
|
||||||
|
$this->info('Cleaning up existing test orders...');
|
||||||
|
$testOrders = Order::where('order_number', 'like', 'TEST-%')->get();
|
||||||
|
foreach ($testOrders as $order) {
|
||||||
|
// Delete order items first, then the order
|
||||||
|
$order->items()->delete();
|
||||||
|
$order->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Creating test orders at various statuses...');
|
||||||
|
|
||||||
|
// Get a buyer business (retailer) and location
|
||||||
|
$buyerBusiness = Business::where('business_type', 'retailer')->first();
|
||||||
|
if (! $buyerBusiness) {
|
||||||
|
$this->error('No buyer business found. Run the main seeder first.');
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$buyerLocation = Location::where('business_id', $buyerBusiness->id)->first();
|
||||||
|
if (! $buyerLocation) {
|
||||||
|
$this->error('No buyer location found. Run the main seeder first.');
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a buyer user
|
||||||
|
$buyerUser = User::where('user_type', 'buyer')->first();
|
||||||
|
if (! $buyerUser) {
|
||||||
|
$this->error('No buyer user found. Run the main seeder first.');
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get products with batches and COAs
|
||||||
|
$products = Product::with(['brand.business', 'batches.coaFiles'])
|
||||||
|
->where('is_active', true)
|
||||||
|
->whereHas('batches.coaFiles')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($products->isEmpty()) {
|
||||||
|
$this->error('No products with COAs found. Run seed:coa-data first.');
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orders = [];
|
||||||
|
|
||||||
|
// 1. Order ready for pre-delivery review (after picking, before delivery)
|
||||||
|
$orders[] = $this->createTestOrder(
|
||||||
|
$buyerBusiness,
|
||||||
|
$buyerLocation,
|
||||||
|
$products->random(3),
|
||||||
|
'ready_for_delivery',
|
||||||
|
'TEST-PREDELIVERY-001',
|
||||||
|
'Order ready for pre-delivery review (Review #1)'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Order delivered and ready for post-delivery acceptance (Review #2)
|
||||||
|
$orders[] = $this->createTestOrder(
|
||||||
|
$buyerBusiness,
|
||||||
|
$buyerLocation,
|
||||||
|
$products->random(3),
|
||||||
|
'delivered',
|
||||||
|
'TEST-DELIVERED-001',
|
||||||
|
'Order delivered and ready for acceptance (Review #2)'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Order in progress (picking)
|
||||||
|
$orders[] = $this->createTestOrder(
|
||||||
|
$buyerBusiness,
|
||||||
|
$buyerLocation,
|
||||||
|
$products->random(2),
|
||||||
|
'in_progress',
|
||||||
|
'TEST-PICKING-001',
|
||||||
|
'Order currently being picked'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Order accepted and approved for delivery
|
||||||
|
$orders[] = $this->createTestOrder(
|
||||||
|
$buyerBusiness,
|
||||||
|
$buyerLocation,
|
||||||
|
$products->random(2),
|
||||||
|
'approved_for_delivery',
|
||||||
|
'TEST-APPROVED-001',
|
||||||
|
'Order approved for delivery (passed Review #1)'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Order out for delivery
|
||||||
|
$orders[] = $this->createTestOrder(
|
||||||
|
$buyerBusiness,
|
||||||
|
$buyerLocation,
|
||||||
|
$products->random(2),
|
||||||
|
'out_for_delivery',
|
||||||
|
'TEST-OUTDELIVERY-001',
|
||||||
|
'Order out for delivery'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('✓ Created '.count($orders).' test orders');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Order Number', 'Status', 'Items', 'Description'],
|
||||||
|
collect($orders)->map(fn ($order) => [
|
||||||
|
$order->order_number,
|
||||||
|
$order->status,
|
||||||
|
$order->items->count(),
|
||||||
|
$this->getOrderDescription($order->order_number),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('You can now test the order flow in the UI:');
|
||||||
|
$this->line(' • Pre-delivery review: /b/'.$buyerBusiness->slug.'/orders/TEST-PREDELIVERY-001/pre-delivery-review');
|
||||||
|
$this->line(' • Post-delivery acceptance: /b/'.$buyerBusiness->slug.'/orders/TEST-DELIVERED-001/acceptance');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTestOrder(
|
||||||
|
Business $buyerBusiness,
|
||||||
|
Location $buyerLocation,
|
||||||
|
$products,
|
||||||
|
string $status,
|
||||||
|
string $orderNumber,
|
||||||
|
string $description
|
||||||
|
): Order {
|
||||||
|
return DB::transaction(function () use ($buyerBusiness, $buyerLocation, $products, $status, $orderNumber) {
|
||||||
|
// Get first product's seller business
|
||||||
|
$sellerBusiness = $products->first()->brand->business;
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
$subtotal = $products->sum(function ($product) {
|
||||||
|
return $product->wholesale_price * 5; // 5 units each
|
||||||
|
});
|
||||||
|
|
||||||
|
$surchargePercent = Order::getSurchargePercentage('net_30');
|
||||||
|
$surcharge = $subtotal * ($surchargePercent / 100);
|
||||||
|
$taxRate = $buyerBusiness->getTaxRate();
|
||||||
|
$tax = ($subtotal + $surcharge) * $taxRate;
|
||||||
|
$total = $subtotal + $surcharge + $tax;
|
||||||
|
|
||||||
|
// Create order
|
||||||
|
$order = Order::create([
|
||||||
|
'order_number' => $orderNumber,
|
||||||
|
'business_id' => $buyerBusiness->id,
|
||||||
|
'seller_business_id' => $sellerBusiness->id,
|
||||||
|
'location_id' => $buyerLocation->id,
|
||||||
|
'status' => $status,
|
||||||
|
'fulfillment_method' => 'delivery',
|
||||||
|
'payment_terms' => 'net_30',
|
||||||
|
'subtotal' => $subtotal,
|
||||||
|
'tax' => $tax,
|
||||||
|
'surcharge' => $surcharge,
|
||||||
|
'total' => $total,
|
||||||
|
'notes' => 'Test order for flow testing',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create order items with batch allocation
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$batch = $product->batches->first();
|
||||||
|
$quantity = 5;
|
||||||
|
|
||||||
|
// Allocate inventory
|
||||||
|
if ($batch) {
|
||||||
|
$batch->allocate($quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
OrderItem::create([
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'batch_id' => $batch?->id,
|
||||||
|
'product_name' => $product->name,
|
||||||
|
'product_sku' => $product->sku,
|
||||||
|
'brand_name' => $product->brand->name,
|
||||||
|
'batch_number' => $batch?->batch_number,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'unit_price' => $product->wholesale_price,
|
||||||
|
'line_total' => $product->wholesale_price * $quantity,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $order->fresh(['items']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getOrderDescription(string $orderNumber): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
str_contains($orderNumber, 'PREDELIVERY') => 'Order ready for pre-delivery review (Review #1)',
|
||||||
|
str_contains($orderNumber, 'DELIVERED') => 'Order delivered and ready for acceptance (Review #2)',
|
||||||
|
str_contains($orderNumber, 'PICKING') => 'Order currently being picked',
|
||||||
|
str_contains($orderNumber, 'APPROVED') => 'Order approved for delivery (passed Review #1)',
|
||||||
|
str_contains($orderNumber, 'OUTDELIVERY') => 'Order out for delivery',
|
||||||
|
default => 'Test order',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
257
app/Console/Commands/SendCrmDailyDigest.php
Normal file
257
app/Console/Commands/SendCrmDailyDigest.php
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\CalendarEvent;
|
||||||
|
use App\Models\Conversation;
|
||||||
|
use App\Models\Crm\CrmTask;
|
||||||
|
use App\Models\SalesOpportunity;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\CrmDailyDigestNotification;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRM Daily Digest Command
|
||||||
|
*
|
||||||
|
* Sends daily summary emails to businesses with CRM enabled. This command
|
||||||
|
* is scheduled to run at 7 AM daily via the Laravel scheduler (Kernel.php).
|
||||||
|
*
|
||||||
|
* Digest Contents:
|
||||||
|
* - New conversations from the last 24 hours
|
||||||
|
* - Tasks due today
|
||||||
|
* - Overdue tasks (up to 10)
|
||||||
|
* - Today's calendar events
|
||||||
|
* - Pipeline summary stats
|
||||||
|
*
|
||||||
|
* Feature Gating:
|
||||||
|
* - Only processes businesses where has_crm = true
|
||||||
|
* - Only sends to businesses where crm_daily_digest_enabled = true
|
||||||
|
* - Skips businesses with no actionable items to report
|
||||||
|
*
|
||||||
|
* Recipients:
|
||||||
|
* - If business.crm_notification_emails is set: those specific emails
|
||||||
|
* - Otherwise: business owner or first admin (up to 3 recipients)
|
||||||
|
*
|
||||||
|
* Queue Configuration:
|
||||||
|
* - Notifications are queued on the 'crm' queue
|
||||||
|
* - Processed by Horizon's CRM worker pool
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan crm:send-daily-digests # Normal run
|
||||||
|
* php artisan crm:send-daily-digests --dry-run # Preview without sending
|
||||||
|
* php artisan crm:send-daily-digests --business=123 # Single business
|
||||||
|
*
|
||||||
|
* Testing:
|
||||||
|
* ./vendor/bin/sail artisan crm:send-daily-digests --dry-run
|
||||||
|
*
|
||||||
|
* @see \App\Console\Kernel::schedule() for scheduler configuration
|
||||||
|
* @see \App\Notifications\CrmDailyDigestNotification for email template
|
||||||
|
*/
|
||||||
|
class SendCrmDailyDigest extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*/
|
||||||
|
protected $signature = 'crm:send-daily-digests
|
||||||
|
{--business= : Process only a specific business ID}
|
||||||
|
{--dry-run : Show what would be sent without actually sending}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*/
|
||||||
|
protected $description = 'Send daily CRM digest emails to businesses with the feature enabled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$businessQuery = Business::where('has_crm', true)
|
||||||
|
->where('crm_daily_digest_enabled', true);
|
||||||
|
|
||||||
|
if ($businessId = $this->option('business')) {
|
||||||
|
$businessQuery->where('id', $businessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$businesses = $businessQuery->get();
|
||||||
|
|
||||||
|
if ($businesses->isEmpty()) {
|
||||||
|
$this->info('No businesses with CRM daily digest enabled.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Processing daily digests for {$businesses->count()} business(es)...");
|
||||||
|
|
||||||
|
foreach ($businesses as $business) {
|
||||||
|
$this->processBusinessDigest($business);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Daily digest processing complete.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process and send the digest for a single business.
|
||||||
|
*/
|
||||||
|
protected function processBusinessDigest(Business $business): void
|
||||||
|
{
|
||||||
|
$this->line("Processing: {$business->name}");
|
||||||
|
|
||||||
|
// Get the digest data
|
||||||
|
$digestData = $this->gatherDigestData($business);
|
||||||
|
|
||||||
|
// Check if there's anything to report
|
||||||
|
if ($this->isDigestEmpty($digestData)) {
|
||||||
|
$this->line(' - No updates to report, skipping.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recipients (business owner or first admin)
|
||||||
|
$recipients = $this->getDigestRecipients($business);
|
||||||
|
|
||||||
|
if ($recipients->isEmpty()) {
|
||||||
|
$this->warn(' - No recipients found, skipping.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('dry-run')) {
|
||||||
|
$this->info(" - [DRY RUN] Would send to: {$recipients->pluck('email')->join(', ')}");
|
||||||
|
$this->displayDigestSummary($digestData);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notifications
|
||||||
|
foreach ($recipients as $recipient) {
|
||||||
|
try {
|
||||||
|
$recipient->notify(new CrmDailyDigestNotification($business, $digestData));
|
||||||
|
$this->line(" - Sent to: {$recipient->email}");
|
||||||
|
|
||||||
|
Log::info('CRM daily digest sent', [
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'user_id' => $recipient->id,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error(" - Failed to send to {$recipient->email}: {$e->getMessage()}");
|
||||||
|
|
||||||
|
Log::error('Failed to send CRM daily digest', [
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'user_id' => $recipient->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gather all the data for the digest.
|
||||||
|
*/
|
||||||
|
protected function gatherDigestData(Business $business): array
|
||||||
|
{
|
||||||
|
$yesterday = now()->subDay();
|
||||||
|
|
||||||
|
return [
|
||||||
|
// New conversations in the last 24 hours
|
||||||
|
'new_conversations' => Conversation::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
|
||||||
|
->where('created_at', '>=', $yesterday)
|
||||||
|
->with('primaryContact')
|
||||||
|
->get(),
|
||||||
|
|
||||||
|
// Tasks due today
|
||||||
|
'tasks_due_today' => CrmTask::where('seller_business_id', $business->id)
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->whereDate('due_at', today())
|
||||||
|
->with(['assignee', 'contact'])
|
||||||
|
->get(),
|
||||||
|
|
||||||
|
// Overdue tasks
|
||||||
|
'overdue_tasks' => CrmTask::where('seller_business_id', $business->id)
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', now())
|
||||||
|
->with(['assignee', 'contact'])
|
||||||
|
->limit(10)
|
||||||
|
->get(),
|
||||||
|
|
||||||
|
// Events today
|
||||||
|
'events_today' => CalendarEvent::where('seller_business_id', $business->id)
|
||||||
|
->whereDate('start_at', today())
|
||||||
|
->where('status', 'scheduled')
|
||||||
|
->with(['assignee', 'contact'])
|
||||||
|
->get(),
|
||||||
|
|
||||||
|
// Stage changes in the last 24 hours (opportunities moved)
|
||||||
|
'stage_changes' => SalesOpportunity::where('seller_business_id', $business->id)
|
||||||
|
->where('updated_at', '>=', $yesterday)
|
||||||
|
->whereColumn('stage_id', '!=', 'original_stage_id')
|
||||||
|
->with(['stage', 'business'])
|
||||||
|
->limit(10)
|
||||||
|
->get(),
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
'stats' => [
|
||||||
|
'open_opportunities' => SalesOpportunity::where('seller_business_id', $business->id)
|
||||||
|
->where('status', 'open')
|
||||||
|
->count(),
|
||||||
|
'total_pipeline_value' => SalesOpportunity::where('seller_business_id', $business->id)
|
||||||
|
->where('status', 'open')
|
||||||
|
->sum('value'),
|
||||||
|
'open_tasks' => CrmTask::where('seller_business_id', $business->id)
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->count(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the digest has any meaningful content.
|
||||||
|
*/
|
||||||
|
protected function isDigestEmpty(array $data): bool
|
||||||
|
{
|
||||||
|
return $data['new_conversations']->isEmpty()
|
||||||
|
&& $data['tasks_due_today']->isEmpty()
|
||||||
|
&& $data['overdue_tasks']->isEmpty()
|
||||||
|
&& $data['events_today']->isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the users who should receive the digest.
|
||||||
|
*/
|
||||||
|
protected function getDigestRecipients(Business $business): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
// If specific emails are set, find those users
|
||||||
|
if ($business->crm_notification_emails) {
|
||||||
|
$emails = array_map('trim', explode(',', $business->crm_notification_emails));
|
||||||
|
|
||||||
|
return User::where('business_id', $business->id)
|
||||||
|
->whereIn('email', $emails)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, send to the business owner or first admin
|
||||||
|
return User::where('business_id', $business->id)
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->where('is_business_owner', true)
|
||||||
|
->orWhere('user_type', 'admin');
|
||||||
|
})
|
||||||
|
->limit(3)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a summary for dry-run mode.
|
||||||
|
*/
|
||||||
|
protected function displayDigestSummary(array $data): void
|
||||||
|
{
|
||||||
|
$this->line(" - New conversations: {$data['new_conversations']->count()}");
|
||||||
|
$this->line(" - Tasks due today: {$data['tasks_due_today']->count()}");
|
||||||
|
$this->line(" - Overdue tasks: {$data['overdue_tasks']->count()}");
|
||||||
|
$this->line(" - Events today: {$data['events_today']->count()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,126 @@ class Kernel extends ConsoleKernel
|
|||||||
*/
|
*/
|
||||||
protected function schedule(Schedule $schedule)
|
protected function schedule(Schedule $schedule)
|
||||||
{
|
{
|
||||||
// $schedule->command('inspire')->hourly();
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// MINUTE-LEVEL JOBS
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Check for scheduled broadcasts every minute
|
||||||
|
$schedule->job(new \App\Jobs\Marketing\ProcessScheduledBroadcastsJob)
|
||||||
|
->everyMinute()
|
||||||
|
->withoutOverlapping();
|
||||||
|
|
||||||
|
// Send CRM task and event reminders every minute
|
||||||
|
$schedule->job(new \App\Jobs\Crm\SendCrmRemindersJob)
|
||||||
|
->everyMinute()
|
||||||
|
->withoutOverlapping();
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// SALES ORCHESTRATOR - "HEAD OF SALES" AUTOMATION
|
||||||
|
// See: docs/HEAD_OF_SALES_ORCHESTRATOR.md
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Generate sales tasks - runs hourly during business hours (weekdays 8AM-6PM)
|
||||||
|
// Monitors buyer behavior and creates actionable OrchestratorTask records
|
||||||
|
$schedule->command('orchestrator:generate-sales-tasks')
|
||||||
|
->hourlyAt(5)
|
||||||
|
->weekdays()
|
||||||
|
->between('08:00', '18:00')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
// Generate marketing tasks - runs hourly during business hours (weekdays 8AM-6PM)
|
||||||
|
// Creates campaign suggestions for marketing team
|
||||||
|
$schedule->command('orchestrator:generate-marketing-tasks')
|
||||||
|
->hourlyAt(15)
|
||||||
|
->weekdays()
|
||||||
|
->between('08:00', '18:00')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
// Evaluate outcomes - runs every 4 hours
|
||||||
|
// Links completed tasks to subsequent views/orders for learning loop
|
||||||
|
$schedule->command('orchestrator:evaluate-outcomes')
|
||||||
|
->everyFourHours()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
// Analyze timing - runs daily at 3 AM
|
||||||
|
// Determines best send times per brand based on historical outcomes
|
||||||
|
$schedule->command('orchestrator:analyze-timing')
|
||||||
|
->dailyAt('03:00')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
// TODO: Buyer scoring currently happens via BuyerScoringService on-demand.
|
||||||
|
// Consider creating orchestrator:score-buyers command for batch scoring if needed.
|
||||||
|
// $schedule->command('orchestrator:score-buyers')
|
||||||
|
// ->dailyAt('04:00')
|
||||||
|
// ->withoutOverlapping()
|
||||||
|
// ->runInBackground();
|
||||||
|
|
||||||
|
// Check Horizon health - runs every 5 minutes
|
||||||
|
// Monitors Redis/Horizon status, creates alerts on failure
|
||||||
|
$schedule->command('orchestrator:check-horizon')
|
||||||
|
->everyFiveMinutes()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
// Watchdog - runs every 15 minutes
|
||||||
|
// Monitors that all orchestrator commands are running on schedule
|
||||||
|
$schedule->command('orchestrator:watchdog')
|
||||||
|
->everyFifteenMinutes()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
// Self-audit - runs daily at 5 AM
|
||||||
|
// Checks for data integrity issues, impossible states, stale tasks
|
||||||
|
$schedule->command('orchestrator:self-audit')
|
||||||
|
->dailyAt('05:00')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
// Evaluate playbook performance - runs daily at 4 AM
|
||||||
|
// Updates 30-day metrics per playbook, can auto-quarantine underperformers
|
||||||
|
$schedule->command('orchestrator:evaluate-playbooks')
|
||||||
|
->dailyAt('04:00')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
// Daily report - runs weekdays at 7 AM
|
||||||
|
// Sends summary email to admins with task stats and alerts
|
||||||
|
$schedule->command('orchestrator:send-daily-report')
|
||||||
|
->weekdays()
|
||||||
|
->dailyAt('07:00')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// HOUSEKEEPING & MAINTENANCE
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Clean up temporary files older than 24 hours (runs daily at 2 AM)
|
||||||
|
$schedule->command('media:cleanup-temp')
|
||||||
|
->dailyAt('02:00')
|
||||||
|
->withoutOverlapping();
|
||||||
|
|
||||||
|
// Prune old audit logs based on business settings (runs daily at 3 AM)
|
||||||
|
$schedule->command('audits:prune')
|
||||||
|
->dailyAt('03:00')
|
||||||
|
->withoutOverlapping();
|
||||||
|
|
||||||
|
// Send CRM daily digest emails at 7 AM
|
||||||
|
$schedule->command('crm:send-daily-digests')
|
||||||
|
->dailyAt('07:00')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->onOneServer();
|
||||||
|
|
||||||
|
// Generate baseline promo recommendations (Promo Engine V3)
|
||||||
|
// Runs daily at 3:30 AM to generate margin-safe promo suggestions
|
||||||
|
$schedule->command('promos:seed-baseline')
|
||||||
|
->dailyAt('03:30')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
56
app/Events/HighIntentBuyerDetected.php
Normal file
56
app/Events/HighIntentBuyerDetected.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Analytics\BuyerEngagementScore;
|
||||||
|
use App\Models\Analytics\IntentSignal;
|
||||||
|
use Illuminate\Broadcasting\Channel;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class HighIntentBuyerDetected implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $sellerBusinessId,
|
||||||
|
public int $buyerBusinessId,
|
||||||
|
public IntentSignal $signal,
|
||||||
|
public ?BuyerEngagementScore $engagementScore = null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the channels the event should broadcast on.
|
||||||
|
*/
|
||||||
|
public function broadcastOn(): Channel
|
||||||
|
{
|
||||||
|
return new Channel("business.{$this->sellerBusinessId}.analytics");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the data to broadcast.
|
||||||
|
*/
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'buyer_business_id' => $this->buyerBusinessId,
|
||||||
|
'buyer_business_name' => $this->signal->buyerBusiness?->name,
|
||||||
|
'signal_type' => $this->signal->signal_type,
|
||||||
|
'signal_strength' => $this->signal->signal_strength,
|
||||||
|
'product_id' => $this->signal->subject_type === 'App\Models\Product' ? $this->signal->subject_id : null,
|
||||||
|
'total_engagement_score' => $this->engagementScore?->total_score,
|
||||||
|
'detected_at' => $this->signal->detected_at->toIso8601String(),
|
||||||
|
'context' => $this->signal->context,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event's broadcast name.
|
||||||
|
*/
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'high-intent-buyer-detected';
|
||||||
|
}
|
||||||
|
}
|
||||||
455
app/Filament/Pages/AiSettings.php
Normal file
455
app/Filament/Pages/AiSettings.php
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\AiSetting;
|
||||||
|
use App\Services\AiClient;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class AiSettings extends Page implements HasForms
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-sparkles';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.ai-settings';
|
||||||
|
|
||||||
|
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'AI Settings (Old)';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 100;
|
||||||
|
|
||||||
|
// Hide from navigation - replaced by AiConnectionResource
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only Superadmins can access AI Settings.
|
||||||
|
* This page is hidden from navigation but still protected.
|
||||||
|
*/
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return auth('admin')->user()?->canManageAi() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ?array $data = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$settings = AiSetting::getInstance();
|
||||||
|
|
||||||
|
$this->form->fill([
|
||||||
|
'is_enabled' => $settings->is_enabled ?? false,
|
||||||
|
'ai_provider' => $settings->ai_provider ?? '',
|
||||||
|
'anthropic_api_key' => '', // Never show the full key
|
||||||
|
'openai_api_key' => '', // Never show the full key
|
||||||
|
'perplexity_api_key' => '', // Never show the full key
|
||||||
|
'canva_api_key' => '', // Never show the full key
|
||||||
|
'jasper_api_key' => '', // Never show the full key
|
||||||
|
'anthropic_model' => $settings->anthropic_model ?? last(config('ai.providers.anthropic.models')),
|
||||||
|
'openai_model' => $settings->openai_model ?? last(config('ai.providers.openai.models')),
|
||||||
|
'perplexity_model' => $settings->perplexity_model ?? last(config('ai.providers.perplexity.models')),
|
||||||
|
'canva_model' => $settings->canva_model ?? last(config('ai.providers.canva.models')),
|
||||||
|
'jasper_model' => $settings->jasper_model ?? last(config('ai.providers.jasper.models')),
|
||||||
|
'max_tokens_per_request' => $settings->max_tokens_per_request ?? 4096,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
$settings = AiSetting::getInstance();
|
||||||
|
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('AI Copilot Configuration')
|
||||||
|
->description('Configure Cannabrands content suggestions for brand settings')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('is_enabled')
|
||||||
|
->label('Enable AI Copilot')
|
||||||
|
->helperText('Enable Cannabrands content suggestions across the platform')
|
||||||
|
->default(false),
|
||||||
|
|
||||||
|
Select::make('ai_provider')
|
||||||
|
->label('AI Provider')
|
||||||
|
->options([
|
||||||
|
'anthropic' => 'Anthropic / Claude',
|
||||||
|
'openai' => 'OpenAI / ChatGPT',
|
||||||
|
'perplexity' => 'Perplexity',
|
||||||
|
'canva' => 'Canva',
|
||||||
|
'jasper' => 'Jasper',
|
||||||
|
])
|
||||||
|
->placeholder('Select an AI provider')
|
||||||
|
->required()
|
||||||
|
->live()
|
||||||
|
->helperText('Choose your preferred AI provider'),
|
||||||
|
|
||||||
|
// Anthropic fields (shown when provider is 'anthropic')
|
||||||
|
TextInput::make('anthropic_api_key')
|
||||||
|
->label('Anthropic API Key')
|
||||||
|
->helperText('Enter your Anthropic API key. Leave blank to keep existing key.')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->placeholder('sk-ant-...')
|
||||||
|
->visible(fn ($get) => $get('ai_provider') === 'anthropic'),
|
||||||
|
|
||||||
|
Select::make('anthropic_model')
|
||||||
|
->label('Default Model')
|
||||||
|
->options(fn () => array_combine(
|
||||||
|
config('ai.providers.anthropic.models'),
|
||||||
|
config('ai.providers.anthropic.models')
|
||||||
|
))
|
||||||
|
->default(fn () => last(config('ai.providers.anthropic.models')))
|
||||||
|
->required()
|
||||||
|
->helperText('Claude model to use')
|
||||||
|
->visible(fn ($get) => $get('ai_provider') === 'anthropic'),
|
||||||
|
|
||||||
|
// OpenAI fields (shown when provider is 'openai')
|
||||||
|
TextInput::make('openai_api_key')
|
||||||
|
->label('OpenAI API Key')
|
||||||
|
->helperText('Enter your OpenAI API key. Leave blank to keep existing key.')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->placeholder('sk-...')
|
||||||
|
->visible(fn ($get) => $get('ai_provider') === 'openai'),
|
||||||
|
|
||||||
|
Select::make('openai_model')
|
||||||
|
->label('Default Model')
|
||||||
|
->options(fn () => array_combine(
|
||||||
|
config('ai.providers.openai.models'),
|
||||||
|
config('ai.providers.openai.models')
|
||||||
|
))
|
||||||
|
->default(fn () => last(config('ai.providers.openai.models')))
|
||||||
|
->required()
|
||||||
|
->helperText('ChatGPT / GPT model to use')
|
||||||
|
->visible(fn ($get) => $get('ai_provider') === 'openai'),
|
||||||
|
|
||||||
|
// Perplexity fields (shown when provider is 'perplexity')
|
||||||
|
TextInput::make('perplexity_api_key')
|
||||||
|
->label('Perplexity API Key')
|
||||||
|
->helperText('Enter your Perplexity API key. Leave blank to keep existing key.')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->placeholder('pplx-...')
|
||||||
|
->visible(fn ($get) => $get('ai_provider') === 'perplexity'),
|
||||||
|
|
||||||
|
Select::make('perplexity_model')
|
||||||
|
->label('Default Model')
|
||||||
|
->options(fn () => array_combine(
|
||||||
|
config('ai.providers.perplexity.models'),
|
||||||
|
config('ai.providers.perplexity.models')
|
||||||
|
))
|
||||||
|
->default(fn () => last(config('ai.providers.perplexity.models')))
|
||||||
|
->required()
|
||||||
|
->helperText('Perplexity model to use')
|
||||||
|
->visible(fn ($get) => $get('ai_provider') === 'perplexity'),
|
||||||
|
|
||||||
|
// Canva fields (shown when provider is 'canva')
|
||||||
|
TextInput::make('canva_api_key')
|
||||||
|
->label('Canva API Key')
|
||||||
|
->helperText('Enter your Canva API key. Leave blank to keep existing key.')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->placeholder('canva-...')
|
||||||
|
->visible(fn ($get) => $get('ai_provider') === 'canva'),
|
||||||
|
|
||||||
|
Select::make('canva_model')
|
||||||
|
->label('Default Model')
|
||||||
|
->options(fn () => array_combine(
|
||||||
|
config('ai.providers.canva.models'),
|
||||||
|
config('ai.providers.canva.models')
|
||||||
|
))
|
||||||
|
->default(fn () => last(config('ai.providers.canva.models')))
|
||||||
|
->required()
|
||||||
|
->helperText('Canva model/feature to use')
|
||||||
|
->visible(fn ($get) => $get('ai_provider') === 'canva'),
|
||||||
|
|
||||||
|
// Jasper fields (shown when provider is 'jasper')
|
||||||
|
TextInput::make('jasper_api_key')
|
||||||
|
->label('Jasper API Key')
|
||||||
|
->helperText('Enter your Jasper API key. Leave blank to keep existing key.')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->placeholder('jasper-...')
|
||||||
|
->visible(fn ($get) => $get('ai_provider') === 'jasper'),
|
||||||
|
|
||||||
|
Select::make('jasper_model')
|
||||||
|
->label('Default Model')
|
||||||
|
->options(fn () => array_combine(
|
||||||
|
config('ai.providers.jasper.models'),
|
||||||
|
config('ai.providers.jasper.models')
|
||||||
|
))
|
||||||
|
->default(fn () => last(config('ai.providers.jasper.models')))
|
||||||
|
->required()
|
||||||
|
->helperText('Jasper model to use')
|
||||||
|
->visible(fn ($get) => $get('ai_provider') === 'jasper'),
|
||||||
|
|
||||||
|
TextInput::make('max_tokens_per_request')
|
||||||
|
->label('Max Tokens Per Request')
|
||||||
|
->helperText('Maximum number of tokens to request from the AI model')
|
||||||
|
->numeric()
|
||||||
|
->default(4096)
|
||||||
|
->required(),
|
||||||
|
|
||||||
|
// Existing Connections Summary
|
||||||
|
Placeholder::make('connections_summary')
|
||||||
|
->label('Existing Connections')
|
||||||
|
->content(fn () => view('filament.components.ai-connections-summary', [
|
||||||
|
'anthropic_configured' => $settings->anthropic_api_key_configured,
|
||||||
|
'openai_configured' => $settings->openai_api_key_configured,
|
||||||
|
'perplexity_configured' => $settings->perplexity_api_key_configured,
|
||||||
|
'canva_configured' => $settings->canva_api_key_configured,
|
||||||
|
'jasper_configured' => $settings->jasper_api_key_configured,
|
||||||
|
])),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->statePath('data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$data = $this->form->getState();
|
||||||
|
|
||||||
|
$settings = AiSetting::getInstance();
|
||||||
|
|
||||||
|
// Update basic settings
|
||||||
|
$settings->is_enabled = $data['is_enabled'];
|
||||||
|
$settings->ai_provider = $data['ai_provider'];
|
||||||
|
$settings->max_tokens_per_request = $data['max_tokens_per_request'];
|
||||||
|
|
||||||
|
// Always save all model fields (preserve values when switching providers)
|
||||||
|
$settings->anthropic_model = $data['anthropic_model'] ?? $settings->anthropic_model;
|
||||||
|
$settings->openai_model = $data['openai_model'] ?? $settings->openai_model;
|
||||||
|
$settings->perplexity_model = $data['perplexity_model'] ?? $settings->perplexity_model;
|
||||||
|
$settings->canva_model = $data['canva_model'] ?? $settings->canva_model;
|
||||||
|
$settings->jasper_model = $data['jasper_model'] ?? $settings->jasper_model;
|
||||||
|
|
||||||
|
// Update API keys only if provided (don't overwrite with empty string)
|
||||||
|
if (! empty($data['anthropic_api_key'])) {
|
||||||
|
$settings->anthropic_api_key = $data['anthropic_api_key'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($data['openai_api_key'])) {
|
||||||
|
$settings->openai_api_key = $data['openai_api_key'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($data['perplexity_api_key'])) {
|
||||||
|
$settings->perplexity_api_key = $data['perplexity_api_key'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($data['canva_api_key'])) {
|
||||||
|
$settings->canva_api_key = $data['canva_api_key'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($data['jasper_api_key'])) {
|
||||||
|
$settings->jasper_api_key = $data['jasper_api_key'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings->save();
|
||||||
|
|
||||||
|
// Clear the AI config cache
|
||||||
|
app(AiClient::class)->clearCache();
|
||||||
|
|
||||||
|
// Refresh the form with saved data
|
||||||
|
$this->mount();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('AI Settings saved successfully')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConnection(): void
|
||||||
|
{
|
||||||
|
$data = $this->form->getState();
|
||||||
|
$provider = $data['ai_provider'];
|
||||||
|
|
||||||
|
if (! $provider) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Please select a provider first')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = AiSetting::getInstance();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$success = false;
|
||||||
|
$message = '';
|
||||||
|
|
||||||
|
switch ($provider) {
|
||||||
|
case 'anthropic':
|
||||||
|
if (empty($settings->anthropic_api_key)) {
|
||||||
|
throw new \Exception('Anthropic API key not configured');
|
||||||
|
}
|
||||||
|
$success = $this->testAnthropicConnection($settings->anthropic_api_key);
|
||||||
|
$message = $success ? 'Anthropic connection successful' : 'Anthropic connection failed';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'openai':
|
||||||
|
if (empty($settings->openai_api_key)) {
|
||||||
|
throw new \Exception('OpenAI API key not configured');
|
||||||
|
}
|
||||||
|
$success = $this->testOpenAiConnection($settings->openai_api_key);
|
||||||
|
$message = $success ? 'OpenAI connection successful' : 'OpenAI connection failed';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'perplexity':
|
||||||
|
if (empty($settings->perplexity_api_key)) {
|
||||||
|
throw new \Exception('Perplexity API key not configured');
|
||||||
|
}
|
||||||
|
$success = $this->testPerplexityConnection($settings->perplexity_api_key);
|
||||||
|
$message = $success ? 'Perplexity connection successful' : 'Perplexity connection failed';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'canva':
|
||||||
|
if (empty($settings->canva_api_key)) {
|
||||||
|
throw new \Exception('Canva API key not configured');
|
||||||
|
}
|
||||||
|
$success = $this->testCanvaConnection($settings->canva_api_key);
|
||||||
|
$message = $success ? 'Canva connection successful' : 'Canva connection failed';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'jasper':
|
||||||
|
if (empty($settings->jasper_api_key)) {
|
||||||
|
throw new \Exception('Jasper API key not configured');
|
||||||
|
}
|
||||||
|
$success = $this->testJasperConnection($settings->jasper_api_key);
|
||||||
|
$message = $success ? 'Jasper connection successful' : 'Jasper connection failed';
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new \Exception('Unknown provider: '.$provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
Notification::make()
|
||||||
|
->title($message)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title($message)
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Connection test failed')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testAnthropicConnection(string $apiKey): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = new \GuzzleHttp\Client;
|
||||||
|
$response = $client->post('https://api.anthropic.com/v1/messages', [
|
||||||
|
'headers' => [
|
||||||
|
'x-api-key' => $apiKey,
|
||||||
|
'anthropic-version' => '2023-06-01',
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
],
|
||||||
|
'json' => [
|
||||||
|
'model' => 'claude-3-5-sonnet-20241022',
|
||||||
|
'max_tokens' => 10,
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'user', 'content' => 'Hi'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'timeout' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response->getStatusCode() === 200;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testOpenAiConnection(string $apiKey): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = new \GuzzleHttp\Client;
|
||||||
|
$response = $client->get('https://api.openai.com/v1/models', [
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => 'Bearer '.$apiKey,
|
||||||
|
],
|
||||||
|
'timeout' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response->getStatusCode() === 200;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testPerplexityConnection(string $apiKey): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = new \GuzzleHttp\Client;
|
||||||
|
$response = $client->post('https://api.perplexity.ai/chat/completions', [
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => 'Bearer '.$apiKey,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
],
|
||||||
|
'json' => [
|
||||||
|
'model' => 'sonar-small',
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'user', 'content' => 'Hi'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'timeout' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response->getStatusCode() === 200;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testCanvaConnection(string $apiKey): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = new \GuzzleHttp\Client;
|
||||||
|
$response = $client->get('https://api.canva.com/v1/users/me', [
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => 'Bearer '.$apiKey,
|
||||||
|
],
|
||||||
|
'timeout' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response->getStatusCode() === 200;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testJasperConnection(string $apiKey): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = new \GuzzleHttp\Client;
|
||||||
|
$response = $client->get('https://api.jasper.ai/v1/account', [
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => 'Bearer '.$apiKey,
|
||||||
|
],
|
||||||
|
'timeout' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response->getStatusCode() === 200;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
216
app/Filament/Pages/CrmSettings.php
Normal file
216
app/Filament/Pages/CrmSettings.php
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\Activity;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\CalendarEvent;
|
||||||
|
use App\Models\Crm\CrmTask;
|
||||||
|
use App\Models\SalesOpportunity;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Repeater;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class CrmSettings extends Page implements HasForms, HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-briefcase';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.crm-settings';
|
||||||
|
|
||||||
|
protected static \UnitEnum|string|null $navigationGroup = 'Modules';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'CRM Module';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 10;
|
||||||
|
|
||||||
|
// Hide from navigation - CRM module settings are now in Business > Modules tab
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
public ?array $data = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
// Load default pipeline stages template
|
||||||
|
$this->form->fill([
|
||||||
|
'default_pipeline_stages' => $this->getDefaultPipelineStages(),
|
||||||
|
'ai_commands_enabled' => true,
|
||||||
|
'reminder_lead_time_minutes' => 30,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('CRM Module Configuration')
|
||||||
|
->description('Global settings for the CRM module')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('ai_commands_enabled')
|
||||||
|
->label('Enable AI Commands')
|
||||||
|
->helperText('Allow Cannabrands command parsing from messages'),
|
||||||
|
|
||||||
|
TextInput::make('reminder_lead_time_minutes')
|
||||||
|
->label('Default Reminder Lead Time (minutes)')
|
||||||
|
->helperText('How many minutes before an event/task to send reminders')
|
||||||
|
->numeric()
|
||||||
|
->default(30)
|
||||||
|
->minValue(5)
|
||||||
|
->maxValue(1440),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Default Pipeline Stages')
|
||||||
|
->description('Template stages for new business pipelines')
|
||||||
|
->schema([
|
||||||
|
Repeater::make('default_pipeline_stages')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('name')
|
||||||
|
->required()
|
||||||
|
->maxLength(50),
|
||||||
|
TextInput::make('color')
|
||||||
|
->type('color')
|
||||||
|
->default('#6366F1'),
|
||||||
|
TextInput::make('probability')
|
||||||
|
->numeric()
|
||||||
|
->suffix('%')
|
||||||
|
->default(50)
|
||||||
|
->minValue(0)
|
||||||
|
->maxValue(100),
|
||||||
|
])
|
||||||
|
->columns(3)
|
||||||
|
->collapsible()
|
||||||
|
->reorderable()
|
||||||
|
->addActionLabel('Add Stage')
|
||||||
|
->defaultItems(0),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->statePath('data');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getFormActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('save')
|
||||||
|
->label('Save Settings')
|
||||||
|
->color('primary')
|
||||||
|
->action('saveSettingsAction'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveSettingsAction(): void
|
||||||
|
{
|
||||||
|
$data = $this->form->getState();
|
||||||
|
|
||||||
|
// Store settings in config cache or database
|
||||||
|
// For now, we'll just show a success notification
|
||||||
|
// In production, this would persist to a settings table
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('CRM Settings saved successfully')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDefaultPipelineStages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['name' => 'Lead', 'color' => '#94A3B8', 'probability' => 10],
|
||||||
|
['name' => 'Qualified', 'color' => '#3B82F6', 'probability' => 25],
|
||||||
|
['name' => 'Proposal', 'color' => '#8B5CF6', 'probability' => 50],
|
||||||
|
['name' => 'Negotiation', 'color' => '#F59E0B', 'probability' => 75],
|
||||||
|
['name' => 'Closed Won', 'color' => '#10B981', 'probability' => 100],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->heading('CRM Module Statistics by Business')
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('business_name')
|
||||||
|
->label('Business')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('has_crm')
|
||||||
|
->label('CRM Enabled')
|
||||||
|
->badge()
|
||||||
|
->color(fn (bool $state): string => $state ? 'success' : 'gray')
|
||||||
|
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No'),
|
||||||
|
TextColumn::make('opportunities_count')
|
||||||
|
->label('Opportunities')
|
||||||
|
->numeric()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('tasks_count')
|
||||||
|
->label('Tasks')
|
||||||
|
->numeric()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('events_count')
|
||||||
|
->label('Events')
|
||||||
|
->numeric()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('activities_count')
|
||||||
|
->label('Activities')
|
||||||
|
->numeric()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('pipeline_value')
|
||||||
|
->label('Pipeline Value')
|
||||||
|
->money('USD')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->query(fn () => $this->getCrmStats())
|
||||||
|
->defaultSort('business_name');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getCrmStats()
|
||||||
|
{
|
||||||
|
// Get all seller businesses with CRM stats
|
||||||
|
return Business::where('business_type', 'seller')
|
||||||
|
->orWhere('business_type', 'both')
|
||||||
|
->select([
|
||||||
|
'businesses.id',
|
||||||
|
'businesses.name as business_name',
|
||||||
|
'businesses.has_crm',
|
||||||
|
])
|
||||||
|
->withCount([
|
||||||
|
'sellerOpportunities as opportunities_count',
|
||||||
|
])
|
||||||
|
->get()
|
||||||
|
->map(function ($business) {
|
||||||
|
// Add additional counts that can't be done via withCount due to custom foreign keys
|
||||||
|
$business->tasks_count = CrmTask::where('seller_business_id', $business->id)->count();
|
||||||
|
$business->events_count = CalendarEvent::where('seller_business_id', $business->id)->count();
|
||||||
|
$business->activities_count = Activity::where('seller_business_id', $business->id)->count();
|
||||||
|
$business->pipeline_value = SalesOpportunity::where('seller_business_id', $business->id)
|
||||||
|
->where('status', 'open')
|
||||||
|
->sum('value');
|
||||||
|
|
||||||
|
return $business;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationBadge(): ?string
|
||||||
|
{
|
||||||
|
// Show count of businesses with CRM enabled
|
||||||
|
$count = Business::where('has_crm', true)->count();
|
||||||
|
|
||||||
|
return $count > 0 ? (string) $count : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationBadgeColor(): ?string
|
||||||
|
{
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
}
|
||||||
1294
app/Filament/Pages/HeadOfSalesOrchestrator.php
Normal file
1294
app/Filament/Pages/HeadOfSalesOrchestrator.php
Normal file
File diff suppressed because it is too large
Load Diff
604
app/Filament/Pages/MarketingOrchestrator.php
Normal file
604
app/Filament/Pages/MarketingOrchestrator.php
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\AutomationRunLog;
|
||||||
|
use App\Models\OrchestratorMarketingConfig;
|
||||||
|
use App\Models\OrchestratorTask;
|
||||||
|
use App\Models\SystemAlert;
|
||||||
|
use App\Services\OrchestratorGovernanceService;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class MarketingOrchestrator extends Page implements HasForms, HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-megaphone';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Head of Marketing';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Orchestrator';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = true;
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.marketing-orchestrator';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Marketing Orchestrator – Head of Marketing';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'orchestrator/head-of-marketing';
|
||||||
|
|
||||||
|
// Form state for playbook settings
|
||||||
|
public ?array $playbookData = [];
|
||||||
|
|
||||||
|
// Current view mode
|
||||||
|
public string $activeTab = 'tasks';
|
||||||
|
|
||||||
|
// Timeframe filter (7, 30, 90 days)
|
||||||
|
public int $timeframe = 30;
|
||||||
|
|
||||||
|
public function updatedTimeframe(): void
|
||||||
|
{
|
||||||
|
// Refresh data
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$config = OrchestratorMarketingConfig::getGlobal();
|
||||||
|
$this->form->fill($config->toArray());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->form->fill([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
$user = auth('admin')->user();
|
||||||
|
|
||||||
|
return $user && in_array($user->user_type, ['admin', 'superadmin']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// KPI Data
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function getKpiData(): array
|
||||||
|
{
|
||||||
|
$startDate = now()->subDays($this->timeframe);
|
||||||
|
|
||||||
|
$createdCount = OrchestratorTask::marketing()
|
||||||
|
->where('created_at', '>=', $startDate)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$pendingCount = OrchestratorTask::marketing()
|
||||||
|
->pending()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$completedCount = OrchestratorTask::marketing()
|
||||||
|
->completed()
|
||||||
|
->where('completed_at', '>=', $startDate)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$dismissedCount = OrchestratorTask::marketing()
|
||||||
|
->dismissed()
|
||||||
|
->where('completed_at', '>=', $startDate)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
// By playbook type
|
||||||
|
$byPlaybook = [
|
||||||
|
'campaign_blast' => OrchestratorTask::marketing()
|
||||||
|
->forType(OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE)
|
||||||
|
->where('created_at', '>=', $startDate)
|
||||||
|
->count(),
|
||||||
|
'segment_refinement' => OrchestratorTask::marketing()
|
||||||
|
->forType(OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT)
|
||||||
|
->where('created_at', '>=', $startDate)
|
||||||
|
->count(),
|
||||||
|
'launch_announcement' => OrchestratorTask::marketing()
|
||||||
|
->forType(OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT)
|
||||||
|
->where('created_at', '>=', $startDate)
|
||||||
|
->count(),
|
||||||
|
'holiday_campaign' => OrchestratorTask::marketing()
|
||||||
|
->forType(OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN)
|
||||||
|
->where('created_at', '>=', $startDate)
|
||||||
|
->count(),
|
||||||
|
'new_sku_feature' => OrchestratorTask::marketing()
|
||||||
|
->forType(OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE)
|
||||||
|
->where('created_at', '>=', $startDate)
|
||||||
|
->count(),
|
||||||
|
'nurture_sequence' => OrchestratorTask::marketing()
|
||||||
|
->forType(OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE)
|
||||||
|
->where('created_at', '>=', $startDate)
|
||||||
|
->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'timeframe' => $this->timeframe,
|
||||||
|
'created' => $createdCount,
|
||||||
|
'pending' => $pendingCount,
|
||||||
|
'completed' => $completedCount,
|
||||||
|
'dismissed' => $dismissedCount,
|
||||||
|
'by_playbook' => $byPlaybook,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Pending Tasks Table
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(
|
||||||
|
OrchestratorTask::query()
|
||||||
|
->marketing()
|
||||||
|
->pending()
|
||||||
|
->with(['business', 'brand', 'customer'])
|
||||||
|
)
|
||||||
|
->defaultSort('due_at', 'asc')
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('type')
|
||||||
|
->label('Type')
|
||||||
|
->formatStateUsing(fn ($state) => match ($state) {
|
||||||
|
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE => 'Campaign Blast',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT => 'Segment Refinement',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT => 'Launch',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN => 'Holiday',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE => 'New SKU',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE => 'Nurture',
|
||||||
|
default => $state,
|
||||||
|
})
|
||||||
|
->badge()
|
||||||
|
->color(fn ($state) => match ($state) {
|
||||||
|
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE => 'primary',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT => 'gray',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT => 'success',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN => 'info',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE => 'warning',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE => 'gray',
|
||||||
|
default => 'gray',
|
||||||
|
}),
|
||||||
|
|
||||||
|
TextColumn::make('business.name')
|
||||||
|
->label('Seller')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
|
||||||
|
TextColumn::make('brand.name')
|
||||||
|
->label('Brand')
|
||||||
|
->placeholder('-')
|
||||||
|
->searchable(),
|
||||||
|
|
||||||
|
TextColumn::make('customer.name')
|
||||||
|
->label('Customer')
|
||||||
|
->placeholder('-')
|
||||||
|
->searchable(),
|
||||||
|
|
||||||
|
TextColumn::make('priority')
|
||||||
|
->badge()
|
||||||
|
->color(fn ($state) => match ($state) {
|
||||||
|
'high' => 'danger',
|
||||||
|
'normal' => 'gray',
|
||||||
|
'low' => 'gray',
|
||||||
|
default => 'gray',
|
||||||
|
}),
|
||||||
|
|
||||||
|
TextColumn::make('due_at')
|
||||||
|
->label('Due')
|
||||||
|
->dateTime('M j, g:i A')
|
||||||
|
->sortable()
|
||||||
|
->color(fn ($record) => $record->due_at && $record->due_at->isPast() ? 'danger' : null),
|
||||||
|
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->label('Created')
|
||||||
|
->since()
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('type')
|
||||||
|
->options([
|
||||||
|
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE => 'Campaign Blast',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT => 'Segment Refinement',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT => 'Launch Announcement',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN => 'Holiday Campaign',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE => 'New SKU Feature',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE => 'Nurture Sequence',
|
||||||
|
]),
|
||||||
|
SelectFilter::make('priority')
|
||||||
|
->options([
|
||||||
|
'high' => 'High',
|
||||||
|
'normal' => 'Normal',
|
||||||
|
'low' => 'Low',
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
\Filament\Actions\Action::make('complete')
|
||||||
|
->label('Done')
|
||||||
|
->icon('heroicon-o-check')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(fn (OrchestratorTask $record) => $record->complete()),
|
||||||
|
|
||||||
|
\Filament\Actions\Action::make('dismiss')
|
||||||
|
->label('Dismiss')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(fn (OrchestratorTask $record) => $record->dismiss()),
|
||||||
|
|
||||||
|
\Filament\Actions\Action::make('snooze')
|
||||||
|
->label('Snooze')
|
||||||
|
->icon('heroicon-o-clock')
|
||||||
|
->color('warning')
|
||||||
|
->form([
|
||||||
|
\Filament\Forms\Components\Select::make('days')
|
||||||
|
->label('Snooze for')
|
||||||
|
->options([
|
||||||
|
1 => '1 day',
|
||||||
|
3 => '3 days',
|
||||||
|
7 => '7 days',
|
||||||
|
])
|
||||||
|
->required(),
|
||||||
|
])
|
||||||
|
->action(fn (OrchestratorTask $record, array $data) => $record->snooze((int) $data['days'])),
|
||||||
|
])
|
||||||
|
->bulkActions([])
|
||||||
|
->emptyStateHeading('No pending marketing tasks')
|
||||||
|
->emptyStateDescription('The Marketing Orchestrator has no pending tasks at this time.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Activity Log Data
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function getActivityLogData(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return OrchestratorTask::marketing()
|
||||||
|
->resolved()
|
||||||
|
->with(['business', 'brand', 'customer', 'completedByUser'])
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->limit(50)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Performance Data
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function getPerformanceData(): array
|
||||||
|
{
|
||||||
|
$thirtyDaysAgo = now()->subDays(30);
|
||||||
|
|
||||||
|
$playbooks = [
|
||||||
|
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE => 'Campaign Blast',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT => 'Segment Refinement',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT => 'Launch Announcement',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN => 'Holiday Campaign',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE => 'New SKU Feature',
|
||||||
|
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE => 'Nurture Sequence',
|
||||||
|
];
|
||||||
|
|
||||||
|
$metrics = [];
|
||||||
|
|
||||||
|
foreach ($playbooks as $type => $label) {
|
||||||
|
$created = OrchestratorTask::marketing()
|
||||||
|
->forType($type)
|
||||||
|
->where('created_at', '>=', $thirtyDaysAgo)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$completed = OrchestratorTask::marketing()
|
||||||
|
->forType($type)
|
||||||
|
->completed()
|
||||||
|
->where('completed_at', '>=', $thirtyDaysAgo)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$dismissed = OrchestratorTask::marketing()
|
||||||
|
->forType($type)
|
||||||
|
->dismissed()
|
||||||
|
->where('completed_at', '>=', $thirtyDaysAgo)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$metrics[$type] = [
|
||||||
|
'label' => $label,
|
||||||
|
'created_30d' => $created,
|
||||||
|
'completed_30d' => $completed,
|
||||||
|
'dismissed_30d' => $dismissed,
|
||||||
|
'completion_rate' => $created > 0 ? round(($completed / $created) * 100, 1) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalCreated = OrchestratorTask::marketing()
|
||||||
|
->where('created_at', '>=', $thirtyDaysAgo)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$totalCompleted = OrchestratorTask::marketing()
|
||||||
|
->completed()
|
||||||
|
->where('completed_at', '>=', $thirtyDaysAgo)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'by_playbook' => $metrics,
|
||||||
|
'total_created_30d' => $totalCreated,
|
||||||
|
'total_completed_30d' => $totalCompleted,
|
||||||
|
'overall_completion_rate' => $totalCreated > 0
|
||||||
|
? round(($totalCompleted / $totalCreated) * 100, 1)
|
||||||
|
: 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Governance & Health Data
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active system alerts for the dashboard.
|
||||||
|
*/
|
||||||
|
public function getActiveAlerts(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
if (! \Schema::hasTable('system_alerts')) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return SystemAlert::unresolved()
|
||||||
|
->orderByRaw("CASE severity WHEN 'critical' THEN 1 WHEN 'warning' THEN 2 ELSE 3 END")
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get alert summary counts.
|
||||||
|
*/
|
||||||
|
public function getAlertSummary(): array
|
||||||
|
{
|
||||||
|
if (! \Schema::hasTable('system_alerts')) {
|
||||||
|
return ['critical' => 0, 'warning' => 0, 'info' => 0, 'total' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return SystemAlert::getSummaryCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get queue/Horizon health data.
|
||||||
|
*/
|
||||||
|
public function getQueueHealthData(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$governance = app(OrchestratorGovernanceService::class);
|
||||||
|
|
||||||
|
return $governance->checkHorizonHealth();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [
|
||||||
|
'status' => 'unknown',
|
||||||
|
'checks' => [],
|
||||||
|
'alerts' => [],
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get automation health summary.
|
||||||
|
*/
|
||||||
|
public function getAutomationHealthData(): array
|
||||||
|
{
|
||||||
|
if (! \Schema::hasTable('automation_run_logs')) {
|
||||||
|
return [
|
||||||
|
'healthy' => 0,
|
||||||
|
'unhealthy' => 0,
|
||||||
|
'unknown' => 0,
|
||||||
|
'total' => 0,
|
||||||
|
'overall_status' => 'unknown',
|
||||||
|
'statuses' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return AutomationRunLog::getAllStatuses();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an alert (action from UI).
|
||||||
|
*/
|
||||||
|
public function resolveAlert(int $alertId): void
|
||||||
|
{
|
||||||
|
$alert = SystemAlert::find($alertId);
|
||||||
|
|
||||||
|
if ($alert) {
|
||||||
|
$alert->resolve(auth()->id(), 'Resolved from marketing dashboard');
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Alert resolved')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Playbook Settings Form
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function form(Schema $form): Schema
|
||||||
|
{
|
||||||
|
return $form
|
||||||
|
->statePath('playbookData')
|
||||||
|
->schema([
|
||||||
|
Section::make('Global Settings')
|
||||||
|
->description('Control throttling and cooldown for marketing tasks')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('max_tasks_per_brand_per_run')
|
||||||
|
->label('Max tasks per brand per run')
|
||||||
|
->numeric()
|
||||||
|
->default(5)
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(20),
|
||||||
|
TextInput::make('cooldown_days')
|
||||||
|
->label('Cooldown (days)')
|
||||||
|
->helperText('Minimum days between marketing touches to same customer')
|
||||||
|
->numeric()
|
||||||
|
->default(7)
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(30),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
|
||||||
|
Section::make('Campaign Blast Candidates')
|
||||||
|
->description('Find high-engagement customers for campaign blasts')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('campaign_blast_enabled')
|
||||||
|
->label('Enabled')
|
||||||
|
->default(true),
|
||||||
|
TextInput::make('campaign_blast_min_engagement_score')
|
||||||
|
->label('Min engagement score')
|
||||||
|
->numeric()
|
||||||
|
->default(50)
|
||||||
|
->minValue(0)
|
||||||
|
->maxValue(100),
|
||||||
|
TextInput::make('campaign_blast_days_since_last_send')
|
||||||
|
->label('Days since last send')
|
||||||
|
->numeric()
|
||||||
|
->default(14)
|
||||||
|
->minValue(7)
|
||||||
|
->maxValue(60),
|
||||||
|
])
|
||||||
|
->columns(3),
|
||||||
|
|
||||||
|
Section::make('Segment Refinement')
|
||||||
|
->description('Suggest customer segmentation for brands with many customers')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('segment_refinement_enabled')
|
||||||
|
->label('Enabled')
|
||||||
|
->default(true),
|
||||||
|
TextInput::make('segment_refinement_min_customers')
|
||||||
|
->label('Min customers')
|
||||||
|
->numeric()
|
||||||
|
->default(50)
|
||||||
|
->minValue(10)
|
||||||
|
->maxValue(500),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
|
||||||
|
Section::make('Launch Announcement')
|
||||||
|
->description('Suggest campaigns for new brands')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('launch_announcement_enabled')
|
||||||
|
->label('Enabled')
|
||||||
|
->default(true),
|
||||||
|
TextInput::make('launch_announcement_days_new')
|
||||||
|
->label('Days new')
|
||||||
|
->helperText('Brands created within this window are "new"')
|
||||||
|
->numeric()
|
||||||
|
->default(30)
|
||||||
|
->minValue(7)
|
||||||
|
->maxValue(90),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
|
||||||
|
Section::make('Holiday Campaign')
|
||||||
|
->description('Suggest holiday-themed campaigns')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('holiday_campaign_enabled')
|
||||||
|
->label('Enabled')
|
||||||
|
->default(true),
|
||||||
|
TextInput::make('holiday_campaign_days_before')
|
||||||
|
->label('Days before holiday')
|
||||||
|
->numeric()
|
||||||
|
->default(14)
|
||||||
|
->minValue(7)
|
||||||
|
->maxValue(30),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
|
||||||
|
Section::make('New SKU Feature')
|
||||||
|
->description('Suggest featuring new products')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('new_sku_feature_enabled')
|
||||||
|
->label('Enabled')
|
||||||
|
->default(true),
|
||||||
|
TextInput::make('new_sku_feature_days_new')
|
||||||
|
->label('Days new')
|
||||||
|
->numeric()
|
||||||
|
->default(7)
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(30),
|
||||||
|
TextInput::make('new_sku_feature_min_products')
|
||||||
|
->label('Min products')
|
||||||
|
->numeric()
|
||||||
|
->default(3)
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(20),
|
||||||
|
])
|
||||||
|
->columns(3),
|
||||||
|
|
||||||
|
Section::make('Nurture Sequence')
|
||||||
|
->description('Suggest nurture sequences for new customers')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('nurture_sequence_enabled')
|
||||||
|
->label('Enabled')
|
||||||
|
->default(true),
|
||||||
|
TextInput::make('nurture_sequence_days_since_first_order')
|
||||||
|
->label('Days since first order')
|
||||||
|
->numeric()
|
||||||
|
->default(30)
|
||||||
|
->minValue(7)
|
||||||
|
->maxValue(90),
|
||||||
|
TextInput::make('nurture_sequence_max_orders')
|
||||||
|
->label('Max orders')
|
||||||
|
->helperText('Only target customers with this many orders or fewer')
|
||||||
|
->numeric()
|
||||||
|
->default(2)
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(5),
|
||||||
|
])
|
||||||
|
->columns(3),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function savePlaybookSettings(): void
|
||||||
|
{
|
||||||
|
$data = $this->form->getState();
|
||||||
|
|
||||||
|
OrchestratorMarketingConfig::updateGlobal($data);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Marketing playbook settings saved')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('run_playbooks')
|
||||||
|
->label('Run Playbooks Now')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Run Marketing Orchestrator Playbooks')
|
||||||
|
->modalDescription('This will generate new marketing tasks based on current signals. Continue?')
|
||||||
|
->action(function () {
|
||||||
|
\Artisan::call('orchestrator:generate-marketing-tasks');
|
||||||
|
Notification::make()
|
||||||
|
->title('Playbooks executed')
|
||||||
|
->body('Marketing Orchestrator tasks have been generated.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/Filament/Pages/MigrationHealth.php
Normal file
104
app/Filament/Pages/MigrationHealth.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
class MigrationHealth extends Page
|
||||||
|
{
|
||||||
|
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-circle-stack';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.migration-health';
|
||||||
|
|
||||||
|
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Migrations';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 97;
|
||||||
|
|
||||||
|
public array $migrations = [];
|
||||||
|
|
||||||
|
public bool $hasPending = false;
|
||||||
|
|
||||||
|
public int $totalMigrations = 0;
|
||||||
|
|
||||||
|
public int $ranMigrations = 0;
|
||||||
|
|
||||||
|
public int $pendingMigrations = 0;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->loadMigrations();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loadMigrations(): void
|
||||||
|
{
|
||||||
|
// Get all migration files from database/migrations
|
||||||
|
$migrationsPath = database_path('migrations');
|
||||||
|
$files = File::files($migrationsPath);
|
||||||
|
|
||||||
|
// Get ran migrations from database
|
||||||
|
$ranMigrations = DB::table('migrations')
|
||||||
|
->select('migration', 'batch')
|
||||||
|
->get()
|
||||||
|
->keyBy('migration')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$migrations = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$filename = $file->getFilename();
|
||||||
|
|
||||||
|
// Skip non-PHP files
|
||||||
|
if (! str_ends_with($filename, '.php')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract migration name (without .php extension)
|
||||||
|
$migrationName = str_replace('.php', '', $filename);
|
||||||
|
|
||||||
|
$ran = isset($ranMigrations[$migrationName]);
|
||||||
|
$batch = $ran ? $ranMigrations[$migrationName]->batch : null;
|
||||||
|
|
||||||
|
$migrations[] = [
|
||||||
|
'name' => $migrationName,
|
||||||
|
'ran' => $ran,
|
||||||
|
'batch' => $batch,
|
||||||
|
'ran_at' => null, // migrations table doesn't have ran_at by default
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! $ran) {
|
||||||
|
$this->hasPending = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort migrations by name (chronological order due to timestamp prefix)
|
||||||
|
usort($migrations, fn ($a, $b) => strcmp($a['name'], $b['name']));
|
||||||
|
|
||||||
|
$this->migrations = $migrations;
|
||||||
|
$this->totalMigrations = count($migrations);
|
||||||
|
$this->ranMigrations = count(array_filter($migrations, fn ($m) => $m['ran']));
|
||||||
|
$this->pendingMigrations = $this->totalMigrations - $this->ranMigrations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusColor(): string
|
||||||
|
{
|
||||||
|
return $this->hasPending ? 'warning' : 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusMessage(): string
|
||||||
|
{
|
||||||
|
if ($this->hasPending) {
|
||||||
|
return 'Pending migrations detected. Please back up your database and run php artisan migrate from the terminal.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'All migrations are up to date.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusIcon(): string
|
||||||
|
{
|
||||||
|
return $this->hasPending ? 'heroicon-o-exclamation-triangle' : 'heroicon-o-check-circle';
|
||||||
|
}
|
||||||
|
}
|
||||||
187
app/Filament/Pages/ModuleUsageReport.php
Normal file
187
app/Filament/Pages/ModuleUsageReport.php
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\Ai\AiPromptLog;
|
||||||
|
use App\Models\Business;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class ModuleUsageReport extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
|
||||||
|
|
||||||
|
protected static \UnitEnum|string|null $navigationGroup = 'AI Settings';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 3;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Usage Reports';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Module Usage Reports';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.module-usage-report';
|
||||||
|
|
||||||
|
public string $dateRange = '30';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only Superadmins can access Module Usage Reports.
|
||||||
|
* Admin Staff will not see this page in navigation.
|
||||||
|
*/
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return auth('admin')->user()?->canManageAi() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->dateRange = request()->get('days', '30');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(
|
||||||
|
Business::query()
|
||||||
|
->where('copilot_enabled', true)
|
||||||
|
->withCount([
|
||||||
|
'aiPromptLogs as copilot_requests' => function (Builder $query) {
|
||||||
|
$query->where('operation', 'copilot')
|
||||||
|
->where('created_at', '>=', now()->subDays((int) $this->dateRange));
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->withSum([
|
||||||
|
'aiPromptLogs as copilot_tokens' => function (Builder $query) {
|
||||||
|
$query->where('operation', 'copilot')
|
||||||
|
->where('created_at', '>=', now()->subDays((int) $this->dateRange));
|
||||||
|
},
|
||||||
|
], 'total_tokens')
|
||||||
|
->withSum([
|
||||||
|
'aiPromptLogs as copilot_cost' => function (Builder $query) {
|
||||||
|
$query->where('operation', 'copilot')
|
||||||
|
->where('created_at', '>=', now()->subDays((int) $this->dateRange));
|
||||||
|
},
|
||||||
|
], 'estimated_cost')
|
||||||
|
)
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->label('Business')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('copilot_tier')
|
||||||
|
->label('Tier')
|
||||||
|
->badge()
|
||||||
|
->color(fn (?string $state): string => match ($state) {
|
||||||
|
'basic' => 'info',
|
||||||
|
'premium' => 'success',
|
||||||
|
'custom' => 'warning',
|
||||||
|
default => 'gray',
|
||||||
|
}),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('copilot_requests')
|
||||||
|
->label('Requests')
|
||||||
|
->numeric()
|
||||||
|
->sortable()
|
||||||
|
->alignEnd(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('copilot_tokens')
|
||||||
|
->label('Tokens')
|
||||||
|
->numeric()
|
||||||
|
->sortable()
|
||||||
|
->alignEnd()
|
||||||
|
->formatStateUsing(fn ($state) => number_format($state ?? 0)),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('copilot_cost')
|
||||||
|
->label('Cost')
|
||||||
|
->money('usd')
|
||||||
|
->sortable()
|
||||||
|
->alignEnd(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('last_copilot_use')
|
||||||
|
->label('Last Used')
|
||||||
|
->getStateUsing(function (Business $record) {
|
||||||
|
$lastLog = AiPromptLog::forBusiness($record->id)
|
||||||
|
->where('operation', 'copilot')
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $lastLog?->created_at;
|
||||||
|
})
|
||||||
|
->dateTime()
|
||||||
|
->sortable(query: function (Builder $query, string $direction) {
|
||||||
|
// This is a computed column, so we can't sort directly
|
||||||
|
return $query;
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('copilot_tier')
|
||||||
|
->label('Tier')
|
||||||
|
->options([
|
||||||
|
'basic' => 'Basic',
|
||||||
|
'premium' => 'Premium',
|
||||||
|
'custom' => 'Custom',
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->recordUrl(fn (Business $record) => route('filament.admin.resources.businesses.edit', $record))
|
||||||
|
->defaultSort('copilot_requests', 'desc')
|
||||||
|
->striped()
|
||||||
|
->paginated([10, 25, 50, 100]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOverviewStats(): array
|
||||||
|
{
|
||||||
|
$days = (int) $this->dateRange;
|
||||||
|
$startDate = now()->subDays($days);
|
||||||
|
|
||||||
|
$stats = AiPromptLog::where('operation', 'copilot')
|
||||||
|
->where('created_at', '>=', $startDate)
|
||||||
|
->selectRaw('
|
||||||
|
COUNT(*) as total_requests,
|
||||||
|
SUM(total_tokens) as total_tokens,
|
||||||
|
SUM(estimated_cost) as total_cost,
|
||||||
|
AVG(latency_ms) as avg_latency,
|
||||||
|
COUNT(DISTINCT business_id) as active_businesses,
|
||||||
|
SUM(CASE WHEN is_error = true THEN 1 ELSE 0 END) as error_count
|
||||||
|
')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$totalBusinessesWithCopilot = Business::where('copilot_enabled', true)->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_requests' => $stats->total_requests ?? 0,
|
||||||
|
'total_tokens' => $stats->total_tokens ?? 0,
|
||||||
|
'total_cost' => $stats->total_cost ?? 0,
|
||||||
|
'avg_latency' => $stats->avg_latency ?? 0,
|
||||||
|
'active_businesses' => $stats->active_businesses ?? 0,
|
||||||
|
'total_businesses' => $totalBusinessesWithCopilot,
|
||||||
|
'error_count' => $stats->error_count ?? 0,
|
||||||
|
'error_rate' => $stats->total_requests > 0
|
||||||
|
? round(($stats->error_count / $stats->total_requests) * 100, 2)
|
||||||
|
: 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDailyUsage(): array
|
||||||
|
{
|
||||||
|
$days = (int) $this->dateRange;
|
||||||
|
|
||||||
|
return AiPromptLog::where('operation', 'copilot')
|
||||||
|
->where('created_at', '>=', now()->subDays($days))
|
||||||
|
->selectRaw('DATE(created_at) as date, COUNT(*) as requests, SUM(total_tokens) as tokens, SUM(estimated_cost) as cost')
|
||||||
|
->groupBy('date')
|
||||||
|
->orderBy('date')
|
||||||
|
->get()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedDateRange(): void
|
||||||
|
{
|
||||||
|
$this->resetTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
206
app/Filament/Pages/NotificationSettings.php
Normal file
206
app/Filament/Pages/NotificationSettings.php
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
class NotificationSettings extends Page implements HasForms
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-envelope';
|
||||||
|
|
||||||
|
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Notification Settings';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 98;
|
||||||
|
|
||||||
|
public ?array $data = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->fillForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function fillForm(): void
|
||||||
|
{
|
||||||
|
$this->form->fill([
|
||||||
|
// Mail settings
|
||||||
|
'mail_driver' => config('mail.default'),
|
||||||
|
'mail_host' => config('mail.mailers.smtp.host'),
|
||||||
|
'mail_port' => config('mail.mailers.smtp.port'),
|
||||||
|
'mail_username' => config('mail.mailers.smtp.username'),
|
||||||
|
'mail_password' => config('mail.mailers.smtp.password'),
|
||||||
|
'mail_encryption' => config('mail.mailers.smtp.encryption'),
|
||||||
|
'mail_from_address' => config('mail.from.address'),
|
||||||
|
'mail_from_name' => config('mail.from.name'),
|
||||||
|
|
||||||
|
// SMS settings (Twilio example)
|
||||||
|
'sms_enabled' => env('SMS_ENABLED', false),
|
||||||
|
'sms_provider' => env('SMS_PROVIDER', 'twilio'),
|
||||||
|
'twilio_sid' => env('TWILIO_SID'),
|
||||||
|
'twilio_auth_token' => env('TWILIO_AUTH_TOKEN'),
|
||||||
|
'twilio_phone_number' => env('TWILIO_PHONE_NUMBER'),
|
||||||
|
|
||||||
|
// WhatsApp settings
|
||||||
|
'whatsapp_enabled' => env('WHATSAPP_ENABLED', false),
|
||||||
|
'whatsapp_provider' => env('WHATSAPP_PROVIDER', 'twilio'),
|
||||||
|
'whatsapp_business_number' => env('WHATSAPP_BUSINESS_NUMBER'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getFormSchema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Forms\Components\Tabs::make('Notification Providers')
|
||||||
|
->tabs([
|
||||||
|
Forms\Components\Tabs\Tab::make('Email')
|
||||||
|
->icon('heroicon-o-envelope')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Section::make('Email Provider Configuration')
|
||||||
|
->description('Configure your email provider for sending transactional emails')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Select::make('mail_driver')
|
||||||
|
->label('Mail Driver')
|
||||||
|
->options([
|
||||||
|
'smtp' => 'SMTP',
|
||||||
|
'sendmail' => 'Sendmail',
|
||||||
|
'mailgun' => 'Mailgun',
|
||||||
|
'ses' => 'Amazon SES',
|
||||||
|
'postmark' => 'Postmark',
|
||||||
|
])
|
||||||
|
->required()
|
||||||
|
->reactive(),
|
||||||
|
Forms\Components\Grid::make(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('mail_host')
|
||||||
|
->label('SMTP Host')
|
||||||
|
->required()
|
||||||
|
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||||
|
Forms\Components\TextInput::make('mail_port')
|
||||||
|
->label('SMTP Port')
|
||||||
|
->required()
|
||||||
|
->numeric()
|
||||||
|
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||||
|
Forms\Components\TextInput::make('mail_username')
|
||||||
|
->label('Username')
|
||||||
|
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||||
|
Forms\Components\TextInput::make('mail_password')
|
||||||
|
->label('Password')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||||
|
Forms\Components\Select::make('mail_encryption')
|
||||||
|
->label('Encryption')
|
||||||
|
->options([
|
||||||
|
'tls' => 'TLS',
|
||||||
|
'ssl' => 'SSL',
|
||||||
|
'' => 'None',
|
||||||
|
])
|
||||||
|
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||||
|
Forms\Components\TextInput::make('mail_from_address')
|
||||||
|
->label('From Address')
|
||||||
|
->email()
|
||||||
|
->required(),
|
||||||
|
Forms\Components\TextInput::make('mail_from_name')
|
||||||
|
->label('From Name')
|
||||||
|
->required(),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
Forms\Components\Tabs\Tab::make('SMS')
|
||||||
|
->icon('heroicon-o-device-phone-mobile')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Section::make('SMS Provider Configuration')
|
||||||
|
->description('Configure your SMS provider for sending text messages')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Toggle::make('sms_enabled')
|
||||||
|
->label('Enable SMS Notifications')
|
||||||
|
->reactive(),
|
||||||
|
Forms\Components\Select::make('sms_provider')
|
||||||
|
->label('SMS Provider')
|
||||||
|
->options([
|
||||||
|
'twilio' => 'Twilio',
|
||||||
|
'nexmo' => 'Vonage (Nexmo)',
|
||||||
|
'aws_sns' => 'AWS SNS',
|
||||||
|
])
|
||||||
|
->required()
|
||||||
|
->reactive()
|
||||||
|
->visible(fn ($get) => $get('sms_enabled')),
|
||||||
|
Forms\Components\Grid::make(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('twilio_sid')
|
||||||
|
->label('Twilio Account SID')
|
||||||
|
->required()
|
||||||
|
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||||
|
Forms\Components\TextInput::make('twilio_auth_token')
|
||||||
|
->label('Twilio Auth Token')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->required()
|
||||||
|
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||||
|
Forms\Components\TextInput::make('twilio_phone_number')
|
||||||
|
->label('Twilio Phone Number')
|
||||||
|
->tel()
|
||||||
|
->required()
|
||||||
|
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
Forms\Components\Tabs\Tab::make('WhatsApp')
|
||||||
|
->icon('heroicon-o-chat-bubble-left-right')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Section::make('WhatsApp Configuration')
|
||||||
|
->description('Configure WhatsApp Business API for sending messages')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Toggle::make('whatsapp_enabled')
|
||||||
|
->label('Enable WhatsApp Notifications')
|
||||||
|
->reactive(),
|
||||||
|
Forms\Components\Select::make('whatsapp_provider')
|
||||||
|
->label('WhatsApp Provider')
|
||||||
|
->options([
|
||||||
|
'twilio' => 'Twilio WhatsApp',
|
||||||
|
'whatsapp_cloud' => 'WhatsApp Cloud API',
|
||||||
|
])
|
||||||
|
->required()
|
||||||
|
->reactive()
|
||||||
|
->visible(fn ($get) => $get('whatsapp_enabled')),
|
||||||
|
Forms\Components\TextInput::make('whatsapp_business_number')
|
||||||
|
->label('WhatsApp Business Number')
|
||||||
|
->tel()
|
||||||
|
->required()
|
||||||
|
->visible(fn ($get) => $get('whatsapp_enabled')),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getFormStatePath(): ?string
|
||||||
|
{
|
||||||
|
return 'data';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getView(): string
|
||||||
|
{
|
||||||
|
return 'filament.pages.notification-settings';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
// TODO: Save settings to environment file or database
|
||||||
|
// For now, this would require implementing a settings storage system
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Settings saved')
|
||||||
|
->success()
|
||||||
|
->body('Note: These settings are read from .env file. To persist changes, update your .env file.')
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Filament/Pages/Queues.php
Normal file
28
app/Filament/Pages/Queues.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
class Queues extends Page
|
||||||
|
{
|
||||||
|
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-circle-stack';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.queues';
|
||||||
|
|
||||||
|
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Queues';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->redirect('/admin/horizon', navigate: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Filament/Pages/Telescope.php
Normal file
26
app/Filament/Pages/Telescope.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
class Telescope extends Page
|
||||||
|
{
|
||||||
|
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-magnifying-glass';
|
||||||
|
|
||||||
|
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Telescope Debug Tool';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 1;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->redirect('/telescope', navigate: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getView(): string
|
||||||
|
{
|
||||||
|
return 'filament.pages.telescope';
|
||||||
|
}
|
||||||
|
}
|
||||||
161
app/Filament/Pages/UsageDashboard.php
Normal file
161
app/Filament/Pages/UsageDashboard.php
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\BusinessUsageCounter;
|
||||||
|
use App\Models\PlanUsageMetric;
|
||||||
|
use App\Models\UsageMetric;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usage Dashboard - Global view of usage across all businesses
|
||||||
|
*
|
||||||
|
* Shows:
|
||||||
|
* - High-level cards with total usage across platform
|
||||||
|
* - Table of businesses with their usage vs included amounts
|
||||||
|
* - Visual status indicators (OK, Warning, Over)
|
||||||
|
*
|
||||||
|
* This is READ-ONLY analytics. It does NOT enforce limits.
|
||||||
|
*
|
||||||
|
* @see docs/USAGE_BASED_BILLING.md
|
||||||
|
*/
|
||||||
|
class UsageDashboard extends Page
|
||||||
|
{
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar-square';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Usage Analytics';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Usage Analytics';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Platform Settings';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.usage-dashboard';
|
||||||
|
|
||||||
|
public function getViewData(): array
|
||||||
|
{
|
||||||
|
$periodStart = Carbon::now()->startOfMonth()->toDateString();
|
||||||
|
$metrics = UsageMetric::active()->ordered()->get();
|
||||||
|
|
||||||
|
// Get all current usage counters
|
||||||
|
$counters = BusinessUsageCounter::with(['business.plan', 'usageMetric'])
|
||||||
|
->where('period_start', $periodStart)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Build summary cards
|
||||||
|
$summaryCards = $this->buildSummaryCards($counters, $metrics);
|
||||||
|
|
||||||
|
// Build business usage table data
|
||||||
|
$businessUsage = $this->buildBusinessUsageData($counters, $metrics);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summaryCards' => $summaryCards,
|
||||||
|
'businessUsage' => $businessUsage,
|
||||||
|
'metrics' => $metrics,
|
||||||
|
'periodLabel' => Carbon::now()->format('F Y'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildSummaryCards(Collection $counters, Collection $metrics): array
|
||||||
|
{
|
||||||
|
$cards = [];
|
||||||
|
|
||||||
|
foreach ($metrics as $metric) {
|
||||||
|
$total = $counters->where('usage_metric_id', $metric->id)->sum('quantity');
|
||||||
|
$cards[] = [
|
||||||
|
'name' => $metric->name,
|
||||||
|
'slug' => $metric->slug,
|
||||||
|
'total' => number_format($total),
|
||||||
|
'unit' => $metric->unit_label,
|
||||||
|
'icon' => $this->getMetricIcon($metric->slug),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildBusinessUsageData(Collection $counters, Collection $metrics): array
|
||||||
|
{
|
||||||
|
$businesses = Business::with('plan')
|
||||||
|
->whereHas('plan')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
|
||||||
|
foreach ($businesses as $business) {
|
||||||
|
$isEnterprise = $business->is_enterprise_plan || ($business->plan && $business->plan->is_enterprise);
|
||||||
|
|
||||||
|
$metricsData = [];
|
||||||
|
foreach ($metrics as $metric) {
|
||||||
|
$counter = $counters
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->where('usage_metric_id', $metric->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$usage = $counter?->quantity ?? 0;
|
||||||
|
|
||||||
|
// Get included amount from plan
|
||||||
|
$included = null;
|
||||||
|
$percentage = null;
|
||||||
|
$status = 'unlimited';
|
||||||
|
|
||||||
|
if (! $isEnterprise && $business->plan) {
|
||||||
|
$planMetric = PlanUsageMetric::where('plan_id', $business->plan_id)
|
||||||
|
->where('usage_metric_id', $metric->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($planMetric && $planMetric->included_per_month !== null) {
|
||||||
|
$included = $planMetric->included_per_month;
|
||||||
|
$percentage = $included > 0 ? round(($usage / $included) * 100, 1) : 100;
|
||||||
|
|
||||||
|
if ($percentage >= 100) {
|
||||||
|
$status = 'over';
|
||||||
|
} elseif ($percentage >= 80) {
|
||||||
|
$status = 'warning';
|
||||||
|
} else {
|
||||||
|
$status = 'ok';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$metricsData[$metric->slug] = [
|
||||||
|
'usage' => $usage,
|
||||||
|
'included' => $included,
|
||||||
|
'percentage' => $percentage,
|
||||||
|
'status' => $status,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data[] = [
|
||||||
|
'business' => $business,
|
||||||
|
'plan_code' => $business->plan?->code ?? 'none',
|
||||||
|
'is_enterprise' => $isEnterprise,
|
||||||
|
'metrics' => $metricsData,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getMetricIcon(string $slug): string
|
||||||
|
{
|
||||||
|
return match ($slug) {
|
||||||
|
'menus_sent' => 'heroicon-o-document-text',
|
||||||
|
'conversations' => 'heroicon-o-chat-bubble-left-right',
|
||||||
|
'promos_active' => 'heroicon-o-megaphone',
|
||||||
|
'contacts' => 'heroicon-o-users',
|
||||||
|
'buyers' => 'heroicon-o-building-storefront',
|
||||||
|
'ai_actions' => 'heroicon-o-sparkles',
|
||||||
|
'campaigns' => 'heroicon-o-paper-airplane',
|
||||||
|
'products' => 'heroicon-o-cube',
|
||||||
|
default => 'heroicon-o-chart-bar',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiConnections;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AiConnections\Pages\CreateAiConnection;
|
||||||
|
use App\Filament\Resources\AiConnections\Pages\EditAiConnection;
|
||||||
|
use App\Filament\Resources\AiConnections\Pages\ListAiConnections;
|
||||||
|
use App\Filament\Resources\AiConnections\Schemas\AiConnectionForm;
|
||||||
|
use App\Filament\Resources\AiConnections\Tables\AiConnectionsTable;
|
||||||
|
use App\Models\AiConnection;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||||
|
|
||||||
|
class AiConnectionResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = AiConnection::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'AI Settings';
|
||||||
|
|
||||||
|
protected static \UnitEnum|string|null $navigationGroup = 'AI Settings';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only Superadmins can access AI Connection settings.
|
||||||
|
* Admin Staff will not see this resource in navigation.
|
||||||
|
*/
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return auth('admin')->user()?->canManageAi() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return AiConnectionForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return AiConnectionsTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListAiConnections::route('/'),
|
||||||
|
'create' => CreateAiConnection::route('/create'),
|
||||||
|
'edit' => EditAiConnection::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return parent::getRecordRouteBindingEloquentQuery()
|
||||||
|
->withoutGlobalScopes([
|
||||||
|
SoftDeletingScope::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiConnections\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AiConnections\AiConnectionResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateAiConnection extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AiConnectionResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiConnections\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AiConnections\AiConnectionResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\ForceDeleteAction;
|
||||||
|
use Filament\Actions\RestoreAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditAiConnection extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AiConnectionResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
ForceDeleteAction::make(),
|
||||||
|
RestoreAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiConnections\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AiConnections\AiConnectionResource;
|
||||||
|
use App\Filament\Widgets\AiStatsOverview;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListAiConnections extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = AiConnectionResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
AiStatsOverview::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiConnections\Schemas;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class AiConnectionForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Section::make('Connection Details')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->helperText('Friendly name for this connection (e.g., "Primary OpenAI", "Anthropic Drafts")'),
|
||||||
|
|
||||||
|
Select::make('provider')
|
||||||
|
->required()
|
||||||
|
->options([
|
||||||
|
'anthropic' => 'Anthropic / Claude',
|
||||||
|
'openai' => 'OpenAI / ChatGPT',
|
||||||
|
'perplexity' => 'Perplexity',
|
||||||
|
'canva' => 'Canva',
|
||||||
|
'jasper' => 'Jasper',
|
||||||
|
])
|
||||||
|
->live()
|
||||||
|
->helperText('AI provider for this connection'),
|
||||||
|
|
||||||
|
TextInput::make('api_key')
|
||||||
|
->required()
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->maxLength(1000)
|
||||||
|
->helperText('API key for this provider (stored encrypted)')
|
||||||
|
->placeholder('Enter API key...'),
|
||||||
|
|
||||||
|
Select::make('model')
|
||||||
|
->options(function ($get) {
|
||||||
|
$provider = $get('provider');
|
||||||
|
if (! $provider) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$models = config("ai.providers.{$provider}.models", []);
|
||||||
|
|
||||||
|
return array_combine($models, $models);
|
||||||
|
})
|
||||||
|
->helperText('Specific model to use')
|
||||||
|
->placeholder('Select a model')
|
||||||
|
->visible(fn ($get) => ! empty($get('provider'))),
|
||||||
|
|
||||||
|
TextInput::make('max_tokens')
|
||||||
|
->numeric()
|
||||||
|
->default(4096)
|
||||||
|
->helperText('Maximum tokens for requests')
|
||||||
|
->placeholder('e.g., 4096'),
|
||||||
|
|
||||||
|
Toggle::make('is_active')
|
||||||
|
->label('Active')
|
||||||
|
->helperText('Whether this connection is active and can be used')
|
||||||
|
->default(true),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiConnections\Tables;
|
||||||
|
|
||||||
|
use App\Models\AiConnection;
|
||||||
|
use App\Services\AiConnectionTestService;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Actions\ForceDeleteBulkAction;
|
||||||
|
use Filament\Actions\RestoreBulkAction;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Tables\Columns\BadgeColumn;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class AiConnectionsTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->weight('medium'),
|
||||||
|
|
||||||
|
BadgeColumn::make('provider')
|
||||||
|
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||||
|
'anthropic' => 'Anthropic',
|
||||||
|
'openai' => 'OpenAI',
|
||||||
|
'perplexity' => 'Perplexity',
|
||||||
|
'canva' => 'Canva',
|
||||||
|
'jasper' => 'Jasper',
|
||||||
|
default => $state,
|
||||||
|
})
|
||||||
|
->colors([
|
||||||
|
'primary' => 'anthropic',
|
||||||
|
'success' => 'openai',
|
||||||
|
'warning' => 'perplexity',
|
||||||
|
'danger' => 'canva',
|
||||||
|
'info' => 'jasper',
|
||||||
|
]),
|
||||||
|
|
||||||
|
TextColumn::make('model')
|
||||||
|
->placeholder('Default')
|
||||||
|
->limit(30),
|
||||||
|
|
||||||
|
BadgeColumn::make('status')
|
||||||
|
->colors([
|
||||||
|
'success' => 'ok',
|
||||||
|
'danger' => 'error',
|
||||||
|
'gray' => 'disabled',
|
||||||
|
]),
|
||||||
|
|
||||||
|
TextColumn::make('recent_usage_stats.requests')
|
||||||
|
->label('Requests')
|
||||||
|
->numeric()
|
||||||
|
->suffix(' reqs')
|
||||||
|
->description('Last 30 days'),
|
||||||
|
|
||||||
|
TextColumn::make('recent_usage_stats.total_tokens')
|
||||||
|
->label('Tokens')
|
||||||
|
->numeric()
|
||||||
|
->description('Last 30 days'),
|
||||||
|
|
||||||
|
TextColumn::make('last_used_at')
|
||||||
|
->dateTime()
|
||||||
|
->since()
|
||||||
|
->placeholder('Never'),
|
||||||
|
|
||||||
|
IconColumn::make('is_active')
|
||||||
|
->label('Active')
|
||||||
|
->boolean()
|
||||||
|
->trueIcon('heroicon-o-check-circle')
|
||||||
|
->falseIcon('heroicon-o-x-circle')
|
||||||
|
->trueColor('success')
|
||||||
|
->falseColor('gray'),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
TrashedFilter::make(),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
Action::make('test')
|
||||||
|
->label('Test')
|
||||||
|
->icon('heroicon-m-bolt')
|
||||||
|
->color('warning')
|
||||||
|
->action(function (AiConnection $record) {
|
||||||
|
$result = AiConnectionTestService::testConnection($record);
|
||||||
|
|
||||||
|
$record->update([
|
||||||
|
'last_tested_at' => now(),
|
||||||
|
'status' => $result->success ? 'ok' : 'error',
|
||||||
|
'last_error' => $result->success ? null : $result->message,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($result->success) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Connection test successful')
|
||||||
|
->body("Response time: {$result->responseTime}ms")
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title('Connection test failed')
|
||||||
|
->body($result->message)
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
EditAction::make(),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
ForceDeleteBulkAction::make(),
|
||||||
|
RestoreBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
265
app/Filament/Resources/AiContentRuleResource.php
Normal file
265
app/Filament/Resources/AiContentRuleResource.php
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AiContentRuleResource\Pages;
|
||||||
|
use App\Models\Ai\AiContentRule;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TagsInput;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class AiContentRuleResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = AiContentRule::class;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
|
||||||
|
|
||||||
|
protected static \UnitEnum|string|null $navigationGroup = 'AI Settings';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Content Rules';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'Content Rule';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'Content Rules';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only Superadmins can access AI Content Rules.
|
||||||
|
* Admin Staff will not see this resource in navigation.
|
||||||
|
*/
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return auth('admin')->user()?->canManageAi() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Section::make('Content Type')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('content_type_key')
|
||||||
|
->label('Content Type Key')
|
||||||
|
->required()
|
||||||
|
->maxLength(100)
|
||||||
|
->unique(ignoreRecord: true)
|
||||||
|
->helperText('Format: context.field (e.g., product.short_description)')
|
||||||
|
->placeholder('product.short_description')
|
||||||
|
->disabled(fn ($record) => $record && ! $record->is_custom),
|
||||||
|
|
||||||
|
TextInput::make('label')
|
||||||
|
->label('Display Label')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->helperText('Human-readable name for this content type'),
|
||||||
|
|
||||||
|
Textarea::make('description')
|
||||||
|
->label('Description')
|
||||||
|
->rows(2)
|
||||||
|
->maxLength(500)
|
||||||
|
->helperText('Help text shown to admins'),
|
||||||
|
])
|
||||||
|
->columns(1),
|
||||||
|
|
||||||
|
Section::make('Character Limits')
|
||||||
|
->schema([
|
||||||
|
Grid::make(2)
|
||||||
|
->schema([
|
||||||
|
TextInput::make('min_length')
|
||||||
|
->label('Minimum Length')
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->suffix('characters')
|
||||||
|
->helperText('Leave empty for no minimum'),
|
||||||
|
|
||||||
|
TextInput::make('max_length')
|
||||||
|
->label('Maximum Length')
|
||||||
|
->numeric()
|
||||||
|
->minValue(1)
|
||||||
|
->suffix('characters')
|
||||||
|
->helperText('Leave empty for no maximum'),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Writing Style')
|
||||||
|
->schema([
|
||||||
|
Select::make('tone')
|
||||||
|
->label('Tone')
|
||||||
|
->options(AiContentRule::TONES)
|
||||||
|
->required()
|
||||||
|
->default('professional')
|
||||||
|
->helperText('The writing style AI should use'),
|
||||||
|
|
||||||
|
Textarea::make('system_prompt')
|
||||||
|
->label('System Prompt')
|
||||||
|
->rows(4)
|
||||||
|
->helperText('Additional instructions for AI. This is appended to the base system prompt.'),
|
||||||
|
|
||||||
|
TagsInput::make('examples')
|
||||||
|
->label('Example Outputs')
|
||||||
|
->helperText('Sample outputs to guide AI. Press Enter after each example.')
|
||||||
|
->placeholder('Add an example...'),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Context Fields')
|
||||||
|
->schema([
|
||||||
|
TagsInput::make('context_fields')
|
||||||
|
->label('Available Context Fields')
|
||||||
|
->helperText('Data fields that can be used for personalization (e.g., product_name, brand_name)')
|
||||||
|
->placeholder('Add a field...'),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Settings')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('is_active')
|
||||||
|
->label('Active')
|
||||||
|
->default(true)
|
||||||
|
->helperText('Inactive rules will use config defaults'),
|
||||||
|
|
||||||
|
Toggle::make('is_custom')
|
||||||
|
->label('Custom Rule')
|
||||||
|
->default(true)
|
||||||
|
->helperText('Custom rules are admin-created, non-custom are synced from config')
|
||||||
|
->disabled(),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('content_type_key')
|
||||||
|
->label('Content Type')
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->copyable(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('label')
|
||||||
|
->label('Label')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('limit_string')
|
||||||
|
->label('Limits')
|
||||||
|
->badge()
|
||||||
|
->color('info'),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('tone')
|
||||||
|
->label('Tone')
|
||||||
|
->badge()
|
||||||
|
->color(fn (string $state): string => match ($state) {
|
||||||
|
'professional' => 'success',
|
||||||
|
'casual' => 'info',
|
||||||
|
'technical' => 'warning',
|
||||||
|
'marketing' => 'primary',
|
||||||
|
default => 'gray',
|
||||||
|
}),
|
||||||
|
|
||||||
|
Tables\Columns\IconColumn::make('is_active')
|
||||||
|
->label('Active')
|
||||||
|
->boolean()
|
||||||
|
->sortable(),
|
||||||
|
|
||||||
|
Tables\Columns\IconColumn::make('is_custom')
|
||||||
|
->label('Custom')
|
||||||
|
->boolean()
|
||||||
|
->trueIcon('heroicon-o-pencil')
|
||||||
|
->falseIcon('heroicon-o-cog')
|
||||||
|
->trueColor('warning')
|
||||||
|
->falseColor('gray'),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
|
->label('Updated')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('tone')
|
||||||
|
->options(AiContentRule::TONES),
|
||||||
|
|
||||||
|
Tables\Filters\TernaryFilter::make('is_active')
|
||||||
|
->label('Active'),
|
||||||
|
|
||||||
|
Tables\Filters\TernaryFilter::make('is_custom')
|
||||||
|
->label('Custom'),
|
||||||
|
|
||||||
|
Tables\Filters\SelectFilter::make('context')
|
||||||
|
->label('Context')
|
||||||
|
->options([
|
||||||
|
'product' => 'Product',
|
||||||
|
'brand' => 'Brand',
|
||||||
|
'email' => 'Email',
|
||||||
|
'menu' => 'Menu',
|
||||||
|
])
|
||||||
|
->query(function ($query, array $data) {
|
||||||
|
if (! empty($data['value'])) {
|
||||||
|
$query->where('content_type_key', 'like', $data['value'].'.%');
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make()
|
||||||
|
->visible(fn ($record) => $record->is_custom),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make()
|
||||||
|
->before(function ($records) {
|
||||||
|
// Only allow deleting custom rules
|
||||||
|
return $records->filter(fn ($record) => $record->is_custom);
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->headerActions([
|
||||||
|
Action::make('sync_from_config')
|
||||||
|
->label('Sync from Config')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Sync Content Rules from Config')
|
||||||
|
->modalDescription('This will create database entries for any content types defined in config that don\'t already exist. Existing customizations will not be overwritten.')
|
||||||
|
->action(function () {
|
||||||
|
$result = AiContentRule::syncFromConfig();
|
||||||
|
|
||||||
|
\Filament\Notifications\Notification::make()
|
||||||
|
->title('Sync Complete')
|
||||||
|
->body('Created '.count($result['created']).' new rules, skipped '.count($result['skipped']).' existing rules.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->defaultSort('content_type_key');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListAiContentRules::route('/'),
|
||||||
|
'create' => Pages\CreateAiContentRule::route('/create'),
|
||||||
|
'edit' => Pages\EditAiContentRule::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiContentRuleResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AiContentRuleResource;
|
||||||
|
use App\Services\Ai\AiContentTypeRegistry;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateAiContentRule extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AiContentRuleResource::class;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$data['is_custom'] = true;
|
||||||
|
$data['created_by'] = auth()->id();
|
||||||
|
$data['updated_by'] = auth()->id();
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
// Clear the content type registry cache
|
||||||
|
app(AiContentTypeRegistry::class)->clearCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiContentRuleResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AiContentRuleResource;
|
||||||
|
use App\Services\Ai\AiContentTypeRegistry;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditAiContentRule extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AiContentRuleResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\DeleteAction::make()
|
||||||
|
->visible(fn () => $this->record->is_custom),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
$data['updated_by'] = auth()->id();
|
||||||
|
|
||||||
|
// Mark as custom if it was originally from config and is being modified
|
||||||
|
if (! $this->record->is_custom) {
|
||||||
|
$data['is_custom'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
// Clear the content type registry cache
|
||||||
|
app(AiContentTypeRegistry::class)->clearCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiContentRuleResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AiContentRuleResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListAiContentRules extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = AiContentRuleResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
163
app/Filament/Resources/BatchResource.php
Normal file
163
app/Filament/Resources/BatchResource.php
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Batches\Schemas\BatchForm;
|
||||||
|
use App\Filament\Resources\Batches\Tables\BatchesTable;
|
||||||
|
use App\Filament\Resources\BatchResource\Pages;
|
||||||
|
use App\Models\Batch;
|
||||||
|
use App\Services\QrCodeService;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Actions\Action;
|
||||||
|
use Filament\Tables\Actions\BulkAction;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class BatchResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Batch::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Batches';
|
||||||
|
|
||||||
|
protected static UnitEnum|string|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return BatchForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
$table = BatchesTable::configure($table);
|
||||||
|
|
||||||
|
// Add custom QR and COA actions
|
||||||
|
return $table
|
||||||
|
->recordActions(array_merge(
|
||||||
|
$table->getRecordActions(),
|
||||||
|
[
|
||||||
|
Action::make('generate_qr')
|
||||||
|
->label('Generate QR')
|
||||||
|
->icon('heroicon-o-qr-code')
|
||||||
|
->action(function (Batch $record) {
|
||||||
|
$qrService = app(QrCodeService::class);
|
||||||
|
$result = $qrService->generateForBatch($record);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
Notification::make()
|
||||||
|
->title('QR Code Generated')
|
||||||
|
->body($result['message'])
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title('Failed to generate QR code')
|
||||||
|
->body($result['message'])
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->visible(fn (Batch $record) => ! $record->qr_code_path),
|
||||||
|
|
||||||
|
Action::make('download_qr')
|
||||||
|
->label('Download QR')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->url(fn (Batch $record) => route('seller.business.manufacturing.batches.qr-code.download', [
|
||||||
|
'business' => $record->business->slug,
|
||||||
|
'batch' => $record->id,
|
||||||
|
]))
|
||||||
|
->openUrlInNewTab()
|
||||||
|
->visible(fn (Batch $record) => $record->qr_code_path),
|
||||||
|
|
||||||
|
Action::make('regenerate_qr')
|
||||||
|
->label('Regenerate QR')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->action(function (Batch $record) {
|
||||||
|
$qrService = app(QrCodeService::class);
|
||||||
|
$result = $qrService->regenerate($record);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
Notification::make()
|
||||||
|
->title('QR Code Regenerated')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title('Failed to regenerate QR code')
|
||||||
|
->body($result['message'])
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Batch $record) => $record->qr_code_path),
|
||||||
|
|
||||||
|
Action::make('view_coa')
|
||||||
|
->label('View COA')
|
||||||
|
->icon('heroicon-o-document-text')
|
||||||
|
->url(fn (Batch $record) => route('public.coa.show', ['batchNumber' => $record->batch_number]))
|
||||||
|
->openUrlInNewTab()
|
||||||
|
->visible(fn (Batch $record) => $record->lab !== null),
|
||||||
|
]
|
||||||
|
))
|
||||||
|
->bulkActions(array_merge(
|
||||||
|
$table->getBulkActions(),
|
||||||
|
[
|
||||||
|
BulkAction::make('generate_qr_codes')
|
||||||
|
->label('Generate QR Codes')
|
||||||
|
->icon('heroicon-o-qr-code')
|
||||||
|
->action(function (Collection $records) {
|
||||||
|
$qrService = app(QrCodeService::class);
|
||||||
|
$batchIds = $records->pluck('id')->toArray();
|
||||||
|
$result = $qrService->bulkGenerate($batchIds);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title("Generated {$result['successful']} QR codes")
|
||||||
|
->body("Failed: {$result['failed']}")
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$query = parent::getEloquentQuery();
|
||||||
|
|
||||||
|
// Scope to user's business unless they're a super admin
|
||||||
|
$user = auth()->user();
|
||||||
|
if ($user && ! $user->hasRole('Super Admin')) {
|
||||||
|
$query->where('business_id', $user->business_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListBatches::route('/'),
|
||||||
|
'create' => Pages\CreateBatch::route('/create'),
|
||||||
|
'view' => Pages\ViewBatch::route('/{record}'),
|
||||||
|
'edit' => Pages\EditBatch::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Filament/Resources/BatchResource/Pages/CreateBatch.php
Normal file
18
app/Filament/Resources/BatchResource/Pages/CreateBatch.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BatchResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BatchResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateBatch extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BatchResource::class;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$data['business_id'] = auth()->user()->business_id;
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Filament/Resources/BatchResource/Pages/EditBatch.php
Normal file
20
app/Filament/Resources/BatchResource/Pages/EditBatch.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BatchResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BatchResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditBatch extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BatchResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\ViewAction::make(),
|
||||||
|
Actions\DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/BatchResource/Pages/ListBatches.php
Normal file
19
app/Filament/Resources/BatchResource/Pages/ListBatches.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BatchResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BatchResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListBatches extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = BatchResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/BatchResource/Pages/ViewBatch.php
Normal file
19
app/Filament/Resources/BatchResource/Pages/ViewBatch.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BatchResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BatchResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewBatch extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BatchResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\EditAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ class BatchResource extends Resource
|
|||||||
|
|
||||||
protected static UnitEnum|string|null $navigationGroup = 'Inventory';
|
protected static UnitEnum|string|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static ?int $navigationSort = 2;
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
protected static ?string $recordTitleAttribute = 'batch_number';
|
protected static ?string $recordTitleAttribute = 'batch_number';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\Batches\Schemas;
|
namespace App\Filament\Resources\Batches\Schemas;
|
||||||
|
|
||||||
|
use Filament\Forms;
|
||||||
use Filament\Forms\Components\DatePicker;
|
use Filament\Forms\Components\DatePicker;
|
||||||
use Filament\Forms\Components\Section;
|
use Filament\Forms\Components\Section;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@@ -18,84 +19,144 @@ class BatchForm
|
|||||||
->components([
|
->components([
|
||||||
Section::make('Batch Information')
|
Section::make('Batch Information')
|
||||||
->schema([
|
->schema([
|
||||||
|
TextInput::make('batch_number')
|
||||||
|
->label('Batch Number')
|
||||||
|
->placeholder('Auto-generated if left blank')
|
||||||
|
->maxLength(255)
|
||||||
|
->helperText('Unique identifier for this batch'),
|
||||||
|
|
||||||
Select::make('product_id')
|
Select::make('product_id')
|
||||||
|
->label('Product')
|
||||||
->relationship('product', 'name')
|
->relationship('product', 'name')
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload()
|
->preload()
|
||||||
->required()
|
->required(),
|
||||||
->columnSpan(2),
|
|
||||||
TextInput::make('batch_number')
|
|
||||||
->required()
|
|
||||||
->unique(ignoreRecord: true)
|
|
||||||
->helperText('Unique identifier for this batch (e.g., TB-AM-240315)'),
|
|
||||||
TextInput::make('internal_code')
|
|
||||||
->helperText('Internal production/tracking code (optional)'),
|
|
||||||
])
|
|
||||||
->columns(2),
|
|
||||||
|
|
||||||
Section::make('Production Dates')
|
Select::make('batch_type')
|
||||||
->schema([
|
->label('Batch Type')
|
||||||
DatePicker::make('production_date')
|
->options([
|
||||||
->helperText('Date the batch was produced/manufactured'),
|
'intake' => 'Intake',
|
||||||
DatePicker::make('harvest_date')
|
'production' => 'Production',
|
||||||
->helperText('Harvest date (for flower products)'),
|
'finished' => 'Finished',
|
||||||
DatePicker::make('package_date')
|
])
|
||||||
->helperText('Date the batch was packaged'),
|
->default('finished')
|
||||||
DatePicker::make('expiration_date')
|
->required()
|
||||||
->helperText('Expiration/best-by date'),
|
->helperText('Type of batch in the production process'),
|
||||||
|
|
||||||
|
Select::make('lab_id')
|
||||||
|
->label('Lab Test')
|
||||||
|
->relationship('lab', 'lab_name')
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->helperText('Associated lab test results'),
|
||||||
|
|
||||||
|
Select::make('parent_batch_id')
|
||||||
|
->label('Parent Batch')
|
||||||
|
->relationship('parentBatch', 'batch_number')
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->helperText('Parent batch if this was produced from another batch'),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
|
|
||||||
Section::make('Inventory Management')
|
Section::make('Inventory Management')
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('quantity_produced')
|
TextInput::make('quantity_produced')
|
||||||
|
->label('Quantity Produced')
|
||||||
->required()
|
->required()
|
||||||
->numeric()
|
->numeric()
|
||||||
->default(0)
|
->default(0)
|
||||||
->helperText('Total units produced in this batch'),
|
->helperText('Total units produced in this batch'),
|
||||||
|
|
||||||
TextInput::make('quantity_available')
|
TextInput::make('quantity_available')
|
||||||
|
->label('Quantity Available')
|
||||||
->required()
|
->required()
|
||||||
->numeric()
|
->numeric()
|
||||||
->default(0)
|
->default(0)
|
||||||
->helperText('Units currently available for sale'),
|
->helperText('Units currently available for sale'),
|
||||||
|
|
||||||
TextInput::make('quantity_allocated')
|
TextInput::make('quantity_allocated')
|
||||||
|
->label('Quantity Allocated')
|
||||||
->numeric()
|
->numeric()
|
||||||
->default(0)
|
->default(0)
|
||||||
->disabled()
|
->disabled()
|
||||||
->dehydrated(false)
|
->dehydrated(false)
|
||||||
->helperText('Units reserved in pending orders (auto-calculated)'),
|
->helperText('Units reserved in pending orders (auto-calculated)'),
|
||||||
|
|
||||||
TextInput::make('quantity_sold')
|
TextInput::make('quantity_sold')
|
||||||
|
->label('Quantity Sold')
|
||||||
->numeric()
|
->numeric()
|
||||||
->default(0)
|
->default(0)
|
||||||
->disabled()
|
->disabled()
|
||||||
->dehydrated(false)
|
->dehydrated(false)
|
||||||
->helperText('Units already sold (auto-calculated)'),
|
->helperText('Units already sold (auto-calculated)'),
|
||||||
])
|
])
|
||||||
->columns(2)
|
->columns(4)
|
||||||
->description('Allocated and sold quantities are automatically managed by the system.'),
|
->description('Allocated and sold quantities are automatically managed by the system.'),
|
||||||
|
|
||||||
Section::make('Status & Compliance')
|
Section::make('Dates')
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('is_active')
|
DatePicker::make('production_date')
|
||||||
->default(true)
|
->label('Production Date')
|
||||||
->helperText('Is this batch available for sale?'),
|
->helperText('Date the batch was produced/manufactured'),
|
||||||
Toggle::make('is_tested')
|
|
||||||
->default(false)
|
|
||||||
->helperText('Has this batch passed lab testing?'),
|
|
||||||
Toggle::make('is_quarantined')
|
|
||||||
->default(false)
|
|
||||||
->helperText('Is this batch quarantined pending results?'),
|
|
||||||
])
|
|
||||||
->columns(3),
|
|
||||||
|
|
||||||
Section::make('Additional Information')
|
DatePicker::make('intake_date')
|
||||||
|
->label('Intake Date')
|
||||||
|
->helperText('Date the batch was received/intake'),
|
||||||
|
|
||||||
|
DatePicker::make('expiration_date')
|
||||||
|
->label('Expiration Date')
|
||||||
|
->helperText('Expiration/best-by date'),
|
||||||
|
|
||||||
|
DatePicker::make('test_date')
|
||||||
|
->label('Test Date')
|
||||||
|
->helperText('Date of lab testing'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
|
||||||
|
Section::make('Warehouse & Location')
|
||||||
->schema([
|
->schema([
|
||||||
|
TextInput::make('warehouse_location')
|
||||||
|
->label('Warehouse Location')
|
||||||
|
->placeholder('e.g., Shelf A-15')
|
||||||
|
->maxLength(255)
|
||||||
|
->helperText('Physical location in warehouse'),
|
||||||
|
|
||||||
|
TextInput::make('container_type')
|
||||||
|
->label('Container Type')
|
||||||
|
->placeholder('e.g., Turkey Bag, Box')
|
||||||
|
->maxLength(255)
|
||||||
|
->helperText('Type of container batch is stored in'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
|
||||||
|
Section::make('Quality & Compliance')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('is_quarantined')
|
||||||
|
->label('Quarantined')
|
||||||
|
->default(false)
|
||||||
|
->helperText('Is this batch quarantined?')
|
||||||
|
->reactive(),
|
||||||
|
|
||||||
|
Textarea::make('quarantine_reason')
|
||||||
|
->label('Quarantine Reason')
|
||||||
|
->rows(2)
|
||||||
|
->helperText('Reason for quarantine')
|
||||||
|
->visible(fn (Forms\Get $get) => $get('is_quarantined'))
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Toggle::make('is_released_for_sale')
|
||||||
|
->label('Released for Sale')
|
||||||
|
->default(false)
|
||||||
|
->helperText('Has this batch been released for sale?'),
|
||||||
|
|
||||||
Textarea::make('notes')
|
Textarea::make('notes')
|
||||||
|
->label('Notes')
|
||||||
->rows(3)
|
->rows(3)
|
||||||
->helperText('Production notes, special handling instructions, etc.')
|
->helperText('Production notes, special handling instructions, etc.')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->collapsible(),
|
->columns(2),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,18 +23,35 @@ class BatchesTable
|
|||||||
return $table
|
return $table
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('batch_number')
|
TextColumn::make('batch_number')
|
||||||
|
->label('Batch #')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable()
|
->sortable()
|
||||||
->copyable()
|
->copyable()
|
||||||
->weight('bold'),
|
->weight('bold'),
|
||||||
TextColumn::make('product.name')
|
TextColumn::make('product.name')
|
||||||
|
->label('Product')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable()
|
->sortable()
|
||||||
->description(fn ($record) => $record->product->sku ?? null),
|
->description(fn ($record) => $record->product->sku ?? null)
|
||||||
|
->limit(30),
|
||||||
|
TextColumn::make('batch_type')
|
||||||
|
->label('Type')
|
||||||
|
->badge()
|
||||||
|
->color(fn (string $state): string => match ($state) {
|
||||||
|
'intake' => 'info',
|
||||||
|
'production' => 'warning',
|
||||||
|
'finished' => 'success',
|
||||||
|
default => 'gray',
|
||||||
|
}),
|
||||||
|
TextColumn::make('warehouse_location')
|
||||||
|
->label('Location')
|
||||||
|
->searchable()
|
||||||
|
->toggleable(),
|
||||||
TextColumn::make('production_date')
|
TextColumn::make('production_date')
|
||||||
|
->label('Produced')
|
||||||
->date()
|
->date()
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('expiration_date')
|
TextColumn::make('expiration_date')
|
||||||
->date()
|
->date()
|
||||||
->sortable()
|
->sortable()
|
||||||
@@ -60,14 +77,13 @@ class BatchesTable
|
|||||||
->label('Status')
|
->label('Status')
|
||||||
->badge()
|
->badge()
|
||||||
->getStateUsing(fn ($record) => $record->is_quarantined ? 'Quarantined' :
|
->getStateUsing(fn ($record) => $record->is_quarantined ? 'Quarantined' :
|
||||||
(! $record->is_active ? 'Inactive' :
|
(! $record->is_released_for_sale ? 'Not Released' : 'Released')
|
||||||
(! $record->is_tested ? 'Pending Test' : 'Active'))
|
|
||||||
)
|
)
|
||||||
->color(fn (string $state): string => match ($state) {
|
->color(fn (string $state): string => match ($state) {
|
||||||
'Active' => Color::Green,
|
'Released' => Color::Green,
|
||||||
'Pending Test' => Color::Yellow,
|
'Not Released' => Color::Yellow,
|
||||||
'Quarantined' => Color::Red,
|
'Quarantined' => Color::Red,
|
||||||
'Inactive' => Color::Gray,
|
default => Color::Gray,
|
||||||
}),
|
}),
|
||||||
TextColumn::make('created_at')
|
TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
@@ -80,19 +96,23 @@ class BatchesTable
|
|||||||
])
|
])
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->filters([
|
->filters([
|
||||||
|
SelectFilter::make('batch_type')
|
||||||
|
->options([
|
||||||
|
'intake' => 'Intake',
|
||||||
|
'production' => 'Production',
|
||||||
|
'finished' => 'Finished',
|
||||||
|
]),
|
||||||
SelectFilter::make('product')
|
SelectFilter::make('product')
|
||||||
->relationship('product', 'name')
|
->relationship('product', 'name')
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
Filter::make('active')
|
Filter::make('released')
|
||||||
->query(fn (Builder $query): Builder => $query->where('is_active', true))
|
->label('Released for Sale')
|
||||||
|
->query(fn (Builder $query): Builder => $query->where('is_released_for_sale', true))
|
||||||
->toggle(),
|
->toggle(),
|
||||||
Filter::make('available')
|
Filter::make('available')
|
||||||
->query(fn (Builder $query): Builder => $query->where('quantity_available', '>', 0))
|
->query(fn (Builder $query): Builder => $query->where('quantity_available', '>', 0))
|
||||||
->toggle(),
|
->toggle(),
|
||||||
Filter::make('tested')
|
|
||||||
->query(fn (Builder $query): Builder => $query->where('is_tested', true))
|
|
||||||
->toggle(),
|
|
||||||
Filter::make('quarantined')
|
Filter::make('quarantined')
|
||||||
->query(fn (Builder $query): Builder => $query->where('is_quarantined', true))
|
->query(fn (Builder $query): Builder => $query->where('is_quarantined', true))
|
||||||
->toggle(),
|
->toggle(),
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BrandAiProfiles;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BrandAiProfiles\Pages\CreateBrandAiProfile;
|
||||||
|
use App\Filament\Resources\BrandAiProfiles\Pages\EditBrandAiProfile;
|
||||||
|
use App\Filament\Resources\BrandAiProfiles\Pages\ListBrandAiProfiles;
|
||||||
|
use App\Filament\Resources\BrandAiProfiles\Schemas\BrandAiProfileForm;
|
||||||
|
use App\Filament\Resources\BrandAiProfiles\Tables\BrandAiProfilesTable;
|
||||||
|
use App\Models\BrandAiProfile;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class BrandAiProfileResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = BrandAiProfile::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedSparkles;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Brand AI Tuning';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'Brand AI Profile';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'Brand AI Profiles';
|
||||||
|
|
||||||
|
protected static \UnitEnum|string|null $navigationGroup = 'AI Settings';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only Superadmins can access Brand AI Tuning.
|
||||||
|
*/
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return auth('admin')->user()?->canManageAi() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return BrandAiProfileForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return BrandAiProfilesTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListBrandAiProfiles::route('/'),
|
||||||
|
'create' => CreateBrandAiProfile::route('/create'),
|
||||||
|
'edit' => EditBrandAiProfile::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BrandAiProfiles\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BrandAiProfiles\BrandAiProfileResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateBrandAiProfile extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BrandAiProfileResource::class;
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): string
|
||||||
|
{
|
||||||
|
return $this->getResource()::getUrl('edit', ['record' => $this->record]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
// Initialize default structures for JSON fields if not set
|
||||||
|
$data['emotional_triggers'] = $data['emotional_triggers'] ?? [];
|
||||||
|
$data['vocabulary_do'] = $data['vocabulary_do'] ?? [];
|
||||||
|
$data['vocabulary_dont'] = $data['vocabulary_dont'] ?? [];
|
||||||
|
$data['product_type_rules'] = $data['product_type_rules'] ?? [];
|
||||||
|
$data['extra'] = $data['extra'] ?? [];
|
||||||
|
|
||||||
|
// Initialize tone_overrides with defaults
|
||||||
|
$data['tone_overrides'] = array_merge([
|
||||||
|
'sentence_length' => 'medium',
|
||||||
|
'energy_level' => 'medium',
|
||||||
|
'slang_allowed' => false,
|
||||||
|
'notes' => '',
|
||||||
|
], $data['tone_overrides'] ?? []);
|
||||||
|
|
||||||
|
// Initialize emoji_style with defaults
|
||||||
|
$data['emoji_style'] = array_merge([
|
||||||
|
'level' => 'medium',
|
||||||
|
'examples' => [],
|
||||||
|
], $data['emoji_style'] ?? []);
|
||||||
|
|
||||||
|
// Initialize name_usage_rules with defaults
|
||||||
|
$data['name_usage_rules'] = array_merge([
|
||||||
|
'non_seo' => 'never_first_word',
|
||||||
|
'seo' => 'allowed_first_word',
|
||||||
|
], $data['name_usage_rules'] ?? []);
|
||||||
|
|
||||||
|
// Initialize seo_overrides with defaults
|
||||||
|
$data['seo_overrides'] = array_merge([
|
||||||
|
'tone' => 'maintain_voice',
|
||||||
|
'keyword_density' => 'light',
|
||||||
|
'notes' => '',
|
||||||
|
], $data['seo_overrides'] ?? []);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BrandAiProfiles\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BrandAiProfiles\BrandAiProfileResource;
|
||||||
|
use App\Services\AI\BrandAiProfileGenerator;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditBrandAiProfile extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BrandAiProfileResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('autoTune')
|
||||||
|
->label('Regenerate with AI')
|
||||||
|
->icon('heroicon-o-sparkles')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Regenerate AI Profile')
|
||||||
|
->modalDescription('This will use AI to regenerate all tuning settings based on the brand\'s products and existing content. Current settings will be overwritten.')
|
||||||
|
->modalSubmitActionLabel('Regenerate')
|
||||||
|
->action(function () {
|
||||||
|
$this->autoTuneWithAi();
|
||||||
|
}),
|
||||||
|
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-tune the profile using AI.
|
||||||
|
*/
|
||||||
|
protected function autoTuneWithAi(): void
|
||||||
|
{
|
||||||
|
$brand = $this->record->brand;
|
||||||
|
|
||||||
|
if (! $brand) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Error')
|
||||||
|
->body('No brand associated with this profile.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$generator = app(BrandAiProfileGenerator::class);
|
||||||
|
$profile = $generator->generateForBrand($brand);
|
||||||
|
|
||||||
|
// Refresh the record to get updated values
|
||||||
|
$this->record->refresh();
|
||||||
|
$this->fillForm();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('AI Profile Regenerated')
|
||||||
|
->body("Successfully regenerated AI tuning profile for {$brand->name}.")
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Generation Failed')
|
||||||
|
->body('AI generation failed: '.$e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BrandAiProfiles\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BrandAiProfiles\BrandAiProfileResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListBrandAiProfiles extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = BrandAiProfileResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make()
|
||||||
|
->label('Create AI Profile'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BrandAiProfiles\Schemas;
|
||||||
|
|
||||||
|
use App\Models\Brand;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TagsInput;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
|
class BrandAiProfileForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
// Brand Spec Snapshot (visible only when editing with specification)
|
||||||
|
Section::make('Brand Spec Snapshot')
|
||||||
|
->description('Current brand personality traits at a glance')
|
||||||
|
->schema([
|
||||||
|
Placeholder::make('_specification_preview')
|
||||||
|
->label('')
|
||||||
|
->content(function ($record) {
|
||||||
|
if (! $record || ! $record->specification) {
|
||||||
|
return new HtmlString('<p class="text-gray-500 italic">No specification defined yet.</p>');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = preg_split('/\r\n|\r|\n/', $record->specification);
|
||||||
|
$bullets = collect($lines)
|
||||||
|
->filter(fn ($line) => trim($line) !== '')
|
||||||
|
->map(fn ($line) => '<li>'.e(trim($line)).'</li>')
|
||||||
|
->implode('');
|
||||||
|
|
||||||
|
return new HtmlString('<ul class="list-disc pl-5 space-y-1">'.$bullets.'</ul>');
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->collapsible()
|
||||||
|
->visible(fn ($record) => $record && $record->specification),
|
||||||
|
|
||||||
|
// Brand Selection Section
|
||||||
|
Section::make('Brand Selection')
|
||||||
|
->schema([
|
||||||
|
Select::make('brand_id')
|
||||||
|
->label('Brand')
|
||||||
|
->relationship('brand', 'name', function ($query) {
|
||||||
|
return $query->with('business')
|
||||||
|
->whereHas('business', function ($q) {
|
||||||
|
$q->where('status', 'approved');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->getOptionLabelFromRecordUsing(fn ($record) => "{$record->business->name} - {$record->name}")
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->required()
|
||||||
|
->unique(ignoreRecord: true)
|
||||||
|
->helperText('One AI profile per brand.')
|
||||||
|
->live()
|
||||||
|
->afterStateUpdated(function ($state, callable $set) {
|
||||||
|
if ($state) {
|
||||||
|
$brand = Brand::find($state);
|
||||||
|
if ($brand) {
|
||||||
|
$set('business_id', $brand->business_id);
|
||||||
|
$set('_brand_voice_display', Brand::BRAND_VOICES[$brand->brand_voice] ?? 'Not Set');
|
||||||
|
$set('_brand_audience_display', Brand::BRAND_AUDIENCES[$brand->brand_audience] ?? 'Not Set');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Hidden field to store business_id
|
||||||
|
Select::make('business_id')
|
||||||
|
->relationship('business', 'name')
|
||||||
|
->hidden()
|
||||||
|
->dehydrated(),
|
||||||
|
|
||||||
|
Grid::make(2)
|
||||||
|
->schema([
|
||||||
|
Placeholder::make('_brand_voice_display')
|
||||||
|
->label('Brand Voice (from Brand)')
|
||||||
|
->content(function ($record) {
|
||||||
|
if ($record && $record->brand) {
|
||||||
|
return Brand::BRAND_VOICES[$record->brand->brand_voice] ?? 'Not Set';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Select a brand';
|
||||||
|
}),
|
||||||
|
|
||||||
|
Placeholder::make('_brand_audience_display')
|
||||||
|
->label('Audience Type (from Brand)')
|
||||||
|
->content(function ($record) {
|
||||||
|
if ($record && $record->brand) {
|
||||||
|
return Brand::BRAND_AUDIENCES[$record->brand->brand_audience] ?? 'Not Set';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Select a brand';
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Brand Specification Section
|
||||||
|
Section::make('Brand Specification')
|
||||||
|
->description('Quick-reference traits that define the brand personality')
|
||||||
|
->schema([
|
||||||
|
Textarea::make('specification')
|
||||||
|
->label('Brand Specification')
|
||||||
|
->placeholder("rebellious\nattitude-heavy\nfast-moving\nlate-night energy\npunchy\nedgy humor")
|
||||||
|
->helperText('One trait per line, e.g. "rebellious", "late-night energy", "punchy".')
|
||||||
|
->rows(6)
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Voice & Tone Section
|
||||||
|
Section::make('Voice & Tone')
|
||||||
|
->description('Define how the AI should write for this brand')
|
||||||
|
->schema([
|
||||||
|
Grid::make(2)
|
||||||
|
->schema([
|
||||||
|
TextInput::make('voice')
|
||||||
|
->label('Voice')
|
||||||
|
->placeholder('e.g., Playful, educational, no fluff')
|
||||||
|
->helperText('How the brand speaks - its personality'),
|
||||||
|
|
||||||
|
TextInput::make('audience')
|
||||||
|
->label('Target Audience')
|
||||||
|
->placeholder('e.g., 21–40, budget-conscious cannabis consumers')
|
||||||
|
->helperText('Who the content is written for'),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Grid::make(3)
|
||||||
|
->schema([
|
||||||
|
Select::make('emoji_level')
|
||||||
|
->label('Emoji Level')
|
||||||
|
->options([
|
||||||
|
'none' => 'None - No emojis',
|
||||||
|
'low' => 'Low - Sparingly (1-2 max)',
|
||||||
|
'medium' => 'Medium - Occasional (2-4)',
|
||||||
|
'high' => 'High - Liberal use',
|
||||||
|
])
|
||||||
|
->default('low')
|
||||||
|
->required(),
|
||||||
|
|
||||||
|
TextInput::make('tone')
|
||||||
|
->label('Tone')
|
||||||
|
->placeholder('e.g., confident, friendly expert')
|
||||||
|
->helperText('The emotional quality of the writing'),
|
||||||
|
|
||||||
|
TextInput::make('writing_style')
|
||||||
|
->label('Writing Style')
|
||||||
|
->placeholder('e.g., short, punchy sentences')
|
||||||
|
->helperText('How sentences are structured'),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Vocabulary Section
|
||||||
|
Section::make('Vocabulary')
|
||||||
|
->description('Words and phrases to use or avoid')
|
||||||
|
->schema([
|
||||||
|
TagsInput::make('banned_words')
|
||||||
|
->label('Banned Words')
|
||||||
|
->placeholder('Add banned word or phrase...')
|
||||||
|
->helperText('Words/phrases the AI must NEVER use for this brand')
|
||||||
|
->splitKeys(['Tab', 'Enter'])
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
TagsInput::make('required_phrases')
|
||||||
|
->label('Required Phrases')
|
||||||
|
->placeholder('Add preferred phrase...')
|
||||||
|
->helperText('Words/phrases the AI should use when appropriate')
|
||||||
|
->splitKeys(['Tab', 'Enter'])
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Content Rules Section
|
||||||
|
Section::make('Content Rules')
|
||||||
|
->description('Additional rules for content generation')
|
||||||
|
->schema([
|
||||||
|
Textarea::make('content_rules_display')
|
||||||
|
->label('Content Rules (JSON)')
|
||||||
|
->helperText('Custom rules in JSON format. Example: {"avoid-medical-claims": true, "instagram-safe": true}')
|
||||||
|
->rows(3)
|
||||||
|
->afterStateHydrated(function ($component, $state, $record) {
|
||||||
|
if ($record && $record->content_rules) {
|
||||||
|
$component->state(json_encode($record->content_rules, JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->dehydrateStateUsing(function ($state) {
|
||||||
|
if (empty($state)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$decoded = json_decode($state, true);
|
||||||
|
|
||||||
|
return json_last_error() === JSON_ERROR_NONE ? $decoded : null;
|
||||||
|
})
|
||||||
|
->statePath('content_rules')
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Metadata Section
|
||||||
|
Section::make('Metadata')
|
||||||
|
->schema([
|
||||||
|
Placeholder::make('last_tuned_at')
|
||||||
|
->label('Last Tuned')
|
||||||
|
->content(function ($record) {
|
||||||
|
if ($record && $record->last_tuned_at) {
|
||||||
|
return $record->last_tuned_at->format('M j, Y g:i A');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Never';
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->collapsed(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BrandAiProfiles\Tables;
|
||||||
|
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class BrandAiProfilesTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('brand.name')
|
||||||
|
->label('Brand')
|
||||||
|
->sortable()
|
||||||
|
->searchable()
|
||||||
|
->weight('bold'),
|
||||||
|
|
||||||
|
TextColumn::make('business.name')
|
||||||
|
->label('Business')
|
||||||
|
->sortable()
|
||||||
|
->searchable(),
|
||||||
|
|
||||||
|
TextColumn::make('specification_summary')
|
||||||
|
->label('Brand Spec')
|
||||||
|
->tooltip(fn ($record) => $record->specification)
|
||||||
|
->wrap()
|
||||||
|
->limit(80),
|
||||||
|
|
||||||
|
TextColumn::make('emoji_level')
|
||||||
|
->label('Emoji')
|
||||||
|
->badge()
|
||||||
|
->color(fn (string $state): string => match ($state) {
|
||||||
|
'none' => 'gray',
|
||||||
|
'low' => 'info',
|
||||||
|
'medium' => 'warning',
|
||||||
|
'high' => 'success',
|
||||||
|
default => 'gray',
|
||||||
|
})
|
||||||
|
->formatStateUsing(fn ($state) => ucfirst($state ?? 'low')),
|
||||||
|
|
||||||
|
TextColumn::make('voice')
|
||||||
|
->label('Voice')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(),
|
||||||
|
|
||||||
|
TextColumn::make('audience')
|
||||||
|
->label('Audience')
|
||||||
|
->limit(30)
|
||||||
|
->tooltip(fn ($state) => $state)
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
|
||||||
|
TextColumn::make('last_tuned_at')
|
||||||
|
->label('Last Tuned')
|
||||||
|
->dateTime('M j, Y')
|
||||||
|
->sortable()
|
||||||
|
->placeholder('Never'),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('brand_id')
|
||||||
|
->label('Brand')
|
||||||
|
->relationship('brand', 'name')
|
||||||
|
->searchable()
|
||||||
|
->preload(),
|
||||||
|
|
||||||
|
SelectFilter::make('emoji_level')
|
||||||
|
->label('Emoji Level')
|
||||||
|
->options([
|
||||||
|
'none' => 'None',
|
||||||
|
'low' => 'Low',
|
||||||
|
'medium' => 'Medium',
|
||||||
|
'high' => 'High',
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
EditAction::make(),
|
||||||
|
])
|
||||||
|
->defaultSort('last_tuned_at', 'desc');
|
||||||
|
}
|
||||||
|
}
|
||||||
220
app/Filament/Resources/BrandIntelligenceConnectionResource.php
Normal file
220
app/Filament/Resources/BrandIntelligenceConnectionResource.php
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BrandIntelligenceConnectionResource\Pages;
|
||||||
|
use App\Models\BrandIntelligenceConnection;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class BrandIntelligenceConnectionResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = BrandIntelligenceConnection::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Brand Intelligence';
|
||||||
|
|
||||||
|
protected static UnitEnum|string|null $navigationGroup = 'Integrations';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 3; // After SMS Provider (which is 2)
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'Brand Intelligence Connection';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'Brand Intelligence Connections';
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Section::make('Connection Details')
|
||||||
|
->description('Configure the external crawler API connection.')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('name')
|
||||||
|
->label('Store / Client Name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->placeholder('Deeply Rooted')
|
||||||
|
->helperText('Friendly name for this connection'),
|
||||||
|
|
||||||
|
Select::make('provider')
|
||||||
|
->label('Provider')
|
||||||
|
->options(BrandIntelligenceConnection::PROVIDERS)
|
||||||
|
->default('crawler')
|
||||||
|
->required()
|
||||||
|
->helperText('Intelligence data provider'),
|
||||||
|
|
||||||
|
TextInput::make('from_domain')
|
||||||
|
->label('Allowed Domain (reference)')
|
||||||
|
->maxLength(255)
|
||||||
|
->placeholder('*.deeplyrooted.com')
|
||||||
|
->helperText('Optional: Domain pattern for your reference'),
|
||||||
|
|
||||||
|
TextInput::make('api_key')
|
||||||
|
->label('API Key')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->maxLength(1000)
|
||||||
|
->placeholder('Enter API key...')
|
||||||
|
->helperText('Paste the API Permission key from the crawler dashboard'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
|
||||||
|
Section::make('Status')
|
||||||
|
->description('Connection status and testing.')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('is_active')
|
||||||
|
->label('Active')
|
||||||
|
->helperText('Enable this connection for fetching intelligence data')
|
||||||
|
->default(true),
|
||||||
|
|
||||||
|
Toggle::make('is_verified')
|
||||||
|
->label('Verified')
|
||||||
|
->helperText('Set automatically when connection test succeeds')
|
||||||
|
->disabled(),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
|
||||||
|
Section::make('Test Status')
|
||||||
|
->schema([
|
||||||
|
Placeholder::make('test_status')
|
||||||
|
->label('Last Test')
|
||||||
|
->content(function (?BrandIntelligenceConnection $record) {
|
||||||
|
if (! $record || ! $record->last_tested_at) {
|
||||||
|
return 'Never tested';
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $record->is_verified ? '✅ Verified' : '❌ Failed';
|
||||||
|
$result = $record->last_test_status ?? 'No result';
|
||||||
|
|
||||||
|
return "{$status} - {$record->last_tested_at->diffForHumans()} - {$result}";
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->visible(fn (?BrandIntelligenceConnection $record) => $record !== null),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('provider')
|
||||||
|
->label('Provider')
|
||||||
|
->formatStateUsing(fn (string $state): string => BrandIntelligenceConnection::PROVIDERS[$state] ?? ucfirst($state))
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->label('Store / Client Name')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
|
||||||
|
Tables\Columns\IconColumn::make('is_active')
|
||||||
|
->label('Active')
|
||||||
|
->boolean()
|
||||||
|
->trueIcon('heroicon-o-check-circle')
|
||||||
|
->falseIcon('heroicon-o-x-circle')
|
||||||
|
->trueColor('success')
|
||||||
|
->falseColor('gray'),
|
||||||
|
|
||||||
|
Tables\Columns\IconColumn::make('is_verified')
|
||||||
|
->label('Verified')
|
||||||
|
->boolean()
|
||||||
|
->trueIcon('heroicon-o-shield-check')
|
||||||
|
->falseIcon('heroicon-o-shield-exclamation')
|
||||||
|
->trueColor('success')
|
||||||
|
->falseColor('warning'),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('last_tested_at')
|
||||||
|
->label('Last Tested')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->placeholder('Never'),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
|
->label('Updated')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\TernaryFilter::make('is_active')
|
||||||
|
->label('Active'),
|
||||||
|
Tables\Filters\TernaryFilter::make('is_verified')
|
||||||
|
->label('Verified'),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Action::make('test')
|
||||||
|
->label('Test')
|
||||||
|
->icon('heroicon-o-signal')
|
||||||
|
->color('info')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Test Connection')
|
||||||
|
->modalDescription('Test the API connection to verify credentials are valid.')
|
||||||
|
->action(function (BrandIntelligenceConnection $record) {
|
||||||
|
// TODO: Replace this stub with a real HTTP call once the crawler
|
||||||
|
// health endpoint is finalized:
|
||||||
|
//
|
||||||
|
// use Illuminate\Support\Facades\Http;
|
||||||
|
//
|
||||||
|
// $response = Http::withToken($record->api_key)
|
||||||
|
// ->timeout(5)
|
||||||
|
// ->get(config('services.crawler.health_endpoint'));
|
||||||
|
//
|
||||||
|
// $ok = $response->successful();
|
||||||
|
// $record->recordTestResult($ok, $ok ? 'success' : 'failed: '.$response->status());
|
||||||
|
|
||||||
|
// For now, mark as success to avoid errors until the endpoint is ready
|
||||||
|
$record->recordTestResult(true, 'success');
|
||||||
|
|
||||||
|
\Filament\Notifications\Notification::make()
|
||||||
|
->title('Connection Test Successful')
|
||||||
|
->body('API connection verified successfully.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->emptyStateHeading('No Brand Intelligence Connections')
|
||||||
|
->emptyStateDescription('Create a Brand Intelligence connection to link Cannabrands to the crawler API.')
|
||||||
|
->emptyStateIcon('heroicon-o-chart-bar')
|
||||||
|
->defaultSort('updated_at', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListBrandIntelligenceConnections::route('/'),
|
||||||
|
'create' => Pages\CreateBrandIntelligenceConnection::route('/create'),
|
||||||
|
'edit' => Pages\EditBrandIntelligenceConnection::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BrandIntelligenceConnectionResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BrandIntelligenceConnectionResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateBrandIntelligenceConnection extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BrandIntelligenceConnectionResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BrandIntelligenceConnectionResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BrandIntelligenceConnectionResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditBrandIntelligenceConnection extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BrandIntelligenceConnectionResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BrandIntelligenceConnectionResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BrandIntelligenceConnectionResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListBrandIntelligenceConnections extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = BrandIntelligenceConnectionResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\CreateAction::make()
|
||||||
|
->label('New Brand Intelligence Connection'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user