Compare commits
67 Commits
feature/pl
...
hotfix/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f353b63f7 | ||
|
|
954a4988b5 | ||
|
|
4ac13268d9 | ||
|
|
84f364de74 | ||
|
|
39c955cdc4 | ||
|
|
e02ca54187 | ||
|
|
ac46ee004b | ||
|
|
17a6eb260d | ||
|
|
5ea80366be | ||
|
|
99aa0cb980 | ||
|
|
3de53a76d0 | ||
|
|
7fa9b6aff8 | ||
|
|
79e156bd24 | ||
|
|
12a6a8eb69 | ||
|
|
eb71477ec1 | ||
|
|
2ed54eced2 | ||
|
|
32fd2b0ab8 | ||
|
|
ded374de3c | ||
|
|
1cd11cbf67 | ||
|
|
26bf7ac377 | ||
|
|
ac1084d6fe | ||
|
|
1e2a579c4f | ||
|
|
37394786be | ||
|
|
0ce850decf | ||
|
|
02facc77c2 | ||
|
|
a5640375c3 | ||
|
|
fb28283f39 | ||
|
|
00903d7cb7 | ||
|
|
18de0fc97a | ||
|
|
4830d53f63 | ||
|
|
43625660bc | ||
|
|
985aec9c8a | ||
|
|
544c955cf4 | ||
|
|
fec27f1aeb | ||
|
|
c898c02b8b | ||
|
|
4ea1dbd1c2 | ||
|
|
b847f3745e | ||
|
|
00fcfe8a21 | ||
|
|
95bad62728 | ||
|
|
a7074d55e1 | ||
|
|
62d3dafe56 | ||
|
|
6ff88440ff | ||
|
|
9f07155517 | ||
|
|
2bdb752c21 | ||
|
|
bf81929587 | ||
|
|
73d4ecc7f5 | ||
|
|
5a1570468e | ||
|
|
b4c5b24294 | ||
|
|
af3a2dc61b | ||
|
|
52a8fe00e1 | ||
|
|
e23f3aff2f | ||
|
|
bd001e9547 | ||
|
|
1933983071 | ||
|
|
ae9de21a16 | ||
|
|
be2b7e56c5 | ||
|
|
d3516cdd60 | ||
|
|
8e0ce323a1 | ||
|
|
a284cb3eb6 | ||
|
|
1df59f527a | ||
|
|
d3f7a374ec | ||
|
|
2380d94d02 | ||
|
|
7c1fe3070f | ||
|
|
079e211a7c | ||
|
|
75c01d54e9 | ||
|
|
6158df0443 | ||
|
|
8e50a61811 | ||
|
|
b2353bfe23 |
9
.blade-formatter.json
Normal file
9
.blade-formatter.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"indentSize": 4,
|
||||
"wrapAttributes": "auto",
|
||||
"wrapLineLength": 120,
|
||||
"endWithNewLine": true,
|
||||
"useTabs": false,
|
||||
"sortTailwindcssClasses": true,
|
||||
"sortHtmlAttributes": "none"
|
||||
}
|
||||
35
.claude/notes/number-input-spinners-removed.md
Normal file
35
.claude/notes/number-input-spinners-removed.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 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
|
||||
@@ -9,7 +9,17 @@
|
||||
"Bash(docker-compose up:*)",
|
||||
"Bash(php --version:*)",
|
||||
"Bash(docker-compose build:*)",
|
||||
"Bash(docker-compose restart:*)"
|
||||
"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": []
|
||||
|
||||
15
.env.example
15
.env.example
@@ -77,10 +77,25 @@ MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
# AWS/MinIO S3 Storage Configuration
|
||||
# Local development: Use FILESYSTEM_DISK=public (default)
|
||||
# Production: Use FILESYSTEM_DISK=s3 with MinIO credentials below
|
||||
AWS_ACCESS_KEY_ID=
|
||||
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
|
||||
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
|
||||
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
|
||||
# AWS_DEFAULT_REGION=us-east-1
|
||||
# AWS_BUCKET=media
|
||||
# AWS_ENDPOINT=https://cdn.cannabrands.app
|
||||
# AWS_URL=https://cdn.cannabrands.app/media
|
||||
# AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -8,6 +8,7 @@
|
||||
/storage/clockwork
|
||||
/vendor
|
||||
.DS_Store
|
||||
docker-compose.override.yml
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
@@ -41,3 +42,20 @@ version.env
|
||||
*SECRETS_BACKUP*
|
||||
.cannabrands-secrets/
|
||||
reverb-keys*
|
||||
|
||||
# Core dumps and debug files
|
||||
core
|
||||
core.*
|
||||
*.core
|
||||
|
||||
# Random image files (screenshots, etc.)
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
!public/**/*.png
|
||||
!public/**/*.jpg
|
||||
!public/**/*.jpeg
|
||||
!resources/**/*.png
|
||||
!resources/**/*.jpg
|
||||
!resources/**/*.jpeg
|
||||
.claude/settings.local.json
|
||||
|
||||
20
.stylelintrc.json
Normal file
20
.stylelintrc.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "stylelint-config-standard",
|
||||
"plugins": [
|
||||
"stylelint-no-unsupported-browser-features"
|
||||
],
|
||||
"rules": {
|
||||
"no-descending-specificity": null,
|
||||
"selector-class-pattern": null,
|
||||
"custom-property-pattern": null,
|
||||
"declaration-block-no-duplicate-properties": true,
|
||||
"no-duplicate-selectors": true
|
||||
},
|
||||
"ignoreFiles": [
|
||||
"**/*.js",
|
||||
"**/*.php",
|
||||
"node_modules/**",
|
||||
"vendor/**",
|
||||
"public/**"
|
||||
]
|
||||
}
|
||||
@@ -11,17 +11,8 @@ when:
|
||||
event: push
|
||||
- event: [pull_request, tag]
|
||||
|
||||
# PHP Syntax Check
|
||||
# Install dependencies first (needed for php-lint to resolve traits/classes)
|
||||
steps:
|
||||
php-lint:
|
||||
image: php:8.3-cli
|
||||
commands:
|
||||
- echo "Checking PHP syntax..."
|
||||
- find app -name "*.php" -exec php -l {} \;
|
||||
- find routes -name "*.php" -exec php -l {} \;
|
||||
- find database -name "*.php" -exec php -l {} \;
|
||||
- echo "PHP syntax check complete!"
|
||||
|
||||
# Restore Composer cache
|
||||
restore-composer-cache:
|
||||
image: meltwater/drone-cache:dev
|
||||
@@ -89,6 +80,16 @@ steps:
|
||||
volumes:
|
||||
- /tmp/woodpecker-cache:/tmp/cache
|
||||
|
||||
# PHP Syntax Check (runs after composer install so traits/classes are available)
|
||||
php-lint:
|
||||
image: php:8.3-cli
|
||||
commands:
|
||||
- echo "Checking PHP syntax..."
|
||||
- find app -name "*.php" -exec php -l {} \;
|
||||
- find routes -name "*.php" -exec php -l {} \;
|
||||
- find database -name "*.php" -exec php -l {} \;
|
||||
- echo "PHP syntax check complete!"
|
||||
|
||||
# Run Laravel Pint (Code Style)
|
||||
code-style:
|
||||
image: php:8.3-cli
|
||||
|
||||
@@ -1,578 +0,0 @@
|
||||
# Git Branching Strategy for Cannabrands
|
||||
|
||||
## Evolution of Your Workflow
|
||||
|
||||
Your branching strategy should evolve with your team size and customer base. This document outlines the transition path.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Pre-Release (Current)
|
||||
|
||||
**Team Size:** 1-2 developers
|
||||
**Customers:** None yet
|
||||
**Goal:** Move fast, iterate quickly
|
||||
|
||||
### Current Workflow: Direct to Master
|
||||
|
||||
```
|
||||
Local changes → Commit to master → Push → CI tests → (Optional: Deploy to dev)
|
||||
```
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
# Make changes
|
||||
git add .
|
||||
git commit -m "feat: add new feature"
|
||||
git push origin master
|
||||
```
|
||||
|
||||
**When this works:**
|
||||
- ✅ Solo developer or tight 2-person team
|
||||
- ✅ No customers depending on stability
|
||||
- ✅ Fast iteration is priority #1
|
||||
- ✅ Quick fixes needed immediately
|
||||
|
||||
**When to stop:**
|
||||
- ❌ First paying customer signs up
|
||||
- ❌ Team grows to 3+ developers
|
||||
- ❌ Too many merge conflicts
|
||||
- ❌ Need code review before deployment
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Feature Branches (Transition)
|
||||
|
||||
**Team Size:** 2-5 developers
|
||||
**Customers:** First few customers OR approaching launch
|
||||
**Goal:** Add safety through code review
|
||||
|
||||
### GitHub Flow: Feature Branches + PRs
|
||||
|
||||
```
|
||||
master (stable, auto-deploys to dev)
|
||||
↑
|
||||
Pull Requests (code review required)
|
||||
↑
|
||||
feature branches (work in progress)
|
||||
```
|
||||
|
||||
**Workflow:**
|
||||
|
||||
```bash
|
||||
# 1. Create feature branch from master
|
||||
git checkout master
|
||||
git pull origin master
|
||||
git checkout -b feature/add-payment-terms
|
||||
|
||||
# 2. Make changes
|
||||
# ... edit files ...
|
||||
git add .
|
||||
git commit -m "feat: add payment term surcharge calculation"
|
||||
|
||||
# 3. Push feature branch
|
||||
git push origin feature/add-payment-terms
|
||||
|
||||
# 4. Create Pull Request in Gitea
|
||||
# - Go to Gitea UI
|
||||
# - Click "New Pull Request"
|
||||
# - Base: master, Compare: feature/add-payment-terms
|
||||
# - Add description and assign reviewer
|
||||
|
||||
# 5. After approval, merge to master
|
||||
# - Click "Merge" button in Gitea
|
||||
# - Delete feature branch
|
||||
|
||||
# 6. Pull updated master
|
||||
git checkout master
|
||||
git pull origin master
|
||||
git branch -d feature/add-payment-terms
|
||||
```
|
||||
|
||||
**Branch Naming Conventions:**
|
||||
```
|
||||
feature/short-description # New features
|
||||
fix/bug-description # Bug fixes
|
||||
refactor/what-changed # Code refactoring
|
||||
docs/what-documented # Documentation only
|
||||
test/what-tested # Test additions
|
||||
|
||||
Examples:
|
||||
feature/buyer-registration
|
||||
fix/invoice-calculation-error
|
||||
refactor/order-service
|
||||
docs/deployment-guide
|
||||
test/checkout-flow
|
||||
```
|
||||
|
||||
**Commit Message Format:**
|
||||
```
|
||||
type(scope): subject
|
||||
|
||||
Examples:
|
||||
feat(checkout): add payment term selection
|
||||
fix(invoice): correct tax calculation for multi-state orders
|
||||
refactor(orders): extract order validation logic
|
||||
docs(readme): update local setup instructions
|
||||
test(auth): add buyer login tests
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Code review before merging
|
||||
- ✅ Catch bugs early
|
||||
- ✅ Knowledge sharing across team
|
||||
- ✅ Cleaner git history
|
||||
- ✅ Can work on multiple features in parallel
|
||||
|
||||
**When to stop:**
|
||||
- ❌ Team grows beyond 10 people
|
||||
- ❌ Need to manage multiple versions
|
||||
- ❌ Need longer release cycles
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Environment Branches (Mature)
|
||||
|
||||
**Team Size:** 5+ developers
|
||||
**Customers:** Growing customer base
|
||||
**Goal:** Staged rollout with stability
|
||||
|
||||
### Two-Branch Model: Develop + Master
|
||||
|
||||
```
|
||||
master (production) ← deploys to app.cannabrands.com
|
||||
↑
|
||||
Pull Requests (from develop, tested on staging)
|
||||
↑
|
||||
develop (integration) ← deploys to staging.cannabrands.app
|
||||
↑
|
||||
Pull Requests (from feature branches)
|
||||
↑
|
||||
feature branches (work in progress)
|
||||
```
|
||||
|
||||
**Workflow:**
|
||||
|
||||
```bash
|
||||
# 1. Create feature branch from develop
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feature/bulk-order-import
|
||||
|
||||
# 2. Make changes and commit
|
||||
git add .
|
||||
git commit -m "feat(orders): add CSV bulk import"
|
||||
git push origin feature/bulk-order-import
|
||||
|
||||
# 3. Create PR: feature → develop
|
||||
# - Merging to develop auto-deploys to staging.cannabrands.app
|
||||
# - Team tests on staging
|
||||
|
||||
# 4. When staging is stable, create PR: develop → master
|
||||
# - Requires approval from team lead
|
||||
# - Merging to master deploys to production
|
||||
|
||||
# 5. Regular develop → master promotions
|
||||
# - Every 1-2 weeks
|
||||
# - Or after major features are tested
|
||||
```
|
||||
|
||||
**Branch Protection Rules:**
|
||||
|
||||
**Master Branch:**
|
||||
- ✅ Require pull request reviews (1+ approvals)
|
||||
- ✅ Require status checks to pass (CI tests)
|
||||
- ✅ Require branches to be up to date
|
||||
- ✅ Restrict who can push (admin only)
|
||||
|
||||
**Develop Branch:**
|
||||
- ✅ Require pull request reviews (1+ approval)
|
||||
- ✅ Require status checks to pass
|
||||
- ⬜ Allow force pushes (optional)
|
||||
|
||||
**Setup in Gitea:**
|
||||
```
|
||||
Settings → Repository → Branches → Add Branch Protection Rule
|
||||
- Branch name pattern: master
|
||||
- Enable protection
|
||||
- Require pull request reviews before merging
|
||||
- Dismiss stale pull request approvals when new commits are pushed
|
||||
- Require status checks to pass before merging
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Staging environment matches production
|
||||
- ✅ Catch integration issues before customers see them
|
||||
- ✅ Multiple features can be tested together
|
||||
- ✅ Rollback is easier (master = last known good)
|
||||
- ✅ QA team has stable environment to test
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Release Branches (Enterprise)
|
||||
|
||||
**Team Size:** 10+ developers
|
||||
**Customers:** Large customer base, SLA commitments
|
||||
**Goal:** Support multiple versions, scheduled releases
|
||||
|
||||
### Git Flow: Full Enterprise Model
|
||||
|
||||
```
|
||||
master (production) ← hotfixes, release branches
|
||||
↑
|
||||
release/v2.1.0 (release preparation)
|
||||
↑
|
||||
develop (integration) ← feature branches
|
||||
↑
|
||||
feature/* (work in progress)
|
||||
```
|
||||
|
||||
**When you need this:**
|
||||
- Multiple versions in production (e.g., self-hosted + SaaS)
|
||||
- Need to support old versions
|
||||
- Scheduled release cycles (monthly/quarterly)
|
||||
- Regulatory requirements for change control
|
||||
|
||||
**This is probably overkill for most cannabis startups.**
|
||||
|
||||
---
|
||||
|
||||
## Hotfix Workflow (All Phases)
|
||||
|
||||
**When:** Critical bug in production needs immediate fix
|
||||
|
||||
### Quick Hotfix Process
|
||||
|
||||
```bash
|
||||
# 1. Create hotfix branch from master
|
||||
git checkout master
|
||||
git pull origin master
|
||||
git checkout -b hotfix/invoice-calculation-fix
|
||||
|
||||
# 2. Make MINIMAL changes to fix the bug
|
||||
git add .
|
||||
git commit -m "fix(invoice): correct tax calculation for CA"
|
||||
|
||||
# 3. Test locally
|
||||
./vendor/bin/sail artisan test
|
||||
|
||||
# 4. Create PR to master (expedited review)
|
||||
# - Mark as "HOTFIX - URGENT"
|
||||
# - Get quick approval from team lead
|
||||
|
||||
# 5. After merging to master:
|
||||
# - Deploy to production immediately
|
||||
# - Backport fix to develop branch
|
||||
|
||||
git checkout develop
|
||||
git merge master
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
**Hotfix Rules:**
|
||||
- ⚠️ Only for critical production bugs
|
||||
- ⚠️ Must be small, focused changes
|
||||
- ⚠️ Expedited review process
|
||||
- ⚠️ Deploy ASAP after merge
|
||||
|
||||
---
|
||||
|
||||
## Recommended Transition Timeline
|
||||
|
||||
### Now → First Customer (Phase 0)
|
||||
|
||||
**Strategy:** Direct to master
|
||||
**Why:** Fast iteration, no customer impact
|
||||
|
||||
```bash
|
||||
# Your current workflow - keep doing this
|
||||
git commit -am "feat: add new feature"
|
||||
git push origin master
|
||||
```
|
||||
|
||||
### First Customer → 5 Customers (Phase 1)
|
||||
|
||||
**Trigger:** First paying customer OR 3+ developers
|
||||
**Strategy:** Feature branches with PRs
|
||||
**Timeline:** Implement within 2 weeks of first customer
|
||||
|
||||
**Action Items:**
|
||||
1. Create `CONTRIBUTING.md` with PR guidelines
|
||||
2. Enable branch protection on master
|
||||
3. Train team on PR workflow
|
||||
4. Set up code review rotation
|
||||
|
||||
### 5+ Customers → Growth Phase (Phase 2)
|
||||
|
||||
**Trigger:**
|
||||
- 5+ developers on team OR
|
||||
- 50+ active customers OR
|
||||
- Need for staging environment
|
||||
|
||||
**Strategy:** Develop + Master branches
|
||||
**Timeline:** Plan 1 month for transition
|
||||
|
||||
**Action Items:**
|
||||
1. Set up staging.cannabrands.app server
|
||||
2. Create develop branch
|
||||
3. Update CI/CD for both branches
|
||||
4. Document new workflow for team
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Best Practices
|
||||
|
||||
### Writing Good PRs
|
||||
|
||||
**Title Format:**
|
||||
```
|
||||
type(scope): brief description
|
||||
|
||||
Examples:
|
||||
feat(orders): add bulk order import
|
||||
fix(invoice): correct tax calculation
|
||||
refactor(auth): simplify login flow
|
||||
```
|
||||
|
||||
**Description Template:**
|
||||
```markdown
|
||||
## What Changed
|
||||
Brief description of what this PR does
|
||||
|
||||
## Why
|
||||
Explain the problem this solves or feature it adds
|
||||
|
||||
## How to Test
|
||||
1. Go to /orders/import
|
||||
2. Upload sample CSV
|
||||
3. Verify orders are created correctly
|
||||
|
||||
## Screenshots (if UI changes)
|
||||
[Attach screenshots]
|
||||
|
||||
## Checklist
|
||||
- [ ] Tests added/updated
|
||||
- [ ] Documentation updated
|
||||
- [ ] Tested locally
|
||||
- [ ] No merge conflicts
|
||||
```
|
||||
|
||||
### Reviewing PRs
|
||||
|
||||
**What to look for:**
|
||||
- ✅ Code solves the stated problem
|
||||
- ✅ Tests cover new functionality
|
||||
- ✅ Follows existing code style
|
||||
- ✅ No obvious bugs or security issues
|
||||
- ✅ Documentation is updated
|
||||
|
||||
**How to provide feedback:**
|
||||
```markdown
|
||||
## Blocking Issues (must fix before merge)
|
||||
- [ ] Line 45: This will cause a division by zero error
|
||||
|
||||
## Suggestions (nice to have)
|
||||
- Line 23: Consider extracting this to a helper method
|
||||
- Could add a comment explaining this logic
|
||||
|
||||
## Praise (always include!)
|
||||
- Great test coverage!
|
||||
- Clean implementation of the CSV parser
|
||||
```
|
||||
|
||||
### PR Etiquette
|
||||
|
||||
**Author:**
|
||||
- Keep PRs small (<400 lines changed)
|
||||
- Respond to feedback within 24 hours
|
||||
- Don't merge your own PRs (unless emergency)
|
||||
- Update PR if master changes
|
||||
|
||||
**Reviewer:**
|
||||
- Review within 24 hours
|
||||
- Be kind and constructive
|
||||
- Ask questions instead of making demands
|
||||
- Approve when ready (don't hold up progress)
|
||||
|
||||
---
|
||||
|
||||
## Merge Strategies
|
||||
|
||||
### Squash and Merge (Recommended for Feature Branches)
|
||||
|
||||
**What it does:** Combines all commits into one when merging
|
||||
|
||||
**Use when:**
|
||||
- Feature branch has messy commit history
|
||||
- Want clean master history
|
||||
- PRs are self-contained features
|
||||
|
||||
**Gitea Setting:** "Squash and merge" button in PR
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Before merge (feature branch):
|
||||
- fix typo
|
||||
- wip: add validation
|
||||
- add tests
|
||||
- fix tests
|
||||
- update docs
|
||||
|
||||
After merge (master):
|
||||
- feat(orders): add bulk import with CSV validation (#42)
|
||||
```
|
||||
|
||||
### Regular Merge (For Long-Running Branches)
|
||||
|
||||
**What it does:** Preserves all commits and creates merge commit
|
||||
|
||||
**Use when:**
|
||||
- Merging develop → master
|
||||
- Want to preserve detailed history
|
||||
- Multiple developers collaborated on branch
|
||||
|
||||
---
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Starting New Feature
|
||||
|
||||
```bash
|
||||
# Update master
|
||||
git checkout master
|
||||
git pull origin master
|
||||
|
||||
# Create feature branch
|
||||
git checkout -b feature/product-variants
|
||||
|
||||
# Make changes
|
||||
# ... work work work ...
|
||||
|
||||
# Commit regularly (small commits)
|
||||
git add .
|
||||
git commit -m "feat(products): add variant model"
|
||||
git push origin feature/product-variants
|
||||
|
||||
# Create PR when ready
|
||||
```
|
||||
|
||||
### Updating Feature Branch with Latest Master
|
||||
|
||||
```bash
|
||||
# Your feature branch is behind master
|
||||
git checkout feature/product-variants
|
||||
|
||||
# Option 1: Merge master into feature (preserves commits)
|
||||
git merge origin/master
|
||||
git push origin feature/product-variants
|
||||
|
||||
# Option 2: Rebase on master (cleaner history)
|
||||
git rebase origin/master
|
||||
git push -f origin feature/product-variants # Force push needed after rebase
|
||||
```
|
||||
|
||||
**When to use each:**
|
||||
- **Merge:** Safer, preserves history, good for collaboration
|
||||
- **Rebase:** Cleaner history, good for solo feature branches
|
||||
|
||||
### Fixing Merge Conflicts
|
||||
|
||||
```bash
|
||||
# Pull latest master
|
||||
git checkout master
|
||||
git pull origin master
|
||||
|
||||
# Try to merge (conflict!)
|
||||
git checkout feature/product-variants
|
||||
git merge master
|
||||
|
||||
# Git shows conflicts
|
||||
# CONFLICT (content): Merge conflict in app/Models/Product.php
|
||||
|
||||
# Open conflicted file
|
||||
nano app/Models/Product.php
|
||||
|
||||
# Look for conflict markers:
|
||||
<<<<<<< HEAD
|
||||
// Your changes
|
||||
=======
|
||||
// Changes from master
|
||||
>>>>>>> master
|
||||
|
||||
# Resolve conflicts, remove markers
|
||||
# Save file
|
||||
|
||||
# Mark as resolved
|
||||
git add app/Models/Product.php
|
||||
git commit -m "fix: resolve merge conflicts with master"
|
||||
git push origin feature/product-variants
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Branch Cleanup
|
||||
|
||||
### Deleting Merged Feature Branches
|
||||
|
||||
```bash
|
||||
# After PR is merged, delete local branch
|
||||
git branch -d feature/product-variants
|
||||
|
||||
# Delete remote branch (Gitea can auto-delete)
|
||||
git push origin --delete feature/product-variants
|
||||
|
||||
# Remove stale remote references
|
||||
git remote prune origin
|
||||
|
||||
# See all branches
|
||||
git branch -a
|
||||
```
|
||||
|
||||
### Finding Old Branches
|
||||
|
||||
```bash
|
||||
# List branches by last commit date
|
||||
git for-each-ref --sort=-committerdate refs/heads/
|
||||
|
||||
# Delete branches older than 30 days
|
||||
git branch --merged master | grep -v "master" | xargs git branch -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary: Your Transition Path
|
||||
|
||||
### Today (Pre-Release)
|
||||
✅ **Direct to master** - current setup
|
||||
- Fast iteration
|
||||
- No PR overhead
|
||||
- Good for solo/pair development
|
||||
|
||||
### First Customer (In 1-3 Months)
|
||||
🔜 **Feature branches + PRs**
|
||||
- Add code review
|
||||
- Protect master branch
|
||||
- Enable CI on PRs
|
||||
|
||||
### Growing Team (In 6-12 Months)
|
||||
🔜 **Develop + Master branches**
|
||||
- Add staging environment
|
||||
- Staged rollouts
|
||||
- Better stability
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Phase | Team Size | Branches | Deploy To | When |
|
||||
|-------|-----------|----------|-----------|------|
|
||||
| 0: Pre-release | 1-2 | master | dev | Now |
|
||||
| 1: Feature branches | 2-5 | master + feature/* | dev | First customer |
|
||||
| 2: Environment branches | 5-10 | master + develop + feature/* | staging + prod | Growing team |
|
||||
| 3: Release branches | 10+ | master + develop + release/* + feature/* | Multiple envs | Enterprise |
|
||||
|
||||
**Recommended for Cannabrands:**
|
||||
- **Now:** Phase 0 (direct to master)
|
||||
- **Next:** Phase 1 (feature branches) - within 2 weeks of first customer
|
||||
- **Future:** Phase 2 (develop branch) - when team grows to 5+
|
||||
|
||||
**Key principle:** *"Choose the simplest workflow that meets your current needs. You can always add complexity later."*
|
||||
166
CLAUDE.md
166
CLAUDE.md
@@ -1,85 +1,123 @@
|
||||
# Claude Code Context
|
||||
|
||||
## Important Documentation to Review
|
||||
## 🚨 Critical Mistakes You Make
|
||||
|
||||
Before implementing any features, please review the following documentation:
|
||||
### 1. Business Isolation (MOST COMMON!)
|
||||
❌ **Wrong:** `Component::findOrFail($id)` then check business_id
|
||||
✅ **Right:** `Component::where('business_id', $business->id)->findOrFail($id)`
|
||||
**Why:** Prevents ID enumeration across tenants (see audit: BomController vulnerability)
|
||||
|
||||
### URL Structure and Architecture
|
||||
- **ALWAYS** review `docs/URL_STRUCTURE.md` before implementing any routing changes
|
||||
- The application uses a three-tier user system: buyers, sellers, and admins
|
||||
- URL prefixes: `/b/` (buyers), `/s/` (sellers), `/admin` (super admins)
|
||||
**Models needing business_id:** Component, Brand, Product, Driver, Vehicle, Contact, Invoice
|
||||
**Exception:** Orders span buyer + seller businesses - use `whereHas('items.product.brand')`
|
||||
|
||||
### Additional Documentation
|
||||
- `docs/DATABASE.md` - Database schema and conventions
|
||||
- `docs/DEVELOPMENT.md` - Development environment setup
|
||||
- `docs/KUBERNETES_DEPLOYMENT.md` - Kubernetes deployment guide
|
||||
- `docs/REGISTRY_CLEANUP.md` - Docker registry cleanup policy
|
||||
- `CONTRIBUTING.md` - Git workflow and PR process (root level)
|
||||
- `k8s/KUBECTL_COMMANDS.md` - kubectl operations reference
|
||||
### 2. Route Prefixes
|
||||
Check `docs/URL_STRUCTURE.md` BEFORE route changes.
|
||||
- `/b/*` → Buyers only
|
||||
- `/s/*` → Sellers only
|
||||
- `/admin` → Super admins only
|
||||
|
||||
### User Types and Account Structure
|
||||
- **Buyers** (Retailers/Dispensaries): Browse marketplace, instant approval
|
||||
- **Sellers** (Brands/Manufacturers): Manage products, require approval
|
||||
- **Admins**: Platform management
|
||||
### 3. Filament Usage Boundary
|
||||
**Filament = `/admin` ONLY** (super admin tools)
|
||||
**DO NOT** use Filament for `/b/` or `/s/` - use DaisyUI + Blade instead
|
||||
**Why:** Filament is admin panel framework, not customer-facing UI
|
||||
|
||||
### Development Guidelines
|
||||
1. Maintain Laravel Breeze compatibility for authentication
|
||||
2. Follow existing code conventions and patterns
|
||||
3. Always check existing components before creating new ones
|
||||
4. Use PostgreSQL-compatible migrations (no IF/ELSE logic)
|
||||
5. Test routes after implementation
|
||||
6. Create informative git commits with clear messages
|
||||
### 4. Multi-Tenancy Architecture
|
||||
**We do NOT use spatie/laravel-multitenancy** - manual business_id scoping
|
||||
**Why:** Two-sided marketplace needs cross-business queries (buyers browse all sellers' products)
|
||||
Orders link TWO businesses: buyer's business_id + seller's product→brand→business_id
|
||||
|
||||
## What NOT to Do
|
||||
### 5. Middleware Protection
|
||||
ALL routes need auth + user type middleware except public pages
|
||||
**Pattern:** `->middleware(['auth', 'verified', 'buyer'])` or `['seller', 'approved']`
|
||||
**Caught in audit:** Unprotected `/onboarding/*` routes - now fixed
|
||||
|
||||
- ❌ **NEVER** commit directly to `develop` or `master` branches
|
||||
- ❌ **NEVER** use raw SQL (use Eloquent/Query Builder)
|
||||
- ❌ **NEVER** skip authentication middleware on protected routes
|
||||
- ❌ **NEVER** commit `.env` files or secrets
|
||||
- ❌ **NEVER** create migrations with IF/ELSE logic (PostgreSQL incompatible)
|
||||
- ❌ **NEVER** bypass CI/CD checks
|
||||
- ❌ **NEVER** skip writing down() methods in migrations
|
||||
### 6. PostgreSQL Migrations
|
||||
❌ No IF/ELSE logic in migrations (not supported)
|
||||
✅ Use Laravel Schema builder or conditional PHP code
|
||||
|
||||
## Testing Requirements
|
||||
### 7. Styling - DaisyUI/Tailwind Only
|
||||
❌ **NEVER use inline `style=""` attributes** in Blade templates
|
||||
✅ **ALWAYS use DaisyUI/Tailwind utility classes**
|
||||
**Why:** Consistency, maintainability, theme switching, and better performance
|
||||
|
||||
- **ALWAYS** run tests before committing: `php artisan test --parallel`
|
||||
- **ALWAYS** check code style: `./vendor/bin/pint`
|
||||
- Tests must pass in CI/CD pipeline before merge
|
||||
**Correct patterns:**
|
||||
- Colors: Use `bg-primary`, `text-primary`, `bg-success`, etc. (defined in `resources/css/app.css`)
|
||||
- Spacing: Use `p-4`, `m-2`, `gap-3` (Tailwind utilities)
|
||||
- Layout: Use `flex`, `grid`, `items-center` (Tailwind utilities)
|
||||
- Custom colors: Add to `resources/css/app.css` theme variables, NOT inline
|
||||
|
||||
## Testing Credentials
|
||||
- Buyer: `dispensary@example.com` / `password`
|
||||
- Seller: `brand@example.com` / `password`
|
||||
- Admin: `admin@example.com` / `password`
|
||||
**Exception:** Only use inline styles for truly dynamic values from database (e.g., user-uploaded brand colors)
|
||||
|
||||
## Current Architecture Decisions
|
||||
- Dual registration flow with informative landing page at `/register`
|
||||
- Separate authentication controllers for buyers and sellers
|
||||
- Marketplace functionality under `/b/` prefix
|
||||
- Brand/seller CRM functionality under `/s/` prefix
|
||||
---
|
||||
|
||||
## Commands to Run After Changes
|
||||
- Clear caches: `php artisan cache:clear && php artisan config:clear && php artisan route:clear`
|
||||
- Run migrations: `php artisan migrate`
|
||||
- Seed test data: `php artisan db:seed --class=DevSeeder`
|
||||
## Tech Stack by Area
|
||||
|
||||
## CI/CD Pipeline
|
||||
| Area | Framework | Users | UI |
|
||||
|------|-----------|-------|-----|
|
||||
| `/admin` | Filament v3 | Super admins | Filament tables/forms |
|
||||
| `/b/` | Blade + DaisyUI | Buyers | Custom marketplace |
|
||||
| `/s/` | Blade + DaisyUI | Sellers | Custom CRM |
|
||||
|
||||
Woodpecker CI runs automatically on push to develop/master:
|
||||
1. PHP syntax check
|
||||
2. Code style check (Pint)
|
||||
3. PHPUnit tests
|
||||
4. Docker image build (only if all checks pass)
|
||||
---
|
||||
|
||||
**Do not merge Pull Requests if CI/CD fails.**
|
||||
## Business Types
|
||||
|
||||
## Server Requirements
|
||||
- `'buyer'` - Dispensary (browses marketplace, places orders)
|
||||
- `'seller'` - Brand (manages products, fulfills orders)
|
||||
- `'both'` - Vertically integrated
|
||||
|
||||
### PDF Generation (DomPDF)
|
||||
This application uses DomPDF (`barryvdh/laravel-dompdf`) for generating cannabis shipping manifests and invoices.
|
||||
Users have `user_type` matching their business type.
|
||||
|
||||
**No special server requirements needed** - DomPDF is pure PHP and works out of the box on all platforms (Linux, macOS, Windows, ARM64, x86_64).
|
||||
---
|
||||
|
||||
**Configuration:**
|
||||
- Package: `barryvdh/laravel-dompdf`
|
||||
- Already installed via Composer
|
||||
- No additional system dependencies required
|
||||
## Testing & Git
|
||||
|
||||
**Before commit:**
|
||||
```bash
|
||||
php artisan test --parallel # REQUIRED
|
||||
./vendor/bin/pint # REQUIRED
|
||||
```
|
||||
|
||||
**Credentials:** `{buyer,seller,admin}@example.com` / `password`
|
||||
|
||||
**Branches:** Never commit to `master`/`develop` directly - use feature branches
|
||||
|
||||
**CI/CD:** Woodpecker checks syntax → Pint → tests → Docker build
|
||||
|
||||
---
|
||||
|
||||
## Common Query Patterns
|
||||
|
||||
```php
|
||||
// Seller viewing their products
|
||||
Product::whereHas('brand', fn($q) => $q->where('business_id', $business->id))->get();
|
||||
|
||||
// Buyer viewing their orders
|
||||
Order::where('business_id', $business->id)->get();
|
||||
|
||||
// Seller viewing incoming orders
|
||||
Order::whereHas('items.product.brand', fn($q) => $q->where('business_id', $business->id))->get();
|
||||
|
||||
// Marketplace (cross-business - intentional!)
|
||||
Product::where('is_active', true)->get(); // No business_id filter!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## External Docs (Read When Needed)
|
||||
|
||||
- `docs/URL_STRUCTURE.md` - **READ BEFORE** routing changes
|
||||
- `docs/DATABASE.md` - **READ BEFORE** migrations
|
||||
- `docs/DEVELOPMENT.md` - Local setup
|
||||
- `CONTRIBUTING.md` - Detailed git workflow
|
||||
|
||||
---
|
||||
|
||||
## What You Often Forget
|
||||
|
||||
✅ Scope by business_id BEFORE finding by ID
|
||||
✅ Use Eloquent (never raw SQL)
|
||||
✅ Protect routes with middleware
|
||||
✅ DaisyUI for buyer/seller, Filament only for admin
|
||||
✅ NO inline styles - use Tailwind/DaisyUI classes only
|
||||
✅ Run tests before committing
|
||||
|
||||
@@ -1,891 +0,0 @@
|
||||
# Claude Code Collaboration Workflow Guide
|
||||
|
||||
**Project:** Cannabrands CRM → Filament Migration
|
||||
**Timeline:** 28 days
|
||||
**Developer:** Solo developer + Claude Code
|
||||
**Last Updated:** January 2025
|
||||
|
||||
---
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
This guide provides prompt templates, reference strategies, and workflows for effectively collaborating with Claude Code during the migration from the legacy Laravel 9 + VentureDrake CRM system to the new Laravel 12 + Filament 4 platform.
|
||||
|
||||
**Key Principle:** The old codebase (`/cannabrands_crm`) contains **business logic** that must be preserved, but **architectural patterns** that should NOT be replicated.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Prompt Templates
|
||||
|
||||
### Template 1: Implementing a New Feature
|
||||
|
||||
```
|
||||
I need to implement [FEATURE NAME] from the old system.
|
||||
|
||||
**Old System Reference:**
|
||||
- Location: [file path in /cannabrands_crm]
|
||||
- Key business rules: [specific rules to preserve]
|
||||
- Data involved: [models/tables]
|
||||
|
||||
**New System Requirements:**
|
||||
- Filament resource: [Yes/No]
|
||||
- Public-facing page: [Yes/No]
|
||||
- Special considerations: [any unique requirements]
|
||||
|
||||
Please:
|
||||
1. Read the old implementation to understand the business logic
|
||||
2. Implement using Filament 4 best practices
|
||||
3. Preserve all validation rules and business logic
|
||||
4. Do NOT copy the CRM-specific patterns
|
||||
|
||||
Reference documents:
|
||||
- FEATURE_IMPLEMENTATION_ROADMAP.md (Day X task)
|
||||
- FILAMENT_RESOURCES_SPEC.md (if applicable)
|
||||
```
|
||||
|
||||
**Example Usage:**
|
||||
```
|
||||
I need to implement the buyer application approval workflow.
|
||||
|
||||
**Old System Reference:**
|
||||
- Location: vendor/venturedrake/laravel-crm/src/Http/Controllers/CompaniesController.php (lines 520-790)
|
||||
- Key business rules:
|
||||
- Requires license document upload
|
||||
- Requires W9 tax form
|
||||
- Admin approval required before account activation
|
||||
- Email notification on approval/rejection
|
||||
- Data involved: companies table, documents, email_verifications
|
||||
|
||||
**New System Requirements:**
|
||||
- Filament resource: Yes (CompanyResource)
|
||||
- Public-facing page: Yes (buyer registration form)
|
||||
- Special considerations: Must integrate with existing email verification system
|
||||
|
||||
Please:
|
||||
1. Read the old implementation to understand document validation
|
||||
2. Implement using Filament 4 actions and notifications
|
||||
3. Preserve license/W9 validation logic
|
||||
4. Do NOT copy the CRM Organization model patterns
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Template 2: Understanding Business Logic
|
||||
|
||||
```
|
||||
I need to understand how [BUSINESS PROCESS] works in the old system.
|
||||
|
||||
**Process:** [name of workflow/calculation/rule]
|
||||
**Why:** [what you're trying to implement that needs this]
|
||||
|
||||
Please:
|
||||
1. Search the old codebase for relevant files
|
||||
2. Trace the workflow from start to finish
|
||||
3. Identify all business rules, validations, and side effects
|
||||
4. Explain the logic in plain English
|
||||
5. Recommend how to implement in new system
|
||||
|
||||
Do NOT implement yet - just analyze and explain.
|
||||
```
|
||||
|
||||
**Example Usage:**
|
||||
```
|
||||
I need to understand how orders convert to invoices in the old system.
|
||||
|
||||
**Process:** Order-to-Invoice conversion workflow
|
||||
**Why:** Need to implement this in new OrderResource and InvoiceResource
|
||||
|
||||
Please:
|
||||
1. Search the old codebase for order/invoice conversion logic
|
||||
2. Trace the workflow from order creation → invoice generation
|
||||
3. Identify all status transitions and triggers
|
||||
4. Explain when/why orders become invoices
|
||||
5. Recommend how to implement in Filament with separate Order/Invoice models
|
||||
|
||||
Do NOT implement yet - just analyze and explain.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Template 3: Creating a Migration Script
|
||||
|
||||
```
|
||||
I need to migrate [DATA TYPE] from old database to new database.
|
||||
|
||||
**Old Table(s):** [table names]
|
||||
**New Table(s):** [table names]
|
||||
**Record Count:** [approximate number]
|
||||
**Special Considerations:** [foreign keys, transformations, etc.]
|
||||
|
||||
Reference:
|
||||
- SCHEMA_TRANSFORMATION.md (Section: [section name])
|
||||
|
||||
Please:
|
||||
1. Create Laravel migration file
|
||||
2. Write data migration script (using DB facade or raw SQL)
|
||||
3. Include data transformation logic from SCHEMA_TRANSFORMATION.md
|
||||
4. Add verification queries to confirm successful migration
|
||||
5. Include rollback logic
|
||||
|
||||
Test with a small subset first (LIMIT 10).
|
||||
```
|
||||
|
||||
**Example Usage:**
|
||||
```
|
||||
I need to migrate products from old database to new database.
|
||||
|
||||
**Old Table(s):** crm_products, crm_brands, products
|
||||
**New Table(s):** products, brands
|
||||
**Record Count:** 883 products
|
||||
**Special Considerations:**
|
||||
- 552 products have NULL brand_id (assign to default "Cannabrands" brand)
|
||||
- Parent-child relationships for product varieties
|
||||
- BOM flags (isAssembly, isRaw, isSellable)
|
||||
|
||||
Reference:
|
||||
- SCHEMA_TRANSFORMATION.md (Section: Products Migration)
|
||||
|
||||
Please:
|
||||
1. Create Laravel migration file
|
||||
2. Write data migration script with brand assignment logic
|
||||
3. Handle NULL brand_id → default to Cannabrands
|
||||
4. Preserve parent_product_id relationships
|
||||
5. Include COUNT verification before/after
|
||||
|
||||
Test with LIMIT 10 first, then show me results before full migration.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Template 4: Debugging Behavior Mismatch
|
||||
|
||||
```
|
||||
The new implementation doesn't match old system behavior.
|
||||
|
||||
**Feature:** [what you implemented]
|
||||
**Expected Behavior:** [what should happen, based on old system]
|
||||
**Actual Behavior:** [what's happening in new system]
|
||||
**Old System Reference:** [file/line where expected behavior exists]
|
||||
|
||||
Please:
|
||||
1. Read the old implementation carefully
|
||||
2. Identify what business logic I missed
|
||||
3. Show me the discrepancy
|
||||
4. Fix the new implementation to match
|
||||
```
|
||||
|
||||
**Example Usage:**
|
||||
```
|
||||
The new implementation doesn't match old system behavior.
|
||||
|
||||
**Feature:** Order acceptance workflow
|
||||
**Expected Behavior:** When seller creates order, buyer must accept before it can be fulfilled. When buyer creates order, seller must accept.
|
||||
**Actual Behavior:** All orders can be immediately marked as accepted regardless of who created them.
|
||||
**Old System Reference:** app/Models/Invoice.php (acceptable() method, lines 45-52)
|
||||
|
||||
Please:
|
||||
1. Read the old Invoice model's acceptable() logic
|
||||
2. Identify the created_by and status checks I missed
|
||||
3. Show me what validation rules are missing
|
||||
4. Update OrderResource to match this business rule
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Reference Strategy
|
||||
|
||||
### ✅ WHEN to Read Old Codebase
|
||||
|
||||
**1. Business Rules & Validation**
|
||||
```php
|
||||
// OLD: vendor/venturedrake/laravel-crm/src/Http/Requests/StoreCompanyRequest.php
|
||||
'license_number' => 'required|string|max:50|unique:companies',
|
||||
'license_document' => 'required|file|mimes:pdf,jpg,png|max:5120',
|
||||
|
||||
// Extract these rules for new CompanyResource
|
||||
```
|
||||
|
||||
**2. Calculations & Formulas**
|
||||
```php
|
||||
// OLD: app/Models/Invoice.php
|
||||
public function calculateTotal() {
|
||||
return $this->lines->sum(function($line) {
|
||||
return $line->quantity * $line->unit_price * (1 - $line->discount/100);
|
||||
}) + $this->shipping_cost + $this->tax_amount;
|
||||
}
|
||||
|
||||
// Preserve exact calculation logic in new Order model
|
||||
```
|
||||
|
||||
**3. Status Workflows**
|
||||
```php
|
||||
// OLD: Check status transitions and conditions
|
||||
if ($order->status == 'new' && $order->created_by === 'seller') {
|
||||
// Buyer must accept
|
||||
} elseif ($order->status == 'accepted') {
|
||||
// Can be fulfilled
|
||||
}
|
||||
|
||||
// Replicate state machine logic in new system
|
||||
```
|
||||
|
||||
**4. Email Templates & Notifications**
|
||||
```php
|
||||
// OLD: resources/views/emails/order/accepted.blade.php
|
||||
// Copy branding, copy, and structure
|
||||
// Update to use new Filament notification patterns
|
||||
```
|
||||
|
||||
**5. Complex Queries**
|
||||
```php
|
||||
// OLD: Multi-table joins, aggregations, report logic
|
||||
$products = Product::with(['brand', 'parent'])
|
||||
->where('is_active', true)
|
||||
->whereHas('brand', fn($q) => $q->where('public', true))
|
||||
->get();
|
||||
|
||||
// Preserve query structure and business logic
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ WHAT NOT to Copy
|
||||
|
||||
**1. VentureDrake Model Patterns**
|
||||
```php
|
||||
// DON'T COPY THIS:
|
||||
namespace VentureDrake\LaravelCrm\Models;
|
||||
use VentureDrake\LaravelCrm\Traits\BelongsToTeams;
|
||||
|
||||
// Instead: Use clean Laravel models
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
```
|
||||
|
||||
**2. CRM-Specific Features**
|
||||
```php
|
||||
// DON'T COPY: Leads, Deals, Pipelines, Campaigns
|
||||
// These are unused CRM bloat - implement only what's needed
|
||||
```
|
||||
|
||||
**3. Vendor Overrides & Hacks**
|
||||
```php
|
||||
// DON'T COPY: Direct vendor file modifications
|
||||
// Use proper Laravel extension patterns instead
|
||||
```
|
||||
|
||||
**4. Inefficient Queries**
|
||||
```php
|
||||
// DON'T COPY: N+1 queries or missing eager loading
|
||||
foreach ($orders as $order) {
|
||||
echo $order->company->name; // N+1 problem
|
||||
}
|
||||
|
||||
// Instead: Optimize with eager loading
|
||||
Order::with('company')->get();
|
||||
```
|
||||
|
||||
**5. Inline Business Logic in Controllers**
|
||||
```php
|
||||
// DON'T COPY: Fat controllers
|
||||
public function store(Request $request) {
|
||||
// 200 lines of business logic...
|
||||
}
|
||||
|
||||
// Instead: Use Services, Actions, or Model methods
|
||||
public function store(Request $request, OrderService $service) {
|
||||
$service->createOrder($request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Development Workflow
|
||||
|
||||
### Git Strategy
|
||||
|
||||
**Branch Naming:**
|
||||
```bash
|
||||
feature/[feature-name] # New features (e.g., feature/shopping-cart)
|
||||
fix/[bug-description] # Bug fixes (e.g., fix/order-total-calculation)
|
||||
migrate/[data-type] # Data migrations (e.g., migrate/products)
|
||||
```
|
||||
|
||||
**Commit Message Pattern:**
|
||||
```
|
||||
[type]: [concise description]
|
||||
|
||||
[Optional detailed explanation]
|
||||
[Reference to old system if applicable]
|
||||
|
||||
Examples:
|
||||
✅ feat: implement buyer application approval workflow
|
||||
- Add CompanyResource approval/rejection actions
|
||||
- Preserve license validation from old CompaniesController.php:520
|
||||
- Add email notifications on status change
|
||||
|
||||
✅ fix: correct order total calculation to match legacy system
|
||||
- Include tax and shipping in total
|
||||
- Reference: app/Models/Invoice.php:calculateTotal()
|
||||
|
||||
✅ migrate: import 883 products from old database
|
||||
- Assign 552 unbranded products to default Cannabrands brand
|
||||
- Preserve parent_product_id relationships
|
||||
- Verified: All products migrated successfully
|
||||
```
|
||||
|
||||
**Daily Workflow:**
|
||||
```bash
|
||||
# Morning: Start day's feature
|
||||
git checkout -b feature/[todays-feature]
|
||||
|
||||
# During: Commit frequently
|
||||
git add .
|
||||
git commit -m "feat: [incremental progress]"
|
||||
|
||||
# End of Day: Merge if complete and tested
|
||||
git checkout feature/migration-implementation
|
||||
git merge feature/[todays-feature] --no-ff
|
||||
git branch -d feature/[todays-feature]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Testing After Each Feature
|
||||
|
||||
**1. Filament Resource Testing**
|
||||
```
|
||||
After implementing [Resource]Resource:
|
||||
|
||||
1. Visual Test:
|
||||
- Visit /admin/[resources] in browser
|
||||
- Create new record
|
||||
- Edit existing record
|
||||
- Test filters and search
|
||||
- Test custom actions
|
||||
|
||||
2. Validation Test:
|
||||
- Try submitting empty form (should fail)
|
||||
- Try invalid data (should show errors)
|
||||
- Try valid data (should succeed)
|
||||
|
||||
3. Relationship Test:
|
||||
- Test relation managers (if any)
|
||||
- Verify related records display
|
||||
- Test creating related records
|
||||
|
||||
4. Permission Test (if roles implemented):
|
||||
- Test as admin (should have full access)
|
||||
- Test as regular user (should have limited access)
|
||||
```
|
||||
|
||||
**2. Public Page Testing**
|
||||
```
|
||||
After implementing public-facing page:
|
||||
|
||||
1. Guest Test:
|
||||
- Access page without login
|
||||
- Submit form with valid data
|
||||
- Submit form with invalid data
|
||||
|
||||
2. Email Test:
|
||||
- Check Mailpit (localhost:8025)
|
||||
- Verify email content and styling
|
||||
- Test email links
|
||||
|
||||
3. Flow Test:
|
||||
- Complete full user journey (register → verify → login)
|
||||
- Check database records created
|
||||
- Verify relationships created
|
||||
```
|
||||
|
||||
**3. Migration Script Testing**
|
||||
```
|
||||
After creating migration script:
|
||||
|
||||
1. Test Run (Small Subset):
|
||||
php artisan migrate:fresh --seed
|
||||
php artisan migrate:legacy:products --limit=10
|
||||
|
||||
2. Verification Queries:
|
||||
SELECT COUNT(*) FROM products;
|
||||
SELECT * FROM products WHERE brand_id IS NULL; -- Should be 0
|
||||
SELECT * FROM products WHERE parent_product_id IS NOT NULL; -- Varieties
|
||||
|
||||
3. Rollback Test:
|
||||
php artisan migrate:rollback
|
||||
-- Verify data removed cleanly
|
||||
|
||||
4. Full Migration (After test passes):
|
||||
php artisan migrate:legacy:products
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Debugging Patterns
|
||||
|
||||
### Pattern 1: Behavior Doesn't Match Old System
|
||||
|
||||
**Symptom:** "This works, but it's not doing what the old system did"
|
||||
|
||||
**Process:**
|
||||
```
|
||||
1. Find old implementation:
|
||||
Prompt: "Search old codebase for [feature] logic in controllers, models, and services"
|
||||
|
||||
2. Trace execution:
|
||||
Prompt: "Read [old file] and explain step-by-step what happens when [action occurs]"
|
||||
|
||||
3. Compare implementations:
|
||||
Prompt: "Here's my new implementation [paste code]. Compare to old system and identify missing business logic"
|
||||
|
||||
4. Fix discrepancies:
|
||||
Prompt: "Update new implementation to include [missing rule] from old system"
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
User: "Order totals in new system don't match old invoices"
|
||||
|
||||
Claude: Let me search for total calculation in old system
|
||||
[Reads app/Models/Invoice.php]
|
||||
|
||||
Claude: I found the issue. Old system includes:
|
||||
- Line items (quantity × price × discount)
|
||||
- Shipping cost
|
||||
- Tax amount
|
||||
|
||||
Your new Order model is missing shipping and tax. Here's the fix:
|
||||
[Provides corrected calculation method]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern 2: Data Migration Produces Incorrect Results
|
||||
|
||||
**Symptom:** "Migration ran, but data looks wrong"
|
||||
|
||||
**Process:**
|
||||
```
|
||||
1. Run diagnostic queries:
|
||||
SELECT COUNT(*), brand_id FROM products GROUP BY brand_id;
|
||||
SELECT * FROM products WHERE [expected_field] IS NULL LIMIT 10;
|
||||
|
||||
2. Compare to old system:
|
||||
-- Run same query on old database
|
||||
SELECT COUNT(*), brand_id FROM crm_products GROUP BY brand_id;
|
||||
|
||||
3. Identify transformation error:
|
||||
Prompt: "My migration produced [X results] but old system has [Y results].
|
||||
Here's my migration script [paste]. Find the bug."
|
||||
|
||||
4. Fix and re-run:
|
||||
php artisan migrate:rollback
|
||||
[Fix migration script]
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern 3: Filament Action Not Working
|
||||
|
||||
**Symptom:** "Button appears but clicking does nothing / shows error"
|
||||
|
||||
**Debugging Steps:**
|
||||
```
|
||||
1. Check browser console for JS errors
|
||||
2. Check Laravel logs: tail -f storage/logs/laravel.log
|
||||
3. Verify action requirements:
|
||||
- Is user authorized?
|
||||
- Are required fields present?
|
||||
- Is model state correct?
|
||||
|
||||
Prompt: "My Filament action [action name] in [Resource] isn't working.
|
||||
Here's the code [paste]. Here's the error [paste].
|
||||
Reference old system at [file path] to see how it should work."
|
||||
```
|
||||
|
||||
**Common Fixes:**
|
||||
```php
|
||||
// Missing authorization
|
||||
Tables\Actions\Action::make('approve')
|
||||
->visible(fn ($record) => $record->status === 'pending') // Add this
|
||||
|
||||
// Missing requiresConfirmation
|
||||
Tables\Actions\Action::make('delete')
|
||||
->requiresConfirmation() // Add this for destructive actions
|
||||
|
||||
// Missing success notification
|
||||
->action(function ($record) {
|
||||
$record->approve();
|
||||
Notification::make()->success()->title('Approved')->send(); // Add this
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Example Interactions
|
||||
|
||||
### Example 1: Day 1 - Company Model Implementation
|
||||
|
||||
**User:**
|
||||
```
|
||||
Starting Day 1 tasks. I need to:
|
||||
1. Rename businesses table to companies
|
||||
2. Add buyer application fields
|
||||
3. Create CompanyResource
|
||||
|
||||
Reference:
|
||||
- FEATURE_IMPLEMENTATION_ROADMAP.md (Day 1)
|
||||
- FILAMENT_RESOURCES_SPEC.md (CompanyResource)
|
||||
- Old system: vendor/venturedrake/laravel-crm/src/Models/Organisation.php
|
||||
|
||||
Please implement step 1 (rename migration) first.
|
||||
```
|
||||
|
||||
**Claude:**
|
||||
```
|
||||
I'll create a migration to rename the businesses table to companies and add buyer application fields.
|
||||
|
||||
[Creates migration file]
|
||||
[Shows migration code]
|
||||
|
||||
This migration:
|
||||
1. Renames businesses → companies
|
||||
2. Adds license_number, license_document_path, w9_document_path
|
||||
3. Adds buyer application fields (status, approved_at, rejected_at, rejection_reason)
|
||||
4. Preserves all existing data
|
||||
|
||||
Run: php artisan migrate
|
||||
|
||||
After confirming this works, I'll proceed to step 2 (CompanyResource).
|
||||
```
|
||||
|
||||
**User:**
|
||||
```
|
||||
Migration ran successfully. Proceed with CompanyResource.
|
||||
```
|
||||
|
||||
**Claude:**
|
||||
```
|
||||
I'll create the CompanyResource with approval workflow.
|
||||
|
||||
First, let me read the old buyer application logic:
|
||||
[Reads vendor/venturedrake/.../CompaniesController.php lines 520-790]
|
||||
|
||||
I see the approval workflow requires:
|
||||
- License document validation
|
||||
- W9 document validation
|
||||
- Admin approval action
|
||||
- Email notification on approval/rejection
|
||||
|
||||
Creating CompanyResource with these features...
|
||||
[Creates resource file]
|
||||
[Shows code]
|
||||
|
||||
Test by:
|
||||
1. Visit /admin/companies
|
||||
2. Click on a pending company
|
||||
3. Click "Approve" action
|
||||
4. Check email in Mailpit (localhost:8025)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 2: Understanding Order Workflow
|
||||
|
||||
**User:**
|
||||
```
|
||||
I need to understand how orders convert to invoices in the old system.
|
||||
|
||||
**Process:** Order-to-Invoice conversion workflow
|
||||
**Why:** Need to implement this in new OrderResource and InvoiceResource
|
||||
|
||||
Please analyze and explain - don't implement yet.
|
||||
```
|
||||
|
||||
**Claude:**
|
||||
```
|
||||
Let me trace the order-to-invoice workflow in the old system.
|
||||
|
||||
[Searches for 'invoice' AND 'order' in old codebase]
|
||||
[Reads app/Models/Invoice.php]
|
||||
[Reads vendor/venturedrake/.../InvoiceController.php]
|
||||
|
||||
Here's how it works:
|
||||
|
||||
**Data Structure:**
|
||||
- Old system uses SINGLE table: invoices
|
||||
- Column: type (values: 'order', 'invoice', 'quote', 'draft')
|
||||
- Column: status (values: 'new', 'accepted', 'create_manifest', 'invoiced', 'delivered')
|
||||
|
||||
**Workflow:**
|
||||
1. Order Created:
|
||||
- type = 'order'
|
||||
- status = 'new'
|
||||
- created_by = 'seller' OR 'buyer'
|
||||
|
||||
2. Acceptance:
|
||||
- If created_by = 'seller' → buyer must accept
|
||||
- If created_by = 'buyer' → seller must accept
|
||||
- Status changes: 'new' → 'accepted'
|
||||
|
||||
3. Fulfillment Prep:
|
||||
- Admin marks order for fulfillment
|
||||
- Status changes: 'accepted' → 'create_manifest'
|
||||
|
||||
4. **CONVERSION TRIGGER** (InvoiceController.php:434):
|
||||
- When status = 'create_manifest'
|
||||
- Type changes: 'order' → 'invoice'
|
||||
- Status changes: 'create_manifest' → 'invoiced'
|
||||
|
||||
5. Delivery:
|
||||
- Status changes: 'invoiced' → 'delivered'
|
||||
- Payment tracking begins
|
||||
|
||||
**Recommendation for New System:**
|
||||
- Use separate Order and Invoice models/tables
|
||||
- When Order.status = 'fulfilled', create Invoice record
|
||||
- Copy order data to invoice (preserve order_id foreign key)
|
||||
- Keep Order record for history (don't delete)
|
||||
|
||||
This gives you:
|
||||
- Cleaner separation of concerns
|
||||
- Better audit trail
|
||||
- Easier reporting
|
||||
|
||||
Would you like me to implement this workflow now?
|
||||
```
|
||||
|
||||
**User:**
|
||||
```
|
||||
Yes, implement this in OrderResource and InvoiceResource.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 3: Debugging Migration Discrepancy
|
||||
|
||||
**User:**
|
||||
```
|
||||
I ran the products migration but the counts are wrong.
|
||||
|
||||
Expected (from old system): 883 products
|
||||
Actual (new system): 1435 products
|
||||
|
||||
Here's my migration script: [pastes script]
|
||||
```
|
||||
|
||||
**Claude:**
|
||||
```
|
||||
Let me investigate. First, let me check if the old system has soft deletes:
|
||||
|
||||
[Reads old products table schema]
|
||||
|
||||
Found the issue! The old crm_products table has a deleted_at column.
|
||||
Your migration script is copying ALL records, including soft-deleted ones.
|
||||
|
||||
Old system:
|
||||
- Total records: 1435
|
||||
- Active (deleted_at IS NULL): 883
|
||||
- Soft-deleted: 552
|
||||
|
||||
Fix:
|
||||
[Shows updated migration script with WHERE deleted_at IS NULL]
|
||||
|
||||
Run this to fix:
|
||||
php artisan migrate:rollback
|
||||
php artisan migrate
|
||||
php artisan migrate:legacy:products
|
||||
|
||||
You should now see exactly 883 products.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Daily Checklist
|
||||
|
||||
**Every Morning:**
|
||||
```
|
||||
[ ] Review FEATURE_IMPLEMENTATION_ROADMAP.md for today's tasks
|
||||
[ ] Create feature branch: git checkout -b feature/[todays-feature]
|
||||
[ ] Identify which old files contain business logic for today's feature
|
||||
```
|
||||
|
||||
**During Implementation:**
|
||||
```
|
||||
[ ] Read old implementation first (understand before coding)
|
||||
[ ] Extract business rules (validations, calculations, workflows)
|
||||
[ ] Implement in Filament/Laravel best practices
|
||||
[ ] Do NOT copy CRM-specific patterns
|
||||
[ ] Commit incrementally (every 1-2 hours)
|
||||
```
|
||||
|
||||
**Before Marking Task Complete:**
|
||||
```
|
||||
[ ] Visual test in browser (if applicable)
|
||||
[ ] Validation test (try to break it)
|
||||
[ ] Check Laravel logs for errors
|
||||
[ ] Run relevant test suite (if tests exist)
|
||||
[ ] Commit final changes with descriptive message
|
||||
```
|
||||
|
||||
**End of Day:**
|
||||
```
|
||||
[ ] Merge feature branch if complete and tested
|
||||
[ ] Update progress in FEATURE_IMPLEMENTATION_ROADMAP.md (add checkmarks)
|
||||
[ ] Note any blockers or questions for tomorrow
|
||||
[ ] Push to remote: git push origin feature/migration-implementation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 When to Ask for Clarification
|
||||
|
||||
**ASK when:**
|
||||
- Old system has conflicting business rules in different files
|
||||
- Data migration produces unexpected results
|
||||
- You're unsure which old implementation is the "source of truth"
|
||||
- Performance implications of copying old approach (e.g., N+1 queries)
|
||||
- Security concerns with old implementation
|
||||
|
||||
**DON'T ASK when:**
|
||||
- You can find answer in old codebase (read it first)
|
||||
- It's covered in migration documents (SCHEMA_TRANSFORMATION.md, etc.)
|
||||
- It's a standard Laravel/Filament pattern (use best practices)
|
||||
- Old implementation is clearly wrong (use correct approach in new system)
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Common Pitfalls & Solutions
|
||||
|
||||
### Pitfall 1: Copying CRM Model Structure
|
||||
```
|
||||
❌ WRONG:
|
||||
namespace App\Models;
|
||||
use VentureDrake\LaravelCrm\Traits\BelongsToTeams;
|
||||
class Company extends Model {
|
||||
use BelongsToTeams; // Don't copy CRM traits
|
||||
}
|
||||
|
||||
✅ RIGHT:
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class Company extends Model {
|
||||
// Clean Laravel model
|
||||
}
|
||||
```
|
||||
|
||||
### Pitfall 2: Assuming Table Names Match
|
||||
```
|
||||
❌ WRONG:
|
||||
Assuming old "organisations" = new "companies" directly
|
||||
|
||||
✅ RIGHT:
|
||||
Check SCHEMA_TRANSFORMATION.md for exact table mappings
|
||||
Old: companies (CRM Contact companies) + organisations (CRM Orgs)
|
||||
New: companies (unified, with type field)
|
||||
```
|
||||
|
||||
### Pitfall 3: Missing Business Rules
|
||||
```
|
||||
❌ WRONG:
|
||||
Creating generic CRUD without checking old validations
|
||||
|
||||
✅ RIGHT:
|
||||
Read old Request classes for validation rules:
|
||||
- StoreCompanyRequest.php
|
||||
- UpdateProductRequest.php
|
||||
Extract rules and apply to Filament resource
|
||||
```
|
||||
|
||||
### Pitfall 4: Over-Engineering Too Soon
|
||||
```
|
||||
❌ WRONG:
|
||||
Implementing features that don't exist in old system
|
||||
"Should I add multi-currency support?"
|
||||
|
||||
✅ RIGHT:
|
||||
Implement only what old system has (feature parity first)
|
||||
Note ideas for post-launch improvements
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Quick Reference
|
||||
|
||||
**Old Codebase Locations:**
|
||||
```
|
||||
Business Logic: app/Models/*.php
|
||||
Controllers: app/Http/Controllers/**/*.php
|
||||
Validations: app/Http/Requests/*.php
|
||||
CRM Controllers: vendor/venturedrake/.../Controllers/*.php
|
||||
CRM Models: vendor/venturedrake/.../Models/*.php
|
||||
Email Templates: resources/views/emails/**/*.blade.php
|
||||
Routes: routes/*.php
|
||||
```
|
||||
|
||||
**New Codebase Locations:**
|
||||
```
|
||||
Models: app/Models/*.php
|
||||
Filament Resources: app/Filament/Resources/*.php
|
||||
Services: app/Services/*.php
|
||||
Migrations: database/migrations/*.php
|
||||
Seeders: database/seeders/*.php
|
||||
Public Pages: app/Http/Controllers/**/*.php
|
||||
Views: resources/views/**/*.blade.php
|
||||
```
|
||||
|
||||
**Key Documents:**
|
||||
```
|
||||
Migration Strategy: MIGRATION_MASTER_PLAN.md
|
||||
Data Mappings: SCHEMA_TRANSFORMATION.md
|
||||
Day-by-Day Tasks: FEATURE_IMPLEMENTATION_ROADMAP.md
|
||||
Filament Code: FILAMENT_RESOURCES_SPEC.md
|
||||
This Guide: CLAUDE_COLLABORATION_WORKFLOW.md
|
||||
```
|
||||
|
||||
**Useful Commands:**
|
||||
```bash
|
||||
# Development
|
||||
php artisan serve
|
||||
php artisan migrate
|
||||
php artisan migrate:fresh --seed
|
||||
|
||||
# Testing
|
||||
php artisan tinker
|
||||
php artisan route:list
|
||||
tail -f storage/logs/laravel.log
|
||||
|
||||
# Email Testing
|
||||
docker-compose up mailpit -d
|
||||
# Visit: localhost:8025
|
||||
|
||||
# Database
|
||||
php artisan db:seed
|
||||
php artisan migrate:rollback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Indicators
|
||||
|
||||
**You're on the right track when:**
|
||||
- New feature behavior matches old system exactly
|
||||
- Migration scripts produce expected record counts
|
||||
- Filament resources are cleaner than old CRM code
|
||||
- Business rules are preserved, but implementation is modern
|
||||
- You can explain why new approach is better than old
|
||||
|
||||
**Red flags:**
|
||||
- Copying vendor file structures
|
||||
- Implementing unused CRM features
|
||||
- Can't explain business rule origin
|
||||
- Migration counts don't match old system
|
||||
- New feature does something old system didn't
|
||||
|
||||
---
|
||||
|
||||
**End of Guide**
|
||||
|
||||
*This document evolves as you progress through the migration. Update it with new patterns, pitfalls, and solutions as you discover them.*
|
||||
258
CONTRIBUTING.md
258
CONTRIBUTING.md
@@ -68,21 +68,59 @@ Our workflow provides audit trails regulators love:
|
||||
|
||||
---
|
||||
|
||||
## Branch Protection & Pull Request Workflow
|
||||
|
||||
**IMPORTANT:** The `develop` and `master` branches are **protected** - you cannot push directly to them.
|
||||
|
||||
### Standard Workflow:
|
||||
|
||||
```bash
|
||||
# 1. Create a feature branch
|
||||
git checkout -b feature/my-feature-name
|
||||
|
||||
# 2. Make changes and commit
|
||||
git add .
|
||||
git commit -m "feat: add new feature"
|
||||
|
||||
# 3. Push to your feature branch
|
||||
git push origin feature/my-feature-name
|
||||
|
||||
# 4. Create Pull Request on Gitea
|
||||
# - Navigate to https://code.cannabrands.app
|
||||
# - Create PR to merge your branch into develop
|
||||
# - CI will run automatically
|
||||
# - Request review from team
|
||||
|
||||
# 5. After approval and passing CI
|
||||
# - Merge PR via Gitea interface
|
||||
# - Delete feature branch
|
||||
```
|
||||
|
||||
### Branch Naming Conventions:
|
||||
|
||||
- `feature/` - New features (e.g., `feature/bulk-import`)
|
||||
- `fix/` - Bug fixes (e.g., `fix/tax-calculation`)
|
||||
- `chore/` - Maintenance tasks (e.g., `chore/upgrade-php`)
|
||||
- `docs/` - Documentation changes (e.g., `docs/update-readme`)
|
||||
|
||||
---
|
||||
|
||||
## Real-World Team Scenarios
|
||||
|
||||
### Scenario 1: Normal Feature Development
|
||||
**Developer Jon adds bulk import feature**
|
||||
|
||||
```bash
|
||||
$ git checkout -b feature/bulk-import # Create feature branch
|
||||
$ vim app/Orders.php # Make changes
|
||||
$ git add .
|
||||
$ git commit -m "feat(orders): add bulk import"
|
||||
🎨 Pre-commit: Pint formats code (1s) ✅
|
||||
|
||||
$ git push origin master
|
||||
$ git push origin feature/bulk-import
|
||||
🧪 Pre-push: Tests run (30s) ✅
|
||||
✅ All tests passed! Pushing...
|
||||
🚀 CI: Full verification (5min) ✅
|
||||
🚀 Create PR → merge to develop → CI verifies (5min) ✅
|
||||
```
|
||||
|
||||
**Time cost: 31 seconds** (vs 5+ minutes if tests failed in CI)
|
||||
@@ -122,14 +160,15 @@ $ git push --no-verify # Skip tests intentionally
|
||||
**Developer Emma fixes production bug**
|
||||
|
||||
```bash
|
||||
$ vim app/Invoice.php # Critical bug fix
|
||||
$ git checkout -b fix/tax-calculation # Create hotfix branch
|
||||
$ vim app/Invoice.php # Critical bug fix
|
||||
$ git commit -m "fix(invoices): correct tax calculation"
|
||||
🎨 Pre-commit: Formats ✅
|
||||
|
||||
$ git push origin master
|
||||
$ git push origin fix/tax-calculation
|
||||
🧪 Pre-push: Tests run (30s) ✅
|
||||
🚀 CI: Passes (5min) ✅
|
||||
📦 Deploy: Safe to release ✅
|
||||
🚀 Create PR → fast-track review → merge to develop ✅
|
||||
📦 CI: Passes (5min) → Safe to release ✅
|
||||
```
|
||||
|
||||
**Safety: Tests caught regression** before it reached production
|
||||
@@ -138,7 +177,8 @@ $ git push origin master
|
||||
**Developer Alex updates dependencies**
|
||||
|
||||
```bash
|
||||
$ vim Dockerfile # Update PHP version
|
||||
$ git checkout -b chore/php-8.3-upgrade # Create branch
|
||||
$ vim Dockerfile # Update PHP version
|
||||
|
||||
# Test locally FIRST (best practice)
|
||||
$ docker build -t cannabrands:test .
|
||||
@@ -146,8 +186,8 @@ $ docker build -t cannabrands:test .
|
||||
|
||||
# Then push
|
||||
$ git commit -m "chore: upgrade PHP to 8.3"
|
||||
$ git push origin master
|
||||
🚀 CI: Rebuilds (8min) ✅
|
||||
$ git push origin chore/php-8.3-upgrade
|
||||
🚀 Create PR → CI rebuilds (8min) ✅
|
||||
```
|
||||
|
||||
**Time saved: 5 minutes** by catching Docker issues locally
|
||||
@@ -170,20 +210,27 @@ Layer 3: CI (REQUIRED) → Final verification (~5 minutes)
|
||||
|
||||
**For most changes:**
|
||||
```bash
|
||||
# 1. Make your changes
|
||||
# 1. Create feature branch
|
||||
git checkout -b feature/my-feature
|
||||
|
||||
# 2. Make your changes
|
||||
vim app/SomeFile.php
|
||||
|
||||
# 2. Commit (formatting happens automatically)
|
||||
# 3. Commit (formatting happens automatically)
|
||||
git add .
|
||||
git commit -m "feat(scope): description"
|
||||
→ Pre-commit runs Laravel Pint ✅
|
||||
→ Code formatted automatically ✅
|
||||
|
||||
# 3. Push (tests run automatically)
|
||||
git push origin master
|
||||
# 4. Push (tests run automatically)
|
||||
git push origin feature/my-feature
|
||||
→ Pre-push runs tests (30 seconds) ✅
|
||||
→ If tests pass, push continues ✅
|
||||
|
||||
# 5. Create Pull Request
|
||||
→ Open PR on Gitea to merge into develop
|
||||
→ CI verifies everything (5 minutes) ✅
|
||||
→ After review, merge PR
|
||||
```
|
||||
|
||||
**For quick documentation changes:**
|
||||
@@ -192,6 +239,163 @@ git push origin master
|
||||
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
|
||||
|
||||
**Always run tests before pushing if you:**
|
||||
@@ -276,7 +480,7 @@ vim app/SomeFile.php
|
||||
# Push fix
|
||||
git add .
|
||||
git commit -m "fix: resolve test failure"
|
||||
git push origin master
|
||||
git push origin feature/my-feature # Push to your feature branch
|
||||
```
|
||||
|
||||
---
|
||||
@@ -304,9 +508,9 @@ git commit --no-verify # Skip formatting (fix in next commit)
|
||||
|
||||
**❌ Skipping because tests fail** → Fix the tests instead
|
||||
**❌ Skipping to avoid formatting** → Let Pint format it
|
||||
**❌ Skipping on master before deploy** → CI will block you anyway
|
||||
**❌ Skipping to merge PR to develop/master** → CI will block you anyway
|
||||
|
||||
**Remember:** CI can't be bypassed, so issues will be caught before production.
|
||||
**Remember:** CI can't be bypassed, and develop/master are protected branches requiring PRs and passing CI.
|
||||
|
||||
---
|
||||
|
||||
@@ -321,7 +525,7 @@ git config core.hooksPath .githooks
|
||||
|
||||
### How it works:
|
||||
```bash
|
||||
$ git push origin master
|
||||
$ git push origin feature/my-feature
|
||||
|
||||
🧪 Running tests before push...
|
||||
(Use 'git push --no-verify' to skip)
|
||||
@@ -347,8 +551,8 @@ docker build -t cannabrands:test .
|
||||
# If successful, test run it
|
||||
docker run --rm cannabrands:test php -v
|
||||
|
||||
# Then push
|
||||
git push origin master
|
||||
# Then push to feature branch
|
||||
git push origin feature/my-feature
|
||||
```
|
||||
|
||||
**Why?** Docker builds take 5-10 minutes in CI vs 2-3 minutes locally.
|
||||
@@ -383,17 +587,23 @@ If you're responsible for creating releases, see:
|
||||
# 1. Determine version (CalVer: YYYY.MM.MICRO)
|
||||
git tag -l "2025.11.*" | sort -V | tail -1 # Check latest
|
||||
|
||||
# 2. Create release tag
|
||||
# 2. Ensure you're on master and up-to-date
|
||||
git checkout master
|
||||
git pull origin master
|
||||
|
||||
# 3. Create release tag on master
|
||||
git tag -a 2025.11.1 -m "Release notes here"
|
||||
git push origin 2025.11.1
|
||||
|
||||
# 3. CI builds production image automatically
|
||||
# 4. CI builds production image automatically
|
||||
|
||||
# 4. Generate changelog
|
||||
# 5. Generate changelog (create PR for this)
|
||||
git checkout -b chore/changelog-2025.11.1
|
||||
npm run changelog
|
||||
git add CHANGELOG.md
|
||||
git commit -m "docs: update changelog for 2025.11.1"
|
||||
git push origin master
|
||||
git push origin chore/changelog-2025.11.1
|
||||
# Create PR to merge into master
|
||||
```
|
||||
|
||||
---
|
||||
@@ -455,7 +665,7 @@ Trust the process, and the process will catch your mistakes before they reach pr
|
||||
|
||||
Consider adding:
|
||||
- Code review requirement for certain files
|
||||
- Protected branches (master requires PR)
|
||||
- ✅ Protected branches already in place (develop/master require PRs)
|
||||
- Mandatory tests on pre-push (harder to skip)
|
||||
|
||||
### If You Reach 50+ developers
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,716 +0,0 @@
|
||||
# 🚀 Cannabrands CRM → New Platform Migration Master Plan
|
||||
|
||||
**Project**: Migration from Laravel 9 + VentureDrake CRM to Laravel 12 + Filament 4
|
||||
**Timeline**: 28 days (End of Month Launch)
|
||||
**Scope**: Full data migration with feature parity for core commerce features
|
||||
**Risk Level**: Medium (greenfield rebuild, but parallel operation possible)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Executive Summary
|
||||
|
||||
### Current State
|
||||
- **Old System**: Laravel 9, VentureDrake CRM, MySQL, ~1000 SKUs, 20 users, 5 brands
|
||||
- **Problem**: Vendor file modifications (100+ commits), blocked Laravel upgrades, maintenance nightmare
|
||||
- **Data**: 1+ year operational history, ~1000 products, 20 active users, ongoing orders
|
||||
|
||||
### Target State
|
||||
- **New System**: Laravel 12, Filament 4, PostgreSQL, clean architecture
|
||||
- **Architecture**: License → Company → Brands → Stores (LeafLink model)
|
||||
- **Launch Date**: End of month (~28 days)
|
||||
- **Cannabrands Brands**: Doobz, Thunderbud, High Expectations, Hash Factory, Twisties
|
||||
|
||||
### Migration Strategy
|
||||
**Two-track parallel development:**
|
||||
1. **New Platform Development**: Build features in new codebase (weeks 1-3)
|
||||
2. **Data Migration**: Import existing data from old system (week 4)
|
||||
3. **Parallel Operation**: Old system stays live until cutover
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Business Goals
|
||||
|
||||
### Primary Objectives (Launch Blockers)
|
||||
1. ✅ **Product Catalog**: 1000 SKUs with strains, lab results, varieties
|
||||
2. ✅ **Shopping Cart + Checkout**: Buyer portal commerce flow
|
||||
3. ✅ **Order Management**: Order lifecycle, statuses, fulfillment
|
||||
4. ✅ **Invoice Generation**: Invoice creation + payment tracking
|
||||
5. ✅ **Buyer Application**: Company signup with compliance approval
|
||||
6. ✅ **Company/Location Management**: Multi-location buyers
|
||||
7. ✅ **Component/BOM System**: Build SKUs from components
|
||||
|
||||
### Post-Launch Features (Deferred)
|
||||
- ⏸️ Raffles system
|
||||
- ⏸️ Sample requests
|
||||
- ⏸️ Feedback system
|
||||
|
||||
### Success Criteria
|
||||
- All 1000 SKUs migrated and searchable
|
||||
- All 20 users can log in with existing credentials
|
||||
- Historical orders visible and accessible
|
||||
- New orders can be placed and fulfilled
|
||||
- Invoices generate correctly
|
||||
- Zero data loss
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture Transformation
|
||||
|
||||
### Old System Architecture
|
||||
```
|
||||
User (Laravel Auth)
|
||||
└─> CrmContact (company_contacts)
|
||||
└─> CrmCompany (companies) - BUYER
|
||||
|
||||
Cannabrands (hardcoded seller)
|
||||
└─> CrmProduct (products)
|
||||
└─> CrmInvoice (type='order')
|
||||
```
|
||||
|
||||
### New System Architecture
|
||||
```
|
||||
Company (License Holder)
|
||||
├─> type: 'seller' (Cannabrands)
|
||||
│ └─> Brands (Stores)
|
||||
│ ├─> Doobz
|
||||
│ ├─> Thunderbud
|
||||
│ ├─> High Expectations
|
||||
│ ├─> Hash Factory
|
||||
│ └─> Twisties
|
||||
│ └─> Products (SKUs)
|
||||
│ └─> Components (BOM)
|
||||
│
|
||||
└─> type: 'buyer' (Dispensaries)
|
||||
├─> Locations (delivery addresses)
|
||||
└─> Contacts (users)
|
||||
└─> Orders → Invoices
|
||||
```
|
||||
|
||||
### Key Changes
|
||||
1. **Rename**: `businesses` table → `companies` table (better terminology)
|
||||
2. **Multi-brand**: Cannabrands operates 5 brands (stores) under one license
|
||||
3. **Separation**: Products belong to Brands, not Companies
|
||||
4. **Dual-purpose removed**: Split `crm_invoices` (type='order'|'invoice') into separate tables
|
||||
5. **Clean schema**: No VentureDrake CRM bloat (70% unused tables eliminated)
|
||||
|
||||
---
|
||||
|
||||
## 📅 4-Week Implementation Timeline
|
||||
|
||||
### Week 1: Foundation & Core Models (Days 1-7)
|
||||
**Goal**: Database schema + authentication ready
|
||||
|
||||
#### Days 1-2: Schema Refactoring
|
||||
- [ ] Rename `businesses` → `companies` (migration + models)
|
||||
- [ ] Create `brands` table and Brand model
|
||||
- [ ] Create `products` table (clean, no CRM dependency)
|
||||
- [ ] Create `components` table (BOM system)
|
||||
- [ ] Create `orders` table (separate from invoices)
|
||||
- [ ] Create `order_items` table (line items)
|
||||
- [ ] Update all relationships and foreign keys
|
||||
|
||||
#### Days 3-4: Authentication & Users
|
||||
- [ ] User migration strategy (preserve passwords)
|
||||
- [ ] Spatie Permission roles: admin, company-owner, company-user, buyer
|
||||
- [ ] Contact model (user relationships)
|
||||
- [ ] Location model (delivery addresses)
|
||||
- [ ] License model (compliance tracking)
|
||||
|
||||
#### Days 5-7: Filament Admin Panel Setup
|
||||
- [ ] CompanyResource (CRUD for license holders)
|
||||
- [ ] BrandResource (store management for sellers)
|
||||
- [ ] UserResource (user management)
|
||||
- [ ] Dashboard widgets (overview stats)
|
||||
- [ ] Navigation structure
|
||||
|
||||
**Milestone 1 Deliverable**: Can create companies, brands, users in Filament
|
||||
|
||||
---
|
||||
|
||||
### Week 2: Product Catalog & BOM (Days 8-14)
|
||||
**Goal**: 1000 SKUs ready to migrate
|
||||
|
||||
#### Days 8-10: Product System
|
||||
- [ ] ProductResource in Filament
|
||||
- Form: name, SKU, description, price, strain, lab
|
||||
- Table: searchable, filterable by brand
|
||||
- RelationManager: ProductImages, ProductPrices
|
||||
- [ ] StrainResource (cannabis strains)
|
||||
- [ ] LabResource (lab test results - THC/CBD)
|
||||
- [ ] ProductCategoryResource (taxonomy)
|
||||
- [ ] Product varieties system (parent-child products)
|
||||
|
||||
#### Days 11-12: Component/BOM System
|
||||
- [ ] ComponentResource (raw materials)
|
||||
- [ ] Product → Components relationship (junction table)
|
||||
- [ ] BOM calculator (cost calculation)
|
||||
- [ ] Inventory tracking (if needed)
|
||||
|
||||
#### Days 13-14: Product Import Preparation
|
||||
- [ ] Extract old product data structure
|
||||
- [ ] Create product mapping script (old SKU → new product)
|
||||
- [ ] Create component extraction script
|
||||
- [ ] Build product seeder from old database
|
||||
- [ ] Test import with 50 sample products
|
||||
|
||||
**Milestone 2 Deliverable**: Can create/manage products with components
|
||||
|
||||
---
|
||||
|
||||
### Week 3: Commerce & Orders (Days 15-21)
|
||||
**Goal**: Full buyer purchasing flow working
|
||||
|
||||
#### Days 15-16: Shopping Cart (Buyer Portal)
|
||||
- [ ] Cart model + session management
|
||||
- [ ] Add to cart functionality
|
||||
- [ ] Cart display page (`/b/cart`)
|
||||
- [ ] Update quantities, remove items
|
||||
- [ ] Cart persistence (logged-in users)
|
||||
|
||||
#### Days 17-18: Checkout Flow
|
||||
- [ ] Checkout page (`/b/checkout`)
|
||||
- [ ] Select delivery location
|
||||
- [ ] Payment terms selection (COD, Net 15/30/60/90)
|
||||
- [ ] Order preview and confirmation
|
||||
- [ ] Order creation from cart
|
||||
|
||||
#### Days 19-20: Order Management
|
||||
- [ ] OrderResource in Filament
|
||||
- Table: order number, buyer, total, status, date
|
||||
- Form: view order details, line items
|
||||
- Actions: Accept, Fulfill, Deliver, Cancel
|
||||
- [ ] Order status workflow (new → accepted → fulfilled → delivered)
|
||||
- [ ] Email notifications (order placed, status changes)
|
||||
- [ ] Buyer order history page (`/b/orders`)
|
||||
|
||||
#### Day 21: Invoice System
|
||||
- [ ] InvoiceResource in Filament
|
||||
- [ ] Generate invoice from order
|
||||
- [ ] Invoice PDF generation
|
||||
- [ ] Payment tracking (paid/unpaid status)
|
||||
- [ ] Invoice email delivery
|
||||
|
||||
**Milestone 3 Deliverable**: Can place order, fulfill, generate invoice
|
||||
|
||||
---
|
||||
|
||||
### Week 4: Data Migration & Launch (Days 22-28)
|
||||
**Goal**: Old data migrated, system live
|
||||
|
||||
#### Days 22-23: Data Migration - Phase 1 (Companies & Users)
|
||||
- [ ] Export old database schema
|
||||
- [ ] Create Cannabrands as Company #1 (type='seller')
|
||||
- [ ] Create 5 brands linked to Cannabrands
|
||||
- Brand 1: Doobz
|
||||
- Brand 2: Thunderbud
|
||||
- Brand 3: High Expectations
|
||||
- Brand 4: Hash Factory
|
||||
- Brand 5: Twisties
|
||||
- [ ] Migrate buyer companies (old `companies` → new `companies` type='buyer')
|
||||
- [ ] Migrate users (preserve password hashes)
|
||||
- [ ] Migrate contacts → users relationship
|
||||
- [ ] Migrate locations (delivery addresses)
|
||||
|
||||
#### Days 24-25: Data Migration - Phase 2 (Products & Catalog)
|
||||
- [ ] Migrate strains table (direct copy)
|
||||
- [ ] Migrate labs table (direct copy)
|
||||
- [ ] Migrate components (BOM data)
|
||||
- [ ] Migrate products (1000 SKUs)
|
||||
- Map old `crm_products` → new `products`
|
||||
- Assign products to appropriate brand (need brand mapping logic)
|
||||
- Migrate product images
|
||||
- Migrate product prices
|
||||
- Migrate product varieties
|
||||
- [ ] Verify product data integrity (spot checks)
|
||||
|
||||
#### Day 26: Data Migration - Phase 3 (Orders & Invoices)
|
||||
- [ ] Migrate historical orders (all time)
|
||||
- Old `crm_invoices` (type='order') → new `orders`
|
||||
- Old `crm_invoice_lines` → new `order_items`
|
||||
- Preserve order statuses and dates
|
||||
- [ ] Migrate invoices
|
||||
- Old `crm_invoices` (type='invoice') → new `invoices`
|
||||
- Link to corresponding orders
|
||||
- [ ] Migrate invoice payments
|
||||
- [ ] Verify order totals match
|
||||
|
||||
#### Day 27: Testing & Bug Fixes
|
||||
- [ ] End-to-end testing
|
||||
- Register new buyer account
|
||||
- Browse products by brand
|
||||
- Add to cart, checkout
|
||||
- Place order
|
||||
- Admin: accept, fulfill order
|
||||
- Generate invoice
|
||||
- Record payment
|
||||
- [ ] User acceptance testing (UAT) with Cannabrands team
|
||||
- [ ] Performance testing (1000 products, 20 concurrent users)
|
||||
- [ ] Fix critical bugs
|
||||
- [ ] Data integrity verification
|
||||
|
||||
#### Day 28: Launch & Cutover
|
||||
- [ ] Final data sync (if parallel operation)
|
||||
- [ ] DNS/domain cutover
|
||||
- [ ] SSL certificate setup
|
||||
- [ ] Monitor error logs
|
||||
- [ ] User training documentation
|
||||
- [ ] Announce launch to users
|
||||
- [ ] Post-launch support monitoring
|
||||
|
||||
**Milestone 4 Deliverable**: New system live, old system retired
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Data Migration Detailed Plan
|
||||
|
||||
### Migration Tools
|
||||
- **Laravel Commands**: Custom Artisan commands for each data type
|
||||
- **Direct SQL**: For bulk operations (faster)
|
||||
- **Seeders**: For reference data (strains, categories)
|
||||
- **Validation**: Hash comparisons to verify data integrity
|
||||
|
||||
### Migration Order (Critical Dependencies)
|
||||
```
|
||||
1. Companies (license holders) - no dependencies
|
||||
2. Brands (stores) - depends on companies
|
||||
3. Users - depends on companies
|
||||
4. Contacts - depends on users + companies
|
||||
5. Locations - depends on companies
|
||||
6. Strains - no dependencies
|
||||
7. Labs - no dependencies
|
||||
8. Components - no dependencies
|
||||
9. Products - depends on brands, strains, labs
|
||||
10. Product Components - depends on products, components
|
||||
11. Orders - depends on companies (buyer), users, products
|
||||
12. Order Items - depends on orders, products
|
||||
13. Invoices - depends on orders
|
||||
14. Invoice Payments - depends on invoices
|
||||
```
|
||||
|
||||
### Data Transformation Scripts
|
||||
|
||||
**Location**: `/Users/jon/projects/cannabrands/cannabrands_new/database/migrations/data/`
|
||||
|
||||
**Scripts to Create**:
|
||||
1. `migrate_companies.php` - Companies + Cannabrands setup
|
||||
2. `migrate_brands.php` - 5 Cannabrands brands
|
||||
3. `migrate_users.php` - User accounts (preserve passwords)
|
||||
4. `migrate_products.php` - 1000 SKUs with relationships
|
||||
5. `migrate_orders.php` - Historical orders + invoices
|
||||
6. `verify_migration.php` - Data integrity checks
|
||||
|
||||
### Password Preservation
|
||||
```php
|
||||
// Old system uses Laravel Hash (bcrypt)
|
||||
// New system uses Laravel Hash (bcrypt)
|
||||
// Direct copy of password hashes works
|
||||
|
||||
User::create([
|
||||
'email' => $oldUser->email,
|
||||
'password' => $oldUser->password, // Direct copy - NO rehashing
|
||||
'email_verified_at' => $oldUser->email_verified_at,
|
||||
]);
|
||||
```
|
||||
|
||||
### Rollback Strategy
|
||||
- Keep old system running in parallel for 2 weeks post-launch
|
||||
- Database snapshots before each migration phase
|
||||
- Export old database to SQL dump (backup)
|
||||
- Document rollback commands for each migration script
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Filament Resources Specification
|
||||
|
||||
### 1. CompanyResource
|
||||
**Purpose**: Manage license holders (buyers + sellers)
|
||||
|
||||
**Table Columns**:
|
||||
- Name (searchable)
|
||||
- Type (badge: buyer/seller/both)
|
||||
- License # (searchable)
|
||||
- Status (badge: active/pending/suspended)
|
||||
- Created date
|
||||
|
||||
**Form Fields**:
|
||||
- Business Information: name, DBA, legal name, type
|
||||
- License: number, expiry, document upload
|
||||
- Contact: email, phone, address
|
||||
- Compliance: W9, insurance, cannabis license
|
||||
|
||||
**Relations**:
|
||||
- Brands (HasMany) - for sellers
|
||||
- Locations (HasMany)
|
||||
- Contacts (HasMany)
|
||||
- Users (BelongsToMany via pivot)
|
||||
|
||||
**Actions**:
|
||||
- Approve Company
|
||||
- Suspend Company
|
||||
- Download Compliance Docs
|
||||
|
||||
---
|
||||
|
||||
### 2. BrandResource
|
||||
**Purpose**: Manage stores (product catalogs under a company)
|
||||
|
||||
**Table Columns**:
|
||||
- Logo (image)
|
||||
- Name (searchable)
|
||||
- Company (relationship)
|
||||
- Product count
|
||||
- Status (active/inactive)
|
||||
|
||||
**Form Fields**:
|
||||
- Brand Identity: name, slug, logo, description
|
||||
- Company (BelongsTo selector)
|
||||
- Social Media: Instagram, website
|
||||
- Settings: active status
|
||||
|
||||
**Relations**:
|
||||
- Products (HasMany)
|
||||
- Company (BelongsTo)
|
||||
|
||||
**Actions**:
|
||||
- View Storefront
|
||||
- Clone Brand
|
||||
- Activate/Deactivate
|
||||
|
||||
---
|
||||
|
||||
### 3. ProductResource
|
||||
**Purpose**: Manage SKUs (products sold on platform)
|
||||
|
||||
**Table Columns**:
|
||||
- Image (thumbnail)
|
||||
- SKU (searchable)
|
||||
- Name (searchable)
|
||||
- Brand (relationship)
|
||||
- Strain (relationship)
|
||||
- Price (money format)
|
||||
- Stock status
|
||||
|
||||
**Form Fields**:
|
||||
- Product Info: name, SKU, description
|
||||
- Brand (BelongsTo selector)
|
||||
- Pricing: base price, quantity breaks
|
||||
- Cannabis: Strain (BelongsTo), Lab results (BelongsTo)
|
||||
- Media: Images (multiple upload)
|
||||
- Inventory: track stock, quantity
|
||||
|
||||
**Relations**:
|
||||
- Brand (BelongsTo)
|
||||
- Strain (BelongsTo)
|
||||
- Lab (BelongsTo)
|
||||
- Components (BelongsToMany) - BOM
|
||||
- ProductImages (HasMany)
|
||||
- ProductPrices (HasMany)
|
||||
- Varieties (HasMany) - parent/child products
|
||||
|
||||
**Actions**:
|
||||
- Clone Product
|
||||
- Generate Barcode
|
||||
- Export to CSV
|
||||
- Activate/Deactivate
|
||||
|
||||
---
|
||||
|
||||
### 4. OrderResource
|
||||
**Purpose**: Manage buyer purchase orders
|
||||
|
||||
**Table Columns**:
|
||||
- Order # (searchable)
|
||||
- Buyer Company (relationship)
|
||||
- Brand (relationship)
|
||||
- Total (money format)
|
||||
- Status (badge with colors)
|
||||
- Order date (sortable)
|
||||
|
||||
**Form Fields**:
|
||||
- Order Info: number, date, buyer, location
|
||||
- Line Items: Repeater (product, quantity, price)
|
||||
- Totals: subtotal, tax, total
|
||||
- Status: workflow selector
|
||||
- Notes: internal notes
|
||||
|
||||
**Relations**:
|
||||
- Company (BelongsTo) - buyer
|
||||
- User (BelongsTo) - who placed it
|
||||
- Location (BelongsTo) - delivery address
|
||||
- OrderItems (HasMany)
|
||||
- Invoice (HasOne)
|
||||
|
||||
**Actions**:
|
||||
- Accept Order
|
||||
- Mark as Fulfilled
|
||||
- Mark as Delivered
|
||||
- Cancel Order
|
||||
- Generate Invoice
|
||||
- Email Customer
|
||||
|
||||
**Status Workflow**:
|
||||
```
|
||||
new → accepted → fulfilled → delivered
|
||||
↓ ↓ ↓
|
||||
cancelled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. ComponentResource (BOM System)
|
||||
**Purpose**: Manage raw materials used to build products
|
||||
|
||||
**Table Columns**:
|
||||
- Name (searchable)
|
||||
- Type (badge: flower/extract/packaging)
|
||||
- Unit cost (money)
|
||||
- Unit (oz/g/each)
|
||||
- Stock
|
||||
|
||||
**Form Fields**:
|
||||
- Component Info: name, description, type
|
||||
- Pricing: cost per unit, unit of measure
|
||||
- Inventory: current stock, reorder point
|
||||
- Supplier: supplier info (optional)
|
||||
|
||||
**Relations**:
|
||||
- Products (BelongsToMany via product_components)
|
||||
|
||||
**Actions**:
|
||||
- View Products Using This Component
|
||||
- Update Cost
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Compliance
|
||||
|
||||
### Data Protection
|
||||
- **Password hashes**: Direct copy (bcrypt compatible)
|
||||
- **Sensitive documents**: Migrate file paths, verify file existence
|
||||
- **License data**: Encrypted at rest in new system
|
||||
- **Payment info**: PCI compliance (if storing cards)
|
||||
|
||||
### Access Control
|
||||
**Roles** (Spatie Permission):
|
||||
- `admin` - Platform administrators (you/your team)
|
||||
- `company-owner` - Company account owner (full company access)
|
||||
- `company-manager` - Can manage orders, products (limited)
|
||||
- `company-user` - Can place orders, view history (buyer role)
|
||||
|
||||
**Permissions**:
|
||||
- `companies.view`, `companies.create`, `companies.edit`, `companies.delete`
|
||||
- `brands.manage` - Create/edit brands (seller only)
|
||||
- `products.manage` - Manage product catalog (seller only)
|
||||
- `orders.place` - Place orders (buyer only)
|
||||
- `orders.manage` - Accept/fulfill orders (seller only)
|
||||
- `invoices.view`, `invoices.generate`
|
||||
|
||||
---
|
||||
|
||||
## 📈 Performance Optimization
|
||||
|
||||
### Database Indexes (Critical for 1000 SKUs)
|
||||
```sql
|
||||
-- Products table
|
||||
CREATE INDEX idx_products_brand_id ON products(brand_id);
|
||||
CREATE INDEX idx_products_sku ON products(sku);
|
||||
CREATE INDEX idx_products_strain_id ON products(strain_id);
|
||||
|
||||
-- Orders table
|
||||
CREATE INDEX idx_orders_company_id ON orders(company_id);
|
||||
CREATE INDEX idx_orders_status ON orders(status);
|
||||
CREATE INDEX idx_orders_created_at ON orders(created_at);
|
||||
|
||||
-- Companies table
|
||||
CREATE INDEX idx_companies_type ON companies(type);
|
||||
CREATE INDEX idx_companies_license ON companies(license_number);
|
||||
```
|
||||
|
||||
### Caching Strategy
|
||||
- **Product catalog**: Cache for 1 hour
|
||||
- **Brand data**: Cache for 24 hours
|
||||
- **Shopping cart**: Session-based (no DB queries)
|
||||
- **Order history**: Cache per user (invalidate on new order)
|
||||
|
||||
### Eager Loading (Prevent N+1 Queries)
|
||||
```php
|
||||
// Products with relationships
|
||||
Product::with(['brand', 'strain', 'lab', 'images', 'prices'])->get();
|
||||
|
||||
// Orders with line items
|
||||
Order::with(['items.product', 'company', 'location'])->get();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Strategy
|
||||
|
||||
### Unit Tests (Pest)
|
||||
- [ ] Product model relationships
|
||||
- [ ] Order total calculations
|
||||
- [ ] BOM cost calculations
|
||||
- [ ] User authentication
|
||||
- [ ] Permission checks
|
||||
|
||||
### Feature Tests
|
||||
- [ ] User registration flow
|
||||
- [ ] Company approval workflow
|
||||
- [ ] Product CRUD operations
|
||||
- [ ] Shopping cart functionality
|
||||
- [ ] Order placement end-to-end
|
||||
- [ ] Invoice generation
|
||||
|
||||
### Migration Verification Tests
|
||||
- [ ] User count matches (20 users)
|
||||
- [ ] Product count matches (~1000 SKUs)
|
||||
- [ ] Order totals match historical data
|
||||
- [ ] Company count matches
|
||||
- [ ] All file paths resolve (images, documents)
|
||||
|
||||
### Load Testing
|
||||
- [ ] 1000 products loaded in catalog page (<2s)
|
||||
- [ ] 20 concurrent users placing orders
|
||||
- [ ] Search performance with 1000 SKUs (<500ms)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Risk Mitigation
|
||||
|
||||
### High-Risk Items
|
||||
|
||||
**1. Password Migration**
|
||||
- **Risk**: Users can't log in if hashes incompatible
|
||||
- **Mitigation**: Test migration with 3 sample users first
|
||||
- **Rollback**: Keep old system for password resets
|
||||
|
||||
**2. Product-to-Brand Mapping**
|
||||
- **Risk**: Old system doesn't track which brand owns which product
|
||||
- **Mitigation**: Manual mapping CSV: product_id → brand_name
|
||||
- **Fallback**: Assign all to "Cannabrands General" brand initially
|
||||
|
||||
**3. Order Data Integrity**
|
||||
- **Risk**: Order totals don't match after migration
|
||||
- **Mitigation**: Checksum verification on order totals
|
||||
- **Testing**: Compare 10 random orders old vs new
|
||||
|
||||
**4. File Path Migration**
|
||||
- **Risk**: Product images, compliance docs not found
|
||||
- **Mitigation**: Copy entire storage directory
|
||||
- **Verification**: Script to check all file paths resolve
|
||||
|
||||
**5. Email Notifications**
|
||||
- **Risk**: Spamming users during migration testing
|
||||
- **Mitigation**: Use Mailpit, disable SMTP until launch
|
||||
- **Testing**: Check email queue, don't send during migration
|
||||
|
||||
### Medium-Risk Items
|
||||
- Component data incomplete (some products missing BOM)
|
||||
- Variety relationships complex (parent-child products)
|
||||
- Historical data too large (performance issues)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Launch Checklist
|
||||
|
||||
### Pre-Launch (Day 27)
|
||||
- [ ] All 1000 products migrated and visible
|
||||
- [ ] All 20 users can log in successfully
|
||||
- [ ] Test order placement end-to-end (3 different buyers)
|
||||
- [ ] Filament admin panel accessible
|
||||
- [ ] Email notifications working (test mode)
|
||||
- [ ] SSL certificate installed
|
||||
- [ ] Database backups automated
|
||||
- [ ] Error monitoring setup (Sentry/Bugsnag)
|
||||
|
||||
### Launch Day (Day 28)
|
||||
- [ ] Final data sync from old database
|
||||
- [ ] DNS cutover to new system
|
||||
- [ ] Announce to users via email
|
||||
- [ ] Monitor error logs (first 2 hours)
|
||||
- [ ] Support team on standby
|
||||
- [ ] Old system in read-only mode (fallback)
|
||||
|
||||
### Post-Launch (Week 5)
|
||||
- [ ] Daily monitoring for 1 week
|
||||
- [ ] User feedback collection
|
||||
- [ ] Bug fix hot patches
|
||||
- [ ] Performance tuning based on real usage
|
||||
- [ ] Retire old system after 2 weeks stable operation
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development Tools & Resources
|
||||
|
||||
### Required Packages (Already Installed)
|
||||
- ✅ Laravel 12
|
||||
- ✅ Filament 4
|
||||
- ✅ Spatie Permission v6
|
||||
- ✅ Laravel Breeze (auth scaffolding)
|
||||
- ✅ Pest (testing)
|
||||
|
||||
### Additional Packages Needed
|
||||
```bash
|
||||
composer require barryvdh/laravel-dompdf # Invoice PDFs
|
||||
composer require intervention/image # Image processing
|
||||
composer require maatwebsite/excel # CSV imports/exports
|
||||
```
|
||||
|
||||
### Development Commands
|
||||
```bash
|
||||
# New project
|
||||
cd /Users/jon/projects/cannabrands/cannabrands_new
|
||||
|
||||
# Old project (reference only)
|
||||
cd /Users/jon/projects/cannabrands/cannabrands_crm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support & Communication
|
||||
|
||||
### Daily Standup (Recommended)
|
||||
- What was completed yesterday
|
||||
- What's planned for today
|
||||
- Any blockers
|
||||
|
||||
### Claude Code Collaboration
|
||||
- Reference old codebase: `/Users/jon/projects/cannabrands/cannabrands_crm`
|
||||
- Active development: `/Users/jon/projects/cannabrands/cannabrands_new`
|
||||
- Use CLAUDE_COLLABORATION_WORKFLOW.md for prompt templates
|
||||
|
||||
### Decision Log
|
||||
| Date | Decision | Rationale |
|
||||
|------|----------|-----------|
|
||||
| Today | Use "companies" not "businesses" | Better industry terminology |
|
||||
| Today | 5 brands under Cannabrands | Matches business structure |
|
||||
| Today | Separate orders/invoices tables | Cleaner than dual-purpose |
|
||||
| Today | PostgreSQL for new system | Better JSON support, scalability |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Metrics
|
||||
|
||||
### Technical Metrics
|
||||
- Zero data loss (100% of records migrated)
|
||||
- <2s page load for product catalog
|
||||
- <500ms search response time
|
||||
- 99.9% uptime in first month
|
||||
|
||||
### Business Metrics
|
||||
- All 5 Cannabrands brands have active storefronts
|
||||
- 20/20 users successfully migrated and active
|
||||
- First order placed within 48 hours of launch
|
||||
- Invoice generation working for all brands
|
||||
|
||||
### User Satisfaction
|
||||
- User training completed (1 hour session)
|
||||
- <5 support tickets in first week
|
||||
- Positive feedback from Cannabrands team
|
||||
- Buyers can easily find products
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: December 2024
|
||||
**Owner**: Cannabrands Development Team
|
||||
**Status**: Ready for Execution
|
||||
**Next Step**: Begin Week 1 - Schema Refactoring
|
||||
144
Makefile
144
Makefile
@@ -1,8 +1,45 @@
|
||||
.PHONY: help dev dev-down dev-build dev-shell dev-logs dev-vite 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-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_GOAL := help
|
||||
|
||||
# ==================== 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
|
||||
GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null)
|
||||
IS_WORKTREE := $(shell echo "$(GIT_DIR)" | grep -q ".worktrees" && echo "true" || echo "false")
|
||||
|
||||
# Set paths based on location
|
||||
ifeq ($(IS_WORKTREE),true)
|
||||
# In a worktree - use worktree-specific path
|
||||
WORKTREE_NAME := $(shell basename $(CURDIR))
|
||||
K8S_VOLUME_PATH := /worktrees/$(WORKTREE_NAME)
|
||||
else
|
||||
# In project root - use root path
|
||||
WORKTREE_NAME := root
|
||||
K8S_VOLUME_PATH := /project-root
|
||||
endif
|
||||
|
||||
# Generate namespace from branch name (feat-branch-name)
|
||||
CURRENT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
|
||||
K8S_NS := $(shell echo "$(CURRENT_BRANCH)" | sed 's/feature\//feat-/' | sed 's/bugfix\//fix-/' | sed 's/\//-/g')
|
||||
# Generate sanitized branch name for database
|
||||
SANITIZED_BRANCH := $(shell echo "$(CURRENT_BRANCH)" | sed 's/[^a-zA-Z0-9]/_/g')
|
||||
# Generate host from branch
|
||||
K8S_HOST := $(shell echo "$(CURRENT_BRANCH)" | sed 's/feature\///' | sed 's/bugfix\///' | sed 's/\//-/g').cannabrands.test
|
||||
# Read database credentials from .env
|
||||
DB_USERNAME := $(shell grep '^DB_USERNAME=' .env 2>/dev/null | cut -d '=' -f2)
|
||||
DB_PASSWORD := $(shell grep '^DB_PASSWORD=' .env 2>/dev/null | cut -d '=' -f2)
|
||||
DB_DATABASE := $(shell grep '^DB_DATABASE=' .env 2>/dev/null | cut -d '=' -f2)
|
||||
|
||||
# ==================== Local Development (Sail) ====================
|
||||
dev: ## Start local development environment with Sail
|
||||
./vendor/bin/sail up -d
|
||||
@@ -31,6 +68,94 @@ dev-composer: ## Run composer command (usage: make dev-composer CMD="install")
|
||||
dev-vite: ## Start Vite dev server (run after 'make dev')
|
||||
./vendor/bin/sail npm run dev
|
||||
|
||||
# ==================== K8s Local Development ====================
|
||||
k-dev: ## Start k8s local environment (like Sail, but with namespace isolation)
|
||||
@echo "🚀 Starting k8s environment"
|
||||
@echo " Location: $(if $(filter true,$(IS_WORKTREE)),Worktree ($(WORKTREE_NAME)),Project Root)"
|
||||
@echo " Namespace: $(K8S_NS)"
|
||||
@echo " Branch: $(CURRENT_BRANCH)"
|
||||
@echo " URL: http://$(K8S_HOST)"
|
||||
@echo ""
|
||||
@# Create namespace
|
||||
@kubectl create ns $(K8S_NS) --dry-run=client -o yaml | kubectl apply -f -
|
||||
@# Create secrets from .env
|
||||
@kubectl -n $(K8S_NS) delete secret app-env --ignore-not-found
|
||||
@kubectl -n $(K8S_NS) create secret generic app-env --from-env-file=.env
|
||||
@# Create PostgreSQL auth secret (using credentials from .env)
|
||||
@kubectl -n $(K8S_NS) create secret generic pg-auth --dry-run=client -o yaml \
|
||||
--from-literal=POSTGRES_DB=$(DB_DATABASE) \
|
||||
--from-literal=POSTGRES_USER=$(DB_USERNAME) \
|
||||
--from-literal=POSTGRES_PASSWORD=$(DB_PASSWORD) | kubectl apply -f -
|
||||
@# Deploy PostgreSQL
|
||||
@export NS=$(K8S_NS) PG_DB=$(DB_DATABASE) PG_USER=$(DB_USERNAME) PG_PASS=$(DB_PASSWORD) && \
|
||||
envsubst < k8s/local/postgres.yaml | kubectl apply -f -
|
||||
@# Deploy Redis
|
||||
@export NS=$(K8S_NS) && \
|
||||
envsubst < k8s/local/redis.yaml | kubectl apply -f -
|
||||
@# Deploy Reverb (WebSocket server)
|
||||
@export NS=$(K8S_NS) K8S_VOLUME_PATH=$(K8S_VOLUME_PATH) K8S_HOST=$(K8S_HOST) && \
|
||||
envsubst < k8s/local/reverb.yaml | kubectl apply -f -
|
||||
@# Wait for DB
|
||||
@echo "⏳ Waiting for PostgreSQL..."
|
||||
@kubectl -n $(K8S_NS) wait --for=condition=ready pod -l app=postgres --timeout=60s
|
||||
@# Deploy app (with code volume mounted)
|
||||
@export NS=$(K8S_NS) K8S_VOLUME_PATH=$(K8S_VOLUME_PATH) K8S_HOST=$(K8S_HOST) && \
|
||||
envsubst < k8s/local/deployment.yaml | kubectl apply -f -
|
||||
@# Create service + ingress
|
||||
@export NS=$(K8S_NS) K8S_HOST=$(K8S_HOST) && \
|
||||
envsubst < k8s/local/service.yaml | kubectl apply -f - && \
|
||||
envsubst < k8s/local/ingress.yaml | kubectl apply -f -
|
||||
@echo ""
|
||||
@echo "✅ Ready! Visit: http://$(K8S_HOST)"
|
||||
@echo ""
|
||||
@echo "💡 Your code is volume-mounted - changes are instant!"
|
||||
@echo " Edit files → refresh browser → see changes"
|
||||
@echo ""
|
||||
@echo "📝 Useful commands:"
|
||||
@echo " make k-logs # View app logs"
|
||||
@echo " make k-shell # Open shell in pod"
|
||||
@echo " make k-vite # Start Vite dev server"
|
||||
@echo ""
|
||||
@echo "🔌 WebSocket (Reverb) available at: ws://reverb.$(K8S_HOST):8080"
|
||||
|
||||
k-down: ## Stop k8s environment
|
||||
@echo "🗑 Removing namespace: $(K8S_NS)"
|
||||
@kubectl delete ns $(K8S_NS) --ignore-not-found
|
||||
@echo "✅ Cleaned up"
|
||||
|
||||
k-logs: ## View app logs
|
||||
@kubectl -n $(K8S_NS) logs -f deploy/web --all-containers=true
|
||||
|
||||
k-shell: ## Shell into app container
|
||||
@kubectl -n $(K8S_NS) exec -it deploy/web -- /bin/bash
|
||||
|
||||
k-artisan: ## Run artisan command (usage: make k-artisan CMD="migrate")
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- php artisan $(CMD)
|
||||
|
||||
k-composer: ## Run composer (usage: make k-composer CMD="install")
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- composer $(CMD)
|
||||
|
||||
k-vite: ## Run Vite dev server in k8s pod
|
||||
@echo "🎨 Starting Vite dev server in pod..."
|
||||
@echo " Access at: http://vite.$(K8S_HOST)"
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- npm run dev
|
||||
|
||||
k-test: ## Run tests in k8s pod
|
||||
@echo "🧪 Running tests in k8s pod..."
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- php artisan test
|
||||
|
||||
k-seed: ## Run database seeders in k8s (usage: make k-seed SEEDER=DevSeeder)
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- php artisan db:seed --class=$(SEEDER)
|
||||
|
||||
k-migrate-fresh: ## Fresh database with seeding in k8s pod
|
||||
@echo "🔄 Running fresh migration with seeding..."
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- php artisan migrate:fresh --seed
|
||||
|
||||
k-status: ## Show k8s environment status
|
||||
@echo "📊 Status for namespace: $(K8S_NS)"
|
||||
@echo ""
|
||||
@kubectl -n $(K8S_NS) get pods,svc,ingress
|
||||
|
||||
# ==================== Production ====================
|
||||
prod-build: ## Build production Docker image
|
||||
docker build -t cannabrands/app:latest -f Dockerfile .
|
||||
@@ -132,10 +257,27 @@ install: ## Initial project setup
|
||||
mailpit: ## Open Mailpit web UI
|
||||
@open http://localhost:8025 || xdg-open http://localhost:8025 || echo "Open http://localhost:8025 in your browser"
|
||||
|
||||
new-worktree: ## Create new worktree (usage: make new-worktree BRANCH=feature/my-feature or make new-worktree BRANCH=feature/my-feature NEW=true)
|
||||
@if [ -z "$(BRANCH)" ]; then \
|
||||
echo "❌ Error: BRANCH parameter required"; \
|
||||
echo ""; \
|
||||
echo "Usage:"; \
|
||||
echo " make new-worktree BRANCH=feature/my-feature # Checkout existing branch"; \
|
||||
echo " make new-worktree BRANCH=feature/my-feature NEW=true # Create new branch"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ "$(NEW)" = "true" ]; then \
|
||||
./scripts/new-worktree.sh -b $(BRANCH); \
|
||||
else \
|
||||
./scripts/new-worktree.sh $(BRANCH); \
|
||||
fi
|
||||
|
||||
help: ## Show this help message
|
||||
@echo "\n📦 CannaBrands Docker Commands\n"
|
||||
@echo "Local Development (Sail):"
|
||||
@grep -E '^dev.*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-25s\033[0m %s\n", $$1, $$2}'
|
||||
@echo "\nK8s Local Development:"
|
||||
@grep -E '^k-.*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[35m%-25s\033[0m %s\n", $$1, $$2}'
|
||||
@echo "\nProduction Testing (Local):"
|
||||
@grep -E '^prod-test.*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[33m%-25s\033[0m %s\n", $$1, $$2}'
|
||||
@echo "\nProduction (K8s/Deployment):"
|
||||
|
||||
237
NOTIFICATIONS.md
237
NOTIFICATIONS.md
@@ -1,237 +0,0 @@
|
||||
# Notification & Email Policy
|
||||
|
||||
**Last Updated**: January 2025
|
||||
**Status**: Initial Implementation (Day 15)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the notification and email strategy for the Cannabrands B2B platform. The system uses both email notifications (via Laravel Mail) and in-app notifications for real-time updates.
|
||||
|
||||
---
|
||||
|
||||
## Email Notifications
|
||||
|
||||
### Order Workflow Emails
|
||||
|
||||
| Event | Recipient | Template | Priority | Notes |
|
||||
|-------|-----------|----------|----------|-------|
|
||||
| **New Order Placed** | Seller (Admin) | `emails.orders.new-order` | High | Includes order summary, buyer info, items list |
|
||||
| **Order Accepted** | Buyer | `emails.orders.order-accepted` | Medium | Confirms acceptance, shows estimated timeline |
|
||||
| **Order Ready for Delivery** | Buyer | `emails.orders.ready-for-delivery` | High | Notifies order is packed and ready to ship |
|
||||
| **Order Delivered** | Buyer | `emails.orders.order-delivered` | Medium | Delivery confirmation with thank you message |
|
||||
| **Order Cancelled** | Buyer | `emails.orders.order-cancelled` | High | Cancellation notice with reason if provided |
|
||||
|
||||
### Invoice Workflow Emails
|
||||
|
||||
| Event | Recipient | Template | Priority | Notes |
|
||||
|-------|-----------|----------|----------|-------|
|
||||
| **Invoice Generated** | Buyer | `emails.invoices.invoice-ready` | High | Invoice ready for buyer approval after seller review |
|
||||
| **Invoice Overdue (3 days)** | Buyer | `emails.invoices.payment-reminder-3day` | High | First reminder |
|
||||
| **Invoice Overdue (7 days)** | Buyer | `emails.invoices.payment-reminder-7day` | High | Second reminder |
|
||||
| **Invoice Overdue (14 days)** | Buyer + Seller | `emails.invoices.payment-reminder-14day` | Critical | Final reminder, copied to seller |
|
||||
| **Payment Received** | Buyer | `emails.invoices.payment-received` | Low | Payment confirmation (future feature) |
|
||||
|
||||
### Picking Workflow Emails
|
||||
|
||||
| Event | Recipient | Template | Priority | Notes |
|
||||
|-------|-----------|----------|----------|-------|
|
||||
| **Picking Complete (100%)** | Seller | `emails.orders.picking-complete` | High | Alerts seller to review and generate invoice |
|
||||
|
||||
---
|
||||
|
||||
## In-App Notifications
|
||||
|
||||
### Notification Types
|
||||
|
||||
**For Buyers:**
|
||||
- 🛒 New order confirmation
|
||||
- ✅ Order accepted by seller
|
||||
- 📦 Order ready for delivery
|
||||
- 🚚 Order delivered
|
||||
- 📄 Invoice ready for approval
|
||||
- ⚠️ Payment due soon (3 days before due date)
|
||||
- 🔴 Payment overdue
|
||||
|
||||
**For Sellers (Admin Panel):**
|
||||
- 🛍️ New order received
|
||||
- 📋 Picking complete - ready for review
|
||||
- 💳 Payment received (future)
|
||||
|
||||
### Notification Bell Behavior
|
||||
|
||||
- **Unread Count Badge**: Shows count of unread notifications
|
||||
- **Auto-refresh**: Checks for new notifications every 30 seconds
|
||||
- **Mark as Read**: Clicking notification marks it as read
|
||||
- **Persistence**: Notifications stored in database, not deleted after reading
|
||||
- **Archive**: Users can manually dismiss notifications (future feature)
|
||||
|
||||
---
|
||||
|
||||
## Email Template Design
|
||||
|
||||
All emails follow the branded template pattern established in `emails.registration.verification`:
|
||||
|
||||
**Brand Elements:**
|
||||
- Logo: `https://cannabrands.app/assets/images/canna_white.png`
|
||||
- Primary Color: `#014847` (teal gradient)
|
||||
- Font: DM Sans
|
||||
- Button Style: Teal gradient background, white text
|
||||
- Footer: Cannabrands © 2025, support contact
|
||||
|
||||
**Template Structure:**
|
||||
```blade
|
||||
@component('mail::message')
|
||||
# [Email Subject Line]
|
||||
|
||||
[Email body content - plain language, buyer/seller focused]
|
||||
|
||||
@component('mail::button', ['url' => $actionUrl, 'color' => 'primary'])
|
||||
[Call to Action Button]
|
||||
@endcomponent
|
||||
|
||||
[Additional information or next steps]
|
||||
|
||||
Thanks,<br>
|
||||
The Cannabrands Team
|
||||
@endcomponent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notification Preferences (Future)
|
||||
|
||||
Allow users to control notification settings:
|
||||
|
||||
### Email Preferences
|
||||
- [ ] Order updates (placed, accepted, delivered)
|
||||
- [ ] Invoice notifications (generated, due soon, overdue)
|
||||
- [ ] Marketing emails (new products, promotions)
|
||||
- [ ] System announcements
|
||||
|
||||
### In-App Preferences
|
||||
- [ ] Real-time notifications
|
||||
- [ ] Desktop push notifications (future)
|
||||
- [ ] Sound alerts
|
||||
|
||||
### Frequency Settings
|
||||
- [ ] Immediate (default)
|
||||
- [ ] Daily digest
|
||||
- [ ] Weekly summary
|
||||
- [ ] Disabled (except critical)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Current Status (Day 15)
|
||||
|
||||
**Implemented:**
|
||||
- Email infrastructure using Laravel Mail
|
||||
- Notification database table and model
|
||||
- In-app notification dropdown (frontend exists, needs backend)
|
||||
- Brand-consistent email templates
|
||||
|
||||
**Not Yet Implemented:**
|
||||
- Automated overdue payment reminders (requires scheduled task)
|
||||
- Email preference management
|
||||
- Notification archiving
|
||||
- Push notifications
|
||||
- SMS notifications (future consideration)
|
||||
|
||||
### Technical Details
|
||||
|
||||
**Email Service**: Mailpit (local), SMTP (production)
|
||||
|
||||
**Notification Storage**:
|
||||
- Table: `notifications`
|
||||
- Model: `App\Models\Notification`
|
||||
- Polymorphic relationship to User
|
||||
|
||||
**Queue System**:
|
||||
- Development: sync driver
|
||||
- Production: database/redis queue recommended for email sending
|
||||
|
||||
**Rate Limiting**:
|
||||
- Payment reminders: Max 1 per invoice per day
|
||||
- Marketing emails: Opt-in only, max 2 per week
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Email Testing
|
||||
- [ ] New order email arrives at seller email
|
||||
- [ ] Order acceptance email arrives at buyer email
|
||||
- [ ] Invoice ready email includes correct invoice number and amount
|
||||
- [ ] Overdue reminders send at correct intervals
|
||||
- [ ] All emails render correctly in Gmail, Outlook, Apple Mail
|
||||
- [ ] All CTA buttons link to correct pages
|
||||
- [ ] Unsubscribe links work (future)
|
||||
|
||||
### In-App Notification Testing
|
||||
- [ ] Notification bell shows unread count
|
||||
- [ ] Clicking notification marks it as read
|
||||
- [ ] Notification links to correct resource (order/invoice)
|
||||
- [ ] Notifications auto-refresh without page reload
|
||||
- [ ] Mark all as read functionality works
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Phase 2 (Post-MVP)
|
||||
1. **Digest Emails**: Daily/weekly summary of activity
|
||||
2. **Smart Notifications**: AI-powered suggestions based on buying patterns
|
||||
3. **SMS Notifications**: Critical alerts via Twilio
|
||||
4. **Webhook Support**: Allow third-party integrations
|
||||
5. **Notification Templates**: Customizable by company
|
||||
|
||||
### Phase 3 (Advanced)
|
||||
1. **Multi-channel**: Email + SMS + Push + Slack
|
||||
2. **Notification Analytics**: Track open rates, click-through
|
||||
3. **A/B Testing**: Test email subject lines and content
|
||||
4. **Scheduled Sends**: Time-zone aware delivery
|
||||
5. **Rich Notifications**: Images, action buttons in notifications
|
||||
|
||||
---
|
||||
|
||||
## Refinement Areas
|
||||
|
||||
**To be reviewed and potentially changed:**
|
||||
|
||||
1. **Overdue Reminder Intervals**: Currently 3/7/14 days - should it be 1/3/7 or 5/10/15?
|
||||
2. **Seller Notifications**: Should sellers get daily digest of new orders or immediate alerts?
|
||||
3. **Picking Alerts**: Should lab crew get notifications when orders are accepted?
|
||||
4. **Invoice Approval**: Should seller be notified when buyer approves invoice?
|
||||
5. **Marketing vs Transactional**: Clear separation needed for CAN-SPAM compliance
|
||||
6. **Notification Retention**: How long to keep old notifications? Auto-archive after 30 days?
|
||||
7. **Critical vs Non-Critical**: Which emails should bypass "unsubscribe" (transactional only)
|
||||
8. **CC Recipients**: Should account managers be CC'd on order emails?
|
||||
|
||||
---
|
||||
|
||||
## Compliance Notes
|
||||
|
||||
**CAN-SPAM Act Requirements:**
|
||||
- ✅ Clear "From" name (Cannabrands)
|
||||
- ✅ Accurate subject lines
|
||||
- ✅ Physical address in footer
|
||||
- ✅ Unsubscribe mechanism (for marketing only)
|
||||
- ⚠️ Transactional emails exempt from unsubscribe requirement
|
||||
|
||||
**GDPR Considerations** (if applicable):
|
||||
- User consent for marketing emails
|
||||
- Right to export notification history
|
||||
- Right to delete notification data
|
||||
- Data retention policies
|
||||
|
||||
---
|
||||
|
||||
## Contact for Policy Changes
|
||||
|
||||
**Product Owner**: [To be filled]
|
||||
**Technical Lead**: [To be filled]
|
||||
**Last Review Date**: January 2025
|
||||
**Next Review Date**: March 2025
|
||||
258
PRODUCT2_INSTRUCTIONS.md
Normal file
258
PRODUCT2_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# 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?
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,368 +0,0 @@
|
||||
# Invoice Approval & Modification System - Testing Guide
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Generate Test Data
|
||||
|
||||
Run this command to create a complete test order with invoice:
|
||||
|
||||
```bash
|
||||
php artisan test:invoice-approval
|
||||
```
|
||||
|
||||
Or specify a specific buyer:
|
||||
```bash
|
||||
php artisan test:invoice-approval --buyer-email=your-buyer@example.com
|
||||
```
|
||||
|
||||
This will:
|
||||
- ✅ Create a test order with 5 random products
|
||||
- ✅ Progress it through the workflow (accepted → in_progress → ready_for_invoice → invoiced)
|
||||
- ✅ Generate an invoice with `approval_status = 'pending_buyer_approval'`
|
||||
- ✅ Display test URLs and credentials
|
||||
|
||||
---
|
||||
|
||||
## Testing Scenarios
|
||||
|
||||
### Scenario 1: Approve Invoice Without Changes
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Navigate to `/b/invoices/{invoice_id}`
|
||||
3. Click **"Approve Invoice"** button
|
||||
4. Confirm the action
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ Invoice `approval_status` → `'buyer_approved'`
|
||||
- ✅ Invoice `approved_at` timestamp set
|
||||
- ✅ Invoice `approved_by` = current user ID
|
||||
- ✅ Order `status` → `'manifest_created'`
|
||||
- ✅ Order `manifest_created_at` timestamp set
|
||||
- ✅ Success message displayed
|
||||
- ✅ Page reloads with success alert
|
||||
|
||||
**Database Verification:**
|
||||
```sql
|
||||
-- Check invoice approval
|
||||
SELECT id, invoice_number, approval_status, approved_at, approved_by
|
||||
FROM invoices
|
||||
WHERE id = {invoice_id};
|
||||
|
||||
-- Check order status progression
|
||||
SELECT id, order_number, status, manifest_created_at
|
||||
FROM orders
|
||||
WHERE id = {order_id};
|
||||
|
||||
-- Should be no changes recorded (direct approval)
|
||||
SELECT COUNT(*) FROM order_changes WHERE order_id = {order_id};
|
||||
-- Expected: 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 2: Reject Invoice
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Navigate to `/b/invoices/{invoice_id}`
|
||||
3. Click **"Reject Invoice"** button
|
||||
4. Modal opens
|
||||
5. Enter rejection reason: "Prices too high"
|
||||
6. Click **"Confirm Rejection"**
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ Invoice `approval_status` → `'rejected'`
|
||||
- ✅ Invoice `rejected_at` timestamp set
|
||||
- ✅ Invoice `rejection_reason` = "Prices too high"
|
||||
- ✅ Order `status` → `'rejected'`
|
||||
- ✅ Order `rejected_at` timestamp set
|
||||
- ✅ Order `rejected_reason` = "Prices too high"
|
||||
- ✅ Redirect to invoices index with success message
|
||||
|
||||
**Database Verification:**
|
||||
```sql
|
||||
SELECT id, invoice_number, approval_status, rejected_at, rejection_reason
|
||||
FROM invoices
|
||||
WHERE id = {invoice_id};
|
||||
|
||||
SELECT id, order_number, status, rejected_at, rejected_reason
|
||||
FROM orders
|
||||
WHERE id = {order_id};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 3: Modify Invoice - Reduce Quantity (Auto-Approved)
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Navigate to `/b/invoices/{invoice_id}`
|
||||
3. Click **"Modify Invoice"** button
|
||||
4. Edit mode activates
|
||||
5. Find a line item with quantity 10
|
||||
6. Change quantity to **9** (10% reduction - should auto-approve)
|
||||
7. Click **"Save Changes"**
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ Invoice `approval_status` → `'buyer_modified'`
|
||||
- ✅ `order_changes` record created:
|
||||
- `change_type` = 'quantity_edit'
|
||||
- `old_value` = 10
|
||||
- `new_value` = 9
|
||||
- `status` = **'auto_approved'** (because <10% reduction)
|
||||
- `negotiation_round` = 1
|
||||
- `user_type` = 'buyer'
|
||||
- ✅ Success message: "Changes saved successfully..."
|
||||
- ✅ Page reloads
|
||||
|
||||
**Database Verification:**
|
||||
```sql
|
||||
-- Check the change was recorded
|
||||
SELECT * FROM order_changes
|
||||
WHERE order_id = {order_id}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- Verify auto-approval
|
||||
SELECT change_type, old_value, new_value, status
|
||||
FROM order_changes
|
||||
WHERE order_id = {order_id} AND status = 'auto_approved';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 4: Modify Invoice - Reduce Quantity (>10%, Needs Review)
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Navigate to `/b/invoices/{invoice_id}`
|
||||
3. Click **"Modify Invoice"**
|
||||
4. Change a quantity from **10 to 5** (50% reduction)
|
||||
5. Click **"Save Changes"**
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ Invoice `approval_status` → `'buyer_modified'`
|
||||
- ✅ `order_changes` record created with `status` = **'pending'** (not auto-approved)
|
||||
- ✅ Seller will need to review this change
|
||||
|
||||
**Database Verification:**
|
||||
```sql
|
||||
SELECT change_type, old_value, new_value, status
|
||||
FROM order_changes
|
||||
WHERE order_id = {order_id} AND status = 'pending';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 5: Delete Line Item
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Navigate to `/b/invoices/{invoice_id}`
|
||||
3. Click **"Modify Invoice"**
|
||||
4. Click **"Remove"** button on a line item
|
||||
5. Item shows as deleted (opacity-50, crossed out)
|
||||
6. Click **"Save Changes"**
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ Invoice `approval_status` → `'buyer_modified'`
|
||||
- ✅ `order_changes` record created:
|
||||
- `change_type` = 'item_delete'
|
||||
- `old_value` = {original_quantity}
|
||||
- `new_value` = 0
|
||||
- `status` = 'pending'
|
||||
- `order_item_id` = {deleted_item_id}
|
||||
|
||||
**Database Verification:**
|
||||
```sql
|
||||
SELECT * FROM order_changes
|
||||
WHERE order_id = {order_id} AND change_type = 'item_delete';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 6: Try to Increase Quantity (Should Fail)
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Navigate to `/b/invoices/{invoice_id}`
|
||||
3. Click **"Modify Invoice"**
|
||||
4. Try to change quantity from **10 to 15**
|
||||
5. Input field should prevent this OR show validation error
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ Validation prevents increase
|
||||
- ✅ Alert: "You can only reduce quantities, not increase them."
|
||||
- ✅ Quantity resets to original value
|
||||
- ✅ No change saved
|
||||
|
||||
---
|
||||
|
||||
### Scenario 7: Multiple Changes in One Modification
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Click **"Modify Invoice"**
|
||||
3. Reduce item 1 quantity from 10 → 8
|
||||
4. Delete item 2 entirely
|
||||
5. Reduce item 3 quantity from 12 → 10
|
||||
6. Click **"Save Changes"**
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ 3 separate `order_changes` records created
|
||||
- ✅ Each tracked independently
|
||||
- ✅ All have same `negotiation_round` (1)
|
||||
- ✅ All have same `created_at` (batch)
|
||||
|
||||
**Database Verification:**
|
||||
```sql
|
||||
SELECT id, change_type, order_item_id, old_value, new_value, status, negotiation_round
|
||||
FROM order_changes
|
||||
WHERE order_id = {order_id}
|
||||
ORDER BY created_at DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 8: View Change History
|
||||
|
||||
**Steps:**
|
||||
1. After making changes (Scenario 7)
|
||||
2. Scroll down to **"Change History"** section
|
||||
3. Review the table
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ Shows recent changes (up to 5)
|
||||
- ✅ Displays: Round, Type, Product, Old Value, New Value, Changed By, Status
|
||||
- ✅ Status badges: success (approved/auto-approved), warning (pending), error (rejected)
|
||||
- ✅ "View All Changes" button visible
|
||||
|
||||
---
|
||||
|
||||
### Scenario 9: Cancel Edit Mode
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Click **"Modify Invoice"**
|
||||
3. Make some changes (reduce qty, delete item)
|
||||
4. Click **"Cancel"** button
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ All changes discarded
|
||||
- ✅ Quantities reset to original values
|
||||
- ✅ Deleted items restored
|
||||
- ✅ Edit mode exits
|
||||
- ✅ No changes saved to database
|
||||
|
||||
---
|
||||
|
||||
### Scenario 10: Negotiation Round Tracking
|
||||
|
||||
**This requires seller response - will test once seller UI is complete**
|
||||
|
||||
1. Buyer modifies invoice (Round 1)
|
||||
2. Seller counter-modifies (Round 2)
|
||||
3. Buyer modifies again (Round 3)
|
||||
4. Check `current_negotiation_round` field increments
|
||||
|
||||
**Expected:**
|
||||
- ✅ Max 3 rounds allowed
|
||||
- ✅ `invoice.current_negotiation_round` increments
|
||||
|
||||
---
|
||||
|
||||
## Browser Console Testing
|
||||
|
||||
Open browser console while testing to see:
|
||||
|
||||
```javascript
|
||||
// Alpine.js state inspection
|
||||
Alpine.store('invoice')
|
||||
|
||||
// Check reactive data
|
||||
$data.items
|
||||
$data.editMode
|
||||
$data.hasChanges
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Network Tab Verification
|
||||
|
||||
Monitor the following API calls:
|
||||
|
||||
### Approve Invoice
|
||||
```
|
||||
POST /b/invoices/{invoice}/approve
|
||||
Response: { success: true, message: "Invoice approved successfully." }
|
||||
```
|
||||
|
||||
### Reject Invoice
|
||||
```
|
||||
POST /b/invoices/{invoice}/reject
|
||||
Body: { reason: "..." }
|
||||
Redirect: /b/invoices
|
||||
```
|
||||
|
||||
### Modify Invoice
|
||||
```
|
||||
POST /b/invoices/{invoice}/modify
|
||||
Body: {
|
||||
items: [
|
||||
{ id: 1, quantity: 8, deleted: false },
|
||||
{ id: 2, quantity: 0, deleted: true }
|
||||
]
|
||||
}
|
||||
Response: { success: true, message: "Changes saved..." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases to Test
|
||||
|
||||
### ❌ Unauthorized Access
|
||||
- Try accessing another company's invoice
|
||||
- Expected: 403 Forbidden
|
||||
|
||||
### ❌ Already Approved Invoice
|
||||
- Try modifying an invoice with `approval_status = 'buyer_approved'`
|
||||
- Expected: Buttons hidden, "This invoice cannot be modified" message
|
||||
|
||||
### ❌ Already Rejected Invoice
|
||||
- Try approving a rejected invoice
|
||||
- Expected: Buttons hidden, rejection reason displayed
|
||||
|
||||
### ❌ Empty Changes
|
||||
- Click "Modify Invoice"
|
||||
- Don't change anything
|
||||
- Click "Save Changes"
|
||||
- Expected: "No changes detected" error
|
||||
|
||||
---
|
||||
|
||||
## Post-Testing Cleanup
|
||||
|
||||
Remove test data:
|
||||
```sql
|
||||
-- Find test orders
|
||||
SELECT * FROM orders WHERE notes LIKE '%Test order for invoice approval%';
|
||||
|
||||
-- Delete test data (cascades to order_items, invoices, order_changes)
|
||||
DELETE FROM orders WHERE notes LIKE '%Test order for invoice approval%';
|
||||
```
|
||||
|
||||
Or keep for repeated testing!
|
||||
|
||||
---
|
||||
|
||||
## Next Steps: Seller Testing (Once UI Complete)
|
||||
|
||||
1. Seller receives notification of buyer modifications
|
||||
2. Seller reviews changes at `/seller/invoices/{invoice}`
|
||||
3. Seller can:
|
||||
- Approve buyer's changes (applies them, moves to amendment_in_progress)
|
||||
- Counter-modify (make own changes, increments negotiation round)
|
||||
- Reject outright
|
||||
|
||||
This will be tested once seller review UI is implemented.
|
||||
@@ -1,174 +0,0 @@
|
||||
# Testing Production Docker Image Locally
|
||||
|
||||
This guide helps you test the production Docker image locally before pushing to CI/CD.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build and start the production image locally
|
||||
docker-compose -f docker-compose.prod-test.yml up --build
|
||||
|
||||
# Access the app at: http://localhost:8080
|
||||
# PostgreSQL accessible at: localhost:5433
|
||||
```
|
||||
|
||||
## Why Test Locally?
|
||||
|
||||
- ✅ **Faster feedback** - 2-5 min vs 10-15 min through CI/CD
|
||||
- ✅ **Save CI resources** - Don't waste build minutes
|
||||
- ✅ **Easier debugging** - Direct container access
|
||||
- ✅ **Catch issues early** - Before they hit version control
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Build and Run
|
||||
```bash
|
||||
# Start everything (builds if needed)
|
||||
docker-compose -f docker-compose.prod-test.yml up
|
||||
|
||||
# Build with no cache (clean build)
|
||||
docker-compose -f docker-compose.prod-test.yml build --no-cache
|
||||
|
||||
# Run in background
|
||||
docker-compose -f docker-compose.prod-test.yml up -d
|
||||
|
||||
# View logs
|
||||
docker-compose -f docker-compose.prod-test.yml logs -f app
|
||||
```
|
||||
|
||||
### Debug Inside Container
|
||||
```bash
|
||||
# Execute shell in running container
|
||||
docker-compose -f docker-compose.prod-test.yml exec app /bin/sh
|
||||
|
||||
# Check supervisor status
|
||||
docker-compose -f docker-compose.prod-test.yml exec app supervisorctl status
|
||||
|
||||
# View nginx logs
|
||||
docker-compose -f docker-compose.prod-test.yml exec app cat /var/log/nginx/error.log
|
||||
|
||||
# View Laravel logs
|
||||
docker-compose -f docker-compose.prod-test.yml exec app tail -f /var/www/html/storage/logs/laravel.log
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
```bash
|
||||
# Stop everything
|
||||
docker-compose -f docker-compose.prod-test.yml down
|
||||
|
||||
# Remove volumes too (fresh database)
|
||||
docker-compose -f docker-compose.prod-test.yml down -v
|
||||
```
|
||||
|
||||
## Testing Workflow
|
||||
|
||||
### Before Pushing to CI/CD:
|
||||
|
||||
1. **Make your changes** to Dockerfile, configs, etc.
|
||||
|
||||
2. **Test locally:**
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod-test.yml up --build
|
||||
```
|
||||
|
||||
3. **Verify it works:**
|
||||
- App accessible at http://localhost:8080
|
||||
- No errors in logs: `docker-compose -f docker-compose.prod-test.yml logs app`
|
||||
- All services running: `docker-compose -f docker-compose.prod-test.yml exec app supervisorctl status`
|
||||
|
||||
4. **If it works, push to develop:**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "fix: your change"
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
5. **If it doesn't work, debug:**
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose -f docker-compose.prod-test.yml logs app
|
||||
|
||||
# Exec into container
|
||||
docker-compose -f docker-compose.prod-test.yml exec app /bin/sh
|
||||
|
||||
# Make fixes and rebuild
|
||||
docker-compose -f docker-compose.prod-test.yml up --build
|
||||
```
|
||||
|
||||
## Differences from Production
|
||||
|
||||
This local test environment differs from production K8s in:
|
||||
|
||||
- Uses local PostgreSQL (not persistent volume)
|
||||
- Runs on localhost:8080 (not ingress with TLS)
|
||||
- Uses test APP_KEY (not secret from K8s)
|
||||
- Single replica (not multiple pods)
|
||||
- No load balancer or ingress
|
||||
|
||||
But it **does test**:
|
||||
- ✅ Dockerfile builds correctly
|
||||
- ✅ All directories exist and have correct permissions
|
||||
- ✅ Supervisor starts all services (nginx, php-fpm, workers, scheduler)
|
||||
- ✅ Laravel boots and connects to database
|
||||
- ✅ Migrations run successfully
|
||||
- ✅ Application responds to HTTP requests
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port conflicts
|
||||
If 8080 or 5433 are already in use, edit `docker-compose.prod-test.yml` and change the ports.
|
||||
|
||||
### Build errors
|
||||
```bash
|
||||
# Clean build from scratch
|
||||
docker-compose -f docker-compose.prod-test.yml build --no-cache --pull
|
||||
```
|
||||
|
||||
### Container crashes immediately
|
||||
```bash
|
||||
# Check logs for error
|
||||
docker-compose -f docker-compose.prod-test.yml logs app
|
||||
|
||||
# Try running without detach to see output
|
||||
docker-compose -f docker-compose.prod-test.yml up
|
||||
```
|
||||
|
||||
### Need fresh database
|
||||
```bash
|
||||
# Remove volumes and recreate
|
||||
docker-compose -f docker-compose.prod-test.yml down -v
|
||||
docker-compose -f docker-compose.prod-test.yml up
|
||||
```
|
||||
|
||||
## Integration with Development Workflow
|
||||
|
||||
### Using alongside Laravel Sail
|
||||
|
||||
Sail and prod-test can run simultaneously:
|
||||
- Sail dev environment: http://localhost (port 80)
|
||||
- Production test: http://localhost:8080
|
||||
|
||||
Just make sure to use different database ports (Sail: 5432, prod-test: 5433).
|
||||
|
||||
### When to use each:
|
||||
|
||||
**Laravel Sail (docker-compose.yml):**
|
||||
- Daily development
|
||||
- Running artisan commands
|
||||
- Testing code changes
|
||||
- Hot reload with Vite
|
||||
|
||||
**Production Test (docker-compose.prod-test.yml):**
|
||||
- Before pushing to develop
|
||||
- Testing Dockerfile changes
|
||||
- Testing supervisor/nginx configs
|
||||
- Verifying production build process
|
||||
|
||||
## Next Steps After Local Success
|
||||
|
||||
Once your image works locally:
|
||||
|
||||
1. ✅ Commit and push to develop
|
||||
2. ✅ CI/CD builds the same image
|
||||
3. ✅ Deploy to Kubernetes with confidence
|
||||
4. ✅ Much faster iteration cycle!
|
||||
197
TEST_RESULTS.md
197
TEST_RESULTS.md
@@ -1,197 +0,0 @@
|
||||
# Business-Scoped Seller Routes - Test Results
|
||||
|
||||
**Test Date:** 2025-10-15
|
||||
**Tester:** Claude Code
|
||||
**Test Account:** jon@cannabrands.com
|
||||
**Business Slug:** cannabrands
|
||||
|
||||
## Test Data Summary
|
||||
- **Business:** Cannabrands (slug: `cannabrands`)
|
||||
- **Brands:** 13
|
||||
- **Products:** 312
|
||||
- **Orders:** 0 (none yet)
|
||||
- **Invoices:** 0 (none yet)
|
||||
- **Drivers:** 1
|
||||
- **Vehicles:** 2
|
||||
|
||||
---
|
||||
|
||||
## Route Tests
|
||||
|
||||
### 1. Business-Scoped Dashboard
|
||||
**URL:** `http://localhost:8000/s/cannabrands/dashboard`
|
||||
**Expected:** Dashboard with business context
|
||||
**Status:** ⏳ Testing...
|
||||
|
||||
### 2. Fleet Management - Drivers
|
||||
**URL:** `http://localhost:8000/s/cannabrands/fleet/drivers`
|
||||
**Expected:** Drivers list page with business context
|
||||
**Status:** ⏳ Testing...
|
||||
|
||||
### 3. Fleet Management - Vehicles
|
||||
**URL:** `http://localhost:8000/s/cannabrands/fleet/vehicles`
|
||||
**Expected:** Vehicles list page with business context
|
||||
**Status:** ⏳ Testing...
|
||||
|
||||
### 4. Orders List
|
||||
**URL:** `http://localhost:8000/s/cannabrands/orders`
|
||||
**Expected:** Orders list (empty state expected)
|
||||
**Status:** ✅ PASSED (Fixed query in OrderController)
|
||||
**Notes:** Fixed relationship chain to use `whereHas('items.product.brand')`
|
||||
|
||||
### 5. Products List
|
||||
**URL:** `http://localhost:8000/s/cannabrands/products`
|
||||
**Expected:** Products list with 312 products
|
||||
**Status:** ⏳ Testing...
|
||||
|
||||
### 6. Invoices List
|
||||
**URL:** `http://localhost:8000/s/cannabrands/invoices`
|
||||
**Expected:** Invoices list (empty state expected)
|
||||
**Status:** ✅ PASSED
|
||||
**Notes:** InvoiceController updated with business scope
|
||||
|
||||
### 7. Product Edit Page
|
||||
**URL:** `http://localhost:8000/s/cannabrands/products/225/edit`
|
||||
**Expected:** Product edit form with audit history
|
||||
**Status:** ✅ PASSED
|
||||
**Notes:** Enhanced UI, audit logging, checkbox handling fixed
|
||||
|
||||
---
|
||||
|
||||
## Access Control Tests
|
||||
|
||||
### Test 1: Business Slug Validation
|
||||
**Test:** Access non-existent business slug
|
||||
**URL:** `http://localhost:8000/s/invalid-slug/dashboard`
|
||||
**Expected:** 404 Not Found
|
||||
**Status:** ⏳ Testing...
|
||||
|
||||
### Test 2: Unauthorized Business Access
|
||||
**Test:** User attempts to access another user's business
|
||||
**Expected:** 403 Forbidden
|
||||
**Status:** ⏳ Testing...
|
||||
**Notes:** Requires second test account
|
||||
|
||||
### Test 3: Unauthenticated Access
|
||||
**Test:** Access business-scoped route without login
|
||||
**Expected:** Redirect to login page
|
||||
**Status:** ⏳ Testing...
|
||||
|
||||
---
|
||||
|
||||
## Route Model Binding Tests
|
||||
|
||||
### Test 1: Business by Slug
|
||||
**Binding:** `{business}` → Business model by slug
|
||||
**Test URL:** `/s/cannabrands/dashboard`
|
||||
**Expected:** Resolve to Business with slug 'cannabrands'
|
||||
**Status:** ⏳ Testing...
|
||||
|
||||
### Test 2: Order by Order Number
|
||||
**Binding:** `{order}` → Order model by order_number
|
||||
**Test URL:** `/s/cannabrands/orders/{order_number}`
|
||||
**Expected:** Resolve to Order by order_number field
|
||||
**Status:** ⏳ Testing... (requires order data)
|
||||
|
||||
### Test 3: Product by ID
|
||||
**Binding:** `{product}` → Product model by ID
|
||||
**Test URL:** `/s/cannabrands/products/225/edit`
|
||||
**Expected:** Resolve to Product with ID 225
|
||||
**Status:** ✅ PASSED
|
||||
|
||||
---
|
||||
|
||||
## Critical Routes Requiring Approval Middleware
|
||||
|
||||
These routes require `approved` middleware:
|
||||
- ✅ Orders: `/s/{business}/orders`
|
||||
- ✅ Invoices: `/s/{business}/invoices`
|
||||
- ✅ Products: `/s/{business}/products`
|
||||
- ✅ Components: `/s/{business}/components`
|
||||
- ✅ Customers: `/s/{business}/customers`
|
||||
|
||||
**Test Account Status:** jon@cannabrands.com is approved ✓
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Fixes Applied
|
||||
|
||||
### Issue 1: OrderController Query Error ✅ FIXED
|
||||
**Error:** `Column "seller_business_id" does not exist`
|
||||
**Fix:** Changed query to use correct relationship chain:
|
||||
```php
|
||||
->whereHas('items.product.brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
```
|
||||
**File:** `app/Http/Controllers/OrderController.php:26`
|
||||
|
||||
### Issue 2: Invoice Model Fillable Array ✅ FIXED
|
||||
**Error:** Using deprecated `company_id` instead of `business_id`
|
||||
**Fix:** Updated fillable array in Invoice model
|
||||
**File:** `app/Models/Invoice.php`
|
||||
|
||||
### Issue 3: Invoice Routes Not Business-Scoped ✅ FIXED
|
||||
**Error:** Invoice routes not accepting business parameter
|
||||
**Fix:** Updated InvoiceController to accept Business parameter and verify ownership
|
||||
**File:** `app/Http/Controllers/Seller/InvoiceController.php`
|
||||
|
||||
### Issue 4: Product Checkbox Not Unchecking ✅ FIXED
|
||||
**Error:** Featured checkbox stays checked when unchecked and saved
|
||||
**Fix:** Added explicit checkbox handling in ProductController:
|
||||
```php
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
$validated['is_featured'] = $request->has('is_featured');
|
||||
```
|
||||
**File:** `app/Http/Controllers/Seller/ProductController.php`
|
||||
|
||||
### Issue 5: Alpine.js FOUC on Sidebar ✅ FIXED
|
||||
**Error:** Brief flash of expanded menu items during page load
|
||||
**Fix:** Added `x-cloak` directive to sidebar menu container
|
||||
**File:** `resources/views/components/seller-sidebar.blade.php:35`
|
||||
|
||||
### Issue 6: Alpine.js FOUC on Notifications ✅ FIXED
|
||||
**Error:** Brief flash of notification dropdown during page load
|
||||
**Fix:** Added `x-cloak` directive to notification dropdown container
|
||||
**File:** `resources/views/layouts/app-with-sidebar.blade.php:67`
|
||||
|
||||
---
|
||||
|
||||
## Manual Testing Checklist
|
||||
|
||||
- [ ] Login as seller (jon@cannabrands.com)
|
||||
- [ ] Navigate to `/s/cannabrands/dashboard`
|
||||
- [ ] Test fleet management pages (drivers, vehicles)
|
||||
- [ ] Test orders page (empty state)
|
||||
- [ ] Test products list and edit
|
||||
- [ ] Test invoices page (empty state)
|
||||
- [ ] Test product audit history
|
||||
- [ ] Test checkbox toggles
|
||||
- [ ] Verify sidebar menu persistence
|
||||
- [ ] Verify notification dropdown works
|
||||
- [ ] Test accessing invalid business slug
|
||||
- [ ] Test logout and re-login flow
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Create test orders** to fully test the order management workflow
|
||||
2. **Add automated tests** for business-scoped routing and access control
|
||||
3. **Monitor production** for any route binding issues
|
||||
4. **Document** the business-scoped routing pattern for future development
|
||||
5. **Consider** adding middleware to verify business ownership on all routes
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Total Routes Tested:** 7
|
||||
**Passed:** 4
|
||||
**In Progress:** 3
|
||||
**Failed:** 0
|
||||
|
||||
**Critical Fixes Applied:** 6
|
||||
**Access Control:** ✅ Implemented via custom route model binding
|
||||
**Business Scope:** ✅ All routes accept business parameter
|
||||
**Database Queries:** ✅ Fixed to use correct relationship chains
|
||||
@@ -97,10 +97,11 @@ class BusinessModuleForm
|
||||
->helperText('Usage limits for this business (overrides defaults)')
|
||||
->default(function (Get $get) {
|
||||
$moduleKey = $get('module_key');
|
||||
if (!$moduleKey) {
|
||||
if (! $moduleKey) {
|
||||
return [];
|
||||
}
|
||||
$module = Module::where('key', $moduleKey)->first();
|
||||
|
||||
return $module?->default_limits ?? [];
|
||||
})
|
||||
->reorderable(),
|
||||
|
||||
@@ -26,8 +26,7 @@ class BusinessModuleInfolist
|
||||
->label('Module')
|
||||
->badge()
|
||||
->color('info')
|
||||
->formatStateUsing(fn (string $state): string =>
|
||||
\App\Models\Module::where('key', $state)->value('name') ?? $state
|
||||
->formatStateUsing(fn (string $state): string => \App\Models\Module::where('key', $state)->value('name') ?? $state
|
||||
),
|
||||
]),
|
||||
|
||||
|
||||
@@ -30,8 +30,7 @@ class BusinessModulesTable
|
||||
->sortable()
|
||||
->badge()
|
||||
->color('info')
|
||||
->formatStateUsing(fn (string $state): string =>
|
||||
\App\Models\Module::where('key', $state)->value('name') ?? $state
|
||||
->formatStateUsing(fn (string $state): string => \App\Models\Module::where('key', $state)->value('name') ?? $state
|
||||
),
|
||||
|
||||
IconColumn::make('enabled')
|
||||
|
||||
@@ -17,7 +17,6 @@ use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use STS\FilamentImpersonate\Tables\Actions\ImpersonateAction;
|
||||
|
||||
class UserResource extends Resource
|
||||
{
|
||||
@@ -239,11 +238,13 @@ class UserResource extends Resource
|
||||
EditAction::make()
|
||||
->label('View/Modify')
|
||||
->icon('heroicon-o-pencil'),
|
||||
ImpersonateAction::make()
|
||||
Action::make('impersonate')
|
||||
->label('Impersonate')
|
||||
->icon('heroicon-o-user-circle')
|
||||
->color('warning')
|
||||
->visible(fn (User $record) => $record->canBeImpersonated()),
|
||||
->visible(fn (User $record) => auth()->user()->canImpersonate() && $record->canBeImpersonated())
|
||||
->url(fn (User $record) => route('admin.impersonate.perform', $record))
|
||||
->openUrlInNewTab(false),
|
||||
Action::make('suspend')
|
||||
->label('Suspend')
|
||||
->icon('heroicon-o-no-symbol')
|
||||
|
||||
@@ -19,7 +19,7 @@ class CartController extends Controller
|
||||
/**
|
||||
* Display the cart page or return JSON data.
|
||||
*/
|
||||
public function index(Request $request): View|JsonResponse
|
||||
public function index(\App\Models\Business $business, Request $request): View|JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$sessionId = $request->session()->getId();
|
||||
@@ -30,8 +30,7 @@ class CartController extends Controller
|
||||
$subtotal = $items->sum(fn ($item) => $item->quantity * ($item->product->wholesale_price ?? 0));
|
||||
|
||||
// Calculate tax based on business tax rate
|
||||
$business = $user?->businesses()->first();
|
||||
$taxRate = $business?->getTaxRate() ?? 0.08;
|
||||
$taxRate = $business->getTaxRate() ?? 0.08;
|
||||
$tax = $subtotal * $taxRate;
|
||||
|
||||
$total = $subtotal + $tax;
|
||||
@@ -62,7 +61,7 @@ class CartController extends Controller
|
||||
/**
|
||||
* Add item to cart (Ajax).
|
||||
*/
|
||||
public function add(Request $request): JsonResponse
|
||||
public function add(\App\Models\Business $business, Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
@@ -75,6 +74,7 @@ class CartController extends Controller
|
||||
|
||||
try {
|
||||
$cart = $this->cartService->addItem(
|
||||
$business,
|
||||
$request->integer('product_id'),
|
||||
$request->integer('quantity', 1),
|
||||
$user,
|
||||
@@ -101,7 +101,7 @@ class CartController extends Controller
|
||||
/**
|
||||
* Update cart item quantity (Ajax).
|
||||
*/
|
||||
public function update(Request $request, int $cartId): JsonResponse
|
||||
public function update(\App\Models\Business $business, Request $request, int $cartId): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'quantity' => 'required|integer|min:1',
|
||||
@@ -111,7 +111,7 @@ class CartController extends Controller
|
||||
$sessionId = $request->session()->getId();
|
||||
|
||||
try {
|
||||
$cart = $this->cartService->updateQuantity($cartId, $request->integer('quantity'));
|
||||
$cart = $this->cartService->updateQuantity($cartId, $request->integer('quantity'), $user, $sessionId);
|
||||
|
||||
// Ensure product is loaded for JSON response
|
||||
$cart->load('product', 'brand');
|
||||
@@ -140,13 +140,13 @@ class CartController extends Controller
|
||||
/**
|
||||
* Remove item from cart (Ajax).
|
||||
*/
|
||||
public function remove(Request $request, int $cartId): JsonResponse
|
||||
public function remove(\App\Models\Business $business, Request $request, int $cartId): JsonResponse
|
||||
{
|
||||
$this->cartService->removeItem($cartId);
|
||||
|
||||
$user = $request->user();
|
||||
$sessionId = $request->session()->getId();
|
||||
|
||||
$this->cartService->removeItem($cartId, $user, $sessionId);
|
||||
|
||||
$subtotal = $this->cartService->getSubtotal($user, $sessionId);
|
||||
$tax = $this->cartService->getTax($user, $sessionId);
|
||||
$total = $this->cartService->getTotal($user, $sessionId);
|
||||
@@ -165,7 +165,7 @@ class CartController extends Controller
|
||||
/**
|
||||
* Clear entire cart.
|
||||
*/
|
||||
public function clear(Request $request): JsonResponse
|
||||
public function clear(\App\Models\Business $business, Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$sessionId = $request->session()->getId();
|
||||
@@ -182,7 +182,7 @@ class CartController extends Controller
|
||||
/**
|
||||
* Get cart count (for header badge).
|
||||
*/
|
||||
public function count(Request $request): JsonResponse
|
||||
public function count(\App\Models\Business $business, Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$sessionId = $request->session()->getId();
|
||||
|
||||
@@ -23,7 +23,7 @@ class CheckoutController extends Controller
|
||||
/**
|
||||
* Display the checkout page.
|
||||
*/
|
||||
public function index(Request $request): View|RedirectResponse
|
||||
public function index(Business $business, Request $request): View|RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$sessionId = $request->session()->getId();
|
||||
@@ -33,7 +33,7 @@ class CheckoutController extends Controller
|
||||
|
||||
// Redirect if cart is empty
|
||||
if ($items->isEmpty()) {
|
||||
return redirect()->route('buyer.cart.index')
|
||||
return redirect()->route('buyer.business.cart.index', $business)
|
||||
->with('error', 'Your cart is empty. Add some products before checking out.');
|
||||
}
|
||||
|
||||
@@ -42,9 +42,6 @@ class CheckoutController extends Controller
|
||||
$tax = $this->cartService->getTax($user, $sessionId);
|
||||
$total = $this->cartService->getTotal($user, $sessionId);
|
||||
|
||||
// Get user's business
|
||||
$business = $user->businesses()->first();
|
||||
|
||||
// Load delivery locations (only locations that accept deliveries)
|
||||
$locations = $business
|
||||
? $business->locations()
|
||||
@@ -78,7 +75,7 @@ class CheckoutController extends Controller
|
||||
/**
|
||||
* Process the order.
|
||||
*/
|
||||
public function process(Request $request): RedirectResponse
|
||||
public function process(Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'location_id' => 'required_if:delivery_method,delivery|nullable|exists:locations,id',
|
||||
@@ -99,17 +96,10 @@ class CheckoutController extends Controller
|
||||
$items = $this->cartService->getCartItems($user, $sessionId);
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
return redirect()->route('buyer.cart.index')
|
||||
return redirect()->route('buyer.business.cart.index', $business)
|
||||
->with('error', 'Your cart is empty.');
|
||||
}
|
||||
|
||||
// Get user's business
|
||||
$business = $user->businesses()->first();
|
||||
|
||||
if (! $business) {
|
||||
return back()->with('error', 'No business associated with your account.');
|
||||
}
|
||||
|
||||
// Calculate due date based on payment terms
|
||||
$paymentTerms = $request->input('payment_terms');
|
||||
$dueDate = $this->calculateDueDate($paymentTerms);
|
||||
@@ -185,24 +175,24 @@ class CheckoutController extends Controller
|
||||
$sellerNotificationService->newOrderReceived($order);
|
||||
|
||||
// Redirect to success page
|
||||
return redirect()->route('buyer.checkout.success', ['order' => $order->order_number])
|
||||
return redirect()->route('buyer.business.checkout.success', ['business' => $business->slug, 'order' => $order->order_number])
|
||||
->with('success', 'Order placed successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display order confirmation page.
|
||||
*/
|
||||
public function success(Request $request, Order $order): View|RedirectResponse
|
||||
public function success(Business $business, Request $request, Order $order): View|RedirectResponse
|
||||
{
|
||||
// Load relationships
|
||||
$order->load(['items.product', 'business', 'location']);
|
||||
|
||||
// Ensure user owns this order
|
||||
if ($order->user_id !== $request->user()->id) {
|
||||
// Ensure order belongs to this business
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
return view('buyer.checkout.success', compact('order'));
|
||||
return view('buyer.checkout.success', compact('order', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,15 +16,11 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Display a listing of the user's invoices.
|
||||
*/
|
||||
public function index()
|
||||
public function index(\App\Models\Business $business)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||
|
||||
$invoices = Invoice::with(['order', 'business'])
|
||||
->whereHas('order', function ($query) use ($user, $userBusinessIds) {
|
||||
$query->where('user_id', $user->id)
|
||||
->orWhereIn('business_id', $userBusinessIds);
|
||||
->whereHas('order', function ($query) use ($business) {
|
||||
$query->forBusiness($business);
|
||||
})
|
||||
->latest()
|
||||
->get();
|
||||
@@ -37,16 +33,16 @@ class InvoiceController extends Controller
|
||||
'overdue' => $invoices->filter(fn ($inv) => $inv->isOverdue())->count(),
|
||||
];
|
||||
|
||||
return view('buyer.invoices.index', compact('invoices', 'stats'));
|
||||
return view('buyer.invoices.index', compact('invoices', 'stats', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified invoice.
|
||||
*/
|
||||
public function show(Invoice $invoice)
|
||||
public function show(\App\Models\Business $business, Invoice $invoice)
|
||||
{
|
||||
// Authorization check
|
||||
if (! $this->canAccessInvoice($invoice)) {
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to view this invoice.');
|
||||
}
|
||||
|
||||
@@ -63,15 +59,15 @@ class InvoiceController extends Controller
|
||||
];
|
||||
})->values();
|
||||
|
||||
return view('buyer.invoices.show', compact('invoice', 'invoiceItems'));
|
||||
return view('buyer.invoices.show', compact('invoice', 'invoiceItems', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve the invoice without modifications.
|
||||
*/
|
||||
public function approve(Invoice $invoice)
|
||||
public function approve(\App\Models\Business $business, Invoice $invoice)
|
||||
{
|
||||
if (! $this->canAccessInvoice($invoice)) {
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to approve this invoice.');
|
||||
}
|
||||
|
||||
@@ -93,13 +89,13 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Reject the invoice.
|
||||
*/
|
||||
public function reject(Request $request, Invoice $invoice)
|
||||
public function reject(\App\Models\Business $business, Request $request, Invoice $invoice)
|
||||
{
|
||||
$request->validate([
|
||||
'reason' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
if (! $this->canAccessInvoice($invoice)) {
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to reject this invoice.');
|
||||
}
|
||||
|
||||
@@ -116,7 +112,7 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Modify the invoice (record buyer's changes).
|
||||
*/
|
||||
public function modify(Request $request, Invoice $invoice, OrderModificationService $modificationService)
|
||||
public function modify(\App\Models\Business $business, Request $request, Invoice $invoice, OrderModificationService $modificationService)
|
||||
{
|
||||
$request->validate([
|
||||
'items' => 'required|array',
|
||||
@@ -125,7 +121,7 @@ class InvoiceController extends Controller
|
||||
'items.*.deleted' => 'required|boolean',
|
||||
]);
|
||||
|
||||
if (! $this->canAccessInvoice($invoice)) {
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Unauthorized to modify this invoice.',
|
||||
@@ -196,9 +192,9 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Download invoice PDF.
|
||||
*/
|
||||
public function downloadPdf(Invoice $invoice, InvoiceService $invoiceService): Response
|
||||
public function downloadPdf(\App\Models\Business $business, Invoice $invoice, InvoiceService $invoiceService): Response
|
||||
{
|
||||
if (! $this->canAccessInvoice($invoice)) {
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to download this invoice.');
|
||||
}
|
||||
|
||||
@@ -215,19 +211,4 @@ class InvoiceController extends Controller
|
||||
'Content-Disposition' => 'inline; filename="'.$invoice->invoice_number.'.pdf"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user can access the invoice.
|
||||
*/
|
||||
protected function canAccessInvoice(Invoice $invoice): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||
$order = $invoice->order;
|
||||
|
||||
return $order && (
|
||||
$order->user_id === $user->id ||
|
||||
in_array($order->business_id, $userBusinessIds)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ class NotificationController extends Controller
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(20);
|
||||
|
||||
return view('buyer.notifications.index', compact('notifications'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.notifications.index', compact('notifications', 'business'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,13 @@ class OrderController extends Controller
|
||||
/**
|
||||
* Display a listing of the user's orders.
|
||||
*/
|
||||
public function index()
|
||||
public function index(\App\Models\Business $business)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||
|
||||
$orders = Order::with(['items', 'business', 'location'])
|
||||
->where(function ($query) use ($user, $userBusinessIds) {
|
||||
$query->where('user_id', $user->id)
|
||||
->orWhereIn('business_id', $userBusinessIds);
|
||||
})
|
||||
// Only show orders for this specific business
|
||||
$orders = Order::forBusiness($business)
|
||||
->with(['items', 'business', 'location'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
@@ -32,30 +29,30 @@ class OrderController extends Controller
|
||||
'delivered' => $orders->where('status', 'delivered')->count(),
|
||||
];
|
||||
|
||||
return view('buyer.orders.index', compact('orders', 'stats'));
|
||||
return view('buyer.orders.index', compact('business', 'orders', 'stats'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified order.
|
||||
*/
|
||||
public function show(Order $order)
|
||||
public function show(\App\Models\Business $business, Order $order)
|
||||
{
|
||||
// Authorization check
|
||||
if (! $this->canAccessOrder($order)) {
|
||||
// Authorization check - order must belong to this business
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to view this order.');
|
||||
}
|
||||
|
||||
$order->load(['items.product', 'business', 'location', 'user', 'invoice', 'manifest']);
|
||||
|
||||
return view('buyer.orders.show', compact('order'));
|
||||
return view('buyer.orders.show', compact('business', 'order'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an order.
|
||||
*/
|
||||
public function accept(Order $order)
|
||||
public function accept(\App\Models\Business $business, Order $order)
|
||||
{
|
||||
if (! $this->canAccessOrder($order)) {
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to modify this order.');
|
||||
}
|
||||
|
||||
@@ -71,9 +68,9 @@ class OrderController extends Controller
|
||||
/**
|
||||
* Cancel an order (buyer-initiated).
|
||||
*/
|
||||
public function cancel(Order $order, Request $request)
|
||||
public function cancel(\App\Models\Business $business, Order $order, Request $request)
|
||||
{
|
||||
if (! $this->canAccessOrder($order)) {
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to modify this order.');
|
||||
}
|
||||
|
||||
@@ -93,9 +90,9 @@ class OrderController extends Controller
|
||||
/**
|
||||
* Update order fulfillment method and related information.
|
||||
*/
|
||||
public function updateFulfillment(Order $order, Request $request)
|
||||
public function updateFulfillment(\App\Models\Business $business, Order $order, Request $request)
|
||||
{
|
||||
if (! $this->canAccessOrder($order)) {
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to modify this order.');
|
||||
}
|
||||
|
||||
@@ -134,9 +131,9 @@ class OrderController extends Controller
|
||||
/**
|
||||
* Download manifest PDF for an order.
|
||||
*/
|
||||
public function downloadManifestPdf(Order $order)
|
||||
public function downloadManifestPdf(\App\Models\Business $business, Order $order)
|
||||
{
|
||||
if (! $this->canAccessOrder($order)) {
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to access this order.');
|
||||
}
|
||||
|
||||
@@ -159,16 +156,4 @@ class OrderController extends Controller
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user can access the order.
|
||||
*/
|
||||
protected function canAccessOrder(Order $order): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||
|
||||
return $order->user_id === $user->id ||
|
||||
in_array($order->business_id, $userBusinessIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,11 @@ class BuyerAuthController extends Controller
|
||||
*/
|
||||
public function profile()
|
||||
{
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.profile', [
|
||||
'user' => auth()->user(),
|
||||
'business' => $business,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,12 @@ class BuyerProfileController extends Controller
|
||||
: 'none',
|
||||
];
|
||||
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.profile', [
|
||||
'user' => $user,
|
||||
'verificationStatus' => $verificationStatus,
|
||||
'business' => $business,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ class BuyerSetupController extends Controller
|
||||
|
||||
if ($firstName) {
|
||||
// Check if AP contact already exists (in case user goes back and forward in wizard)
|
||||
$existingAp = Contact::where('business_id', $business->id)
|
||||
$existingAp = Contact::forBusiness($business)
|
||||
->where('contact_type', 'accounts_payable')
|
||||
->where('first_name', $firstName)
|
||||
->where('last_name', $lastName)
|
||||
|
||||
@@ -9,7 +9,9 @@ class DispensarySetupController extends Controller
|
||||
public function create(Request $request, $step = 1)
|
||||
{
|
||||
// TODO: Implement dispensary setup wizard
|
||||
return view('buyer.dispensary.setup', compact('step'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.dispensary.setup', compact('step', 'business'));
|
||||
}
|
||||
|
||||
public function store(Request $request, $step = 1)
|
||||
|
||||
@@ -13,7 +13,7 @@ class DriverController extends Controller
|
||||
*/
|
||||
public function index(\App\Models\Business $business)
|
||||
{
|
||||
$drivers = Driver::where('business_id', $business->id)
|
||||
$drivers = Driver::forBusiness($business)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
@@ -52,7 +52,7 @@ class DriverController extends Controller
|
||||
public function update(\App\Models\Business $business, Request $request, Driver $driver)
|
||||
{
|
||||
// Ensure driver belongs to this business
|
||||
if ($driver->business_id !== $business->id) {
|
||||
if (! $driver->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ class DriverController extends Controller
|
||||
public function destroy(\App\Models\Business $business, Driver $driver)
|
||||
{
|
||||
// Ensure driver belongs to this business
|
||||
if ($driver->business_id !== $business->id) {
|
||||
if (! $driver->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class DriverController extends Controller
|
||||
public function toggle(\App\Models\Business $business, Driver $driver)
|
||||
{
|
||||
// Ensure driver belongs to this business
|
||||
if ($driver->business_id !== $business->id) {
|
||||
if (! $driver->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,23 +2,24 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class FavoriteController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
public function index(Business $business, Request $request)
|
||||
{
|
||||
// TODO: Implement favorites index
|
||||
return view('buyer.favorites.index');
|
||||
return view('buyer.favorites.index', compact('business'));
|
||||
}
|
||||
|
||||
public function add(Request $request, $product)
|
||||
public function add(Business $business, Request $request, $product)
|
||||
{
|
||||
// TODO: Implement add to favorites
|
||||
return back();
|
||||
}
|
||||
|
||||
public function remove(Request $request, $product)
|
||||
public function remove(Business $business, Request $request, $product)
|
||||
{
|
||||
// TODO: Implement remove from favorites
|
||||
return back();
|
||||
|
||||
@@ -78,7 +78,9 @@ class MarketplaceController extends Controller
|
||||
->limit(3)
|
||||
->get();
|
||||
|
||||
return view('buyer.marketplace.index', compact('products', 'brands', 'featuredProducts'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.marketplace.index', compact('products', 'brands', 'featuredProducts', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +104,9 @@ class MarketplaceController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('buyer.marketplace.brands', compact('brands'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.marketplace.brands', compact('brands', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,7 +114,9 @@ class MarketplaceController extends Controller
|
||||
*/
|
||||
public function category($category)
|
||||
{
|
||||
return view('buyer.marketplace.category', compact('category'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.marketplace.category', compact('category', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,7 +165,9 @@ class MarketplaceController extends Controller
|
||||
->limit(4)
|
||||
->get();
|
||||
|
||||
return view('buyer.marketplace.product', compact('product', 'relatedProducts', 'brand'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.marketplace.product', compact('product', 'relatedProducts', 'brand', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,6 +199,8 @@ class MarketplaceController extends Controller
|
||||
->orderBy('name')
|
||||
->paginate(20);
|
||||
|
||||
return view('buyer.marketplace.brand', compact('brand', 'featuredProducts', 'products'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.marketplace.brand', compact('brand', 'featuredProducts', 'products', 'business'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ class OrderController extends Controller
|
||||
public function index(\App\Models\Business $business, Request $request): View
|
||||
{
|
||||
$query = Order::with(['business', 'user', 'items.product'])
|
||||
->whereHas('items.product.brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
->whereHas('items.product', function ($query) use ($business) {
|
||||
$query->forBusiness($business);
|
||||
})
|
||||
->whereIn('status', [
|
||||
'new',
|
||||
@@ -226,12 +226,12 @@ class OrderController extends Controller
|
||||
$order->load(['business', 'location']);
|
||||
|
||||
// Load active drivers and vehicles for this business
|
||||
$drivers = \App\Models\Driver::where('business_id', $business->id)
|
||||
$drivers = \App\Models\Driver::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->orderBy('first_name')
|
||||
->get();
|
||||
|
||||
$vehicles = \App\Models\Vehicle::where('business_id', $business->id)
|
||||
$vehicles = \App\Models\Vehicle::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
@@ -397,12 +397,12 @@ class OrderController extends Controller
|
||||
]);
|
||||
|
||||
// Get active drivers and vehicles for the edit modal
|
||||
$drivers = \App\Models\Driver::where('business_id', $business->id)
|
||||
$drivers = \App\Models\Driver::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->orderBy('first_name')
|
||||
->get();
|
||||
|
||||
$vehicles = \App\Models\Vehicle::where('business_id', $business->id)
|
||||
$vehicles = \App\Models\Vehicle::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
@@ -30,8 +30,8 @@ class BrandSwitcherController extends Controller
|
||||
return back()->with('error', 'No business associated with your account');
|
||||
}
|
||||
|
||||
$brand = Brand::where('id', $brandId)
|
||||
->where('business_id', $business->id)
|
||||
$brand = Brand::forBusiness($business)
|
||||
->where('id', $brandId)
|
||||
->first();
|
||||
|
||||
if (! $brand) {
|
||||
@@ -62,8 +62,8 @@ class BrandSwitcherController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
return Brand::where('id', $brandId)
|
||||
->where('business_id', $business->id)
|
||||
return Brand::forBusiness($business)
|
||||
->where('id', $brandId)
|
||||
->first();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class ComponentController extends Controller
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Build query - components are business-scoped
|
||||
$query = Component::where('business_id', $business->id)
|
||||
$query = Component::forBusiness($business)
|
||||
->orderBy('name', 'asc');
|
||||
|
||||
// Search filter
|
||||
@@ -113,7 +113,7 @@ class ComponentController extends Controller
|
||||
public function edit(Business $business, Component $component)
|
||||
{
|
||||
// Verify component belongs to this business
|
||||
if ($component->business_id !== $business->id) {
|
||||
if (! $component->belongsToBusiness($business)) {
|
||||
abort(403, 'This component does not belong to your business');
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ class ComponentController extends Controller
|
||||
public function update(Request $request, Business $business, Component $component)
|
||||
{
|
||||
// Verify component belongs to this business
|
||||
if ($component->business_id !== $business->id) {
|
||||
if (! $component->belongsToBusiness($business)) {
|
||||
abort(403, 'This component does not belong to your business');
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ class ComponentController extends Controller
|
||||
public function destroy(Business $business, Component $component)
|
||||
{
|
||||
// Verify component belongs to this business
|
||||
if ($component->business_id !== $business->id) {
|
||||
if (! $component->belongsToBusiness($business)) {
|
||||
abort(403, 'This component does not belong to your business');
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,7 @@ class InvoiceController extends Controller
|
||||
->get();
|
||||
|
||||
// Get all products from brands owned by this business with images, stock levels, and batches
|
||||
$products = \App\Models\Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
$products = \App\Models\Product::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->with(['brand', 'images', 'availableBatches.labs'])
|
||||
->select('id', 'brand_id', 'name', 'sku', 'description', 'wholesale_price', 'msrp_price',
|
||||
@@ -79,9 +77,7 @@ class InvoiceController extends Controller
|
||||
});
|
||||
|
||||
// Get recently invoiced products (last 30 days, top 10 most common)
|
||||
$recentProducts = \App\Models\Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
$recentProducts = \App\Models\Product::forBusiness($business)
|
||||
->whereHas('orderItems.order.invoice', function ($query) {
|
||||
$query->where('created_at', '>=', now()->subDays(30));
|
||||
})
|
||||
@@ -164,8 +160,8 @@ class InvoiceController extends Controller
|
||||
{
|
||||
// Get invoices where orders contain items from brands under this business
|
||||
$invoices = Invoice::with(['order.items.product.brand', 'business'])
|
||||
->whereHas('order.items.product.brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
->whereHas('order.items.product', function ($query) use ($business) {
|
||||
$query->forBusiness($business);
|
||||
})
|
||||
->latest()
|
||||
->get();
|
||||
@@ -191,7 +187,7 @@ class InvoiceController extends Controller
|
||||
|
||||
// Check if any of the order's items belong to brands owned by this business
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
return $item->product && $item->product->brand && $item->product->brand->business_id === $business->id;
|
||||
return $item->product && $item->product->belongsToBusiness($business);
|
||||
});
|
||||
|
||||
if (! $belongsToBusiness) {
|
||||
@@ -211,7 +207,7 @@ class InvoiceController extends Controller
|
||||
|
||||
// Check if any of the order's items belong to brands owned by this business
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
return $item->product && $item->product->brand && $item->product->brand->business_id === $business->id;
|
||||
return $item->product && $item->product->belongsToBusiness($business);
|
||||
});
|
||||
|
||||
if (! $belongsToBusiness) {
|
||||
@@ -288,7 +284,7 @@ class InvoiceController extends Controller
|
||||
|
||||
// Check if any of the order's items belong to brands owned by this business
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
return $item->product && $item->product->brand && $item->product->brand->business_id === $business->id;
|
||||
return $item->product && $item->product->belongsToBusiness($business);
|
||||
});
|
||||
|
||||
if (! $belongsToBusiness) {
|
||||
@@ -345,7 +341,7 @@ class InvoiceController extends Controller
|
||||
|
||||
// Check if any of the order's items belong to brands owned by this business
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
return $item->product && $item->product->brand && $item->product->brand->business_id === $business->id;
|
||||
return $item->product && $item->product->belongsToBusiness($business);
|
||||
});
|
||||
|
||||
if (! $belongsToBusiness) {
|
||||
|
||||
@@ -16,7 +16,7 @@ class BomController extends Controller
|
||||
public function index(Request $request, Business $business, Product $product)
|
||||
{
|
||||
// Verify product belongs to business (through brand)
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403, 'This product does not belong to your business.');
|
||||
}
|
||||
|
||||
@@ -28,18 +28,16 @@ class BomController extends Controller
|
||||
|
||||
// Get all available components for client-side filtering
|
||||
// (No server-side filters - Alpine.js handles filtering for better UX)
|
||||
$availableComponents = Component::where('business_id', $business->id)
|
||||
$availableComponents = Component::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get recently used components (from other products)
|
||||
$recentComponents = Component::where('business_id', $business->id)
|
||||
$recentComponents = Component::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->whereHas('products', function ($q) use ($business) {
|
||||
$q->whereHas('brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
});
|
||||
$q->forBusiness($business);
|
||||
})
|
||||
->orderBy('updated_at', 'desc')
|
||||
->limit(8)
|
||||
@@ -103,15 +101,13 @@ class BomController extends Controller
|
||||
]);
|
||||
|
||||
// Verify product belongs to business (through brand)
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Verify component belongs to business
|
||||
$component = Component::findOrFail($validated['component_id']);
|
||||
if ($component->business_id !== $business->id) {
|
||||
abort(403, 'This component does not belong to your business.');
|
||||
}
|
||||
$component = Component::forBusiness($business)
|
||||
->findOrFail($validated['component_id']);
|
||||
|
||||
// Check if already attached
|
||||
if ($product->components()->where('component_id', $validated['component_id'])->exists()) {
|
||||
@@ -142,7 +138,7 @@ class BomController extends Controller
|
||||
]);
|
||||
|
||||
// Verify product belongs to business (through brand)
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@@ -165,7 +161,7 @@ class BomController extends Controller
|
||||
public function detach(Business $business, Product $product, Component $component)
|
||||
{
|
||||
// Verify product belongs to business (through brand)
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@@ -185,7 +181,7 @@ class BomController extends Controller
|
||||
]);
|
||||
|
||||
// Verify product belongs to business (through brand)
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@@ -204,7 +200,7 @@ class BomController extends Controller
|
||||
public function downloadPdf(Business $business, Product $product)
|
||||
{
|
||||
// Verify product belongs to business (through brand)
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403, 'This product does not belong to your business.');
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductLine;
|
||||
use App\Models\ProductPackaging;
|
||||
use App\Models\Strain;
|
||||
use App\Models\Unit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -69,7 +73,13 @@ class ProductController extends Controller
|
||||
// Get all brands for filter dropdown
|
||||
$brands = $business->brands()->orderBy('name')->get();
|
||||
|
||||
return view('seller.products.index', compact('business', 'products', 'brands'));
|
||||
// Get product lines for this business with products count
|
||||
$productLines = ProductLine::where('business_id', $business->id)
|
||||
->withCount('products')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.products.index', compact('business', 'products', 'brands', 'productLines'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,9 +123,8 @@ class ProductController extends Controller
|
||||
]);
|
||||
|
||||
// Verify brand belongs to this business
|
||||
$brand = Brand::where('id', $validated['brand_id'])
|
||||
->where('business_id', $business->id)
|
||||
->firstOrFail();
|
||||
$brand = Brand::forBusiness($business)
|
||||
->findOrFail($validated['brand_id']);
|
||||
|
||||
// Generate slug
|
||||
$validated['slug'] = Str::slug($validated['name']);
|
||||
@@ -149,23 +158,101 @@ class ProductController extends Controller
|
||||
*/
|
||||
public function edit(Business $business, Product $product)
|
||||
{
|
||||
// Eager load relationships
|
||||
$product->load(['brand', 'images']);
|
||||
// CRITICAL BUSINESS ISOLATION: Scope by business_id BEFORE finding by ID
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
->with(['brand', 'unit', 'strain', 'packaging', 'varieties', 'images'])
|
||||
->findOrFail($product->id);
|
||||
|
||||
// Verify product belongs to a brand under this business
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
abort(403, 'This product does not belong to your business');
|
||||
}
|
||||
// Prepare dropdown data
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
$strains = Strain::all();
|
||||
$packagings = ProductPackaging::all();
|
||||
$units = Unit::all();
|
||||
$productLines = ProductLine::where('business_id', $business->id)->orderBy('name')->get();
|
||||
|
||||
$brands = $business->brands()->orderBy('name')->get();
|
||||
// Product type options (for category dropdown)
|
||||
$productTypes = [
|
||||
'flower' => 'Flower',
|
||||
'preroll' => 'Pre-Roll',
|
||||
'vape' => 'Vape',
|
||||
'concentrate' => 'Concentrate',
|
||||
'edible' => 'Edible',
|
||||
'topical' => 'Topical',
|
||||
'tincture' => 'Tincture',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
// Load audits with pagination (10 per page) for the audit history tab
|
||||
$audits = $product->audits()
|
||||
->with('user')
|
||||
->latest()
|
||||
->paginate(10);
|
||||
// Status options
|
||||
$statusOptions = [
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Inactive',
|
||||
'discontinued' => 'Discontinued',
|
||||
];
|
||||
|
||||
return view('seller.products.edit', compact('business', 'product', 'brands', 'audits'));
|
||||
return view('seller.products.edit', compact(
|
||||
'business',
|
||||
'product',
|
||||
'brands',
|
||||
'strains',
|
||||
'packagings',
|
||||
'units',
|
||||
'productLines',
|
||||
'productTypes',
|
||||
'statusOptions'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified product (edit1 - top header layout)
|
||||
*/
|
||||
public function edit1(Business $business, Product $product)
|
||||
{
|
||||
// CRITICAL BUSINESS ISOLATION: Scope by business_id BEFORE finding by ID
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
->with(['brand', 'unit', 'strain', 'packaging', 'varieties', 'images'])
|
||||
->findOrFail($product->id);
|
||||
|
||||
// Prepare dropdown data
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
$strains = Strain::all();
|
||||
$packagings = ProductPackaging::all();
|
||||
$units = Unit::all();
|
||||
$productLines = ProductLine::where('business_id', $business->id)->orderBy('name')->get();
|
||||
|
||||
// Product type options (for category dropdown)
|
||||
$productTypes = [
|
||||
'flower' => 'Flower',
|
||||
'preroll' => 'Pre-Roll',
|
||||
'vape' => 'Vape',
|
||||
'concentrate' => 'Concentrate',
|
||||
'edible' => 'Edible',
|
||||
'topical' => 'Topical',
|
||||
'tincture' => 'Tincture',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
// Status options
|
||||
$statusOptions = [
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Inactive',
|
||||
'discontinued' => 'Discontinued',
|
||||
];
|
||||
|
||||
return view('seller.products.edit1', compact(
|
||||
'business',
|
||||
'product',
|
||||
'brands',
|
||||
'strains',
|
||||
'packagings',
|
||||
'units',
|
||||
'productLines',
|
||||
'productTypes',
|
||||
'statusOptions'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,62 +260,150 @@ class ProductController extends Controller
|
||||
*/
|
||||
public function update(Request $request, Business $business, Product $product)
|
||||
{
|
||||
// Eager load brand relationship
|
||||
$product->load('brand');
|
||||
|
||||
// Verify product belongs to a brand under this business
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
abort(403, 'This product does not belong to your business');
|
||||
}
|
||||
|
||||
// Comprehensive validation
|
||||
$validated = $request->validate([
|
||||
// Basic Information
|
||||
'brand_id' => 'required|exists:brands,id',
|
||||
'name' => 'required|string|max:255',
|
||||
'sku' => 'required|string|max:100|unique:products,sku,'.$product->id,
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|string|in:flower,pre-roll,concentrate,edible,beverage,topical,tincture,vaporizer,other',
|
||||
'category' => 'nullable|string|max:100',
|
||||
'wholesale_price' => 'required|numeric|min:0',
|
||||
'price_unit' => 'required|string|in:each,gram,oz,lb,kg,ml,l',
|
||||
'sku' => 'required|string|max:100',
|
||||
'barcode' => 'nullable|string|max:100',
|
||||
'type' => 'nullable|string',
|
||||
'product_line_id' => 'nullable|exists:product_lines,id',
|
||||
'unit_id' => 'required|exists:units,id',
|
||||
'sell_multiples' => 'nullable|boolean',
|
||||
'fractional_quantities' => 'nullable|boolean',
|
||||
'allow_sample' => 'nullable|boolean',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'is_featured' => 'nullable|boolean',
|
||||
|
||||
// Inventory - now includes threshold type
|
||||
'status' => 'required|string',
|
||||
'launch_date' => 'nullable|date',
|
||||
'quantity_on_hand' => 'nullable|integer|min:0',
|
||||
'quantity_allocated' => 'nullable|integer|min:0',
|
||||
'sync_bamboo' => 'nullable|boolean',
|
||||
'low_stock_threshold' => 'nullable|numeric|min:0',
|
||||
'low_stock_threshold_type' => 'nullable|string|in:qty,percent',
|
||||
'low_stock_alert_enabled' => 'nullable|boolean',
|
||||
'is_assembly' => 'nullable|boolean',
|
||||
'show_inventory_to_buyers' => 'nullable|boolean',
|
||||
'packaging_id' => 'nullable|exists:product_packagings,id',
|
||||
|
||||
// Pricing & Units
|
||||
'cost_per_unit' => 'nullable|numeric|min:0',
|
||||
'wholesale_price' => 'nullable|numeric|min:0',
|
||||
'msrp' => 'nullable|numeric|min:0',
|
||||
'net_weight' => 'nullable|numeric|min:0',
|
||||
'weight_unit' => 'nullable|string|in:g,oz,lb,kg,ml,l',
|
||||
'units_per_case' => 'nullable|integer|min:1',
|
||||
'weight_unit' => 'nullable|string|max:20',
|
||||
'units_per_case' => 'nullable|integer|min:0',
|
||||
'cased_qty' => 'nullable|integer|min:0',
|
||||
'boxed_qty' => 'nullable|integer|min:0',
|
||||
'min_order_qty' => 'nullable|integer|min:0',
|
||||
'max_order_qty' => 'nullable|integer|min:0',
|
||||
'is_case' => 'nullable|boolean',
|
||||
'is_box' => 'nullable|boolean',
|
||||
'has_varieties' => 'nullable|boolean',
|
||||
|
||||
// Cannabis Information
|
||||
'thc_percentage' => 'nullable|numeric|min:0|max:100',
|
||||
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
|
||||
'is_active' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'strain_id' => 'nullable|exists:strains,id',
|
||||
'thc_content_mg' => 'nullable|numeric|min:0',
|
||||
'cbd_content_mg' => 'nullable|numeric|min:0',
|
||||
'strain_value' => 'nullable|numeric|min:0',
|
||||
'ingredients' => 'nullable|string',
|
||||
'effects' => 'nullable|string',
|
||||
'dosage_guidelines' => 'nullable|string',
|
||||
|
||||
// Arizona Compliance
|
||||
'arz_total_weight' => 'nullable|numeric|min:0',
|
||||
'arz_usable_mmj' => 'nullable|numeric|min:0',
|
||||
'metrc_id' => 'nullable|string|max:255',
|
||||
|
||||
// Compliance & Tracking
|
||||
'license_number' => 'nullable|string|max:255',
|
||||
'harvest_date' => 'nullable|date',
|
||||
'package_date' => 'nullable|date',
|
||||
'test_date' => 'nullable|date',
|
||||
|
||||
// Product Details
|
||||
'description' => 'nullable|string|max:100',
|
||||
'long_description' => 'nullable|string',
|
||||
'product_link' => 'nullable|url|max:255',
|
||||
'creatives_json' => 'nullable|json',
|
||||
|
||||
// Advanced Settings
|
||||
'is_sellable' => 'nullable|boolean',
|
||||
'is_fpr' => 'nullable|boolean',
|
||||
'is_raw_material' => 'nullable|boolean',
|
||||
'brand_display_order' => 'nullable|integer|min:0',
|
||||
'parent_product_id' => 'nullable|exists:products,id',
|
||||
'category' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
// Verify new brand belongs to this business
|
||||
// Convert checkboxes to boolean
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
$validated['is_featured'] = $request->has('is_featured');
|
||||
$validated['sell_multiples'] = $request->has('sell_multiples');
|
||||
$validated['fractional_quantities'] = $request->has('fractional_quantities');
|
||||
$validated['allow_sample'] = $request->has('allow_sample');
|
||||
$validated['is_case'] = $request->has('is_case');
|
||||
$validated['is_box'] = $request->has('is_box');
|
||||
$validated['has_varieties'] = $request->has('has_varieties');
|
||||
$validated['sync_bamboo'] = $request->has('sync_bamboo');
|
||||
$validated['low_stock_alert_enabled'] = $request->has('low_stock_alert_enabled');
|
||||
$validated['is_assembly'] = $request->has('is_assembly');
|
||||
$validated['show_inventory_to_buyers'] = $request->has('show_inventory_to_buyers');
|
||||
$validated['is_sellable'] = $request->has('is_sellable');
|
||||
$validated['is_fpr'] = $request->has('is_fpr');
|
||||
$validated['is_raw_material'] = $request->has('is_raw_material');
|
||||
|
||||
// Store creatives JSON
|
||||
if (isset($validated['creatives_json'])) {
|
||||
$validated['creatives'] = $validated['creatives_json'];
|
||||
unset($validated['creatives_json']);
|
||||
}
|
||||
|
||||
// CRITICAL BUSINESS ISOLATION: Verify brand belongs to the business
|
||||
$brand = Brand::where('id', $validated['brand_id'])
|
||||
->where('business_id', $business->id)
|
||||
->firstOrFail();
|
||||
|
||||
// Update slug if name changed
|
||||
if ($validated['name'] !== $product->name) {
|
||||
$validated['slug'] = Str::slug($validated['name']);
|
||||
}
|
||||
// CRITICAL BUSINESS ISOLATION: Ensure the product belongs to this business through brand relationship
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
->findOrFail($product->id);
|
||||
|
||||
// Handle checkbox fields - set to false if not present in request
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
$validated['is_featured'] = $request->has('is_featured');
|
||||
// BUSINESS RULE: Only one active product per brand
|
||||
if ($request->has('is_active') && $request->boolean('is_active')) {
|
||||
$existingActiveProduct = Product::where('brand_id', $validated['brand_id'])
|
||||
->where('is_active', true)
|
||||
->where('id', '!=', $product->id)
|
||||
->first();
|
||||
|
||||
if ($existingActiveProduct) {
|
||||
// Check if user wants to force-activate this product
|
||||
if ($request->has('force_activate') && $request->boolean('force_activate')) {
|
||||
// Deactivate the existing active product
|
||||
$existingActiveProduct->update(['is_active' => false]);
|
||||
} else {
|
||||
// Show error with option to force activate
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('existing_active_product', $existingActiveProduct)
|
||||
->withErrors(['is_active' => "Only one product can be active per brand at a time. '{$existingActiveProduct->name}' (SKU: {$existingActiveProduct->sku}) is currently active."]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update product
|
||||
$product->update($validated);
|
||||
|
||||
// Handle new image uploads if present
|
||||
if ($request->hasFile('images')) {
|
||||
foreach ($request->file('images') as $index => $image) {
|
||||
$path = $image->store('products', 'public');
|
||||
$product->images()->create([
|
||||
'path' => $path,
|
||||
'type' => 'product',
|
||||
'is_primary' => $product->images()->count() === 0 && $index === 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', "Product '{$product->name}' updated successfully!");
|
||||
return redirect()
|
||||
->route('seller.business.products.edit', [$business->slug, $product->id])
|
||||
->with('success', 'Product updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,11 +411,8 @@ class ProductController extends Controller
|
||||
*/
|
||||
public function destroy(Business $business, Product $product)
|
||||
{
|
||||
// Eager load brand relationship
|
||||
$product->load('brand');
|
||||
|
||||
// Verify product belongs to a brand under this business
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
// Verify product belongs to this business
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403, 'This product does not belong to your business');
|
||||
}
|
||||
|
||||
|
||||
166
app/Http/Controllers/Seller/ProductImageController.php
Normal file
166
app/Http/Controllers/Seller/ProductImageController.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductImage;
|
||||
use App\Traits\FileStorageHelper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ProductImageController extends Controller
|
||||
{
|
||||
use FileStorageHelper;
|
||||
|
||||
/**
|
||||
* Upload a new product image
|
||||
*/
|
||||
public function upload(Request $request, Business $business, Product $product)
|
||||
{
|
||||
// CRITICAL: Ensure product belongs to this business through brand
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($product->id);
|
||||
|
||||
// Validate image
|
||||
$request->validate([
|
||||
'image' => 'required|image|mimes:jpeg,jpg,png|max:2048|dimensions:min_width=750,min_height=384', // 2MB max, 750x384 min
|
||||
]);
|
||||
|
||||
// Check if product already has 6 images
|
||||
if ($product->images()->count() >= 6) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Maximum of 6 images allowed per product',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Store the image using trait method
|
||||
$path = $this->storeFile($request->file('image'), 'products');
|
||||
|
||||
// Determine if this should be the primary image (first one)
|
||||
$isPrimary = $product->images()->count() === 0;
|
||||
|
||||
// If setting as primary, unset other primary images
|
||||
if ($isPrimary) {
|
||||
$product->images()->update(['is_primary' => false]);
|
||||
}
|
||||
|
||||
// Create the image record
|
||||
$image = $product->images()->create([
|
||||
'path' => $path,
|
||||
'is_primary' => $isPrimary,
|
||||
'sort_order' => $product->images()->max('sort_order') + 1,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'image' => [
|
||||
'id' => $image->id,
|
||||
'path' => $image->path,
|
||||
'is_primary' => $image->is_primary,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a product image
|
||||
*/
|
||||
public function delete(Business $business, Product $product, ProductImage $image)
|
||||
{
|
||||
// CRITICAL: Ensure product belongs to this business through brand
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($product->id);
|
||||
|
||||
// Ensure image belongs to this product
|
||||
if ($image->product_id !== $product->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Image not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Delete the file from storage using trait method
|
||||
$this->deleteFile($image->path);
|
||||
|
||||
// If deleting primary image, set next image as primary
|
||||
if ($image->is_primary) {
|
||||
$nextImage = $product->images()
|
||||
->where('id', '!=', $image->id)
|
||||
->orderBy('sort_order')
|
||||
->first();
|
||||
|
||||
if ($nextImage) {
|
||||
$nextImage->update(['is_primary' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
$image->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder product images
|
||||
*/
|
||||
public function reorder(Request $request, Business $business, Product $product)
|
||||
{
|
||||
// CRITICAL: Ensure product belongs to this business through brand
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($product->id);
|
||||
|
||||
$request->validate([
|
||||
'order' => 'required|array',
|
||||
'order.*' => 'required|integer|exists:product_images,id',
|
||||
]);
|
||||
|
||||
$order = $request->input('order');
|
||||
|
||||
// Update sort order and set first image as primary
|
||||
foreach ($order as $index => $imageId) {
|
||||
$image = ProductImage::where('id', $imageId)
|
||||
->where('product_id', $product->id)
|
||||
->first();
|
||||
|
||||
if ($image) {
|
||||
$image->update([
|
||||
'sort_order' => $index,
|
||||
'is_primary' => $index === 0, // First image is primary
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an image as primary
|
||||
*/
|
||||
public function setPrimary(Business $business, Product $product, ProductImage $image)
|
||||
{
|
||||
// CRITICAL: Ensure product belongs to this business through brand
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($product->id);
|
||||
|
||||
// Ensure image belongs to this product
|
||||
if ($image->product_id !== $product->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Image not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Unset all primary flags for this product
|
||||
$product->images()->update(['is_primary' => false]);
|
||||
|
||||
// Set this image as primary
|
||||
$image->update(['is_primary' => true]);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
70
app/Http/Controllers/Seller/ProductLineController.php
Normal file
70
app/Http/Controllers/Seller/ProductLineController.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\ProductLine;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProductLineController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255|unique:product_lines,name,NULL,id,business_id,'.$business->id,
|
||||
]);
|
||||
|
||||
ProductLine::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $request->name,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.products.index', $business->slug)
|
||||
->with('success', 'Product line created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, Business $business, ProductLine $productLine)
|
||||
{
|
||||
// Ensure business isolation
|
||||
if ($productLine->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255|unique:product_lines,name,'.$productLine->id.',id,business_id,'.$business->id,
|
||||
]);
|
||||
|
||||
$productLine->update([
|
||||
'name' => $request->name,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.products.index', $business->slug)
|
||||
->with('success', 'Product line updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Business $business, ProductLine $productLine)
|
||||
{
|
||||
// Ensure business isolation
|
||||
if ($productLine->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$productLine->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.products.index', $business->slug)
|
||||
->with('success', 'Product line deleted successfully.');
|
||||
}
|
||||
}
|
||||
118
app/Http/Controllers/Seller/SettingsController.php
Normal file
118
app/Http/Controllers/Seller/SettingsController.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the company information settings page.
|
||||
*/
|
||||
public function companyInformation(Business $business)
|
||||
{
|
||||
return view('seller.settings.company-information', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the company information.
|
||||
*/
|
||||
public function updateCompanyInformation(Business $business, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'business_type' => 'nullable|string',
|
||||
'tin_ein' => 'nullable|string|max:20',
|
||||
'license_number' => 'nullable|string|max:255',
|
||||
'license_type' => 'nullable|string',
|
||||
'physical_address' => 'nullable|string|max:255',
|
||||
'physical_city' => 'nullable|string|max:100',
|
||||
'physical_state' => 'nullable|string|max:2',
|
||||
'physical_zipcode' => 'nullable|string|max:10',
|
||||
'business_phone' => 'nullable|string|max:20',
|
||||
'business_email' => 'nullable|email|max:255',
|
||||
]);
|
||||
|
||||
$business->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.company-information', $business->slug)
|
||||
->with('success', 'Company information updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the users management settings page.
|
||||
*/
|
||||
public function users(Business $business)
|
||||
{
|
||||
return view('seller.settings.users', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the order settings page.
|
||||
*/
|
||||
public function orders(Business $business)
|
||||
{
|
||||
return view('seller.settings.orders', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the brands management page.
|
||||
*/
|
||||
public function brands(Business $business)
|
||||
{
|
||||
return view('seller.settings.brands', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the payment settings page.
|
||||
*/
|
||||
public function payments(Business $business)
|
||||
{
|
||||
return view('seller.settings.payments', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the invoice settings page.
|
||||
*/
|
||||
public function invoices(Business $business)
|
||||
{
|
||||
return view('seller.settings.invoices', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the manage licenses page.
|
||||
*/
|
||||
public function manageLicenses(Business $business)
|
||||
{
|
||||
return view('seller.settings.manage-licenses', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the plans and billing page.
|
||||
*/
|
||||
public function plansAndBilling(Business $business)
|
||||
{
|
||||
return view('seller.settings.plans-and-billing', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the notification preferences page.
|
||||
*/
|
||||
public function notifications(Business $business)
|
||||
{
|
||||
return view('seller.settings.notifications', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the report settings page.
|
||||
*/
|
||||
public function reports(Business $business)
|
||||
{
|
||||
return view('seller.settings.reports', compact('business'));
|
||||
}
|
||||
}
|
||||
@@ -51,16 +51,22 @@ class ShopController extends Controller
|
||||
'taxes' => 731.25,
|
||||
];
|
||||
|
||||
return view('buyer.shop.index', compact('featuredBrand', 'featuredProducts', 'promos', 'cart'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.shop.index', compact('featuredBrand', 'featuredProducts', 'promos', 'cart', 'business'));
|
||||
}
|
||||
|
||||
public function brand($brand)
|
||||
{
|
||||
return view('buyer.shop.brand', compact('brand'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.shop.brand', compact('brand', 'business'));
|
||||
}
|
||||
|
||||
public function product($product)
|
||||
{
|
||||
return view('buyer.shop.product', compact('product'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.shop.product', compact('product', 'business'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class VehicleController extends Controller
|
||||
*/
|
||||
public function index(\App\Models\Business $business)
|
||||
{
|
||||
$vehicles = Vehicle::where('business_id', $business->id)
|
||||
$vehicles = Vehicle::forBusiness($business)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
@@ -53,7 +53,7 @@ class VehicleController extends Controller
|
||||
public function update(\App\Models\Business $business, Request $request, Vehicle $vehicle)
|
||||
{
|
||||
// Ensure vehicle belongs to this business
|
||||
if ($vehicle->business_id !== $business->id) {
|
||||
if (! $vehicle->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class VehicleController extends Controller
|
||||
public function destroy(\App\Models\Business $business, Vehicle $vehicle)
|
||||
{
|
||||
// Ensure vehicle belongs to this business
|
||||
if ($vehicle->business_id !== $business->id) {
|
||||
if (! $vehicle->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ class VehicleController extends Controller
|
||||
public function toggle(\App\Models\Business $business, Vehicle $vehicle)
|
||||
{
|
||||
// Ensure vehicle belongs to this business
|
||||
if ($vehicle->business_id !== $business->id) {
|
||||
if (! $vehicle->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessViaProduct;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Batch extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessViaProduct, HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -11,7 +12,7 @@ use Str;
|
||||
|
||||
class Brand extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
// Product Categories that can be organized under brands
|
||||
public const PRODUCT_CATEGORIES = [
|
||||
@@ -96,11 +97,6 @@ class Brand extends Model
|
||||
return $query->where('is_featured', true);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopePublic($query)
|
||||
{
|
||||
return $query->where('is_public', true);
|
||||
|
||||
@@ -101,7 +101,7 @@ class BusinessModule extends Model
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->enabled && !$this->isExpired();
|
||||
return $this->enabled && ! $this->isExpired();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -101,7 +100,7 @@ class BusinessModuleUsage extends Model
|
||||
*/
|
||||
public function withinLimit(): bool
|
||||
{
|
||||
return !$this->isExceeded();
|
||||
return ! $this->isExceeded();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ class Cart extends Model
|
||||
'product_id',
|
||||
'batch_id',
|
||||
'brand_id',
|
||||
'business_id',
|
||||
'quantity',
|
||||
'session_id',
|
||||
];
|
||||
@@ -57,6 +58,14 @@ class Cart extends Model
|
||||
return $this->belongsTo(Batch::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the business associated with this cart item.
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the line total (quantity * product price).
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -12,7 +13,7 @@ use Illuminate\Support\Str;
|
||||
|
||||
class Component extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
@@ -115,11 +116,6 @@ class Component extends Model
|
||||
return $query->where('vendor_name', $vendorName);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeSellable($query)
|
||||
{
|
||||
return $query->where('is_sellable', true);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use DateTime;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Contact extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
// Contact Types for Cannabis Business (LeafLink-aligned)
|
||||
public const CONTACT_TYPES = [
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Driver extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
@@ -56,11 +57,6 @@ class Driver extends Model
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeForCompany($query, int $companyId)
|
||||
{
|
||||
return $query->where('business_id', $companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper Methods
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Invoice extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'invoice_number',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessViaBatch;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Lab extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessViaBatch, HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use DateTime;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class License extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
// Arizona Cannabis License Types
|
||||
public const LICENSE_TYPES = [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Location extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
// Location Types (LeafLink Facilities)
|
||||
public const LOCATION_TYPES = [
|
||||
|
||||
@@ -565,8 +565,8 @@ class Manifest extends Model
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
// Return seller company address
|
||||
$seller = $this->sellerCompany;
|
||||
// Return seller business address
|
||||
$seller = $this->sellerBusiness;
|
||||
if (! $seller) {
|
||||
return 'Lab Address Not Available';
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -13,7 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Order extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
/**
|
||||
* Get the route key name for Laravel route model binding.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessViaBrand;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -13,68 +14,155 @@ use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
class Product extends Model implements Auditable
|
||||
{
|
||||
use HasFactory, \OwenIt\Auditing\Auditable, SoftDeletes;
|
||||
use BelongsToBusinessViaBrand, HasFactory, \OwenIt\Auditing\Auditable, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
// Foreign Keys
|
||||
'brand_id',
|
||||
'strain_id',
|
||||
'parent_product_id',
|
||||
'packaging_id',
|
||||
'unit_id',
|
||||
|
||||
// Product Identity
|
||||
'name',
|
||||
'slug',
|
||||
'sku',
|
||||
'barcode',
|
||||
'description',
|
||||
'long_description',
|
||||
|
||||
// Product Type & Classification
|
||||
'type',
|
||||
'category',
|
||||
'product_line',
|
||||
'product_link',
|
||||
'creatives',
|
||||
|
||||
// BOM Flags
|
||||
'is_assembly',
|
||||
'is_raw_material',
|
||||
|
||||
// Configuration Flags
|
||||
'has_varieties',
|
||||
'sell_multiples',
|
||||
'fractional_quantities',
|
||||
'allow_sample',
|
||||
'is_fpr',
|
||||
'is_sellable',
|
||||
|
||||
// Pricing
|
||||
'wholesale_price',
|
||||
'msrp_price',
|
||||
'msrp',
|
||||
'cost_per_unit',
|
||||
'price_unit',
|
||||
|
||||
// Packaging & Units
|
||||
'net_weight',
|
||||
'weight_unit',
|
||||
'units_per_case',
|
||||
'is_case',
|
||||
'cased_qty',
|
||||
'is_box',
|
||||
'boxed_qty',
|
||||
|
||||
// Cannabis-specific
|
||||
'thc_percentage',
|
||||
'cbd_percentage',
|
||||
'thc_content_mg',
|
||||
'cbd_content_mg',
|
||||
'strain_value',
|
||||
'ingredients',
|
||||
'effects',
|
||||
'dosage_guidelines',
|
||||
|
||||
// Inventory & Status
|
||||
'quantity_on_hand',
|
||||
'quantity_allocated',
|
||||
'reorder_point',
|
||||
'min_order_qty',
|
||||
'max_order_qty',
|
||||
'low_stock_threshold',
|
||||
'low_stock_alert_enabled',
|
||||
'sync_bamboo',
|
||||
'is_active',
|
||||
'is_featured',
|
||||
'show_inventory_to_buyers',
|
||||
'status',
|
||||
|
||||
// Compliance & Tracking
|
||||
'metrc_id',
|
||||
'license_number',
|
||||
'arz_total_weight',
|
||||
'arz_usable_mmj',
|
||||
'harvest_date',
|
||||
'package_date',
|
||||
'test_date',
|
||||
'launch_date',
|
||||
|
||||
// Display & SEO
|
||||
'sort_order',
|
||||
'brand_display_order',
|
||||
'image_path',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
// Pricing
|
||||
'wholesale_price' => 'decimal:2',
|
||||
'msrp_price' => 'decimal:2',
|
||||
'msrp' => 'decimal:2',
|
||||
'cost_per_unit' => 'decimal:2',
|
||||
|
||||
// Measurements
|
||||
'net_weight' => 'decimal:3',
|
||||
'strain_value' => 'decimal:2',
|
||||
'arz_total_weight' => 'decimal:3',
|
||||
'arz_usable_mmj' => 'decimal:3',
|
||||
|
||||
// Cannabis
|
||||
'thc_percentage' => 'decimal:2',
|
||||
'cbd_percentage' => 'decimal:2',
|
||||
'thc_content_mg' => 'decimal:2',
|
||||
'cbd_content_mg' => 'decimal:2',
|
||||
|
||||
// Inventory
|
||||
'quantity_on_hand' => 'integer',
|
||||
'quantity_allocated' => 'integer',
|
||||
'reorder_point' => 'integer',
|
||||
'min_order_qty' => 'integer',
|
||||
'max_order_qty' => 'integer',
|
||||
'low_stock_threshold' => 'integer',
|
||||
|
||||
// Packaging
|
||||
'units_per_case' => 'integer',
|
||||
'cased_qty' => 'integer',
|
||||
'boxed_qty' => 'integer',
|
||||
'brand_display_order' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
|
||||
// Booleans
|
||||
'is_assembly' => 'boolean',
|
||||
'is_raw_material' => 'boolean',
|
||||
'has_varieties' => 'boolean',
|
||||
'sell_multiples' => 'boolean',
|
||||
'fractional_quantities' => 'boolean',
|
||||
'allow_sample' => 'boolean',
|
||||
'is_fpr' => 'boolean',
|
||||
'is_sellable' => 'boolean',
|
||||
'is_case' => 'boolean',
|
||||
'is_box' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
'show_inventory_to_buyers' => 'boolean',
|
||||
'low_stock_alert_enabled' => 'boolean',
|
||||
'sync_bamboo' => 'boolean',
|
||||
|
||||
// Dates
|
||||
'harvest_date' => 'date',
|
||||
'package_date' => 'date',
|
||||
'test_date' => 'date',
|
||||
'launch_date' => 'date',
|
||||
];
|
||||
|
||||
// Audit configuration - exclude timestamps and system-managed fields
|
||||
@@ -106,11 +194,26 @@ class Product extends Model implements Auditable
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
public function productLine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductLine::class);
|
||||
}
|
||||
|
||||
public function strain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Strain::class);
|
||||
}
|
||||
|
||||
public function packaging(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductPackaging::class, 'packaging_id');
|
||||
}
|
||||
|
||||
public function unit(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Unit::class);
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'parent_product_id');
|
||||
@@ -123,7 +226,7 @@ class Product extends Model implements Auditable
|
||||
|
||||
public function images(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductImage::class)->orderBy('order');
|
||||
return $this->hasMany(ProductImage::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function primaryImage(): HasMany
|
||||
|
||||
@@ -12,12 +12,14 @@ class ProductImage extends Model
|
||||
'path',
|
||||
'type',
|
||||
'order',
|
||||
'sort_order',
|
||||
'is_primary',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_primary' => 'boolean',
|
||||
'order' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function product(): BelongsTo
|
||||
|
||||
31
app/Models/ProductLine.php
Normal file
31
app/Models/ProductLine.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ProductLine extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'name',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the business that owns the product line.
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the products for this product line.
|
||||
*/
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class);
|
||||
}
|
||||
}
|
||||
39
app/Models/ProductPackaging.php
Normal file
39
app/Models/ProductPackaging.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ProductPackaging extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'images',
|
||||
'is_recyclable',
|
||||
'compliance_details',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'images' => 'array',
|
||||
'is_recyclable' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class, 'packaging_id');
|
||||
}
|
||||
|
||||
// Scopes
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
}
|
||||
37
app/Models/Unit.php
Normal file
37
app/Models/Unit.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Unit extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'unit',
|
||||
'name',
|
||||
'abbreviation',
|
||||
'type',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class);
|
||||
}
|
||||
|
||||
// Scopes
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,13 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Lab404\Impersonate\Models\Impersonate;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
use STS\FilamentImpersonate\Concerns\Impersonatable;
|
||||
|
||||
class User extends Authenticatable implements FilamentUser
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, HasRoles, Notifiable, Impersonatable;
|
||||
use HasFactory, HasRoles, Impersonate, Notifiable;
|
||||
|
||||
/**
|
||||
* User type constants
|
||||
@@ -295,17 +295,17 @@ class User extends Authenticatable implements FilamentUser
|
||||
*/
|
||||
public function getImpersonateRedirectUrl(): string
|
||||
{
|
||||
$business = $this->primaryBusiness();
|
||||
|
||||
if ($this->user_type === 'seller' && $business) {
|
||||
return '/s/'.$business->slug;
|
||||
if ($this->user_type === 'seller') {
|
||||
// Redirect to seller dashboard
|
||||
return '/s/dashboard';
|
||||
}
|
||||
|
||||
if ($this->user_type === 'buyer' && $business) {
|
||||
return '/b/'.$business->slug;
|
||||
if ($this->user_type === 'buyer') {
|
||||
// Redirect to buyer browse page (marketplace)
|
||||
return '/b/browse';
|
||||
}
|
||||
|
||||
// Fallback to home if no business or unknown type
|
||||
// Fallback to home if unknown user type
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Vehicle extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
@@ -58,11 +59,6 @@ class Vehicle extends Model
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeForCompany($query, int $companyId)
|
||||
{
|
||||
return $query->where('business_id', $companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper Methods
|
||||
*/
|
||||
|
||||
@@ -49,37 +49,64 @@ class AppServiceProvider extends ServiceProvider
|
||||
//
|
||||
// We cache for 5 seconds - long enough to prevent running on every view
|
||||
// in a single page load, but short enough that dirty status updates quickly
|
||||
$versionData = cache()->remember('app.version_data', now()->addSeconds(5), function () {
|
||||
$version = 'dev';
|
||||
$commit = 'unknown';
|
||||
//
|
||||
// Wrap in try-catch to handle cache (Redis) connection failures gracefully
|
||||
// This allows the app to boot even if Redis is temporarily unavailable
|
||||
try {
|
||||
$versionData = cache()->remember('app.version_data', now()->addSeconds(5), function () {
|
||||
$version = 'dev';
|
||||
$commit = 'unknown';
|
||||
|
||||
// For Docker: read from version.env (injected at build time)
|
||||
$versionFile = base_path('version.env');
|
||||
|
||||
if (File::exists($versionFile)) {
|
||||
$data = parse_ini_file($versionFile);
|
||||
$version = $data['VERSION'] ?? 'dev';
|
||||
$commit = $data['COMMIT'] ?? 'unknown';
|
||||
}
|
||||
// For local dev: read from git directly (but cached for 5 seconds)
|
||||
// Check for .git (directory for regular repos, file for worktrees)
|
||||
elseif (file_exists(base_path('.git'))) {
|
||||
// Try to get current commit SHA (7 characters)
|
||||
// Use 'cd' to ensure git commands work in worktrees
|
||||
$gitCommand = sprintf('cd %s && git rev-parse --short=7 HEAD 2>/dev/null', escapeshellarg(base_path()));
|
||||
$commit = trim(shell_exec($gitCommand) ?: '');
|
||||
|
||||
// Only proceed if we successfully got a commit SHA
|
||||
if ($commit !== '' && $commit !== 'unknown') {
|
||||
// Check for uncommitted changes (dirty working directory)
|
||||
$diffCommand = sprintf('cd %s && git diff --quiet 2>/dev/null; echo $?', escapeshellarg(base_path()));
|
||||
$cachedCommand = sprintf('cd %s && git diff --cached --quiet 2>/dev/null; echo $?', escapeshellarg(base_path()));
|
||||
|
||||
$isDirty = (int) trim(shell_exec($diffCommand) ?: '1');
|
||||
$isCachedDirty = (int) trim(shell_exec($cachedCommand) ?: '1');
|
||||
|
||||
if ($isDirty !== 0 || $isCachedDirty !== 0) {
|
||||
$commit .= '-dirty';
|
||||
}
|
||||
} else {
|
||||
// Git command failed (e.g., in k8s where git metadata isn't accessible)
|
||||
$commit = 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'commit' => $commit,
|
||||
];
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
// If cache fails (e.g., Redis not ready), calculate version without caching
|
||||
$versionData = ['version' => 'dev', 'commit' => 'unknown'];
|
||||
|
||||
// For Docker: read from version.env (injected at build time)
|
||||
$versionFile = base_path('version.env');
|
||||
|
||||
if (File::exists($versionFile)) {
|
||||
$data = parse_ini_file($versionFile);
|
||||
$version = $data['VERSION'] ?? 'dev';
|
||||
$commit = $data['COMMIT'] ?? 'unknown';
|
||||
$versionData['version'] = $data['VERSION'] ?? 'dev';
|
||||
$versionData['commit'] = $data['COMMIT'] ?? 'unknown';
|
||||
}
|
||||
// For local dev: read from git directly (but cached for 5 seconds)
|
||||
elseif (is_dir(base_path('.git'))) {
|
||||
// Get current commit SHA (7 characters)
|
||||
$commit = trim(shell_exec('git rev-parse --short=7 HEAD 2>/dev/null') ?: 'unknown');
|
||||
|
||||
// Check for uncommitted changes (dirty working directory)
|
||||
$isDirty = (int) trim(shell_exec('git diff --quiet 2>/dev/null; echo $?') ?: '1');
|
||||
$isCachedDirty = (int) trim(shell_exec('git diff --cached --quiet 2>/dev/null; echo $?') ?: '1');
|
||||
|
||||
if ($isDirty !== 0 || $isCachedDirty !== 0) {
|
||||
$commit .= '-dirty';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'commit' => $commit,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
View::composer('*', function ($view) use ($versionData) {
|
||||
$view->with([
|
||||
|
||||
@@ -20,6 +20,7 @@ use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
// use STS\FilamentImpersonate\Impersonate;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Services;
|
||||
|
||||
use App\Events\CartUpdated;
|
||||
use App\Models\Batch;
|
||||
use App\Models\Business;
|
||||
use App\Models\Cart;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
@@ -44,12 +45,36 @@ class CartService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify cart ownership - ensures the cart belongs to the current user/session.
|
||||
*
|
||||
* SECURITY: Prevents users from modifying other users' cart items.
|
||||
*
|
||||
* @throws \Exception if cart doesn't belong to user/session
|
||||
*/
|
||||
private function verifyCartOwnership(Cart $cart, ?User $user, ?string $sessionId): void
|
||||
{
|
||||
if ($user) {
|
||||
// Authenticated user: cart must belong to this user
|
||||
if ($cart->user_id !== $user->id) {
|
||||
throw new \Exception('Unauthorized: This cart item does not belong to you.');
|
||||
}
|
||||
} else {
|
||||
// Guest: cart must belong to this session
|
||||
if ($cart->session_id !== $sessionId) {
|
||||
throw new \Exception('Unauthorized: This cart item does not belong to your session.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add item to cart (or update quantity if exists).
|
||||
*
|
||||
* SECURITY: Stores business_id for cart authorization and audit trail.
|
||||
*
|
||||
* @throws \Exception if quantity exceeds available stock
|
||||
*/
|
||||
public function addItem(int $productId, int $quantity = 1, ?User $user = null, ?string $sessionId = null, ?int $batchId = null): Cart
|
||||
public function addItem(Business $business, int $productId, int $quantity = 1, ?User $user = null, ?string $sessionId = null, ?int $batchId = null): Cart
|
||||
{
|
||||
$product = Product::findOrFail($productId);
|
||||
$batch = null;
|
||||
@@ -104,6 +129,7 @@ class CartService
|
||||
'product_id' => $productId,
|
||||
'batch_id' => $batchId,
|
||||
'brand_id' => $product->brand_id,
|
||||
'business_id' => $business->id,
|
||||
'quantity' => $quantity,
|
||||
];
|
||||
|
||||
@@ -133,12 +159,17 @@ class CartService
|
||||
/**
|
||||
* Update cart item quantity.
|
||||
*
|
||||
* @throws \Exception if quantity exceeds available stock
|
||||
* SECURITY: Verifies cart ownership before allowing updates.
|
||||
*
|
||||
* @throws \Exception if quantity exceeds available stock or cart doesn't belong to user
|
||||
*/
|
||||
public function updateQuantity(int $cartId, int $quantity): Cart
|
||||
public function updateQuantity(int $cartId, int $quantity, ?User $user = null, ?string $sessionId = null): Cart
|
||||
{
|
||||
$cart = Cart::with('product', 'user')->findOrFail($cartId);
|
||||
|
||||
// SECURITY: Verify this cart belongs to the current user/session
|
||||
$this->verifyCartOwnership($cart, $user, $sessionId);
|
||||
|
||||
// Ensure at least 1
|
||||
$quantity = max(1, $quantity);
|
||||
|
||||
@@ -160,11 +191,18 @@ class CartService
|
||||
|
||||
/**
|
||||
* Remove item from cart.
|
||||
*
|
||||
* SECURITY: Verifies cart ownership before allowing deletion.
|
||||
*
|
||||
* @throws \Exception if cart doesn't belong to user
|
||||
*/
|
||||
public function removeItem(int $cartId): bool
|
||||
public function removeItem(int $cartId, ?User $user = null, ?string $sessionId = null): bool
|
||||
{
|
||||
$cart = Cart::with('user')->findOrFail($cartId);
|
||||
|
||||
// SECURITY: Verify this cart belongs to the current user/session
|
||||
$this->verifyCartOwnership($cart, $user, $sessionId);
|
||||
|
||||
// Clear cached cart count before deleting
|
||||
$this->clearCartCache($cart->user, $cart->session_id);
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class ModuleService
|
||||
$module = Module::where('key', $moduleKey)->firstOrFail();
|
||||
|
||||
// Check if module requires approval
|
||||
if ($module->requires_approval && !$activatedBy) {
|
||||
if ($module->requires_approval && ! $activatedBy) {
|
||||
throw new \Exception('This module requires approval by an administrator.');
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ class ModuleService
|
||||
string $period = 'monthly'
|
||||
): void {
|
||||
// Check if business has the module enabled
|
||||
if (!$business->hasModule($moduleKey)) {
|
||||
if (! $business->hasModule($moduleKey)) {
|
||||
throw new \Exception("Module {$moduleKey} is not enabled for this business.");
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ class ModuleService
|
||||
string $period = 'monthly'
|
||||
): bool {
|
||||
// Check if module is enabled
|
||||
if (!$business->hasModule($moduleKey)) {
|
||||
if (! $business->hasModule($moduleKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ class ModuleService
|
||||
->get()
|
||||
->map(function ($usage) {
|
||||
$usage->resetIfNeeded();
|
||||
|
||||
return [
|
||||
'metric' => $usage->metric,
|
||||
'current' => $usage->current_count,
|
||||
@@ -184,7 +185,7 @@ class ModuleService
|
||||
->get();
|
||||
|
||||
foreach ($defaultModules as $module) {
|
||||
if (!$business->hasModule($module->key)) {
|
||||
if (! $business->hasModule($module->key)) {
|
||||
$business->enableModule(
|
||||
$module->key,
|
||||
$activatedBy
|
||||
@@ -212,7 +213,7 @@ class ModuleService
|
||||
): BusinessModule {
|
||||
$businessModule = $business->getModule($moduleKey);
|
||||
|
||||
if (!$businessModule) {
|
||||
if (! $businessModule) {
|
||||
throw new \Exception("Module {$moduleKey} not found for this business.");
|
||||
}
|
||||
|
||||
@@ -239,7 +240,7 @@ class ModuleService
|
||||
): BusinessModule {
|
||||
$businessModule = $business->getModule($moduleKey);
|
||||
|
||||
if (!$businessModule) {
|
||||
if (! $businessModule) {
|
||||
throw new \Exception("Module {$moduleKey} not found for this business.");
|
||||
}
|
||||
|
||||
@@ -258,7 +259,7 @@ class ModuleService
|
||||
): BusinessModule {
|
||||
$businessModule = $business->getModule($moduleKey);
|
||||
|
||||
if (!$businessModule) {
|
||||
if (! $businessModule) {
|
||||
throw new \Exception("Module {$moduleKey} not found for this business.");
|
||||
}
|
||||
|
||||
|
||||
42
app/Traits/BelongsToBusinessDirectly.php
Normal file
42
app/Traits/BelongsToBusinessDirectly.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Trait for models that belong directly to a Business
|
||||
*
|
||||
* Used by models like Brand, Order that have direct business_id foreign key.
|
||||
*
|
||||
* Usage:
|
||||
* - Add trait to model: use BelongsToBusinessDirectly;
|
||||
* - Query: Brand::forBusiness($business)->get();
|
||||
*/
|
||||
trait BelongsToBusinessDirectly
|
||||
{
|
||||
/**
|
||||
* Scope query to only include records belonging to the specified business.
|
||||
*/
|
||||
public function scopeForBusiness(Builder $query, Business $business): Builder
|
||||
{
|
||||
return $query->where('business_id', $business->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope query to exclude records belonging to the specified business.
|
||||
*/
|
||||
public function scopeNotForBusiness(Builder $query, Business $business): Builder
|
||||
{
|
||||
return $query->where('business_id', '!=', $business->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this model instance belongs to the specified business.
|
||||
*/
|
||||
public function belongsToBusiness(Business $business): bool
|
||||
{
|
||||
return $this->business_id === $business->id;
|
||||
}
|
||||
}
|
||||
54
app/Traits/BelongsToBusinessViaBatch.php
Normal file
54
app/Traits/BelongsToBusinessViaBatch.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Trait for models that belong to a Business via Batch → Product → Brand relationship
|
||||
*
|
||||
* Used by models like Lab that have:
|
||||
* Lab → Batch → Product → Brand → Business
|
||||
*
|
||||
* Note: Lab has both batch_id (primary) and product_id (convenience).
|
||||
* We scope via batch_id since that's what the lab actually tests.
|
||||
*
|
||||
* Usage:
|
||||
* - Add trait to model: use BelongsToBusinessViaBatch;
|
||||
* - Query: Lab::forBusiness($business)->get();
|
||||
*/
|
||||
trait BelongsToBusinessViaBatch
|
||||
{
|
||||
/**
|
||||
* Scope query to only include records belonging to the specified business
|
||||
* via their batch → product → brand relationship.
|
||||
*/
|
||||
public function scopeForBusiness(Builder $query, Business $business): Builder
|
||||
{
|
||||
return $query->whereHas('batch.product.brand', function (Builder $q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope query to exclude records belonging to the specified business.
|
||||
*/
|
||||
public function scopeNotForBusiness(Builder $query, Business $business): Builder
|
||||
{
|
||||
return $query->whereHas('batch.product.brand', function (Builder $q) use ($business) {
|
||||
$q->where('business_id', '!=', $business->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this model instance belongs to the specified business.
|
||||
*/
|
||||
public function belongsToBusiness(Business $business): bool
|
||||
{
|
||||
return $this->batch
|
||||
&& $this->batch->product
|
||||
&& $this->batch->product->brand
|
||||
&& $this->batch->product->brand->business_id === $business->id;
|
||||
}
|
||||
}
|
||||
48
app/Traits/BelongsToBusinessViaBrand.php
Normal file
48
app/Traits/BelongsToBusinessViaBrand.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Trait for models that belong to a Business via a Brand relationship
|
||||
*
|
||||
* Used by models like Product that have:
|
||||
* Product -> Brand -> Business
|
||||
*
|
||||
* Usage:
|
||||
* - Add trait to model: use BelongsToBusinessViaBrand;
|
||||
* - Query: Product::forBusiness($business)->get();
|
||||
*/
|
||||
trait BelongsToBusinessViaBrand
|
||||
{
|
||||
/**
|
||||
* Scope query to only include records belonging to the specified business
|
||||
* via their brand relationship.
|
||||
*/
|
||||
public function scopeForBusiness(Builder $query, Business $business): Builder
|
||||
{
|
||||
return $query->whereHas('brand', function (Builder $q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope query to exclude records belonging to the specified business.
|
||||
*/
|
||||
public function scopeNotForBusiness(Builder $query, Business $business): Builder
|
||||
{
|
||||
return $query->whereHas('brand', function (Builder $q) use ($business) {
|
||||
$q->where('business_id', '!=', $business->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this model instance belongs to the specified business.
|
||||
*/
|
||||
public function belongsToBusiness(Business $business): bool
|
||||
{
|
||||
return $this->brand && $this->brand->business_id === $business->id;
|
||||
}
|
||||
}
|
||||
50
app/Traits/BelongsToBusinessViaProduct.php
Normal file
50
app/Traits/BelongsToBusinessViaProduct.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Trait for models that belong to a Business via Product → Brand relationship
|
||||
*
|
||||
* Used by models like Batch that have:
|
||||
* Batch → Product → Brand → Business
|
||||
*
|
||||
* Usage:
|
||||
* - Add trait to model: use BelongsToBusinessViaProduct;
|
||||
* - Query: Batch::forBusiness($business)->get();
|
||||
*/
|
||||
trait BelongsToBusinessViaProduct
|
||||
{
|
||||
/**
|
||||
* Scope query to only include records belonging to the specified business
|
||||
* via their product → brand relationship.
|
||||
*/
|
||||
public function scopeForBusiness(Builder $query, Business $business): Builder
|
||||
{
|
||||
return $query->whereHas('product.brand', function (Builder $q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope query to exclude records belonging to the specified business.
|
||||
*/
|
||||
public function scopeNotForBusiness(Builder $query, Business $business): Builder
|
||||
{
|
||||
return $query->whereHas('product.brand', function (Builder $q) use ($business) {
|
||||
$q->where('business_id', '!=', $business->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this model instance belongs to the specified business.
|
||||
*/
|
||||
public function belongsToBusiness(Business $business): bool
|
||||
{
|
||||
return $this->product
|
||||
&& $this->product->brand
|
||||
&& $this->product->brand->business_id === $business->id;
|
||||
}
|
||||
}
|
||||
122
app/Traits/FileStorageHelper.php
Normal file
122
app/Traits/FileStorageHelper.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait FileStorageHelper
|
||||
{
|
||||
/**
|
||||
* Get the current storage disk
|
||||
*/
|
||||
protected function getStorageDisk(): string
|
||||
{
|
||||
return config('filesystems.default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if using S3/MinIO storage
|
||||
*/
|
||||
protected function isUsingS3(): bool
|
||||
{
|
||||
return $this->getStorageDisk() === 's3';
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a file and return its path
|
||||
*
|
||||
* @param \Illuminate\Http\UploadedFile $file
|
||||
*/
|
||||
protected function storeFile($file, string $folder, ?string $filename = null): string
|
||||
{
|
||||
$disk = Storage::disk($this->getStorageDisk());
|
||||
|
||||
if ($filename) {
|
||||
$path = $folder.'/'.$filename;
|
||||
$disk->put($path, file_get_contents($file));
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
return $file->store($folder, $this->getStorageDisk());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique filename
|
||||
*/
|
||||
protected function generateUniqueFilename(string $originalName): string
|
||||
{
|
||||
$extension = pathinfo($originalName, PATHINFO_EXTENSION);
|
||||
$basename = pathinfo($originalName, PATHINFO_FILENAME);
|
||||
$slug = Str::slug($basename);
|
||||
|
||||
return $slug.'-'.Str::random(8).'.'.$extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public URL for a file
|
||||
*/
|
||||
protected function getFileUrl(?string $path): ?string
|
||||
{
|
||||
if (! $path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->isUsingS3()) {
|
||||
// For S3/MinIO, return full CDN URL
|
||||
return Storage::disk('s3')->url($path);
|
||||
}
|
||||
|
||||
// For local storage, return app URL + storage path
|
||||
return asset('storage/'.$path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file
|
||||
*/
|
||||
protected function deleteFile(?string $path): bool
|
||||
{
|
||||
if (! $path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($this->getStorageDisk());
|
||||
|
||||
if ($disk->exists($path)) {
|
||||
return $disk->delete($path);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old file and store new one
|
||||
*
|
||||
* @param \Illuminate\Http\UploadedFile $newFile
|
||||
*/
|
||||
protected function replaceFile($newFile, ?string $oldPath, string $folder): string
|
||||
{
|
||||
// Delete old file if it exists
|
||||
if ($oldPath) {
|
||||
$this->deleteFile($oldPath);
|
||||
}
|
||||
|
||||
// Store new file
|
||||
return $this->storeFile($newFile, $folder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage info for debugging
|
||||
*/
|
||||
protected function getStorageInfo(): array
|
||||
{
|
||||
return [
|
||||
'disk' => $this->getStorageDisk(),
|
||||
'is_s3' => $this->isUsingS3(),
|
||||
'driver' => config('filesystems.disks.'.$this->getStorageDisk().'.driver'),
|
||||
'endpoint' => config('filesystems.disks.'.$this->getStorageDisk().'.endpoint'),
|
||||
'bucket' => config('filesystems.disks.'.$this->getStorageDisk().'.bucket'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -97,11 +97,12 @@ trait HasModules
|
||||
{
|
||||
$businessModule = $this->getModule($moduleKey);
|
||||
|
||||
if (!$businessModule) {
|
||||
if (! $businessModule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$businessModule->enabled = false;
|
||||
|
||||
return $businessModule->save();
|
||||
}
|
||||
|
||||
@@ -133,7 +134,7 @@ trait HasModules
|
||||
->where('period_end', '>', now())
|
||||
->first();
|
||||
|
||||
if (!$usage) {
|
||||
if (! $usage) {
|
||||
// Get limit from business module if not provided
|
||||
if ($limit === null) {
|
||||
$businessModule = $this->getModule($moduleKey);
|
||||
@@ -173,7 +174,7 @@ trait HasModules
|
||||
->currentPeriod()
|
||||
->first();
|
||||
|
||||
if (!$usage) {
|
||||
if (! $usage) {
|
||||
return true; // No usage record means within limit
|
||||
}
|
||||
|
||||
|
||||
1065
claude-rules-kelly
Normal file
1065
claude-rules-kelly
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,11 +13,12 @@
|
||||
"laravel/reverb": "^1.6",
|
||||
"laravel/telescope": "*",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"owen-it/laravel-auditing": "^14.0",
|
||||
"predis/predis": "*",
|
||||
"rahulhaque/laravel-filepond": "*",
|
||||
"spatie/laravel-permission": "^6.18",
|
||||
"stechstudio/filament-impersonate": "*",
|
||||
"stechstudio/filament-impersonate": "^4.0",
|
||||
"tapp/filament-auditing": "^4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
|
||||
274
composer.lock
generated
274
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "01a43b84767363442ae74c1c06d30746",
|
||||
"content-hash": "6045e661b5737a9c8dd40aeeaf8c9897",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -72,6 +72,157 @@
|
||||
},
|
||||
"time": "2025-07-30T15:45:57+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
"version": "v1.2.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/awslabs/aws-crt-php.git",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
|
||||
"yoast/phpunit-polyfills": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "AWS SDK Common Runtime Team",
|
||||
"email": "aws-sdk-common-runtime@amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS Common Runtime for PHP",
|
||||
"homepage": "https://github.com/awslabs/aws-crt-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"crt",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/awslabs/aws-crt-php/issues",
|
||||
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
|
||||
},
|
||||
"time": "2024-10-18T22:15:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-sdk-php",
|
||||
"version": "3.359.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||
"reference": "8d2ab3687196f15209c316080a431911f2e02bb5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/8d2ab3687196f15209c316080a431911f2e02bb5",
|
||||
"reference": "8d2ab3687196f15209c316080a431911f2e02bb5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-crt-php": "^1.2.3",
|
||||
"ext-json": "*",
|
||||
"ext-pcre": "*",
|
||||
"ext-simplexml": "*",
|
||||
"guzzlehttp/guzzle": "^7.4.5",
|
||||
"guzzlehttp/promises": "^2.0",
|
||||
"guzzlehttp/psr7": "^2.4.5",
|
||||
"mtdowling/jmespath.php": "^2.8.0",
|
||||
"php": ">=8.1",
|
||||
"psr/http-message": "^1.0 || ^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"andrewsville/php-token-reflection": "^1.4",
|
||||
"aws/aws-php-sns-message-validator": "~1.0",
|
||||
"behat/behat": "~3.0",
|
||||
"composer/composer": "^2.7.8",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.4.0",
|
||||
"doctrine/cache": "~1.4",
|
||||
"ext-dom": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-pcntl": "*",
|
||||
"ext-sockets": "*",
|
||||
"phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5",
|
||||
"psr/cache": "^2.0 || ^3.0",
|
||||
"psr/simple-cache": "^2.0 || ^3.0",
|
||||
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
|
||||
"symfony/filesystem": "^v6.4.0 || ^v7.1.0",
|
||||
"yoast/phpunit-polyfills": "^2.0"
|
||||
},
|
||||
"suggest": {
|
||||
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
|
||||
"doctrine/cache": "To use the DoctrineCacheAdapter",
|
||||
"ext-curl": "To send requests using cURL",
|
||||
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
|
||||
"ext-sockets": "To use client-side monitoring"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Aws\\": "src/"
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"src/data/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Amazon Web Services",
|
||||
"homepage": "http://aws.amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
|
||||
"homepage": "http://aws.amazon.com/sdkforphp",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"cloud",
|
||||
"dynamodb",
|
||||
"ec2",
|
||||
"glacier",
|
||||
"s3",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.359.6"
|
||||
},
|
||||
"time": "2025-11-05T19:08:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "barryvdh/laravel-dompdf",
|
||||
"version": "v3.1.1",
|
||||
@@ -3431,6 +3582,61 @@
|
||||
},
|
||||
"time": "2025-10-20T15:35:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
"version": "3.30.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
|
||||
"reference": "d286e896083bed3190574b8b088b557b59eb66f5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/d286e896083bed3190574b8b088b557b59eb66f5",
|
||||
"reference": "d286e896083bed3190574b8b088b557b59eb66f5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-sdk-php": "^3.295.10",
|
||||
"league/flysystem": "^3.10.0",
|
||||
"league/mime-type-detection": "^1.0.0",
|
||||
"php": "^8.0.2"
|
||||
},
|
||||
"conflict": {
|
||||
"guzzlehttp/guzzle": "<7.0",
|
||||
"guzzlehttp/ringphp": "<1.1.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Flysystem\\AwsS3V3\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Frank de Jonge",
|
||||
"email": "info@frankdejonge.nl"
|
||||
}
|
||||
],
|
||||
"description": "AWS S3 filesystem adapter for Flysystem.",
|
||||
"keywords": [
|
||||
"Flysystem",
|
||||
"aws",
|
||||
"file",
|
||||
"files",
|
||||
"filesystem",
|
||||
"s3",
|
||||
"storage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.30.1"
|
||||
},
|
||||
"time": "2025-10-20T15:27:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-local",
|
||||
"version": "3.30.0",
|
||||
@@ -4038,6 +4244,72 @@
|
||||
],
|
||||
"time": "2025-03-24T10:02:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mtdowling/jmespath.php",
|
||||
"version": "2.8.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jmespath/jmespath.php.git",
|
||||
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
|
||||
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"symfony/polyfill-mbstring": "^1.17"
|
||||
},
|
||||
"require-dev": {
|
||||
"composer/xdebug-handler": "^3.0.3",
|
||||
"phpunit/phpunit": "^8.5.33"
|
||||
},
|
||||
"bin": [
|
||||
"bin/jp.php"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/JmesPath.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"JmesPath\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
}
|
||||
],
|
||||
"description": "Declaratively specify how to extract elements from a JSON document",
|
||||
"keywords": [
|
||||
"json",
|
||||
"jsonpath"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/jmespath/jmespath.php/issues",
|
||||
"source": "https://github.com/jmespath/jmespath.php/tree/2.8.0"
|
||||
},
|
||||
"time": "2024-09-04T18:46:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.10.3",
|
||||
|
||||
44
config/filament-impersonate.php
Normal file
44
config/filament-impersonate.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// This is the guard used when logging in as the impersonated user.
|
||||
'guard' => env('FILAMENT_IMPERSONATE_GUARD', 'web'),
|
||||
|
||||
// After impersonating this is where we'll redirect you to.
|
||||
'redirect_to' => env('FILAMENT_IMPERSONATE_REDIRECT', '/'),
|
||||
|
||||
// We wire up a route for the "leave" button. You can change the middleware stack here if needed.
|
||||
'leave_middleware' => env('FILAMENT_IMPERSONATE_LEAVE_MIDDLEWARE', 'web'),
|
||||
|
||||
// Add a prefix for routes - Useful for apps installed with a subdirectory
|
||||
'route_prefix' => env('FILAMENT_IMPERSONATE_ROUTE_PREFIX', null),
|
||||
|
||||
'allow_soft_deleted' => env('FILAMENT_IMPERSONATE_ALLOW_SOFT_DELETED', false),
|
||||
|
||||
'banner' => [
|
||||
// Available hooks: https://filamentphp.com/docs/3.x/support/render-hooks#available-render-hooks
|
||||
'render_hook' => env('FILAMENT_IMPERSONATE_BANNER_RENDER_HOOK', 'panels::body.start'),
|
||||
|
||||
// Currently supports 'dark', 'light' and 'auto'.
|
||||
'style' => env('FILAMENT_IMPERSONATE_BANNER_STYLE', 'auto'),
|
||||
|
||||
// Turn this off if you want `absolute` positioning, so the banner scrolls out of view
|
||||
'fixed' => env('FILAMENT_IMPERSONATE_BANNER_FIXED', true),
|
||||
|
||||
// Currently supports 'top' and 'bottom'.
|
||||
'position' => env('FILAMENT_IMPERSONATE_BANNER_POSITION', 'top'),
|
||||
|
||||
'styles' => [
|
||||
'light' => [
|
||||
'text' => '#7f1d1d',
|
||||
'background' => '#fee2e2',
|
||||
'border' => '#fca5a5',
|
||||
],
|
||||
'dark' => [
|
||||
'text' => '#fef2f2',
|
||||
'background' => '#991b1b',
|
||||
'border' => '#b91c1c',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
70
database/factories/OrderFactory.php
Normal file
70
database/factories/OrderFactory.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Order>
|
||||
*/
|
||||
class OrderFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'order_number' => 'ORD-'.fake()->unique()->numerify('######'),
|
||||
'picking_ticket_number' => 'PT-'.fake()->unique()->numerify('######'),
|
||||
'business_id' => Business::factory(),
|
||||
'user_id' => User::factory(),
|
||||
'contact_id' => null,
|
||||
'location_id' => null,
|
||||
'subtotal' => $subtotal = fake()->randomFloat(2, 100, 5000),
|
||||
'surcharge' => $surcharge = fake()->randomFloat(2, 0, 50),
|
||||
'tax' => $tax = round($subtotal * 0.08, 2), // 8% tax
|
||||
'total' => round($subtotal + $surcharge + ($subtotal * 0.08), 2),
|
||||
'status' => fake()->randomElement(['new', 'accepted', 'in_progress', 'ready_for_manifest', 'ready_for_delivery', 'delivered', 'cancelled']),
|
||||
'notes' => fake()->optional()->sentence(),
|
||||
'delivery_date' => fake()->optional()->dateTimeBetween('now', '+30 days'),
|
||||
'delivery_instructions' => fake()->optional()->sentence(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Order with new status.
|
||||
*/
|
||||
public function pending(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'status' => 'new',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Order with accepted status.
|
||||
*/
|
||||
public function accepted(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'status' => 'accepted',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Order with delivered status.
|
||||
*/
|
||||
public function delivered(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'status' => 'delivered',
|
||||
'delivery_date' => fake()->dateTimeBetween('-30 days', 'now'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->date('delivery_date')->nullable()->after('notes');
|
||||
$table->text('delivery_instructions')->nullable()->after('delivery_date');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->dropColumn(['delivery_date', 'delivery_instructions']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('carts', function (Blueprint $table) {
|
||||
// Add business_id column (nullable initially to allow backfill)
|
||||
$table->foreignId('business_id')->nullable()->after('brand_id')->constrained('businesses')->cascadeOnDelete();
|
||||
$table->index('business_id');
|
||||
});
|
||||
|
||||
// Backfill business_id from brand relationship for existing cart records
|
||||
// This ensures existing cart items get the correct business_id
|
||||
\Illuminate\Support\Facades\DB::statement('
|
||||
UPDATE carts
|
||||
SET business_id = (
|
||||
SELECT business_id
|
||||
FROM brands
|
||||
WHERE brands.id = carts.brand_id
|
||||
)
|
||||
WHERE brand_id IS NOT NULL
|
||||
');
|
||||
|
||||
// Make business_id NOT NULL after backfill (all records should now have business_id)
|
||||
Schema::table('carts', function (Blueprint $table) {
|
||||
$table->foreignId('business_id')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('carts', function (Blueprint $table) {
|
||||
$table->dropForeign(['business_id']);
|
||||
$table->dropIndex(['business_id']);
|
||||
$table->dropColumn('business_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
37
database/migrations/2025_11_05_060900_create_units_table.php
Normal file
37
database/migrations/2025_11_05_060900_create_units_table.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('units', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('unit'); // Short code (EA, GM, OZ, etc.)
|
||||
$table->string('name')->nullable(); // Full name (Each, Grams, Ounces, etc.)
|
||||
$table->string('abbreviation')->nullable(); // Alternative abbreviation
|
||||
$table->string('type')->default('weight'); // weight, volume, count
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// Indexes
|
||||
$table->index('unit');
|
||||
$table->index('is_active');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('units');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('product_packagings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->json('images')->nullable();
|
||||
$table->boolean('is_recyclable')->default(false);
|
||||
$table->text('compliance_details')->nullable(); // Compliance information
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes
|
||||
$table->index('is_active');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('product_packagings');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
// Foreign Keys
|
||||
$table->foreignId('packaging_id')->nullable()->after('strain_id')->constrained('product_packagings')->onDelete('set null');
|
||||
$table->foreignId('unit_id')->nullable()->after('packaging_id')->constrained('units')->onDelete('set null');
|
||||
|
||||
// Metadata
|
||||
$table->string('product_line')->nullable()->after('category');
|
||||
$table->text('product_link')->nullable()->after('product_line'); // External URL
|
||||
$table->text('creatives')->nullable()->after('product_link'); // Marketing assets
|
||||
$table->string('barcode')->nullable()->after('sku');
|
||||
$table->integer('brand_display_order')->nullable()->after('sort_order');
|
||||
|
||||
// Configuration Flags
|
||||
$table->boolean('has_varieties')->default(false)->after('is_raw_material');
|
||||
$table->boolean('sell_multiples')->default(false)->after('has_varieties');
|
||||
$table->boolean('fractional_quantities')->default(false)->after('sell_multiples');
|
||||
$table->boolean('allow_sample')->default(false)->after('fractional_quantities');
|
||||
$table->boolean('is_fpr')->default(false)->after('allow_sample'); // Finished Product Ready
|
||||
$table->boolean('is_sellable')->default(false)->after('is_fpr');
|
||||
|
||||
// Case/Box Packaging
|
||||
$table->boolean('is_case')->default(false)->after('units_per_case');
|
||||
$table->integer('cased_qty')->default(0)->after('is_case');
|
||||
$table->boolean('is_box')->default(false)->after('cased_qty');
|
||||
$table->integer('boxed_qty')->default(0)->after('is_box');
|
||||
|
||||
// Dates
|
||||
$table->date('launch_date')->nullable()->after('test_date');
|
||||
|
||||
// Inventory Management
|
||||
$table->integer('inventory_manage_pct')->nullable()->after('reorder_point'); // 0-100%
|
||||
$table->integer('min_order_qty')->nullable()->after('inventory_manage_pct');
|
||||
$table->integer('max_order_qty')->nullable()->after('min_order_qty');
|
||||
$table->integer('low_stock_threshold')->nullable()->after('max_order_qty');
|
||||
$table->boolean('low_stock_alert_enabled')->default(false)->after('low_stock_threshold');
|
||||
|
||||
// Strain Value
|
||||
$table->decimal('strain_value', 8, 2)->nullable()->after('cbd_content_mg');
|
||||
|
||||
// Arizona Compliance
|
||||
$table->decimal('arz_total_weight', 10, 3)->nullable()->after('license_number');
|
||||
$table->decimal('arz_usable_mmj', 10, 3)->nullable()->after('arz_total_weight');
|
||||
|
||||
// Extended Descriptions
|
||||
$table->text('long_description')->nullable()->after('description');
|
||||
$table->text('ingredients')->nullable()->after('long_description');
|
||||
$table->text('effects')->nullable()->after('ingredients');
|
||||
$table->text('dosage_guidelines')->nullable()->after('effects');
|
||||
|
||||
// Visibility
|
||||
$table->boolean('show_inventory_to_buyers')->default(false)->after('is_featured');
|
||||
|
||||
// Threshold Automation
|
||||
$table->integer('decreasing_qty_threshold')->nullable()->after('low_stock_alert_enabled');
|
||||
$table->string('decreasing_qty_action')->nullable()->after('decreasing_qty_threshold');
|
||||
$table->integer('increasing_qty_threshold')->nullable()->after('decreasing_qty_action');
|
||||
$table->string('increasing_qty_action')->nullable()->after('increasing_qty_threshold');
|
||||
|
||||
// Enhanced Status (update from boolean to enum)
|
||||
$table->enum('status', ['available', 'archived', 'sample', 'backorder', 'internal', 'unavailable'])
|
||||
->default('available')
|
||||
->after('is_active');
|
||||
|
||||
// MSRP for retail pricing
|
||||
$table->decimal('msrp', 10, 2)->nullable()->after('wholesale_price');
|
||||
|
||||
// Add indexes for new foreign keys and frequently queried fields
|
||||
$table->index('packaging_id');
|
||||
$table->index('unit_id');
|
||||
$table->index('status');
|
||||
$table->index('is_sellable');
|
||||
$table->index('launch_date');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
// Drop indexes first
|
||||
$table->dropIndex(['packaging_id']);
|
||||
$table->dropIndex(['unit_id']);
|
||||
$table->dropIndex(['status']);
|
||||
$table->dropIndex(['is_sellable']);
|
||||
$table->dropIndex(['launch_date']);
|
||||
|
||||
// Drop foreign keys
|
||||
$table->dropForeign(['packaging_id']);
|
||||
$table->dropForeign(['unit_id']);
|
||||
|
||||
// Drop columns in reverse order
|
||||
$table->dropColumn([
|
||||
'packaging_id',
|
||||
'unit_id',
|
||||
'product_line',
|
||||
'product_link',
|
||||
'creatives',
|
||||
'barcode',
|
||||
'brand_display_order',
|
||||
'has_varieties',
|
||||
'sell_multiples',
|
||||
'fractional_quantities',
|
||||
'allow_sample',
|
||||
'is_fpr',
|
||||
'is_sellable',
|
||||
'is_case',
|
||||
'cased_qty',
|
||||
'is_box',
|
||||
'boxed_qty',
|
||||
'launch_date',
|
||||
'inventory_manage_pct',
|
||||
'min_order_qty',
|
||||
'max_order_qty',
|
||||
'low_stock_threshold',
|
||||
'low_stock_alert_enabled',
|
||||
'strain_value',
|
||||
'arz_total_weight',
|
||||
'arz_usable_mmj',
|
||||
'long_description',
|
||||
'ingredients',
|
||||
'effects',
|
||||
'dosage_guidelines',
|
||||
'show_inventory_to_buyers',
|
||||
'decreasing_qty_threshold',
|
||||
'decreasing_qty_action',
|
||||
'increasing_qty_threshold',
|
||||
'increasing_qty_action',
|
||||
'status',
|
||||
'msrp',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
// Add sync_bamboo field - when enabled, inventory is synced from Bamboo
|
||||
$table->boolean('sync_bamboo')->default(false)->after('show_inventory_to_buyers');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->dropColumn('sync_bamboo');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('product_lines', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained()->onDelete('cascade');
|
||||
$table->string('name');
|
||||
$table->timestamps();
|
||||
|
||||
// Ensure unique product line names per business
|
||||
$table->unique(['business_id', 'name']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('product_lines');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
// Drop the old text column
|
||||
$table->dropColumn('product_line');
|
||||
|
||||
// Add foreign key to product_lines table (nullable to allow products without a line)
|
||||
$table->foreignId('product_line_id')->nullable()->after('brand_id')->constrained()->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
// Drop the foreign key
|
||||
$table->dropForeign(['product_line_id']);
|
||||
$table->dropColumn('product_line_id');
|
||||
|
||||
// Restore the old text column
|
||||
$table->string('product_line')->nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('product_images', function (Blueprint $table) {
|
||||
$table->integer('sort_order')->default(0)->after('is_primary');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('product_images', function (Blueprint $table) {
|
||||
$table->dropColumn('sort_order');
|
||||
});
|
||||
}
|
||||
};
|
||||
41
database/seeders/ProductLineSeeder.php
Normal file
41
database/seeders/ProductLineSeeder.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\ProductLine;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ProductLineSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Default product line names
|
||||
$defaultLines = [
|
||||
'Flower',
|
||||
'Resin',
|
||||
'Rosin',
|
||||
'Pre-Rolls',
|
||||
'Wax',
|
||||
];
|
||||
|
||||
// Get all seller businesses
|
||||
$sellerBusinesses = Business::where('type', 'seller')
|
||||
->orWhere('type', 'both')
|
||||
->get();
|
||||
|
||||
foreach ($sellerBusinesses as $business) {
|
||||
foreach ($defaultLines as $lineName) {
|
||||
ProductLine::firstOrCreate(
|
||||
[
|
||||
'business_id' => $business->id,
|
||||
'name' => $lineName,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
database/seeders/ProductPackagingSeeder.php
Normal file
81
database/seeders/ProductPackagingSeeder.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\ProductPackaging;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ProductPackagingSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$packagings = [
|
||||
[
|
||||
'name' => 'Glass Jar',
|
||||
'description' => 'Standard glass jar packaging for flower products',
|
||||
'is_recyclable' => true,
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Plastic Container',
|
||||
'description' => 'Child-resistant plastic container',
|
||||
'is_recyclable' => false,
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Mylar Bag',
|
||||
'description' => 'Resealable mylar bag packaging',
|
||||
'is_recyclable' => false,
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Pre-Roll Tube',
|
||||
'description' => 'Individual pre-roll tube packaging',
|
||||
'is_recyclable' => false,
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Tin Container',
|
||||
'description' => 'Metal tin container for concentrates',
|
||||
'is_recyclable' => true,
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Cartridge Box',
|
||||
'description' => 'Cardboard box for vape cartridges',
|
||||
'is_recyclable' => true,
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Bottle',
|
||||
'description' => 'Bottle packaging for tinctures and oils',
|
||||
'is_recyclable' => true,
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Blister Pack',
|
||||
'description' => 'Blister pack for edibles',
|
||||
'is_recyclable' => false,
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Miscellaneous',
|
||||
'description' => 'Other packaging types',
|
||||
'is_recyclable' => false,
|
||||
'is_active' => true,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($packagings as $packagingData) {
|
||||
ProductPackaging::updateOrCreate(
|
||||
['name' => $packagingData['name']],
|
||||
$packagingData
|
||||
);
|
||||
}
|
||||
|
||||
$this->command->info('Created '.count($packagings).' product packaging types');
|
||||
}
|
||||
}
|
||||
16
database/seeders/StrainClassificationSeeder.php
Normal file
16
database/seeders/StrainClassificationSeeder.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class StrainClassificationSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
83
database/seeders/UnitSeeder.php
Normal file
83
database/seeders/UnitSeeder.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Unit;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class UnitSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$units = [
|
||||
[
|
||||
'unit' => 'EA',
|
||||
'name' => 'Each',
|
||||
'abbreviation' => 'ea',
|
||||
'type' => 'count',
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'unit' => 'GM',
|
||||
'name' => 'Grams',
|
||||
'abbreviation' => 'g',
|
||||
'type' => 'weight',
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'unit' => 'OZ',
|
||||
'name' => 'Ounces',
|
||||
'abbreviation' => 'oz',
|
||||
'type' => 'weight',
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'unit' => 'FL OZ',
|
||||
'name' => 'Fluid Ounces',
|
||||
'abbreviation' => 'fl oz',
|
||||
'type' => 'volume',
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'unit' => 'ML',
|
||||
'name' => 'Milliliters',
|
||||
'abbreviation' => 'ml',
|
||||
'type' => 'volume',
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'unit' => 'LB',
|
||||
'name' => 'Pounds',
|
||||
'abbreviation' => 'lb',
|
||||
'type' => 'weight',
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'unit' => 'KG',
|
||||
'name' => 'Kilograms',
|
||||
'abbreviation' => 'kg',
|
||||
'type' => 'weight',
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'unit' => 'L',
|
||||
'name' => 'Liters',
|
||||
'abbreviation' => 'l',
|
||||
'type' => 'volume',
|
||||
'is_active' => true,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($units as $unitData) {
|
||||
Unit::updateOrCreate(
|
||||
['unit' => $unitData['unit']],
|
||||
$unitData
|
||||
);
|
||||
}
|
||||
|
||||
$this->command->info('Created '.count($units).' units');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user