Compare commits

...

26 Commits

Author SHA1 Message Date
Kelly
66b2fd8bbd Update composer.lock with simple-qrcode package dependencies
Fixed composer.lock being out of sync with composer.json by running composer update for simplesoftwareio/simple-qrcode. This resolves the CI/CD error where the package was in composer.json but not in the lock file.

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 21:58:05 -07:00
Jon Leopard
160d18d1a6 fix: merge develop's CLAUDE.md improvements properly
Previous merge kept old CLAUDE.md format. This properly merges:
- develop's 'Critical Mistakes' structure (better for AI)
- Worktree-specific git workflow documentation
- Updated doc paths (docs/architecture/, docs/development/)
- All critical security/business isolation reminders

Also added DEVELOPER_HANDOVER_MESSAGE.md to repo.
2025-11-08 10:45:14 -07:00
Jon Leopard
ea5b261e38 fix: use develop's k8s config with worktree auto-detection
The previous merge incorrectly kept the old Makefile/k8s configs.
Develop's version has IS_WORKTREE detection and K8S_VOLUME_PATH
which automatically adjusts based on whether we're in a worktree
or project root.

This fixes the 'hostPath type check failed' error.
2025-11-08 10:43:36 -07:00
Jon Leopard
afb532689f merge: integrate latest develop changes into batch-tracking-coa-qr
Merged origin/develop (84f364d) into feature/batch-tracking-coa-qr branch.

Conflicts resolved:
- routes/seller.php: Kept both batch/lab routes AND settings routes
- Makefile/CLAUDE.md/k8s configs: Kept our worktree-specific versions
- composer.lock: Used develop's version and regenerated

New from develop:
- Module system (business modules, module service)
- Product line/packaging system
- Cart business_id security fix
- Settings controller
- Nexus design system upgrade (HTML → Laravel)
- Documentation reorganization
- Multiple new traits for business scoping

This brings the batch tracking branch up-to-date with latest codebase
changes while preserving QR code/COA/batch functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 10:26:28 -07:00
Jon
84f364de74 Merge pull request 'Cleanup product PR: Remove debug files and add tests' (#32) from fix/cleanup-product-pr-v2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/32
2025-11-06 23:39:28 +00:00
Jon Leopard
39c955cdc4 Fix ProductLineController route names
Changed all redirects from 'seller.business.products.index1' to
'seller.business.products.index' to match the actual route definition.

The index1 route doesn't exist in origin/develop, causing test failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:31:46 -07:00
Jon Leopard
e02ca54187 Update drop shadow values to match dashboard styling
Changed all card shadows from shadow-xl to shadow to be consistent
with the dashboard page styling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
ac46ee004b Fix product edit header: theme support and remove breadcrumb
Fixed top header container styling issues:
- Changed hard-coded bg-white/gray colors to theme-aware DaisyUI classes
- Restored proper shadow (shadow-xl instead of shadow-sm)
- Updated all color classes to use base-* theme variables
- Converted buttons to proper DaisyUI btn components
- Removed breadcrumb navigation element

Container now properly respects theme switcher (light/dark mode).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
17a6eb260d Add comprehensive tests for ProductLineController
Added test coverage for all ProductLineController methods:
- Store: validates required name, uniqueness per business, cross-business duplicates OK
- Update: validates name, uniqueness, business isolation
- Destroy: deletes product line, business isolation

Tests verify business_id scoping prevents cross-tenant access.

Note: Tests use standard HTTP methods (not JSON) which may have CSRF token issues
in current test environment (project-wide issue).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
5ea80366be Add comprehensive tests for ProductImageController
Added test coverage for all ProductImageController methods:
- Upload: validates dimensions, file type, max 6 images, business isolation
- Delete: handles primary image reassignment, business isolation
- Reorder: updates sort_order, sets first as primary, business isolation
- SetPrimary: updates is_primary flag, cross-product validation

Also fixed ProductImage model to include sort_order in fillable/casts.

Note: Tests currently fail with 419 CSRF errors (project-wide test issue affecting
PUT/POST/DELETE requests). Tests are correctly structured and will pass once CSRF
handling is fixed in the test environment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
99aa0cb980 Remove development and test artifacts from product PR
Removed debugging tools and test files that should not be in production:
- check_blade.php and check_blade.js (Blade syntax checkers)
- StorageTestController and storage-test view (MinIO testing scaffolds)
- edit.blade.php.backup and edit1.blade.php (development iterations)
- Storage test routes from web.php

These files were used during development but are not needed in the codebase.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon
3de53a76d0 Merge pull request 'docs: add comprehensive guide for keeping feature branches up-to-date' (#30) from docs/add-feature-branch-sync-guide-clean into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/30
2025-11-06 22:17:08 +00:00
Jon Leopard
7fa9b6aff8 docs: add comprehensive guide for keeping feature branches up-to-date
Added new section "Keeping Your Feature Branch Up-to-Date" covering:
- Daily start-of-work routine for syncing with develop
- Merge vs rebase best practices for teams
- Step-by-step conflict resolution guide
- When and how to ask for help with complex conflicts
- Real-world example of multi-day feature work

This addresses common questions from contributors about branch
management and helps prevent large merge conflicts by encouraging
regular syncing with develop.
2025-11-06 15:05:27 -07:00
Jon Leopard
15d10f1464 fix: improve git version detection to support worktrees
Updated AppServiceProvider to properly detect git repositories in both
regular repos and worktrees by checking for .git as either a file or
directory.

Changes:
- Use file_exists() instead of is_dir() to detect .git
- Add 'cd' to git commands to ensure they work in worktrees
- Gracefully fall back to 'unknown' when git metadata is inaccessible
- Add proper shell escaping for security

This fixes the "sha-unknown" issue in k8s when using git worktrees,
where .git is a file pointing to host metadata that isn't accessible
in the container.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 15:27:20 -07:00
Jon Leopard
fc147605f7 docs: fix test user credentials in CLAUDE.md
Updated testing credentials to match actual DevSeeder data:
- buyer@example.com (was incorrectly: dispensary@example.com)
- seller@example.com (was incorrectly: brand@example.com)
- admin@example.com (unchanged)

All test users use password: 'password'

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-01 13:10:39 -07:00
Jon Leopard
7616c5e7f4 feat: add automated worktree creation script with make command 2025-10-31 15:34:51 -07:00
Jon Leopard
0406d13b92 fix: increase liveness/readiness probe timeouts for initial build
First-time startup requires 3-5 minutes for composer install, npm install,
and Vite build. Increased probe delays to prevent premature restarts:
- Liveness: 90s → 300s (5 minutes)
- Readiness: 60s → 240s (4 minutes)

Subsequent starts are still fast (~10 seconds) since code is volume-mounted
and dependencies are already installed.
2025-10-31 13:00:39 -07:00
Jon Leopard
d0ad85c943 Merge branch 'feature/k8s-local-dev' into feature/batch-tracking-coa-qr 2025-10-31 12:25:12 -07:00
Jon Leopard
8f41e08bc6 fix: update pre-push hook to support both Sail and K8s environments 2025-10-31 12:20:07 -07:00
Jon Leopard
2c82099bdd feat: add k-test command for running tests in k8s pod
Adds 'make k-test' command to run tests inside k8s pod, mirroring
the Sail 'make dev-test' workflow. This allows developers to run
tests before pushing without needing Sail running.

Usage:
  make k-test    # Run all tests in k8s pod
2025-10-31 12:08:22 -07:00
Jon Leopard
dd967ff223 fix: update k8s local dev to match Sail workflow with production parity
## Major Changes

**Deployment Manifest (k8s/local/deployment.yaml):**
- Switch from PHP 8.2 to PHP 8.3 (matches production Dockerfile)
- Add PHP_EXTENSIONS env var for intl, pdo_pgsql, pgsql, redis, gd, zip, bcmath
- Set ABSOLUTE_APACHE_DOCUMENT_ROOT to /var/www/html/public
- Remove init container (Sail-like approach: composer runs in main container)
- Add composer install, npm install, and npm build to startup script
- Use TCP connection checks instead of pg_isready/redis-cli (not in image)
- Increase health check delays and failure thresholds for slower startup

**Makefile:**
- Read DB_USERNAME, DB_PASSWORD, DB_DATABASE from .env (not hardcoded)
- PostgreSQL credentials now match .env for consistent auth

**DNS Setup Script:**
- Add scripts/setup-local-dns.sh for one-time dnsmasq configuration
- Idempotent script that's safe to run multiple times
- Works on macOS with Homebrew dnsmasq

## Architecture

Now fully Sail-like:
- Code volume-mounted from worktree (instant changes)
- Composer/npm run inside container at startup
- No pre-installation needed on host
- Each worktree = isolated k8s namespace
- Database credentials from .env (like Sail)

## Testing

Startup sequence verified:
1. Wait for PostgreSQL + Redis
2. Composer install
3. npm install + build
4. Migrations
5. Cache clearing
6. Apache starts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 11:13:05 -07:00
Jon Leopard
569e84562e docs: add comprehensive next steps guide for QR code and k8s features 2025-10-31 10:21:51 -07:00
Jon Leopard
a51398a336 feat: add k8s local development setup with git worktree support
Adds Kubernetes local development environment that mirrors Laravel Sail workflow
with namespace isolation per git worktree.

## Features

**K8s Manifests (k8s/local/):**
- Namespace configuration with worktree labels
- PostgreSQL StatefulSet with PVC (isolated per namespace)
- Redis Deployment
- Laravel app Deployment using Sail-like image with volume mounts
- Service exposing ports 80 and 5173 (Vite)
- Ingress with wildcard routing (*.cannabrands.test)

**Makefile Targets (k- prefix):**
- `make k-dev` - Start k8s environment (auto-detects branch/namespace)
- `make k-down` - Stop k8s environment
- `make k-logs` - View app logs
- `make k-shell` - Shell into app container
- `make k-artisan CMD="..."` - Run artisan commands
- `make k-composer CMD="..."` - Run composer
- `make k-vite` - Start Vite dev server in pod
- `make k-status` - Show namespace status

**Documentation:**
- docs/K8S_LOCAL_SETUP.md - Complete setup guide
- docs/K8S_LIKE_SAIL.md - Philosophy and implementation details

## Architecture

Uses Sail-like approach:
- Pre-built PHP 8.2 image with Apache and Node.js
- Code volume-mounted from worktree (instant changes, no rebuilds)
- Each worktree = isolated k8s namespace
- Custom domain per feature: [branch].cannabrands.test

## Workflow

```bash
# One-time k3d setup (see docs/K8S_LOCAL_SETUP.md)

# Per-worktree usage
cd .worktrees/feature-name
make k-dev              # Start isolated k8s env
# Code changes are instant!
make k-down             # Cleanup
```

Follows Laravel community best practice: fast local dev (Sail-like) with
production parity testing in staging cluster.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 10:20:40 -07:00
Jon Leopard
6e97798f5b feat: add QR code generation for batches with download and bulk operations
- Add QR code generation endpoints in BatchController
- Add Filament actions for QR code management (generate, download, regenerate)
- Add QR code display in batch edit view and public COA page
- Add comprehensive test suite for QR code functionality
- Add routes for single and bulk QR code operations
- Update composer.lock with simple-qrcode package

Features:
- Single batch QR code generation
- Bulk QR code generation for multiple batches
- QR code download functionality
- QR code regeneration with old file cleanup
- Business ownership validation
- Public COA QR code display

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 10:15:52 -07:00
Jon Leopard
25181ec31b feat: implement comprehensive batch management with COA tracking and dynamic cannabinoid units
Add complete batch tracking system with Certificate of Analysis (COA) management, QR codes, and flexible cannabinoid unit support.

**Batch Management Features:**
- Batch creation/editing with integrated test results
- COA file uploads and public viewing
- Batch allocation and order fulfillment tracking
- Batch components and genealogy (BOM)
- QR code generation for batch tracking
- Work order management

**Cannabinoid Unit Support:**
- Dynamic unit selection (%, MG/ML, MG/G, MG/UNIT)
- Alpine.js reactive labels that update based on selected unit
- Unit-aware validation (max 100 for %, max 1000 for mg-based units)
- Default unit of '%' applied automatically

**Testing:**
- 8 unit tests for Batch model cannabinoid functionality
- 10 feature tests for BatchController with authorization
- All tests passing (93 passed total)

**Database Changes:**
- Added cannabinoid_unit field to batches table
- Created batch_coa_files table for COA attachments
- Created order_item_batch_allocations for inventory tracking
- Created batch_components for Bill of Materials
- Created work_orders for production tracking
- Enhanced batches table with lab test fields

**Controllers & Services:**
- BatchController: Full CRUD with cannabinoid unit support
- LabController: Lab test management
- PublicCoaController: Public COA viewing
- BatchAllocationService: Inventory allocation logic
- PickingTicketService: Order fulfillment PDFs
- QrCodeService: QR code generation

**Filament Admin:**
- BatchResource with full CRUD views
- LabResource with form schemas and table views
- Admin panel management for batches and labs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 16:37:03 -07:00
Jon Leopard
e8a1a62898 chore: add .worktrees/ to .gitignore for parallel development
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 16:31:02 -07:00
74 changed files with 9717 additions and 3440 deletions

3
.gitignore vendored
View File

@@ -30,6 +30,9 @@ yarn-error.log
# Node symlink (for ARM-based machines)
/node
# Git worktrees directory
/.worktrees/
# Database backups
*.gz
*.sql.gz

143
CLAUDE.md
View File

@@ -11,7 +11,7 @@
**Exception:** Orders span buyer + seller businesses - use `whereHas('items.product.brand')`
### 2. Route Prefixes
Check `docs/URL_STRUCTURE.md` BEFORE route changes.
Check `docs/architecture/URL_STRUCTURE.md` BEFORE route changes.
- `/b/*` → Buyers only
- `/s/*` → Sellers only
- `/admin` → Super admins only
@@ -106,9 +106,9 @@ 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
- `docs/architecture/URL_STRUCTURE.md` - **READ BEFORE** routing changes
- `docs/architecture/DATABASE.md` - **READ BEFORE** migrations
- `docs/development/LOCAL_DEV.md` - Local setup
- `CONTRIBUTING.md` - Detailed git workflow
---
@@ -121,3 +121,138 @@ Product::where('is_active', true)->get(); // No business_id filter!
✅ DaisyUI for buyer/seller, Filament only for admin
✅ NO inline styles - use Tailwind/DaisyUI classes only
✅ Run tests before committing
---
## Git Workflow - Rebase First
**Prefer `rebase` over `merge` for keeping feature branches up-to-date.**
### When to Rebase (✅ Use These)
**1. Updating feature branch with latest develop:**
```bash
git fetch origin
git rebase origin/develop # ✅ Creates clean linear history
```
**2. Before creating a PR:**
```bash
# Update your branch, resolve conflicts locally
git rebase origin/develop
git push --force-with-lease origin feature/my-branch
```
**3. Cleaning up commits before PR:**
```bash
git rebase -i HEAD~5 # Squash/reword/reorder commits
```
### When to Merge (✅ Use These)
**1. Merging feature branch INTO develop/main:**
```bash
# Create merge commit to preserve feature context
git checkout develop
git merge feature/my-branch # ✅ Preserves feature history
```
**2. When branch is shared with others:**
- Never rebase shared branches
- Use merge to avoid breaking collaborators
### Rebase Workflow (Standard for Claude)
When asked to prepare a branch for PR:
```bash
# 1. Fetch latest
git fetch origin
# 2. Rebase onto develop (not merge)
git rebase origin/develop
# 3. Resolve conflicts if any
git add .
git rebase --continue
# 4. Run tests
php artisan test --parallel
./vendor/bin/pint
# 5. Force push (safe with --force-with-lease)
git push --force-with-lease origin feature/my-branch
# 6. Create PR
```
### Handling Conflicts with Multiple Worktrees
When multiple worktrees modify overlapping code:
1. Merge worktree A into develop first
2. Update worktree B by rebasing onto develop
3. Resolve conflicts with context about both features
4. Tell Claude what each worktree does when resolving conflicts
**Example:**
```
"Worktree A added QR code generation.
Worktree B added PDF export.
Keep both features and integrate them."
```
## What NOT to Do
- ❌ **NEVER** commit directly to `develop` or `master` branches
- ❌ **NEVER** rebase public/shared branches (develop, master)
- ❌ **NEVER** use `git push --force` (use `--force-with-lease` instead)
- ❌ **NEVER** rebase after others have based work on your branch
- ❌ **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
## Testing Requirements
- **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
## Testing Credentials
- Buyer: `buyer@example.com` / `password`
- Seller: `seller@example.com` / `password`
- Admin: `admin@example.com` / `password`
## 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`
## CI/CD Pipeline
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.**
## Server Requirements
### PDF Generation (DomPDF)
This application uses DomPDF (`barryvdh/laravel-dompdf`) for generating cannabis shipping manifests and invoices.
**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

View File

@@ -239,6 +239,163 @@ git push origin feature/my-feature
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:**

View File

@@ -0,0 +1,156 @@
# Developer Handover Message
Hi! 👋
I'm handing over the **`feature/batch-tracking-coa-qr`** branch to you. This is a work-in-progress feature for batch tracking with QR code generation and COA management.
## 🚀 Quick Start
```bash
# Clone and checkout the branch
git clone <repo-url>
git checkout feature/batch-tracking-coa-qr
# Install dependencies
composer install
npm install
# Setup environment
cp .env.example .env
# Configure your database credentials
# Run migrations
php artisan migrate
# Seed test data (optional)
php artisan db:seed --class=DevSeeder
```
## 📋 What's Implemented
**Core batch tracking system** - Database schema, models, relationships
**Batch CRUD** - Seller UI for creating/editing batches (`/s/{business}/batches`)
**Lab/COA management** - Upload and link lab test results to batches
**QR code generation** - Generate QR codes that link to public COA pages
**Public COA viewing** - Unauthenticated page at `/coa/{batchNumber}`
**Batch allocation tracking** - Link batches to order items for traceability
**Latest develop merged** - Branch is up-to-date (merged November 8, 2025)
## ⚠️ What's INCOMPLETE (Your Priority)
This branch is **NOT ready for merge**. Here's what needs to be finished:
### 🔴 HIGH PRIORITY
**1. Brand Logo in QR Codes**
- **What:** When generating a QR code, user should be able to select a brand and the brand's logo should appear in the center of the QR code
- **How:** Use SimpleSoftwareIO's `merge()` method to overlay brand logo
- **Files:** `app/Services/QrCodeService.php`, `resources/views/seller/batches/edit.blade.php`
- **See:** `HANDOVER.md` section "Developer Notes & Context" for implementation details
**2. QR Code Customization**
- Color selection (foreground/background)
- Error correction level (L/M/Q/H)
- Size presets (small/medium/large for label printing)
**3. Buyer Batch Selection UI**
- Show available batches on product detail pages
- Let buyers choose which batch(es) to purchase
- Update cart to track batch selections
### 🟡 MEDIUM PRIORITY
4. Batch allocation strategies (FIFO/LIFO/Manual)
5. COA file versioning and bulk upload
6. Work order/picking ticket integration
7. Batch expiration notifications
### 🟢 LOWER PRIORITY
8. METRC integration
9. Batch analytics and reporting
**Full details:** See `HANDOVER.md` → "Incomplete Features" section
## 📚 Documentation
- **`HANDOVER.md`** - Comprehensive handover doc (READ THIS FIRST!)
- **`docs/features/BATCH_SYSTEM.md`** - Batch system architecture
- **`CLAUDE.md`** - Project coding standards and common mistakes
- **`docs/architecture/URL_STRUCTURE.md`** - Routing conventions
## 🧪 Testing
```bash
# Run tests (requires Redis extension)
php artisan test --parallel
# Code style check
./vendor/bin/pint
```
**Test credentials:**
- Seller: `seller@example.com` / `password`
- Buyer: `buyer@example.com` / `password`
- Admin: `admin@example.com` / `password`
## 🏗️ Architecture Notes
**Multi-Tenancy:** Manual `business_id` scoping (no spatie package)
**Routes:** `/s/{business}/batches` for seller batch management
**Public Route:** `/coa/{batchNumber}` for public COA viewing
**QR Code Storage:** `storage/app/public/businesses/{uuid}/qr-codes/`
**Key Models:**
- `Batch` - Production batch tracking
- `Lab` - Lab test results (COA)
- `BatchCoaFile` - COA PDF files
- `OrderItemBatchAllocation` - Links batches to sold items
## ⚡ Quick Commands
```bash
# Start local dev (Kubernetes)
make k-dev
# Or use Laravel Sail
make dev
# Run Pint (required before commit)
./vendor/bin/pint
# Create migration
php artisan make:migration create_something_table
```
## 🚨 Critical Reminders
1. **Business Isolation:** ALWAYS scope by `business_id` before finding by ID
2. **No inline styles:** Use DaisyUI/Tailwind classes only
3. **Filament = /admin only:** Use DaisyUI for seller/buyer UIs
4. **Run tests before commit:** `php artisan test --parallel`
5. **Check CLAUDE.md:** Common mistakes and patterns documented
## 🎯 Your First Task
1. Read `HANDOVER.md` completely
2. Get the app running locally
3. Test batch creation at `/s/{business}/batches`
4. Start with **brand logo in QR codes** (highest priority incomplete feature)
## 📧 Questions?
- Check `HANDOVER.md` for detailed technical context
- Review existing batch tests in `tests/Feature/QrCodeGenerationTest.php`
- Follow patterns in `app/Http/Controllers/Seller/BatchController.php`
## 🎉 Good Luck!
This feature is about 70% complete. The hard infrastructure work is done - now it needs the finishing touches to make it production-ready. You've got this! 💪
---
**Branch:** `feature/batch-tracking-coa-qr`
**Last Updated:** November 8, 2025
**Handed Over By:** Claude Code
**Status:** Work in Progress - NOT ready for merge

425
HANDOVER.md Normal file
View File

@@ -0,0 +1,425 @@
# Branch Handover: feature/batch-tracking-coa-qr
**Branch Name:** `feature/batch-tracking-coa-qr`
**Created From:** `develop` (merged with `feature/k8s-local-dev`)
**Status:** ⚠️ WORK IN PROGRESS - NOT READY FOR MERGE
**Date:** November 8, 2025
## Overview
⚠️ **This is a WORK IN PROGRESS feature branch being handed over for continued development.**
This branch implements comprehensive batch tracking with QR code generation and COA (Certificate of Analysis) management for cannabis products. It enables sellers to track individual production batches, link lab test results to specific batches, and generate QR codes for public COA viewing.
**Current Status:** Core infrastructure is in place, but several features remain incomplete. See "Incomplete Features" section below for details.
## Major Features Implemented
### 1. Batch Management System
- **Location:** `app/Models/Batch.php`, `app/Http/Controllers/Seller/BatchController.php`
- **Database:** `batches` table with comprehensive tracking fields
- **Features:**
- Batch number generation and tracking
- Production, harvest, package, and expiration date management
- Inventory tracking (produced, available, allocated, sold)
- Status flags (active, tested, quarantined)
- Dynamic cannabinoid unit support (percentage, mg/g, mg/unit)
- Batch component tracking (BOM - Bill of Materials)
### 2. QR Code Generation ⚠️ (PARTIALLY COMPLETE)
- **Service:** `app/Services/QrCodeService.php`
- **Features:**
- ✅ Generate QR codes that link to public COA pages
- ✅ Batch-level QR codes stored in business-specific directories
- ✅ SVG format for scalability
- ✅ Download individual QR codes
- ✅ Bulk QR code generation for multiple batches
- ✅ Public COA viewing page (no authentication required)
- ❌ **INCOMPLETE:** Brand logo in center of QR code (auto-populate from brand selection)
- ❌ **INCOMPLETE:** Logo preview when selecting brand
- ❌ **INCOMPLETE:** QR code customization options (colors, error correction level)
### 3. Lab/COA Management
- **Models:** `app/Models/Lab.php`, `app/Models/LabCoaFile.php`, `app/Models/BatchCoaFile.php`
- **Features:**
- Link lab test results to specific batches
- Upload PDF COA files
- Store both batch-level and lab-level COA files
- Public COA viewing with batch information
- Migrated 95 existing COAs from old CRM
### 4. Order Item Batch Allocation
- **Model:** `app/Models/OrderItemBatchAllocation.php`
- **Service:** `app/Services/BatchAllocationService.php`
- **Features:**
- Track which batches were used to fulfill each order item
- Support partial allocations from multiple batches
- Inventory management (allocate, release, mark sold)
- Compliance and traceability for recalls
### 5. Work Orders & Picking Tickets
- **Model:** `app/Models/WorkOrder.php`
- **Service:** `app/Services/PickingTicketService.php`
- **Features:**
- Generate picking tickets for warehouse fulfillment
- PDF generation with batch details
- QR code integration on picking tickets
- Work order status tracking
### 6. Kubernetes Local Development
- **Documentation:** `docs/K8S_LOCAL_SETUP.md`, `docs/K8S_LIKE_SAIL.md`
- **Features:**
- Full Kubernetes local development environment
- Matches production infrastructure
- Git worktree support for parallel development
- `make` commands for easy setup
- Local DNS setup script
## Files Changed Summary
**76 files changed, 9,717 insertions(+), 95 deletions(-)**
### Key New Files
**Controllers:**
- `app/Http/Controllers/PublicCoaController.php` - Public COA viewing
- `app/Http/Controllers/Seller/BatchController.php` - Batch CRUD
- `app/Http/Controllers/Seller/LabController.php` - Lab/COA management
**Models:**
- `app/Models/Batch.php` - Core batch model (enhanced)
- `app/Models/BatchCoaFile.php` - Batch-level COA files
- `app/Models/Lab.php` - Lab test results (enhanced)
- `app/Models/LabCoaFile.php` - Lab-level COA files
- `app/Models/OrderItemBatchAllocation.php` - Batch allocation tracking
- `app/Models/WorkOrder.php` - Warehouse work orders
**Services:**
- `app/Services/QrCodeService.php` - QR code generation
- `app/Services/BatchAllocationService.php` - Inventory allocation logic
- `app/Services/PickingTicketService.php` - Picking ticket generation
**Filament Admin:**
- `app/Filament/Resources/BatchResource.php` - Admin batch management
- `app/Filament/Resources/LabResource.php` - Admin lab management
**Views:**
- `resources/views/seller/batches/*.blade.php` - Seller batch UI
- `resources/views/seller/labs/*.blade.php` - Seller lab UI
- `resources/views/public/coa/show.blade.php` - Public COA page
- `resources/views/pdfs/picking-ticket.blade.php` - Picking ticket PDF
**Migrations:**
- `2025_10_30_000001_enhance_batches_table.php` - Core batch fields
- `2025_10_30_000002_create_order_item_batch_allocations_table.php`
- `2025_10_30_000003_create_batch_components_table.php`
- `2025_10_30_000005_create_lab_coa_files_table.php`
- `2025_10_30_000006_add_barcode_to_products_table.php`
- `2025_10_30_000007_create_work_orders_table.php`
- `2025_10_30_000008_add_buyer_approval_to_orders_table.php`
- `2025_10_30_210000_add_lab_fields_to_batches_table.php`
- `2025_10_30_210001_create_batch_coa_files_table.php`
- `2025_11_08_223624_add_cannabinoid_unit_to_batches_table.php`
**Tests:**
- `tests/Feature/QrCodeGenerationTest.php` - QR code functionality tests
- `tests/Feature/BatchCannabinoidUnitControllerTest.php` - Cannabinoid unit tests
- `tests/Unit/BatchCannabinoidUnitTest.php` - Unit tests
**Documentation:**
- `docs/BATCH_SYSTEM.md` - Comprehensive batch system documentation
- `docs/K8S_LOCAL_SETUP.md` - Kubernetes local dev setup
- `docs/K8S_LIKE_SAIL.md` - K8s workflow guide
## Testing Status
**Code Style:** Passes Laravel Pint checks
⚠️ **Tests:** Cannot run in worktree (Redis extension required)
📝 **Manual Testing:** Required in main environment
**Test Coverage:**
- QR code generation (unit + feature tests)
- Batch cannabinoid unit conversion
- Batch model methods
- Public COA viewing
## Database Migrations
All migrations are **reversible** with proper `down()` methods.
**Migration Status:**
- ✅ Migrations created and tested
- ✅ Foreign keys properly indexed
- ✅ Nullable fields for backward compatibility
- ✅ PostgreSQL compatible (no IF/ELSE logic)
**Data Seeding:**
- DevSeeder updated with batch creation examples
- 95 COAs from old CRM ready for batch linking
## Routes Added
**Public:**
- `GET /coa/{batchNumber}` - Public COA viewing page
**Seller:**
- `GET /s/batches` - List batches
- `GET /s/batches/create` - Create batch form
- `POST /s/batches` - Store new batch
- `GET /s/batches/{batch}` - View batch (currently redirects to edit)
- `GET /s/batches/{batch}/edit` - Edit batch
- `PUT /s/batches/{batch}` - Update batch
- `DELETE /s/batches/{batch}` - Delete batch
- `POST /s/batches/{batch}/qr-code` - Generate QR code
- `GET /s/batches/{batch}/qr-code/download` - Download QR code
- `POST /s/batches/qr-codes/bulk` - Bulk QR generation
**Labs:**
- Full CRUD for `/s/labs`
## Configuration Changes
**Composer Dependencies:**
- Added: `simplesoftwareio/simple-qrcode` (QR code generation)
**Environment:**
- No new `.env` variables required
- Uses existing storage/public configuration
## Incomplete Features (TODO for Next Developer)
### High Priority
1. **Brand Logo in QR Codes** 🔴
- When user selects a brand on QR code creation page, auto-populate brand logo in the center placeholder square of the QR code
- Need to fetch brand logo from brand model/database
- Integrate logo into QR code generation (SimpleSoftwareIO supports `merge()` method)
- Show preview of QR code with logo before generating
- **Files to modify:**
- `app/Services/QrCodeService.php` - Add logo merging logic
- `resources/views/seller/batches/edit.blade.php` - Add brand selector and logo preview
- `app/Http/Controllers/Seller/BatchController.php` - Pass brand data to view
2. **QR Code Customization Options**
- Allow sellers to customize QR code appearance
- Color selection (foreground/background)
- Error correction level selection (L, M, Q, H)
- Size presets (small, medium, large for different label sizes)
3. **Buyer Batch Selection UI**
- Implement batch selection on product detail pages (`/b/products/{id}`)
- Show available batches with cannabinoid levels
- Allow buyers to select which batch(es) to purchase
- Update cart to track batch selections
### Medium Priority
4. **Batch Allocation Logic**
- Implement automatic batch depletion strategies (FIFO/LIFO/Manual)
- Configure default strategy per seller/product
- Handle partial allocations intelligently
- Warning when batch inventory is low
5. **COA File Management**
- Upload multiple COA files per batch
- Version tracking for updated COAs
- Thumbnail generation for PDF COAs
- Bulk COA upload
6. **Work Order Integration**
- Complete picking ticket generation
- Warehouse workflow (pick → pack → ship)
- Barcode scanning integration
- Batch tracking through fulfillment
### Lower Priority
7. **METRC Integration**
- Auto-create batches from METRC package imports
- Sync inventory levels with METRC
- Report sales back to METRC at batch level
8. **Batch Expiration & Notifications**
- Email alerts for expiring batches
- Dashboard widgets for expiration overview
- Automatic quarantine on expiration
9. **Batch Analytics**
- Revenue per batch reporting
- Sell-through rate tracking
- Average cannabinoid levels over time
- Waste/shrinkage tracking
## Known Issues / Tech Debt
1. **Tests require Redis:** Cannot run full test suite in worktree without Redis extension
2. **Brand logo integration incomplete:** QR codes currently don't include brand logos (see Incomplete Features #1)
3. **No QR code preview:** Users can't preview QR code before generating
4. **Limited error handling:** File upload validation could be more robust
5. **Performance:** Bulk QR generation not optimized for large batches (>100)
## Breaking Changes
⚠️ **None** - All changes are backward compatible:
- Existing products work without batches
- `batch_id` is nullable in all tables
- Legacy lab tests remain linked to products
## Next Steps for New Developer
### 1. Environment Setup
```bash
# Switch to this branch
git checkout feature/batch-tracking-coa-qr
# Install dependencies
composer install
npm install
# Run migrations
php artisan migrate
# Run seeders (optional)
php artisan db:seed --class=DevSeeder
# Run tests (if not in worktree)
php artisan test --parallel
```
### 2. Review Key Documentation
- Read `docs/BATCH_SYSTEM.md` for architecture overview
- Review `docs/URL_STRUCTURE.md` for routing patterns
- Check `CLAUDE.md` for project coding standards
### 3. Test Manually
Login credentials:
- Seller: `seller@example.com` / `password`
- Buyer: `buyer@example.com` / `password`
- Admin: `admin@example.com` / `password`
**Test workflow:**
1. Login as seller
2. Navigate to `/s/batches`
3. Create a new batch for an existing product
4. Generate QR code for batch
5. Visit public COA page (scan QR or visit URL)
6. Upload COA file
7. View batch in admin panel
### 4. Code Review Checklist
- [ ] Review migration `up()` and `down()` methods
- [ ] Check business_id scoping in controllers (tenant isolation)
- [ ] Verify middleware protection on all routes
- [ ] Review Eloquent relationships in models
- [ ] Check for SQL injection vulnerabilities (should be none - using Eloquent)
- [ ] Verify file upload security (COA PDFs)
- [ ] Test QR code generation performance
- [ ] Review test coverage
### 5. Before Merging
```bash
# Update from develop
git fetch origin
git rebase origin/develop
# Resolve conflicts if any
git add .
git rebase --continue
# Run tests
php artisan test --parallel
# Check code style
./vendor/bin/pint
# Push
git push --force-with-lease origin feature/batch-tracking-coa-qr
```
### 6. Continue Development
⚠️ **DO NOT CREATE A PULL REQUEST YET** - This feature is incomplete.
**Next developer should:**
1. Review the "Incomplete Features" section above
2. Start with **High Priority** items (especially brand logo in QR codes)
3. Test each feature thoroughly before moving to next
4. Add tests for new functionality
5. Update this HANDOVER.md as features are completed
6. Only create PR when ALL incomplete features are done
## Additional Context
### Why Git Worktrees?
This branch was developed in a git worktree (`/Users/jon/projects/cannabrands/cannabrands_new/.worktrees/batch-tracking-coa-qr`) to allow parallel development without affecting the main working directory.
**Worktree benefits:**
- Test different features simultaneously
- Avoid branch switching overhead
- Isolated dependencies per worktree
- Scripts provided in `scripts/new-worktree.sh`
### Why K8s Local Dev?
Kubernetes local development was added to match production infrastructure, providing:
- Production parity
- Better testing of deployments
- Multi-service orchestration
- Realistic resource constraints
### Architecture Decisions
**Multi-Tenancy:**
- Manual business_id scoping (no spatie/laravel-multitenancy)
- Two-sided marketplace requires cross-business queries
- Orders link TWO businesses (buyer + seller)
**Batch System Design:**
- Batches are optional (backward compatible)
- Inventory tracked at batch level
- Four quantity states (produced, available, allocated, sold)
- Support for partial allocations across batches
**QR Code Strategy:**
- SVG format for scalability
- Stored in business-specific directories
- Link to public COA pages (no auth required)
- Compliance-friendly for product labels
## Contact & Questions
**Branch Creator:** Claude Code
**Handover Date:** November 8, 2025
**Documentation:** See `docs/` folder for detailed architecture docs
**Support:** Check `CLAUDE.md` for common patterns and mistakes to avoid
---
## Developer Notes & Context
### Brand Logo Integration - Technical Details
The SimpleSoftwareIO QR Code package supports adding logos via the `merge()` method:
```php
QrCode::size(300)
->style('round')
->margin(1)
->merge('/path/to/logo.png', 0.3, true) // 30% of QR code size, absolute path
->generate($url);
```
**Implementation approach:**
1. Add brand relationship to Batch model (or get via Product → Brand)
2. On QR generation form, add brand selector (if batch has product, pre-select that brand)
3. When brand is selected, fetch brand logo path via AJAX
4. Show preview of logo in the center square placeholder
5. Pass brand logo path to `QrCodeService::generateForBatch()`
6. Use `merge()` method to overlay logo on QR code
7. Ensure logo has transparent background (or add background removal)
**Challenges to consider:**
- Brand logos might not be square (need to crop/resize)
- Logo might not have transparent background (affects readability)
- Error correction level must be high enough (H level recommended for logos)
- Logo size should be ~20-30% of QR code size for scannability
---
**Status: WORK IN PROGRESS - Continue development based on priorities above.**

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\Batches\Schemas\BatchForm;
use App\Filament\Resources\Batches\Tables\BatchesTable;
use App\Filament\Resources\BatchResource\Pages;
use App\Models\Batch;
use App\Services\QrCodeService;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use UnitEnum;
class BatchResource extends Resource
{
protected static ?string $model = Batch::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
protected static ?string $navigationLabel = 'Batches';
protected static UnitEnum|string|null $navigationGroup = 'Inventory';
protected static ?int $navigationSort = 2;
public static function form(Schema $schema): Schema
{
return BatchForm::configure($schema);
}
public static function table(Table $table): Table
{
$table = BatchesTable::configure($table);
// Add custom QR and COA actions
return $table
->recordActions(array_merge(
$table->getRecordActions(),
[
Action::make('generate_qr')
->label('Generate QR')
->icon('heroicon-o-qr-code')
->action(function (Batch $record) {
$qrService = app(QrCodeService::class);
$result = $qrService->generateForBatch($record);
if ($result['success']) {
Notification::make()
->title('QR Code Generated')
->body($result['message'])
->success()
->send();
} else {
Notification::make()
->title('Failed to generate QR code')
->body($result['message'])
->danger()
->send();
}
})
->visible(fn (Batch $record) => ! $record->qr_code_path),
Action::make('download_qr')
->label('Download QR')
->icon('heroicon-o-arrow-down-tray')
->url(fn (Batch $record) => route('seller.business.batches.qr-code.download', [
'business' => $record->business->slug,
'batch' => $record->id,
]))
->openUrlInNewTab()
->visible(fn (Batch $record) => $record->qr_code_path),
Action::make('regenerate_qr')
->label('Regenerate QR')
->icon('heroicon-o-arrow-path')
->action(function (Batch $record) {
$qrService = app(QrCodeService::class);
$result = $qrService->regenerate($record);
if ($result['success']) {
Notification::make()
->title('QR Code Regenerated')
->success()
->send();
} else {
Notification::make()
->title('Failed to regenerate QR code')
->body($result['message'])
->danger()
->send();
}
})
->requiresConfirmation()
->visible(fn (Batch $record) => $record->qr_code_path),
Action::make('view_coa')
->label('View COA')
->icon('heroicon-o-document-text')
->url(fn (Batch $record) => route('public.coa.show', ['batchNumber' => $record->batch_number]))
->openUrlInNewTab()
->visible(fn (Batch $record) => $record->lab !== null),
]
))
->bulkActions(array_merge(
$table->getBulkActions(),
[
BulkAction::make('generate_qr_codes')
->label('Generate QR Codes')
->icon('heroicon-o-qr-code')
->action(function (Collection $records) {
$qrService = app(QrCodeService::class);
$batchIds = $records->pluck('id')->toArray();
$result = $qrService->bulkGenerate($batchIds);
Notification::make()
->title("Generated {$result['successful']} QR codes")
->body("Failed: {$result['failed']}")
->success()
->send();
}),
]
));
}
public static function getRelations(): array
{
return [
//
];
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
// Scope to user's business unless they're a super admin
if (! auth()->user()->hasRole('super_admin')) {
$query->where('business_id', auth()->user()->business_id);
}
return $query;
}
public static function getPages(): array
{
return [
'index' => Pages\ListBatches::route('/'),
'create' => Pages\CreateBatch::route('/create'),
'view' => Pages\ViewBatch::route('/{record}'),
'edit' => Pages\EditBatch::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Filament\Resources\BatchResource\Pages;
use App\Filament\Resources\BatchResource;
use Filament\Resources\Pages\CreateRecord;
class CreateBatch extends CreateRecord
{
protected static string $resource = BatchResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['business_id'] = auth()->user()->business_id;
return $data;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\BatchResource\Pages;
use App\Filament\Resources\BatchResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditBatch extends EditRecord
{
protected static string $resource = BatchResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\BatchResource\Pages;
use App\Filament\Resources\BatchResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListBatches extends ListRecords
{
protected static string $resource = BatchResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\BatchResource\Pages;
use App\Filament\Resources\BatchResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewBatch extends ViewRecord
{
protected static string $resource = BatchResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Resources\Batches\Schemas;
use Filament\Forms;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
@@ -18,84 +19,144 @@ class BatchForm
->components([
Section::make('Batch Information')
->schema([
TextInput::make('batch_number')
->label('Batch Number')
->placeholder('Auto-generated if left blank')
->maxLength(255)
->helperText('Unique identifier for this batch'),
Select::make('product_id')
->label('Product')
->relationship('product', 'name')
->searchable()
->preload()
->required()
->columnSpan(2),
TextInput::make('batch_number')
->required()
->unique(ignoreRecord: true)
->helperText('Unique identifier for this batch (e.g., TB-AM-240315)'),
TextInput::make('internal_code')
->helperText('Internal production/tracking code (optional)'),
])
->columns(2),
->required(),
Section::make('Production Dates')
->schema([
DatePicker::make('production_date')
->helperText('Date the batch was produced/manufactured'),
DatePicker::make('harvest_date')
->helperText('Harvest date (for flower products)'),
DatePicker::make('package_date')
->helperText('Date the batch was packaged'),
DatePicker::make('expiration_date')
->helperText('Expiration/best-by date'),
Select::make('batch_type')
->label('Batch Type')
->options([
'intake' => 'Intake',
'production' => 'Production',
'finished' => 'Finished',
])
->default('finished')
->required()
->helperText('Type of batch in the production process'),
Select::make('lab_id')
->label('Lab Test')
->relationship('lab', 'lab_name')
->searchable()
->preload()
->helperText('Associated lab test results'),
Select::make('parent_batch_id')
->label('Parent Batch')
->relationship('parentBatch', 'batch_number')
->searchable()
->preload()
->helperText('Parent batch if this was produced from another batch'),
])
->columns(2),
Section::make('Inventory Management')
->schema([
TextInput::make('quantity_produced')
->label('Quantity Produced')
->required()
->numeric()
->default(0)
->helperText('Total units produced in this batch'),
TextInput::make('quantity_available')
->label('Quantity Available')
->required()
->numeric()
->default(0)
->helperText('Units currently available for sale'),
TextInput::make('quantity_allocated')
->label('Quantity Allocated')
->numeric()
->default(0)
->disabled()
->dehydrated(false)
->helperText('Units reserved in pending orders (auto-calculated)'),
TextInput::make('quantity_sold')
->label('Quantity Sold')
->numeric()
->default(0)
->disabled()
->dehydrated(false)
->helperText('Units already sold (auto-calculated)'),
])
->columns(2)
->columns(4)
->description('Allocated and sold quantities are automatically managed by the system.'),
Section::make('Status & Compliance')
Section::make('Dates')
->schema([
Toggle::make('is_active')
->default(true)
->helperText('Is this batch available for sale?'),
Toggle::make('is_tested')
->default(false)
->helperText('Has this batch passed lab testing?'),
Toggle::make('is_quarantined')
->default(false)
->helperText('Is this batch quarantined pending results?'),
])
->columns(3),
DatePicker::make('production_date')
->label('Production Date')
->helperText('Date the batch was produced/manufactured'),
Section::make('Additional Information')
DatePicker::make('intake_date')
->label('Intake Date')
->helperText('Date the batch was received/intake'),
DatePicker::make('expiration_date')
->label('Expiration Date')
->helperText('Expiration/best-by date'),
DatePicker::make('test_date')
->label('Test Date')
->helperText('Date of lab testing'),
])
->columns(2),
Section::make('Warehouse & Location')
->schema([
TextInput::make('warehouse_location')
->label('Warehouse Location')
->placeholder('e.g., Shelf A-15')
->maxLength(255)
->helperText('Physical location in warehouse'),
TextInput::make('container_type')
->label('Container Type')
->placeholder('e.g., Turkey Bag, Box')
->maxLength(255)
->helperText('Type of container batch is stored in'),
])
->columns(2),
Section::make('Quality & Compliance')
->schema([
Toggle::make('is_quarantined')
->label('Quarantined')
->default(false)
->helperText('Is this batch quarantined?')
->reactive(),
Textarea::make('quarantine_reason')
->label('Quarantine Reason')
->rows(2)
->helperText('Reason for quarantine')
->visible(fn (Forms\Get $get) => $get('is_quarantined'))
->columnSpanFull(),
Toggle::make('is_released_for_sale')
->label('Released for Sale')
->default(false)
->helperText('Has this batch been released for sale?'),
Textarea::make('notes')
->label('Notes')
->rows(3)
->helperText('Production notes, special handling instructions, etc.')
->columnSpanFull(),
])
->collapsible(),
->columns(2),
]);
}
}

View File

@@ -2,19 +2,24 @@
namespace App\Filament\Resources\Batches\Tables;
use App\Services\QrCodeService;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Actions\ViewAction;
use Filament\Notifications\Notification;
use Filament\Support\Colors\Color;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class BatchesTable
{
@@ -23,18 +28,35 @@ class BatchesTable
return $table
->columns([
TextColumn::make('batch_number')
->label('Batch #')
->searchable()
->sortable()
->copyable()
->weight('bold'),
TextColumn::make('product.name')
->label('Product')
->searchable()
->sortable()
->description(fn ($record) => $record->product->sku ?? null),
->description(fn ($record) => $record->product->sku ?? null)
->limit(30),
TextColumn::make('batch_type')
->label('Type')
->badge()
->color(fn (string $state): string => match ($state) {
'intake' => 'info',
'production' => 'warning',
'finished' => 'success',
default => 'gray',
}),
TextColumn::make('warehouse_location')
->label('Location')
->searchable()
->toggleable(),
TextColumn::make('production_date')
->label('Produced')
->date()
->sortable()
->toggleable(),
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('expiration_date')
->date()
->sortable()
@@ -60,14 +82,13 @@ class BatchesTable
->label('Status')
->badge()
->getStateUsing(fn ($record) => $record->is_quarantined ? 'Quarantined' :
(! $record->is_active ? 'Inactive' :
(! $record->is_tested ? 'Pending Test' : 'Active'))
(! $record->is_released_for_sale ? 'Not Released' : 'Released')
)
->color(fn (string $state): string => match ($state) {
'Active' => Color::Green,
'Pending Test' => Color::Yellow,
'Released' => Color::Green,
'Not Released' => Color::Yellow,
'Quarantined' => Color::Red,
'Inactive' => Color::Gray,
default => Color::Gray,
}),
TextColumn::make('created_at')
->dateTime()
@@ -80,19 +101,23 @@ class BatchesTable
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('batch_type')
->options([
'intake' => 'Intake',
'production' => 'Production',
'finished' => 'Finished',
]),
SelectFilter::make('product')
->relationship('product', 'name')
->searchable()
->preload(),
Filter::make('active')
->query(fn (Builder $query): Builder => $query->where('is_active', true))
Filter::make('released')
->label('Released for Sale')
->query(fn (Builder $query): Builder => $query->where('is_released_for_sale', true))
->toggle(),
Filter::make('available')
->query(fn (Builder $query): Builder => $query->where('quantity_available', '>', 0))
->toggle(),
Filter::make('tested')
->query(fn (Builder $query): Builder => $query->where('is_tested', true))
->toggle(),
Filter::make('quarantined')
->query(fn (Builder $query): Builder => $query->where('is_quarantined', true))
->toggle(),
@@ -109,9 +134,104 @@ class BatchesTable
->recordActions([
ViewAction::make(),
EditAction::make(),
Action::make('generate_qr')
->label('Generate QR')
->icon('heroicon-o-qrcode')
->color('success')
->action(function ($record) {
$qrService = app(QrCodeService::class);
$result = $qrService->generateWithLogo($record);
if ($result['success']) {
Notification::make()
->success()
->title('QR Code Generated')
->body($result['message'])
->send();
} else {
Notification::make()
->danger()
->title('Failed')
->body($result['message'])
->send();
}
})
->visible(fn ($record) => ! $record->qr_code_path),
Action::make('download_qr')
->label('Download QR')
->icon('heroicon-o-arrow-down-tray')
->color('primary')
->action(function ($record) {
$qrService = app(QrCodeService::class);
$download = $qrService->download($record);
if (! $download) {
Notification::make()
->danger()
->title('QR code not found')
->send();
return;
}
return $download;
})
->visible(fn ($record) => $record->qr_code_path),
Action::make('regenerate_qr')
->label('Regenerate QR')
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->modalHeading('Regenerate QR Code?')
->modalDescription('This will delete the existing QR code and generate a new one.')
->action(function ($record) {
$qrService = app(QrCodeService::class);
$result = $qrService->regenerate($record);
if ($result['success']) {
Notification::make()
->success()
->title('QR Code Regenerated')
->send();
} else {
Notification::make()
->danger()
->title('Failed')
->body($result['message'])
->send();
}
})
->visible(fn ($record) => $record->qr_code_path),
])
->toolbarActions([
BulkActionGroup::make([
BulkAction::make('bulk_generate_qr')
->label('Generate QR Codes')
->icon('heroicon-o-qrcode')
->color('success')
->action(function (Collection $records) {
$qrService = app(QrCodeService::class);
$batchIds = $records->pluck('id')->toArray();
$result = $qrService->bulkGenerate($batchIds);
if ($result['successful'] > 0) {
Notification::make()
->success()
->title('QR Codes Generated')
->body("Successfully generated {$result['successful']} QR codes.".
($result['failed'] > 0 ? " {$result['failed']} failed." : ''))
->send();
}
if ($result['failed'] > 0 && $result['successful'] === 0) {
Notification::make()
->danger()
->title('QR Generation Failed')
->body("Failed to generate {$result['failed']} QR codes.")
->send();
}
})
->deselectRecordsAfterCompletion(),
DeleteBulkAction::make(),
ForceDeleteBulkAction::make(),
RestoreBulkAction::make(),

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\LabResource\Pages;
use App\Filament\Resources\LabResource\Schemas\LabForm;
use App\Filament\Resources\LabResource\Tables\LabsTable;
use App\Models\Lab;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class LabResource extends Resource
{
protected static ?string $model = Lab::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-beaker';
protected static ?string $navigationLabel = 'Lab Tests';
protected static UnitEnum|string|null $navigationGroup = 'Inventory';
protected static ?int $navigationSort = 3;
public static function form(Schema $schema): Schema
{
return LabForm::configure($schema);
}
public static function table(Table $table): Table
{
return LabsTable::configure($table);
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
// Scope to user's business products and batches unless they're a super admin
if (! auth()->user()->hasRole('super_admin')) {
$businessId = auth()->user()->business_id;
$query->where(function ($q) use ($businessId) {
// Include labs for products owned by this business
$q->whereHas('product', function ($productQuery) use ($businessId) {
$productQuery->whereHas('brand', function ($brandQuery) use ($businessId) {
$brandQuery->where('business_id', $businessId);
});
})
// OR labs for batches owned by this business
->orWhereHas('batch', function ($batchQuery) use ($businessId) {
$batchQuery->where('business_id', $businessId);
});
});
}
return $query;
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListLabs::route('/'),
'create' => Pages\CreateLab::route('/create'),
'view' => Pages\ViewLab::route('/{record}'),
'edit' => Pages\EditLab::route('/{record}/edit'),
];
}
public static function getNavigationBadge(): ?string
{
// Show count of recent lab tests (last 30 days)
return cache()->remember('recent_lab_tests_count', 300, function () {
$query = static::getEloquentQuery();
return $query->where('test_date', '>=', now()->subDays(30))
->count() ?: null;
});
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\LabResource\Pages;
use App\Filament\Resources\LabResource;
use Filament\Resources\Pages\CreateRecord;
class CreateLab extends CreateRecord
{
protected static string $resource = LabResource::class;
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\LabResource\Pages;
use App\Filament\Resources\LabResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditLab extends EditRecord
{
protected static string $resource = LabResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\LabResource\Pages;
use App\Filament\Resources\LabResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListLabs extends ListRecords
{
protected static string $resource = LabResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\LabResource\Pages;
use App\Filament\Resources\LabResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewLab extends ViewRecord
{
protected static string $resource = LabResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@@ -0,0 +1,298 @@
<?php
namespace App\Filament\Resources\LabResource\Schemas;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
class LabForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Tabs::make('Lab Test Information')
->tabs([
Tab::make('Basic Information')
->schema([
Section::make('Test Details')
->schema([
Select::make('product_id')
->label('Product')
->relationship('product', 'name')
->searchable()
->preload()
->helperText('Product this test is for'),
Select::make('batch_id')
->label('Batch')
->relationship('batch', 'batch_number')
->searchable()
->preload()
->helperText('Specific batch tested'),
TextInput::make('lab_name')
->required()
->maxLength(255)
->helperText('Testing laboratory name'),
TextInput::make('lab_license_number')
->label('Lab License #')
->maxLength(255)
->helperText('State license number'),
DatePicker::make('test_date')
->required()
->default(now())
->helperText('Date test was performed'),
TextInput::make('batch_number')
->label('Lab Batch Number')
->maxLength(255)
->helperText('Internal lab tracking number'),
TextInput::make('sample_id')
->label('Sample ID')
->maxLength(255)
->helperText('Sample identification'),
])
->columns(2),
]),
Tab::make('Cannabinoids')
->schema([
Section::make('Primary Cannabinoids')
->schema([
TextInput::make('thc_percentage')
->label('THC %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
TextInput::make('thca_percentage')
->label('THCA %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
TextInput::make('cbd_percentage')
->label('CBD %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
TextInput::make('cbda_percentage')
->label('CBDA %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
])
->columns(4),
Section::make('Minor Cannabinoids')
->schema([
TextInput::make('cbg_percentage')
->label('CBG %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
TextInput::make('cbn_percentage')
->label('CBN %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
TextInput::make('thcv_percentage')
->label('THCV %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
TextInput::make('cbdv_percentage')
->label('CBDV %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
])
->columns(4),
Section::make('Calculated Totals')
->schema([
TextInput::make('total_thc')
->label('Total THC')
->numeric()
->disabled()
->dehydrated(false)
->suffix('%')
->helperText('Auto-calculated from THC + (THCA × 0.877)'),
TextInput::make('total_cbd')
->label('Total CBD')
->numeric()
->disabled()
->dehydrated(false)
->suffix('%')
->helperText('Auto-calculated from CBD + (CBDA × 0.877)'),
TextInput::make('total_cannabinoids')
->label('Total Cannabinoids')
->numeric()
->disabled()
->dehydrated(false)
->suffix('%')
->helperText('Sum of all cannabinoids'),
])
->columns(3)
->description('These values are automatically calculated on save'),
]),
Tab::make('Terpenes')
->schema([
Repeater::make('terpenes')
->schema([
TextInput::make('name')
->required()
->helperText('Terpene name (e.g., Myrcene)'),
TextInput::make('percentage')
->required()
->numeric()
->minValue(0)
->step(0.001)
->suffix('%')
->helperText('Percentage'),
])
->columns(2)
->collapsible()
->helperText('Add terpene profile data'),
]),
Tab::make('Compliance Tests')
->schema([
Section::make('Safety Tests')
->schema([
Toggle::make('pesticides_pass')
->label('Pesticides Pass')
->default(true)
->inline(false),
Toggle::make('heavy_metals_pass')
->label('Heavy Metals Pass')
->default(true)
->inline(false),
Toggle::make('microbials_pass')
->label('Microbials Pass')
->default(true)
->inline(false),
Toggle::make('mycotoxins_pass')
->label('Mycotoxins Pass')
->default(true)
->inline(false),
Toggle::make('residual_solvents_pass')
->label('Residual Solvents Pass')
->default(true)
->inline(false),
Toggle::make('foreign_material_pass')
->label('Foreign Material Pass')
->default(true)
->inline(false),
])
->columns(3)
->description('All tests must pass for overall compliance'),
Section::make('Additional Tests')
->schema([
TextInput::make('moisture_content')
->label('Moisture Content %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
Toggle::make('compliance_pass')
->label('Overall Compliance Pass')
->default(true)
->disabled()
->dehydrated(false)
->helperText('Auto-calculated from all safety tests'),
])
->columns(2),
]),
Tab::make('COA Files')
->schema([
Section::make('Certificate of Analysis Files')
->schema([
Repeater::make('coaFiles')
->relationship()
->schema([
FileUpload::make('file_path')
->label('File')
->required()
->directory('compliance/coas')
->acceptedFileTypes(['application/pdf', 'image/*'])
->maxSize(10240),
TextInput::make('description')
->maxLength(255)
->helperText('Optional description'),
Toggle::make('is_primary')
->label('Primary COA')
->inline(false),
])
->columns(3)
->collapsible()
->helperText('Upload COA files (PDF or images)'),
TextInput::make('certificate_url')
->label('External COA URL')
->url()
->maxLength(255)
->helperText('Link to COA on external site (optional)'),
]),
]),
Tab::make('Notes')
->schema([
Textarea::make('notes')
->rows(5)
->columnSpanFull()
->helperText('Additional notes about this test'),
]),
])
->columnSpanFull(),
]);
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Filament\Resources\LabResource\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Support\Colors\Color;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class LabsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('lab_name')
->label('Lab')
->searchable()
->sortable()
->weight('bold'),
TextColumn::make('product.name')
->label('Product')
->searchable()
->sortable()
->limit(30),
TextColumn::make('batch.batch_number')
->label('Batch')
->searchable()
->sortable()
->toggleable(),
TextColumn::make('test_date')
->date('M d, Y')
->sortable()
->color(fn ($record) => $record->test_date < now()->subDays(90) ? Color::Orange : null),
TextColumn::make('total_thc')
->label('THC')
->numeric(decimalPlaces: 2)
->suffix('%')
->sortable()
->color(fn ($state) => $state > 20 ? Color::Green : ($state > 15 ? Color::Amber : Color::Gray)),
TextColumn::make('total_cbd')
->label('CBD')
->numeric(decimalPlaces: 2)
->suffix('%')
->sortable()
->toggleable(),
TextColumn::make('total_cannabinoids')
->label('Total')
->numeric(decimalPlaces: 2)
->suffix('%')
->sortable()
->toggleable(),
IconColumn::make('compliance_pass')
->label('Compliance')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor(Color::Green)
->falseColor(Color::Red)
->sortable(),
TextColumn::make('terpene_profile')
->label('Top Terpenes')
->limit(40)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('test_date', 'desc')
->filters([
SelectFilter::make('lab_name')
->options(function () {
return \App\Models\Lab::query()
->distinct('lab_name')
->pluck('lab_name', 'lab_name')
->toArray();
})
->searchable(),
SelectFilter::make('product')
->relationship('product', 'name')
->searchable()
->preload(),
SelectFilter::make('batch')
->relationship('batch', 'batch_number')
->searchable()
->preload(),
TernaryFilter::make('compliance_pass')
->label('Compliant'),
Filter::make('recent')
->label('Recent (Last 30 days)')
->query(fn (Builder $query): Builder => $query->where('test_date', '>=', now()->subDays(30)))
->toggle(),
Filter::make('high_thc')
->label('High THC (>20%)')
->query(fn (Builder $query): Builder => $query->where('total_thc', '>', 20))
->toggle(),
Filter::make('high_cbd')
->label('High CBD (>10%)')
->query(fn (Builder $query): Builder => $query->where('total_cbd', '>', 10))
->toggle(),
])
->recordActions([
ViewAction::make(),
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers;
use App\Models\Batch;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PublicCoaController extends Controller
{
/**
* Display COA information for a specific batch
* Public route: /coa/{batchNumber}
*/
public function show(string $batchNumber)
{
// Find batch by batch number
$batch = Batch::where('batch_number', $batchNumber)
->with(['product', 'lab.coaFiles', 'business'])
->first();
if (! $batch) {
abort(404, 'Batch not found');
}
// Get lab test and COA files
$lab = $batch->lab;
if (! $lab) {
abort(404, 'No lab test available for this batch');
}
// Get all COA files
$coaFiles = $lab->getAllCoas();
$primaryCoa = $lab->getPrimaryCoa();
return view('public.coa.show', [
'batch' => $batch,
'lab' => $lab,
'coaFiles' => $coaFiles,
'primaryCoa' => $primaryCoa,
'product' => $batch->product,
'business' => $batch->business,
]);
}
/**
* Download a specific COA file
*/
public function download(string $batchNumber, int $coaFileId)
{
// Find batch
$batch = Batch::where('batch_number', $batchNumber)
->with('lab.coaFiles')
->first();
if (! $batch || ! $batch->lab) {
abort(404, 'Batch or lab test not found');
}
// Find COA file
$coaFile = $batch->lab->coaFiles()->find($coaFileId);
if (! $coaFile) {
abort(404, 'COA file not found');
}
// Check if file exists
if (! $coaFile->exists()) {
abort(404, 'File not found in storage');
}
// Download the file
return Storage::download($coaFile->file_path, $coaFile->file_name);
}
/**
* View a specific COA file inline (for PDFs)
*/
public function view(string $batchNumber, int $coaFileId): StreamedResponse
{
// Find batch
$batch = Batch::where('batch_number', $batchNumber)
->with('lab.coaFiles')
->first();
if (! $batch || ! $batch->lab) {
abort(404, 'Batch or lab test not found');
}
// Find COA file
$coaFile = $batch->lab->coaFiles()->find($coaFileId);
if (! $coaFile) {
abort(404, 'COA file not found');
}
// Check if file exists
if (! $coaFile->exists()) {
abort(404, 'File not found in storage');
}
// Stream the file for inline viewing
return Storage::response($coaFile->file_path, $coaFile->file_name, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$coaFile->file_name.'"',
]);
}
/**
* Legacy route support: /retail/labs/{batchNumber}
* Redirects to new COA route
*/
public function legacyShow(string $batchNumber)
{
return redirect()->route('public.coa.show', ['batchNumber' => $batchNumber], 301);
}
}

View File

@@ -0,0 +1,352 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Batch;
use App\Models\Business;
use App\Models\Product;
use App\Services\QrCodeService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class BatchController extends Controller
{
/**
* Display a listing of batches for the business
*/
public function index(Request $request, Business $business)
{
// Build query for batches
$query = Batch::where('business_id', $business->id)
->with(['product.brand', 'coaFiles'])
->orderBy('production_date', 'desc');
// Search filter
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('batch_number', 'LIKE', "%{$search}%")
->orWhere('test_id', 'LIKE', "%{$search}%")
->orWhere('lot_number', 'LIKE', "%{$search}%")
->orWhereHas('product', function ($productQuery) use ($search) {
$productQuery->where('name', 'LIKE', "%{$search}%");
});
});
}
$batches = $query->paginate(20)->withQueryString();
// Separate active and inactive batches
$activeBatches = $batches->filter(fn ($batch) => $batch->is_active);
$inactiveBatches = $batches->filter(fn ($batch) => ! $batch->is_active);
return view('seller.batches.index', compact('business', 'batches', 'activeBatches', 'inactiveBatches'));
}
/**
* Show the form for creating a new batch
*/
public function create(Request $request, Business $business)
{
// Get products owned by this business
$products = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->orderBy('name', 'asc')->get();
return view('seller.batches.create', compact('business', 'products'));
}
/**
* Store a newly created batch
*/
public function store(Request $request, Business $business)
{
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
$maxValue = $request->cannabinoid_unit === '%' ? 100 : 1000;
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'cannabinoid_unit' => 'required|string|in:%,MG/ML,MG/G,MG/UNIT',
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number',
'production_date' => 'nullable|date',
'test_date' => 'nullable|date',
'test_id' => 'nullable|string|max:100',
'lot_number' => 'nullable|string|max:100',
'lab_name' => 'nullable|string|max:255',
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'notes' => 'nullable|string',
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
]);
// Verify product belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($validated['product_id']);
// Set business_id
$validated['business_id'] = $business->id;
$validated['is_active'] = true; // New batches are active by default
// Create batch (calculations happen in model boot method)
$batch = Batch::create($validated);
// Handle COA file uploads
if ($request->hasFile('coa_files')) {
foreach ($request->file('coa_files') as $index => $file) {
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
$filePath = $file->storeAs($storagePath, $fileName, 'public');
$batch->coaFiles()->create([
'file_name' => $file->getClientOriginalName(),
'file_path' => $filePath,
'file_type' => $file->getClientOriginalExtension(),
'file_size' => $file->getSize(),
'is_primary' => $index === 0,
'display_order' => $index,
]);
}
}
// Auto-generate QR code for the new batch (with brand logo if available)
$qrService = app(QrCodeService::class);
$qrService->generateWithLogo($batch);
return redirect()
->route('seller.business.batches.index', $business->slug)
->with('success', 'Batch created successfully.');
}
/**
* Show the form for editing the specified batch
*/
public function edit(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
// Get products owned by this business
$products = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->orderBy('name', 'asc')->get();
$batch->load('coaFiles');
return view('seller.batches.edit', compact('business', 'batch', 'products'));
}
/**
* Update the specified batch
*/
public function update(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
$maxValue = $request->cannabinoid_unit === '%' ? 100 : 1000;
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'cannabinoid_unit' => 'required|string|in:%,MG/ML,MG/G,MG/UNIT',
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number,'.$batch->id,
'production_date' => 'nullable|date',
'test_date' => 'nullable|date',
'test_id' => 'nullable|string|max:100',
'lot_number' => 'nullable|string|max:100',
'lab_name' => 'nullable|string|max:255',
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'notes' => 'nullable|string',
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
]);
// Verify product belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($validated['product_id']);
// Update batch (calculations happen in model boot method)
$batch->update($validated);
// Handle new COA file uploads
if ($request->hasFile('coa_files')) {
$existingFilesCount = $batch->coaFiles()->count();
foreach ($request->file('coa_files') as $index => $file) {
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
$filePath = $file->storeAs($storagePath, $fileName, 'public');
$batch->coaFiles()->create([
'file_name' => $file->getClientOriginalName(),
'file_path' => $filePath,
'file_type' => $file->getClientOriginalExtension(),
'file_size' => $file->getSize(),
'is_primary' => $existingFilesCount === 0 && $index === 0,
'display_order' => $existingFilesCount + $index,
]);
}
}
return redirect()
->route('seller.business.batches.index', $business->slug)
->with('success', 'Batch updated successfully.');
}
/**
* Remove the specified batch
*/
public function destroy(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
// Delete associated COA files from storage
foreach ($batch->coaFiles as $coaFile) {
if (Storage::disk('public')->exists($coaFile->file_path)) {
Storage::disk('public')->delete($coaFile->file_path);
}
}
$batch->delete();
return redirect()
->route('seller.business.batches.index', $business->slug)
->with('success', 'Batch deleted successfully.');
}
/**
* Generate QR code for a batch
*/
public function generateQrCode(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
$qrService = app(QrCodeService::class);
$result = $qrService->generateWithLogo($batch);
// Refresh batch to get updated qr_code_path
$batch->refresh();
return response()->json([
'success' => $result['success'],
'message' => $result['message'],
'qr_code_url' => $batch->qr_code_path ? Storage::url($batch->qr_code_path) : null,
'download_url' => $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : null,
]);
}
/**
* Download QR code for a batch
*/
public function downloadQrCode(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
$qrService = app(QrCodeService::class);
$download = $qrService->download($batch);
if (! $download) {
return back()->with('error', 'QR code not found');
}
return $download;
}
/**
* Regenerate QR code for a batch
*/
public function regenerateQrCode(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
$qrService = app(QrCodeService::class);
$result = $qrService->regenerate($batch);
// Refresh batch to get updated qr_code_path
$batch->refresh();
return response()->json([
'success' => $result['success'],
'message' => $result['message'],
'qr_code_url' => $batch->qr_code_path ? Storage::url($batch->qr_code_path) : null,
'download_url' => $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : null,
]);
}
/**
* Delete QR code for a batch
*/
public function deleteQrCode(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
$qrService = app(QrCodeService::class);
$result = $qrService->delete($batch);
return response()->json([
'success' => $result['success'],
'message' => $result['message'],
]);
}
/**
* Bulk generate QR codes for multiple batches
*/
public function bulkGenerateQrCodes(Request $request, Business $business)
{
$validated = $request->validate([
'batch_ids' => 'required|array',
'batch_ids.*' => 'exists:batches,id',
]);
// Verify all batches belong to this business
$batches = Batch::whereIn('id', $validated['batch_ids'])
->where('business_id', $business->id)
->get();
if ($batches->count() !== count($validated['batch_ids'])) {
return response()->json([
'success' => false,
'message' => 'Some batches do not belong to this business',
], 403);
}
$qrService = app(QrCodeService::class);
$result = $qrService->bulkGenerate($validated['batch_ids']);
return response()->json($result);
}
}

View File

@@ -0,0 +1,217 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Lab;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class LabController extends Controller
{
/**
* Display a listing of lab tests for the business
*/
public function index(Request $request, Business $business)
{
// Get products that belong to brands owned by this business
$productIds = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->pluck('id');
// Build query for labs
$query = Lab::whereIn('product_id', $productIds)
->with(['product.brand', 'brand', 'coaFiles'])
->orderBy('test_date', 'desc');
// Search filter
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('test_id', 'LIKE', "%{$search}%")
->orWhere('batch_number', 'LIKE', "%{$search}%")
->orWhere('lot_number', 'LIKE', "%{$search}%")
->orWhereHas('product', function ($productQuery) use ($search) {
$productQuery->where('name', 'LIKE', "%{$search}%");
});
});
}
$labs = $query->paginate(20)->withQueryString();
return view('seller.labs.index', compact('business', 'labs'));
}
/**
* Show the form for creating a new lab test
*/
public function create(Request $request, Business $business)
{
// Get products owned by brands of this business
$products = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->orderBy('name', 'asc')->get();
return view('seller.labs.create', compact('business', 'products'));
}
/**
* Store a newly created lab test
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'test_id' => 'nullable|string|max:100|unique:labs,test_id',
'batch_number' => 'nullable|string|max:100',
'lot_number' => 'nullable|string|max:100',
'test_date' => 'required|date',
'lab_name' => 'nullable|string|max:255',
'thc_percentage' => 'nullable|numeric|min:0|max:100',
'thca_percentage' => 'nullable|numeric|min:0|max:100',
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
'cbda_percentage' => 'nullable|numeric|min:0|max:100',
'delta_9_percentage' => 'nullable|numeric|min:0|max:100',
'total_terps_percentage' => 'nullable|numeric|min:0|max:100',
'notes' => 'nullable|string',
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
]);
// Verify product belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($validated['product_id']);
// Auto-set brand_id from the selected product
$validated['brand_id'] = $product->brand_id;
// Create lab test
$lab = Lab::create($validated);
// Handle COA file uploads
if ($request->hasFile('coa_files')) {
foreach ($request->file('coa_files') as $index => $file) {
$storagePath = "businesses/{$business->uuid}/labs/{$lab->id}";
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
$filePath = $file->storeAs($storagePath, $fileName, 'public');
$lab->coaFiles()->create([
'file_name' => $file->getClientOriginalName(),
'file_path' => $filePath,
'is_primary' => $index === 0,
'display_order' => $index,
]);
}
}
return redirect()
->route('seller.business.labs.index', $business->slug)
->with('success', 'Lab test created successfully.');
}
/**
* Show the form for editing the specified lab test
*/
public function edit(Request $request, Business $business, Lab $lab)
{
// Verify lab belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($lab->product_id);
// Get products owned by brands of this business
$products = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->orderBy('name', 'asc')->get();
$lab->load('coaFiles');
return view('seller.labs.edit', compact('business', 'lab', 'products'));
}
/**
* Update the specified lab test
*/
public function update(Request $request, Business $business, Lab $lab)
{
// Verify lab belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($lab->product_id);
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'test_id' => 'nullable|string|max:100|unique:labs,test_id,'.$lab->id,
'batch_number' => 'nullable|string|max:100',
'lot_number' => 'nullable|string|max:100',
'test_date' => 'required|date',
'lab_name' => 'nullable|string|max:255',
'thc_percentage' => 'nullable|numeric|min:0|max:100',
'thca_percentage' => 'nullable|numeric|min:0|max:100',
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
'cbda_percentage' => 'nullable|numeric|min:0|max:100',
'delta_9_percentage' => 'nullable|numeric|min:0|max:100',
'total_terps_percentage' => 'nullable|numeric|min:0|max:100',
'notes' => 'nullable|string',
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
]);
// Get the updated product to auto-set brand_id
$updatedProduct = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($validated['product_id']);
// Auto-set brand_id from the selected product
$validated['brand_id'] = $updatedProduct->brand_id;
// Update lab test
$lab->update($validated);
// Handle new COA file uploads
if ($request->hasFile('coa_files')) {
$existingFilesCount = $lab->coaFiles()->count();
foreach ($request->file('coa_files') as $index => $file) {
$storagePath = "businesses/{$business->uuid}/labs/{$lab->id}";
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
$filePath = $file->storeAs($storagePath, $fileName, 'public');
$lab->coaFiles()->create([
'file_name' => $file->getClientOriginalName(),
'file_path' => $filePath,
'is_primary' => $existingFilesCount === 0 && $index === 0,
'display_order' => $existingFilesCount + $index,
]);
}
}
return redirect()
->route('seller.business.labs.index', $business->slug)
->with('success', 'Lab test updated successfully.');
}
/**
* Remove the specified lab test
*/
public function destroy(Request $request, Business $business, Lab $lab)
{
// Verify lab belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($lab->product_id);
// Delete associated COA files from storage
foreach ($lab->coaFiles as $coaFile) {
if (Storage::disk('public')->exists($coaFile->file_path)) {
Storage::disk('public')->delete($coaFile->file_path);
}
}
$lab->delete();
return redirect()
->route('seller.business.labs.index', $business->slug)
->with('success', 'Lab test deleted successfully.');
}
}

View File

@@ -24,7 +24,7 @@ class ProductLineController extends Controller
]);
return redirect()
->route('seller.business.products.index1', $business->slug)
->route('seller.business.products.index', $business->slug)
->with('success', 'Product line created successfully.');
}
@@ -47,7 +47,7 @@ class ProductLineController extends Controller
]);
return redirect()
->route('seller.business.products.index1', $business->slug)
->route('seller.business.products.index', $business->slug)
->with('success', 'Product line updated successfully.');
}
@@ -64,7 +64,7 @@ class ProductLineController extends Controller
$productLine->delete();
return redirect()
->route('seller.business.products.index1', $business->slug)
->route('seller.business.products.index', $business->slug)
->with('success', 'Product line deleted successfully.');
}
}

View File

@@ -1,62 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Traits\FileStorageHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class StorageTestController extends Controller
{
use FileStorageHelper;
/**
* Test storage configuration
*/
public function test(Request $request)
{
$results = [];
$results['storage_info'] = $this->getStorageInfo();
// Test file upload if provided
if ($request->hasFile('test_file')) {
try {
$file = $request->file('test_file');
// Store test file
$path = $this->storeFile($file, 'tests');
$results['upload'] = [
'success' => true,
'path' => $path,
'url' => $this->getFileUrl($path),
];
// Verify file exists
$disk = Storage::disk($this->getStorageDisk());
$results['verification'] = [
'exists' => $disk->exists($path),
'size' => $disk->size($path),
];
// Delete test file
$deleted = $this->deleteFile($path);
$results['cleanup'] = [
'deleted' => $deleted,
'still_exists' => $disk->exists($path),
];
} catch (\Exception $e) {
$results['error'] = $e->getMessage();
}
}
return response()->json($results, 200, [], JSON_PRETTY_PRINT);
}
/**
* Show test upload form
*/
public function form()
{
return view('storage-test');
}
}

View File

@@ -6,8 +6,11 @@ use App\Traits\BelongsToBusinessViaProduct;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class Batch extends Model
{
@@ -15,34 +18,127 @@ class Batch extends Model
protected $fillable = [
'product_id',
'lab_id',
'parent_batch_id',
'business_id',
'cannabinoid_unit',
'batch_number',
'internal_code',
'batch_type',
'production_date',
'harvest_date',
'package_date',
'intake_date',
'expiration_date',
'test_date',
'quantity_produced',
'quantity_available',
'quantity_allocated',
'quantity_sold',
'warehouse_location',
'container_type',
'is_active',
'is_tested',
'is_quarantined',
'quarantine_reason',
'is_released_for_sale',
'notes',
'metadata',
'qr_code_path',
// Lab/Test fields (Leaflink approach: batch includes COA data)
'test_id',
'lot_number',
'lab_name',
'lab_license_number',
'thc_percentage',
'thca_percentage',
'cbd_percentage',
'cbda_percentage',
'cbg_percentage',
'cbn_percentage',
'thcv_percentage',
'cbdv_percentage',
'delta_9_percentage',
'total_thc',
'total_cbd',
'total_cannabinoids',
'total_terps_percentage',
'terpenes',
];
protected $attributes = [
'cannabinoid_unit' => '%',
];
protected $casts = [
'production_date' => 'date',
'harvest_date' => 'date',
'package_date' => 'date',
'intake_date' => 'date',
'expiration_date' => 'date',
'test_date' => 'date',
'quantity_produced' => 'integer',
'quantity_available' => 'integer',
'quantity_allocated' => 'integer',
'quantity_sold' => 'integer',
'is_active' => 'boolean',
'is_tested' => 'boolean',
'is_quarantined' => 'boolean',
'is_released_for_sale' => 'boolean',
'metadata' => 'array',
// Cannabinoid percentages
'thc_percentage' => 'decimal:2',
'thca_percentage' => 'decimal:2',
'cbd_percentage' => 'decimal:2',
'cbda_percentage' => 'decimal:2',
'cbg_percentage' => 'decimal:2',
'cbn_percentage' => 'decimal:2',
'thcv_percentage' => 'decimal:2',
'cbdv_percentage' => 'decimal:2',
'delta_9_percentage' => 'decimal:2',
'total_thc' => 'decimal:2',
'total_cbd' => 'decimal:2',
'total_cannabinoids' => 'decimal:2',
'total_terps_percentage' => 'decimal:2',
'terpenes' => 'array',
];
// Auto-generate batch number on creation and auto-calculate cannabinoid totals
protected static function boot()
{
parent::boot();
static::creating(function ($batch) {
if (empty($batch->batch_number)) {
$batch->batch_number = self::generateBatchNumber();
}
});
// Auto-calculate cannabinoid totals before saving
static::saving(function ($batch) {
// Auto-calculate total THC and CBD if not set
if (is_null($batch->total_thc)) {
$batch->total_thc = $batch->calculateTotalThc();
}
if (is_null($batch->total_cbd)) {
$batch->total_cbd = $batch->calculateTotalCbd();
}
// Calculate total cannabinoids
if (is_null($batch->total_cannabinoids)) {
$batch->total_cannabinoids = round(
($batch->total_thc ?? 0) +
($batch->total_cbd ?? 0) +
($batch->cbg_percentage ?? 0) +
($batch->cbn_percentage ?? 0) +
($batch->thcv_percentage ?? 0) +
($batch->cbdv_percentage ?? 0),
2
);
}
});
}
/**
* Relationships
*/
@@ -51,9 +147,52 @@ class Batch extends Model
return $this->belongsTo(Product::class);
}
public function labs(): HasMany
public function lab(): BelongsTo
{
return $this->hasMany(Lab::class);
return $this->belongsTo(Lab::class);
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function parentBatch(): BelongsTo
{
return $this->belongsTo(Batch::class, 'parent_batch_id');
}
public function childBatches(): HasMany
{
return $this->hasMany(Batch::class, 'parent_batch_id');
}
/**
* Component batches used to create this batch (for finished products)
*/
public function componentBatches(): BelongsToMany
{
return $this->belongsToMany(Batch::class, 'batch_components', 'finished_batch_id', 'component_batch_id')
->withPivot('quantity_used', 'unit_of_measure', 'product_id', 'component_product_id')
->withTimestamps();
}
/**
* Finished batches that used this batch as a component
*/
public function usedInBatches(): BelongsToMany
{
return $this->belongsToMany(Batch::class, 'batch_components', 'component_batch_id', 'finished_batch_id')
->withPivot('quantity_used', 'unit_of_measure', 'product_id', 'component_product_id')
->withTimestamps();
}
/**
* Order allocations for this batch
*/
public function orderAllocations(): HasMany
{
return $this->hasMany(OrderItemBatchAllocation::class);
}
public function orderItems(): HasMany
@@ -61,6 +200,14 @@ class Batch extends Model
return $this->hasMany(OrderItem::class);
}
/**
* COA Files for this batch (Leaflink approach: batches have COA files)
*/
public function coaFiles(): HasMany
{
return $this->hasMany(BatchCoaFile::class)->ordered();
}
/**
* Scopes
*/
@@ -199,4 +346,238 @@ class Batch extends Model
{
return strtoupper($this->batch_number);
}
/**
* Generate unique batch number
*/
public static function generateBatchNumber(): string
{
$prefix = 'B';
$timestamp = now()->format('ymd');
$random = strtoupper(Str::random(4));
$batchNumber = "{$prefix}-{$timestamp}-{$random}";
// Ensure uniqueness
while (self::where('batch_number', $batchNumber)->exists()) {
$random = strtoupper(Str::random(4));
$batchNumber = "{$prefix}-{$timestamp}-{$random}";
}
return $batchNumber;
}
/**
* Get the COA file path from linked lab
*/
public function getCoaPath(): ?string
{
if (! $this->lab) {
return null;
}
// Assuming lab has 'coa_file_path' field
return $this->lab->coa_file_path;
}
/**
* Get public COA viewing URL
*/
public function getPublicCoaUrl(): ?string
{
if (! $this->lab) {
return null;
}
return route('public.coa.show', ['batchNumber' => $this->batch_number]);
}
/**
* Get QR code URL
*/
public function getQrCodeUrl(): ?string
{
if (! $this->qr_code_path) {
return null;
}
return Storage::url($this->qr_code_path);
}
/**
* Check if batch can be allocated for a given quantity
*/
public function canAllocate(int $quantity): bool
{
return $this->is_active
&& ! $this->is_quarantined
&& $this->is_released_for_sale
&& $this->quantity_available >= $quantity
&& ($this->expiration_date === null || $this->expiration_date > now());
}
/**
* Get full genealogy chain (all parent batches)
*/
public function getGenealogy(): array
{
$genealogy = [];
$current = $this;
while ($current->parentBatch) {
$parent = $current->parentBatch;
$genealogy[] = [
'id' => $parent->id,
'batch_number' => $parent->batch_number,
'product' => $parent->product->name ?? null,
'production_date' => $parent->production_date?->format('Y-m-d'),
];
$current = $parent;
}
return $genealogy;
}
/**
* Get breakdown of all component batches used
*/
public function getComponentsBreakdown(): array
{
return $this->componentBatches->map(function ($componentBatch) {
return [
'batch_number' => $componentBatch->batch_number,
'product' => $componentBatch->product->name ?? null,
'quantity_used' => $componentBatch->pivot->quantity_used,
'unit_of_measure' => $componentBatch->pivot->unit_of_measure,
'production_date' => $componentBatch->production_date?->format('Y-m-d'),
];
})->toArray();
}
/**
* Get all warnings for this batch
*/
public function getWarnings(): array
{
$warnings = [];
if ($this->is_quarantined) {
$warnings[] = [
'type' => 'quarantined',
'severity' => 'critical',
'message' => 'This batch is quarantined: '.$this->quarantine_reason,
];
}
if ($this->isExpired()) {
$warnings[] = [
'type' => 'expired',
'severity' => 'critical',
'message' => 'This batch expired on '.$this->expiration_date->format('M d, Y'),
];
}
if ($this->isExpiringSoon()) {
$warnings[] = [
'type' => 'expiring_soon',
'severity' => 'warning',
'message' => 'This batch expires on '.$this->expiration_date->format('M d, Y'),
];
}
if (! $this->is_tested && $this->batch_type !== 'intake') {
$warnings[] = [
'type' => 'not_tested',
'severity' => 'warning',
'message' => 'This batch has not been lab tested yet',
];
}
if (! $this->is_released_for_sale && $this->is_tested) {
$warnings[] = [
'type' => 'not_released',
'severity' => 'info',
'message' => 'This batch has not been released for sale',
];
}
if ($this->quantity_available <= 0 && $this->is_active) {
$warnings[] = [
'type' => 'out_of_stock',
'severity' => 'warning',
'message' => 'This batch is out of stock',
];
}
return $warnings;
}
/**
* Cannabinoid Calculation Methods (Leaflink approach)
*/
/**
* Calculate total THC from THC and THCa
*/
public function calculateTotalThc(): float
{
$thc = $this->thc_percentage ?? 0;
$thca = $this->thca_percentage ?? 0;
// THCA converts to THC at 87.7% efficiency when decarboxylated
return round($thc + ($thca * 0.877), 2);
}
/**
* Calculate total CBD from CBD and CBDa
*/
public function calculateTotalCbd(): float
{
$cbd = $this->cbd_percentage ?? 0;
$cbda = $this->cbda_percentage ?? 0;
// CBDA converts to CBD at 87.7% efficiency when decarboxylated
return round($cbd + ($cbda * 0.877), 2);
}
/**
* COA File Management Methods
*/
/**
* Get the primary COA file
*/
public function getPrimaryCoa(): ?BatchCoaFile
{
return $this->coaFiles()->primary()->first();
}
/**
* Check if batch has any COA files
*/
public function hasCoaFiles(): bool
{
return $this->coaFiles()->exists();
}
/**
* Get COA file count
*/
public function getCoaFileCount(): int
{
return $this->coaFiles()->count();
}
/**
* Get the URL for the primary COA
*/
public function getCoaUrl(): ?string
{
$primaryCoa = $this->getPrimaryCoa();
if ($primaryCoa) {
return $primaryCoa->getUrl();
}
return null;
}
}

129
app/Models/BatchCoaFile.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class BatchCoaFile extends Model
{
use HasFactory;
protected $fillable = [
'batch_id',
'file_name',
'file_path',
'file_type',
'file_size',
'description',
'is_primary',
'display_order',
];
protected $casts = [
'is_primary' => 'boolean',
'file_size' => 'integer',
'display_order' => 'integer',
];
/**
* Relationships
*/
public function batch(): BelongsTo
{
return $this->belongsTo(Batch::class);
}
/**
* Get the public URL for this COA file
*/
public function getUrl(): string
{
return Storage::url($this->file_path);
}
/**
* Get the full storage path
*/
public function getFullPath(): string
{
return Storage::path($this->file_path);
}
/**
* Get human-readable file size
*/
public function getFormattedFileSizeAttribute(): string
{
if (! $this->file_size) {
return 'Unknown size';
}
$bytes = $this->file_size;
$units = ['B', 'KB', 'MB', 'GB'];
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, 2).' '.$units[$i];
}
/**
* Check if file exists in storage
*/
public function exists(): bool
{
return Storage::exists($this->file_path);
}
/**
* Delete file from storage
*/
public function deleteFile(): bool
{
if ($this->exists()) {
return Storage::delete($this->file_path);
}
return false;
}
/**
* Scope: Get primary COA files
*/
public function scopePrimary($query)
{
return $query->where('is_primary', true);
}
/**
* Scope: Get files by type
*/
public function scopeByType($query, string $type)
{
return $query->where('file_type', $type);
}
/**
* Scope: Order by display order
*/
public function scopeOrdered($query)
{
return $query->orderBy('display_order')->orderBy('created_at');
}
/**
* Auto-delete file from storage when model is deleted
*/
protected static function boot()
{
parent::boot();
static::deleting(function ($coaFile) {
$coaFile->deleteFile();
});
}
}

View File

@@ -6,7 +6,10 @@ use App\Traits\BelongsToBusinessViaBatch;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class Lab extends Model
{
@@ -14,12 +17,15 @@ class Lab extends Model
protected $fillable = [
'product_id',
'brand_id',
'batch_id',
'lab_name',
'lab_license_number',
'test_date',
'batch_number',
'lot_number',
'sample_id',
'test_id',
'thc_percentage',
'thca_percentage',
'cbd_percentage',
@@ -28,9 +34,11 @@ class Lab extends Model
'cbn_percentage',
'thcv_percentage',
'cbdv_percentage',
'delta_9_percentage',
'total_thc',
'total_cbd',
'total_cannabinoids',
'total_terps_percentage',
'terpenes',
'pesticides_pass',
'heavy_metals_pass',
@@ -55,9 +63,11 @@ class Lab extends Model
'cbn_percentage' => 'decimal:2',
'thcv_percentage' => 'decimal:2',
'cbdv_percentage' => 'decimal:2',
'delta_9_percentage' => 'decimal:2',
'total_thc' => 'decimal:2',
'total_cbd' => 'decimal:2',
'total_cannabinoids' => 'decimal:2',
'total_terps_percentage' => 'decimal:2',
'terpenes' => 'array',
'pesticides_pass' => 'boolean',
'heavy_metals_pass' => 'boolean',
@@ -81,6 +91,16 @@ class Lab extends Model
return $this->belongsTo(Batch::class);
}
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class);
}
public function coaFiles(): HasMany
{
return $this->hasMany(LabCoaFile::class)->ordered();
}
// Scopes
public function scopeCompliant($query)
@@ -233,4 +253,132 @@ class Lab extends Model
return $t['name'].' ('.$t['percentage'].'%)';
}, $top));
}
/**
* COA File Management Methods
*/
/**
* Get the primary COA file
*/
public function getPrimaryCoa(): ?LabCoaFile
{
return $this->coaFiles()->primary()->first();
}
/**
* Get all COA files
*/
public function getAllCoas()
{
return $this->coaFiles()->ordered()->get();
}
/**
* Add a COA file
*/
public function addCoaFile(
UploadedFile $file,
?string $description = null,
bool $isPrimary = false
): LabCoaFile {
// Generate storage path
$business = $this->product->business ?? $this->batch?->business;
$businessUuid = $business?->uuid ?? 'unknown';
$fileName = time().'_'.$file->getClientOriginalName();
$storagePath = "businesses/{$businessUuid}/compliance/coas/{$fileName}";
// Store the file
$file->storeAs(
dirname($storagePath),
basename($storagePath),
'public'
);
// If this is primary, unset all other primary flags
if ($isPrimary) {
$this->coaFiles()->update(['is_primary' => false]);
}
// Get the next display order
$nextOrder = $this->coaFiles()->max('display_order') + 1;
// Create the COA file record
return $this->coaFiles()->create([
'file_name' => $file->getClientOriginalName(),
'file_path' => $storagePath,
'file_type' => $file->getClientOriginalExtension(),
'file_size' => $file->getSize(),
'description' => $description,
'is_primary' => $isPrimary,
'display_order' => $nextOrder,
]);
}
/**
* Set a COA file as primary
*/
public function setPrimaryCoa(LabCoaFile $coaFile): void
{
if ($coaFile->lab_id !== $this->id) {
throw new \InvalidArgumentException('COA file does not belong to this lab test');
}
// Unset all other primary flags
$this->coaFiles()->update(['is_primary' => false]);
// Set this one as primary
$coaFile->update(['is_primary' => true]);
}
/**
* Remove a COA file
*/
public function removeCoaFile(LabCoaFile $coaFile): bool
{
if ($coaFile->lab_id !== $this->id) {
throw new \InvalidArgumentException('COA file does not belong to this lab test');
}
return $coaFile->delete();
}
/**
* Check if lab has any COA files
*/
public function hasCoaFiles(): bool
{
return $this->coaFiles()->exists();
}
/**
* Get COA file count
*/
public function getCoaFileCount(): int
{
return $this->coaFiles()->count();
}
/**
* Get the URL for the primary COA (fallback to legacy certificate_path)
*/
public function getCoaUrl(): ?string
{
// Try new multi-file system first
$primaryCoa = $this->getPrimaryCoa();
if ($primaryCoa) {
return $primaryCoa->getUrl();
}
// Fallback to legacy single file
if ($this->certificate_path) {
return Storage::url($this->certificate_path);
}
if ($this->certificate_url) {
return $this->certificate_url;
}
return null;
}
}

129
app/Models/LabCoaFile.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class LabCoaFile extends Model
{
use HasFactory;
protected $fillable = [
'lab_id',
'file_name',
'file_path',
'file_type',
'file_size',
'description',
'is_primary',
'display_order',
];
protected $casts = [
'is_primary' => 'boolean',
'file_size' => 'integer',
'display_order' => 'integer',
];
/**
* Relationships
*/
public function lab(): BelongsTo
{
return $this->belongsTo(Lab::class);
}
/**
* Get the public URL for this COA file
*/
public function getUrl(): string
{
return Storage::url($this->file_path);
}
/**
* Get the full storage path
*/
public function getFullPath(): string
{
return Storage::path($this->file_path);
}
/**
* Get human-readable file size
*/
public function getFormattedFileSizeAttribute(): string
{
if (! $this->file_size) {
return 'Unknown size';
}
$bytes = $this->file_size;
$units = ['B', 'KB', 'MB', 'GB'];
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, 2).' '.$units[$i];
}
/**
* Check if file exists in storage
*/
public function exists(): bool
{
return Storage::exists($this->file_path);
}
/**
* Delete file from storage
*/
public function deleteFile(): bool
{
if ($this->exists()) {
return Storage::delete($this->file_path);
}
return false;
}
/**
* Scope: Get primary COA files
*/
public function scopePrimary($query)
{
return $query->where('is_primary', true);
}
/**
* Scope: Get files by type
*/
public function scopeByType($query, string $type)
{
return $query->where('file_type', $type);
}
/**
* Scope: Order by display order
*/
public function scopeOrdered($query)
{
return $query->orderBy('display_order')->orderBy('created_at');
}
/**
* Auto-delete file from storage when model is deleted
*/
protected static function boot()
{
parent::boot();
static::deleting(function ($coaFile) {
$coaFile->deleteFile();
});
}
}

View File

@@ -45,6 +45,10 @@ class Order extends Model
'accepted_at',
'in_progress_at',
'ready_for_invoice_at',
'awaiting_buyer_approval_at',
'buyer_approved_at',
'buyer_approved_by',
'buyer_approval_notes',
'invoiced_at',
'amendment_in_progress_at',
'amendment_completed_at',
@@ -76,6 +80,8 @@ class Order extends Model
'accepted_at' => 'datetime',
'in_progress_at' => 'datetime',
'ready_for_invoice_at' => 'datetime',
'awaiting_buyer_approval_at' => 'datetime',
'buyer_approved_at' => 'datetime',
'invoiced_at' => 'datetime',
'amendment_in_progress_at' => 'datetime',
'amendment_completed_at' => 'datetime',
@@ -158,6 +164,40 @@ class Order extends Model
return $this->belongsTo(User::class, 'amendment_completed_by');
}
/**
* Get the buyer user who approved the order.
*/
public function buyerApprovedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'buyer_approved_by');
}
/**
* Get the seller business (opposite of buyer business).
*/
public function sellerBusiness(): BelongsTo
{
// Assuming there's a seller_business_id field or similar
// This may need adjustment based on your actual schema
return $this->belongsTo(Business::class, 'seller_business_id');
}
/**
* Get the buyer business (the business placing the order).
*/
public function buyerBusiness(): BelongsTo
{
return $this->belongsTo(Business::class, 'business_id');
}
/**
* Alias for orderItems relationship
*/
public function orderItems(): HasMany
{
return $this->items();
}
/**
* Scope: Get new orders.
*/
@@ -238,6 +278,62 @@ class Order extends Model
return $query->where('status', 'amendment_in_progress');
}
/**
* Scope: Get orders awaiting buyer approval.
*/
public function scopeAwaitingBuyerApproval($query)
{
return $query->where('status', 'awaiting_buyer_approval');
}
/**
* Scope: Get buyer-approved orders.
*/
public function scopeBuyerApproved($query)
{
return $query->whereNotNull('buyer_approved_at');
}
/**
* Helper: Submit order for buyer approval
*/
public function submitForBuyerApproval(): void
{
$this->update([
'status' => 'awaiting_buyer_approval',
'awaiting_buyer_approval_at' => now(),
]);
}
/**
* Helper: Approve order as buyer
*/
public function approveAsBuyer(?int $userId = null, ?string $notes = null): void
{
$this->update([
'status' => 'buyer_approved',
'buyer_approved_at' => now(),
'buyer_approved_by' => $userId ?? auth()->id(),
'buyer_approval_notes' => $notes,
]);
}
/**
* Helper: Check if order is awaiting buyer approval
*/
public function isAwaitingBuyerApproval(): bool
{
return $this->status === 'awaiting_buyer_approval';
}
/**
* Helper: Check if buyer has approved
*/
public function isBuyerApproved(): bool
{
return $this->buyer_approved_at !== null;
}
/**
* Calculate picking completion percentage.
* Returns 0-100 based on picked_qty vs quantity across all items.

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class OrderItem extends Model
{
@@ -51,9 +52,19 @@ class OrderItem extends Model
/**
* Get the specific batch purchased.
* NOTE: This is for backward compatibility. New system uses batchAllocations()
*/
public function batch(): BelongsTo
{
return $this->belongsTo(Batch::class);
}
/**
* Get all batch allocations for this order item
* Multiple batches can be used to fulfill a single order item (FIFO)
*/
public function batchAllocations(): HasMany
{
return $this->hasMany(OrderItemBatchAllocation::class);
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OrderItemBatchAllocation extends Model
{
use HasFactory;
protected $fillable = [
'order_item_id',
'batch_id',
'quantity_allocated',
'quantity_picked',
'is_picked',
'picked_at',
'picked_by_user_id',
'is_verified',
'verified_at',
'verified_by_user_id',
'notes',
];
protected $casts = [
'quantity_allocated' => 'integer',
'quantity_picked' => 'integer',
'is_picked' => 'boolean',
'picked_at' => 'datetime',
'is_verified' => 'boolean',
'verified_at' => 'datetime',
];
/**
* Relationships
*/
public function orderItem(): BelongsTo
{
return $this->belongsTo(OrderItem::class);
}
public function batch(): BelongsTo
{
return $this->belongsTo(Batch::class);
}
public function pickedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'picked_by_user_id');
}
public function verifiedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'verified_by_user_id');
}
/**
* Scopes
*/
public function scopePicked($query)
{
return $query->where('is_picked', true);
}
public function scopeUnpicked($query)
{
return $query->where('is_picked', false);
}
public function scopeVerified($query)
{
return $query->where('is_verified', true);
}
public function scopeUnverified($query)
{
return $query->where('is_verified', false);
}
/**
* Helper Methods
*/
/**
* Mark this allocation as picked
*/
public function markPicked(int $quantityPicked, ?int $userId = null): void
{
$this->update([
'quantity_picked' => $quantityPicked,
'is_picked' => true,
'picked_at' => now(),
'picked_by_user_id' => $userId ?? auth()->id(),
]);
}
/**
* Mark this allocation as verified
*/
public function markVerified(?int $userId = null): void
{
$this->update([
'is_verified' => true,
'verified_at' => now(),
'verified_by_user_id' => $userId ?? auth()->id(),
]);
}
/**
* Check if allocation is complete (picked quantity matches allocated)
*/
public function isComplete(): bool
{
return $this->quantity_picked >= $this->quantity_allocated;
}
/**
* Get shortage (if picked quantity is less than allocated)
*/
public function getShortage(): int
{
$shortage = $this->quantity_allocated - $this->quantity_picked;
return max(0, $shortage);
}
/**
* Get pick completion percentage
*/
public function getPickCompletionPercentage(): float
{
if ($this->quantity_allocated == 0) {
return 0.0;
}
return round(($this->quantity_picked / $this->quantity_allocated) * 100, 1);
}
}

View File

@@ -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

257
app/Models/WorkOrder.php Normal file
View File

@@ -0,0 +1,257 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class WorkOrder extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'business_id',
'product_id',
'finished_batch_id',
'created_by_user_id',
'started_by_user_id',
'completed_by_user_id',
'work_order_number',
'status',
'quantity_to_produce',
'quantity_produced',
'scheduled_date',
'started_at',
'completed_at',
'notes',
'component_requirements',
];
protected $casts = [
'quantity_to_produce' => 'integer',
'quantity_produced' => 'integer',
'scheduled_date' => 'date',
'started_at' => 'datetime',
'completed_at' => 'datetime',
'component_requirements' => 'array',
];
/**
* Auto-generate work order number on creation
*/
protected static function boot()
{
parent::boot();
static::creating(function ($workOrder) {
if (empty($workOrder->work_order_number)) {
$workOrder->work_order_number = self::generateWorkOrderNumber();
}
});
}
/**
* Relationships
*/
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function finishedBatch(): BelongsTo
{
return $this->belongsTo(Batch::class, 'finished_batch_id');
}
public function createdByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function startedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'started_by_user_id');
}
public function completedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'completed_by_user_id');
}
/**
* Scopes
*/
public function scopePending($query)
{
return $query->where('status', 'pending');
}
public function scopeInProgress($query)
{
return $query->where('status', 'in_progress');
}
public function scopeCompleted($query)
{
return $query->where('status', 'completed');
}
public function scopeCancelled($query)
{
return $query->where('status', 'cancelled');
}
public function scopeScheduledFor($query, $date)
{
return $query->whereDate('scheduled_date', $date);
}
/**
* Helper Methods
*/
/**
* Generate unique work order number
*/
public static function generateWorkOrderNumber(): string
{
$prefix = 'WO';
$timestamp = now()->format('ymd');
$random = strtoupper(Str::random(4));
$woNumber = "{$prefix}-{$timestamp}-{$random}";
// Ensure uniqueness
while (self::where('work_order_number', $woNumber)->exists()) {
$random = strtoupper(Str::random(4));
$woNumber = "{$prefix}-{$timestamp}-{$random}";
}
return $woNumber;
}
/**
* Start the work order
*/
public function start(?int $userId = null): void
{
$this->update([
'status' => 'in_progress',
'started_at' => now(),
'started_by_user_id' => $userId ?? auth()->id(),
]);
}
/**
* Complete the work order and create finished batch
*/
public function complete(int $quantityProduced, Batch $finishedBatch, ?int $userId = null): void
{
$this->update([
'status' => 'completed',
'quantity_produced' => $quantityProduced,
'finished_batch_id' => $finishedBatch->id,
'completed_at' => now(),
'completed_by_user_id' => $userId ?? auth()->id(),
]);
}
/**
* Cancel the work order
*/
public function cancel(?string $reason = null): void
{
$this->update([
'status' => 'cancelled',
'notes' => $this->notes ? $this->notes."\n\nCancelled: ".$reason : 'Cancelled: '.$reason,
]);
}
/**
* Check if work order can be started
*/
public function canStart(): bool
{
return $this->status === 'pending';
}
/**
* Check if work order can be completed
*/
public function canComplete(): bool
{
return $this->status === 'in_progress';
}
/**
* Get completion percentage
*/
public function getCompletionPercentage(): float
{
if ($this->quantity_to_produce == 0) {
return 0.0;
}
return round(($this->quantity_produced / $this->quantity_to_produce) * 100, 1);
}
/**
* Check if work order is overdue
*/
public function isOverdue(): bool
{
if (! $this->scheduled_date || $this->status === 'completed' || $this->status === 'cancelled') {
return false;
}
return $this->scheduled_date < now()->toDateString();
}
/**
* Get production duration in hours
*/
public function getProductionDuration(): ?float
{
if (! $this->started_at || ! $this->completed_at) {
return null;
}
return $this->started_at->diffInHours($this->completed_at, true);
}
/**
* Get formatted work order status
*/
public function getStatusLabelAttribute(): string
{
return match ($this->status) {
'pending' => 'Pending',
'in_progress' => 'In Progress',
'completed' => 'Completed',
'cancelled' => 'Cancelled',
default => ucfirst($this->status),
};
}
/**
* Get status color for UI
*/
public function getStatusColorAttribute(): string
{
return match ($this->status) {
'pending' => 'gray',
'in_progress' => 'blue',
'completed' => 'green',
'cancelled' => 'red',
default => 'gray',
};
}
}

View File

@@ -0,0 +1,315 @@
<?php
namespace App\Services;
use App\Models\Batch;
use App\Models\OrderItem;
use App\Models\OrderItemBatchAllocation;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class BatchAllocationService
{
/**
* Allocate batches to an order item using FIFO strategy
*
* @param bool $autoAllocate If false, returns available batches without allocating
* @return array ['success' => bool, 'allocations' => array, 'message' => string]
*/
public function allocateToOrderItem(OrderItem $orderItem, bool $autoAllocate = true): array
{
$quantityNeeded = $orderItem->quantity;
$productId = $orderItem->product_id;
// Get available batches for this product (FIFO order)
$availableBatches = $this->getAvailableBatches($productId);
if ($availableBatches->isEmpty()) {
return [
'success' => false,
'allocations' => [],
'message' => 'No available batches found for this product',
];
}
// Check if we have enough total quantity
$totalAvailable = $availableBatches->sum('quantity_available');
if ($totalAvailable < $quantityNeeded) {
return [
'success' => false,
'allocations' => [],
'available_batches' => $availableBatches,
'message' => "Insufficient inventory. Need {$quantityNeeded}, have {$totalAvailable}",
];
}
if (! $autoAllocate) {
return [
'success' => true,
'allocations' => [],
'available_batches' => $availableBatches,
'message' => 'Available batches retrieved successfully',
];
}
// Allocate batches using FIFO
return $this->performFifoAllocation($orderItem, $availableBatches, $quantityNeeded);
}
/**
* Manually allocate specific batch to order item
*
* @return array ['success' => bool, 'allocation' => OrderItemBatchAllocation|null, 'message' => string]
*/
public function allocateSpecificBatch(OrderItem $orderItem, Batch $batch, int $quantity): array
{
// Validate batch belongs to same product
if ($batch->product_id !== $orderItem->product_id) {
return [
'success' => false,
'allocation' => null,
'message' => 'Batch does not belong to the ordered product',
];
}
// Check if batch can be allocated
if (! $batch->canAllocate($quantity)) {
$warnings = $batch->getWarnings();
$warningMessages = array_map(fn ($w) => $w['message'], $warnings);
return [
'success' => false,
'allocation' => null,
'message' => 'Batch cannot be allocated: '.implode(', ', $warningMessages),
'warnings' => $warnings,
];
}
try {
DB::beginTransaction();
// Allocate the batch
$batch->allocate($quantity);
// Create allocation record
$allocation = OrderItemBatchAllocation::create([
'order_item_id' => $orderItem->id,
'batch_id' => $batch->id,
'quantity_allocated' => $quantity,
'quantity_picked' => 0,
'is_picked' => false,
]);
DB::commit();
return [
'success' => true,
'allocation' => $allocation,
'message' => "Successfully allocated {$quantity} units from batch {$batch->batch_number}",
];
} catch (\Exception $e) {
DB::rollBack();
return [
'success' => false,
'allocation' => null,
'message' => 'Allocation failed: '.$e->getMessage(),
];
}
}
/**
* Deallocate a specific batch allocation
*/
public function deallocateAllocation(OrderItemBatchAllocation $allocation): array
{
try {
DB::beginTransaction();
$batch = $allocation->batch;
$quantity = $allocation->quantity_allocated;
// Release the allocation
$batch->releaseAllocation($quantity);
// Delete the allocation record
$allocation->delete();
DB::commit();
return [
'success' => true,
'message' => "Successfully deallocated {$quantity} units from batch {$batch->batch_number}",
];
} catch (\Exception $e) {
DB::rollBack();
return [
'success' => false,
'message' => 'Deallocation failed: '.$e->getMessage(),
];
}
}
/**
* Deallocate all batches for an order item
*/
public function deallocateOrderItem(OrderItem $orderItem): array
{
try {
DB::beginTransaction();
$allocations = $orderItem->batchAllocations;
foreach ($allocations as $allocation) {
$batch = $allocation->batch;
$batch->releaseAllocation($allocation->quantity_allocated);
$allocation->delete();
}
DB::commit();
return [
'success' => true,
'message' => 'Successfully deallocated all batches for order item',
];
} catch (\Exception $e) {
DB::rollBack();
return [
'success' => false,
'message' => 'Deallocation failed: '.$e->getMessage(),
];
}
}
/**
* Get available batches for a product (FIFO order)
*/
protected function getAvailableBatches(int $productId): Collection
{
return Batch::where('product_id', $productId)
->where('is_active', true)
->where('is_quarantined', false)
->where('is_released_for_sale', true)
->where('quantity_available', '>', 0)
->where(function ($query) {
$query->whereNull('expiration_date')
->orWhere('expiration_date', '>', now());
})
->orderBy('production_date', 'asc') // FIFO: oldest first
->orderBy('intake_date', 'asc')
->orderBy('created_at', 'asc')
->get();
}
/**
* Perform FIFO allocation across multiple batches
*/
protected function performFifoAllocation(
OrderItem $orderItem,
Collection $availableBatches,
int $quantityNeeded
): array {
try {
DB::beginTransaction();
$allocations = [];
$remainingQuantity = $quantityNeeded;
foreach ($availableBatches as $batch) {
if ($remainingQuantity <= 0) {
break;
}
// Determine how much to allocate from this batch
$quantityToAllocate = min($remainingQuantity, $batch->quantity_available);
// Allocate the batch
$batch->allocate($quantityToAllocate);
// Create allocation record
$allocation = OrderItemBatchAllocation::create([
'order_item_id' => $orderItem->id,
'batch_id' => $batch->id,
'quantity_allocated' => $quantityToAllocate,
'quantity_picked' => 0,
'is_picked' => false,
]);
$allocations[] = [
'batch_number' => $batch->batch_number,
'quantity' => $quantityToAllocate,
'allocation' => $allocation,
];
$remainingQuantity -= $quantityToAllocate;
}
DB::commit();
return [
'success' => true,
'allocations' => $allocations,
'message' => "Successfully allocated {$quantityNeeded} units across ".count($allocations).' batch(es)',
];
} catch (\Exception $e) {
DB::rollBack();
return [
'success' => false,
'allocations' => [],
'message' => 'Allocation failed: '.$e->getMessage(),
];
}
}
/**
* Check if order item can be fully allocated
*/
public function canFullyAllocate(OrderItem $orderItem): array
{
$availableBatches = $this->getAvailableBatches($orderItem->product_id);
$totalAvailable = $availableBatches->sum('quantity_available');
$quantityNeeded = $orderItem->quantity;
$canAllocate = $totalAvailable >= $quantityNeeded;
return [
'can_allocate' => $canAllocate,
'quantity_needed' => $quantityNeeded,
'quantity_available' => $totalAvailable,
'shortage' => $canAllocate ? 0 : ($quantityNeeded - $totalAvailable),
'available_batches' => $availableBatches,
];
}
/**
* Get allocation summary for an order item
*/
public function getAllocationSummary(OrderItem $orderItem): array
{
$allocations = $orderItem->batchAllocations()->with('batch')->get();
$totalAllocated = $allocations->sum('quantity_allocated');
$totalPicked = $allocations->sum('quantity_picked');
return [
'order_item_id' => $orderItem->id,
'quantity_ordered' => $orderItem->quantity,
'quantity_allocated' => $totalAllocated,
'quantity_picked' => $totalPicked,
'is_fully_allocated' => $totalAllocated >= $orderItem->quantity,
'is_fully_picked' => $totalPicked >= $orderItem->quantity,
'allocations' => $allocations->map(function ($allocation) {
return [
'id' => $allocation->id,
'batch_number' => $allocation->batch->batch_number,
'quantity_allocated' => $allocation->quantity_allocated,
'quantity_picked' => $allocation->quantity_picked,
'is_picked' => $allocation->is_picked,
];
}),
];
}
}

View File

@@ -0,0 +1,258 @@
<?php
namespace App\Services;
use App\Models\Order;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Facades\Storage;
class PickingTicketService
{
/**
* Generate picking ticket for an order
*
* @param array $options ['format' => 'pdf'|'html', 'download' => true|false]
* @return array ['success' => bool, 'path' => string|null, 'html' => string|null, 'pdf' => mixed]
*/
public function generate(Order $order, array $options = []): array
{
$format = $options['format'] ?? 'pdf';
$download = $options['download'] ?? false;
try {
// Get order with all necessary relationships
$order->load([
'orderItems.product',
'orderItems.batchAllocations.batch',
'buyerBusiness',
'sellerBusiness',
]);
// Prepare data for template
$data = $this->preparePickingData($order);
// Generate HTML
$html = view('pdfs.picking-ticket', $data)->render();
if ($format === 'html') {
return [
'success' => true,
'html' => $html,
'path' => null,
'pdf' => null,
];
}
// Generate PDF
$pdf = Pdf::loadHTML($html)
->setPaper('a4', 'portrait')
->setOption('enable-local-file-access', true);
if ($download) {
return [
'success' => true,
'pdf' => $pdf->download("picking-ticket-{$order->order_number}.pdf"),
'path' => null,
'html' => null,
];
}
// Store PDF
$fileName = "picking-ticket-{$order->order_number}-".time().'.pdf';
$storagePath = "businesses/{$order->sellerBusiness->uuid}/picking-tickets/{$fileName}";
Storage::disk('local')->put($storagePath, $pdf->output());
return [
'success' => true,
'path' => $storagePath,
'html' => null,
'pdf' => $pdf,
];
} catch (\Exception $e) {
return [
'success' => false,
'path' => null,
'html' => null,
'pdf' => null,
'message' => 'Failed to generate picking ticket: '.$e->getMessage(),
];
}
}
/**
* Prepare picking data from order
*/
protected function preparePickingData(Order $order): array
{
$pickingItems = [];
foreach ($order->orderItems as $orderItem) {
$allocations = $orderItem->batchAllocations;
$itemAllocations = [];
foreach ($allocations as $allocation) {
$batch = $allocation->batch;
$itemAllocations[] = [
'batch_number' => $batch->batch_number,
'quantity' => $allocation->quantity_allocated,
'warehouse_location' => $batch->warehouse_location ?? 'Not specified',
'container_type' => $batch->container_type ?? 'N/A',
'expiration_date' => $batch->expiration_date?->format('m/d/Y'),
'is_picked' => $allocation->is_picked,
'quantity_picked' => $allocation->quantity_picked,
];
}
$pickingItems[] = [
'product_name' => $orderItem->product_name,
'product_sku' => $orderItem->product_sku,
'quantity_ordered' => $orderItem->quantity,
'allocations' => $itemAllocations,
'total_allocated' => $allocations->sum('quantity_allocated'),
];
}
return [
'order' => $order,
'buyer' => $order->buyerBusiness,
'seller' => $order->sellerBusiness,
'pickingItems' => $pickingItems,
'generatedAt' => now(),
];
}
/**
* Generate picking tickets for multiple orders (batch processing)
*/
public function generateBatch(array $orderIds, array $options = []): array
{
$results = [];
$successful = 0;
$failed = 0;
foreach ($orderIds as $orderId) {
$order = Order::find($orderId);
if (! $order) {
$failed++;
$results[] = [
'order_id' => $orderId,
'success' => false,
'message' => 'Order not found',
];
continue;
}
$result = $this->generate($order, $options);
$results[] = array_merge($result, ['order_id' => $orderId]);
if ($result['success']) {
$successful++;
} else {
$failed++;
}
}
return [
'successful' => $successful,
'failed' => $failed,
'results' => $results,
];
}
/**
* Get picking ticket download response
*/
public function download(Order $order)
{
$result = $this->generate($order, ['format' => 'pdf', 'download' => true]);
if ($result['success']) {
return $result['pdf'];
}
return null;
}
/**
* Stream picking ticket inline (view in browser)
*/
public function stream(Order $order)
{
$order->load([
'orderItems.product',
'orderItems.batchAllocations.batch',
'buyerBusiness',
'sellerBusiness',
]);
$data = $this->preparePickingData($order);
$html = view('pdfs.picking-ticket', $data)->render();
$pdf = Pdf::loadHTML($html)
->setPaper('a4', 'portrait')
->setOption('enable-local-file-access', true);
return $pdf->stream("picking-ticket-{$order->order_number}.pdf");
}
/**
* Check if order is ready for picking (has allocations)
*/
public function isReadyForPicking(Order $order): bool
{
$order->load('orderItems.batchAllocations');
foreach ($order->orderItems as $orderItem) {
if ($orderItem->batchAllocations->isEmpty()) {
return false;
}
}
return true;
}
/**
* Get picking completion status
*/
public function getPickingStatus(Order $order): array
{
$order->load('orderItems.batchAllocations');
$totalItems = 0;
$totalPicked = 0;
$totalAllocations = 0;
$allocationsComplete = 0;
foreach ($order->orderItems as $orderItem) {
$totalItems++;
foreach ($orderItem->batchAllocations as $allocation) {
$totalAllocations++;
if ($allocation->is_picked) {
$allocationsComplete++;
}
$totalPicked += $allocation->quantity_picked;
}
}
$isFullyPicked = $allocationsComplete === $totalAllocations && $totalAllocations > 0;
$pickingPercentage = $totalAllocations > 0
? round(($allocationsComplete / $totalAllocations) * 100, 1)
: 0;
return [
'is_fully_picked' => $isFullyPicked,
'picking_percentage' => $pickingPercentage,
'total_items' => $totalItems,
'total_allocations' => $totalAllocations,
'allocations_complete' => $allocationsComplete,
'total_quantity_picked' => $totalPicked,
];
}
}

View File

@@ -0,0 +1,267 @@
<?php
namespace App\Services;
use App\Models\Batch;
use App\Models\Business;
use Illuminate\Support\Facades\Storage;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
class QrCodeService
{
/**
* Generate QR code for a batch
* Links to public COA viewing page
*
* @param array $options ['size' => 300, 'with_logo' => true]
* @return array ['success' => bool, 'path' => string|null, 'url' => string|null, 'message' => string]
*/
public function generateForBatch(Batch $batch, array $options = []): array
{
$size = $options['size'] ?? 300;
$withLogo = $options['with_logo'] ?? false;
try {
// Generate the URL that QR code will link to
$coaUrl = route('public.coa.show', ['batchNumber' => $batch->batch_number]);
// Generate QR code SVG
$qrCodeSvg = QrCode::size($size)
->style('round')
->margin(1)
->generate($coaUrl);
// Determine storage path
$business = $batch->business;
$businessUuid = $business?->uuid ?? 'unknown';
$fileName = "qr-{$batch->batch_number}-".time().'.svg';
$storagePath = "businesses/{$businessUuid}/qr-codes/{$fileName}";
// Store the QR code
Storage::disk('public')->put($storagePath, $qrCodeSvg);
// Update batch with QR code path
$batch->update(['qr_code_path' => $storagePath]);
$publicUrl = Storage::url($storagePath);
return [
'success' => true,
'path' => $storagePath,
'url' => $publicUrl,
'message' => "QR code generated successfully for batch {$batch->batch_number}",
];
} catch (\Exception $e) {
return [
'success' => false,
'path' => null,
'url' => null,
'message' => 'Failed to generate QR code: '.$e->getMessage(),
];
}
}
/**
* Generate QR code with brand logo overlay
* Creates PNG format for logo support
* Priority: Brand logo > Business logo > No logo
*
* @param string|null $logoPath Path to brand logo (optional override)
* @param array $options ['size' => 300, 'logo_size' => 80]
*/
public function generateWithLogo(Batch $batch, ?string $logoPath = null, array $options = []): array
{
$size = $options['size'] ?? 300;
$logoSize = $options['logo_size'] ?? 80;
try {
// Generate the URL that QR code will link to
$coaUrl = route('public.coa.show', ['batchNumber' => $batch->batch_number]);
// Get business for storage path
$business = $batch->business;
$businessUuid = $business?->uuid ?? 'unknown';
// If no logo path provided, try to get from Brand first, then Business
if (! $logoPath) {
// Priority 1: Get brand logo from product
if ($batch->product && $batch->product->brand && $batch->product->brand->hasLogo()) {
$logoPath = Storage::disk('public')->path($batch->product->brand->logo_path);
}
// Priority 2: Fall back to business logo
elseif ($business && $business->logo_path) {
$logoPath = Storage::disk('public')->path($business->logo_path);
}
}
// Generate QR code with logo if available
$qrCode = QrCode::size($size)
->style('round')
->margin(1);
// Add logo if path exists (centered square in middle of QR code)
if ($logoPath && file_exists($logoPath)) {
// The second parameter (0.3) controls logo size relative to QR code
// The third parameter (true) creates a white background square for the logo
$qrCode->merge($logoPath, .3, true);
}
$qrCodePng = $qrCode->format('png')->generate($coaUrl);
// Determine storage path
$fileName = "qr-{$batch->batch_number}-".time().'.png';
$storagePath = "businesses/{$businessUuid}/qr-codes/{$fileName}";
// Store the QR code
Storage::disk('public')->put($storagePath, $qrCodePng);
// Update batch with QR code path
$batch->update(['qr_code_path' => $storagePath]);
$publicUrl = Storage::url($storagePath);
$logoSource = 'no logo';
if ($logoPath && file_exists($logoPath)) {
if ($batch->product && $batch->product->brand && $batch->product->brand->hasLogo()) {
$logoSource = 'brand logo';
} elseif ($business && $business->logo_path) {
$logoSource = 'business logo';
}
}
return [
'success' => true,
'path' => $storagePath,
'url' => $publicUrl,
'message' => "QR code generated successfully for batch {$batch->batch_number} ({$logoSource})",
];
} catch (\Exception $e) {
return [
'success' => false,
'path' => null,
'url' => null,
'message' => 'Failed to generate QR code with logo: '.$e->getMessage(),
];
}
}
/**
* Bulk generate QR codes for multiple batches with brand logos
*
* @return array ['successful' => int, 'failed' => int, 'results' => array]
*/
public function bulkGenerate(array $batchIds, array $options = []): array
{
$results = [];
$successful = 0;
$failed = 0;
foreach ($batchIds as $batchId) {
$batch = Batch::find($batchId);
if (! $batch) {
$failed++;
$results[] = [
'batch_id' => $batchId,
'success' => false,
'message' => 'Batch not found',
];
continue;
}
// Use generateWithLogo to include brand logos
$result = $this->generateWithLogo($batch, null, $options);
$results[] = array_merge($result, ['batch_id' => $batchId]);
if ($result['success']) {
$successful++;
} else {
$failed++;
}
}
return [
'successful' => $successful,
'failed' => $failed,
'results' => $results,
];
}
/**
* Regenerate QR code for batch with logo (delete old, create new)
*/
public function regenerate(Batch $batch, array $options = []): array
{
// Delete old QR code if exists
if ($batch->qr_code_path && Storage::disk('public')->exists($batch->qr_code_path)) {
Storage::disk('public')->delete($batch->qr_code_path);
}
// Generate new QR code with brand logo
return $this->generateWithLogo($batch, null, $options);
}
/**
* Delete QR code for batch
*/
public function delete(Batch $batch): array
{
try {
if ($batch->qr_code_path && Storage::disk('public')->exists($batch->qr_code_path)) {
Storage::disk('public')->delete($batch->qr_code_path);
$batch->update(['qr_code_path' => null]);
return [
'success' => true,
'message' => 'QR code deleted successfully',
];
}
return [
'success' => false,
'message' => 'No QR code found for this batch',
];
} catch (\Exception $e) {
return [
'success' => false,
'message' => 'Failed to delete QR code: '.$e->getMessage(),
];
}
}
/**
* Get QR code download response for batch
*
* @return \Symfony\Component\HttpFoundation\StreamedResponse|null
*/
public function download(Batch $batch)
{
if (! $batch->qr_code_path || ! Storage::disk('public')->exists($batch->qr_code_path)) {
return null;
}
$fileName = "QR-{$batch->batch_number}.svg";
return Storage::disk('public')->download($batch->qr_code_path, $fileName);
}
/**
* Generate QR code data URL (base64 encoded for inline display)
*/
public function generateDataUrl(Batch $batch, int $size = 200): ?string
{
try {
$coaUrl = route('public.coa.show', ['batchNumber' => $batch->batch_number]);
$qrCodeSvg = QrCode::size($size)
->style('round')
->margin(1)
->generate($coaUrl);
return 'data:image/svg+xml;base64,'.base64_encode($qrCodeSvg);
} catch (\Exception $e) {
return null;
}
}
}

View File

@@ -1,56 +0,0 @@
const fs = require('fs');
const content = fs.readFileSync('resources/views/seller/products/edit11.blade.php', 'utf8');
const lines = content.split('\n');
let depth = 0;
const stack = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
// Skip lines that are Alpine.js @error handlers
if (line.includes('@error') && line.includes('$event')) {
continue;
}
// Check for @if (but not in @endif, @error, @enderror)
if (/@if\s*\(/.test(line) && !/@endif/.test(line)) {
depth++;
stack.push({ line: lineNum, type: 'if', content: line.trim().substring(0, 80) });
console.log(`${lineNum}: [depth +${depth}] @if`);
}
// Check for @elseif
if (/@elseif\s*\(/.test(line)) {
console.log(`${lineNum}: [depth =${depth}] @elseif`);
}
// Check for @else (but not @elseif, @endforelse, @enderror)
if (/@else\b/.test(line) && !/@elseif/.test(line) && !/@endforelse/.test(line) && !/@enderror/.test(line)) {
console.log(`${lineNum}: [depth =${depth}] @else`);
}
// Check for @endif
if (/@endif\b/.test(line)) {
console.log(`${lineNum}: [depth -${depth}] @endif`);
if (depth > 0) {
depth--;
stack.pop();
} else {
console.log(`ERROR: Extra @endif at line ${lineNum}`);
}
}
}
console.log(`\nFinal depth: ${depth}`);
if (depth > 0) {
console.log(`\nUNBALANCED: Missing ${depth} @endif statement(s)`);
console.log('\nUnclosed @if statements:');
stack.forEach(item => {
console.log(` Line ${item.line}: ${item.content}`);
});
} else {
console.log('\nAll @if/@endif pairs are balanced!');
}

View File

@@ -1,42 +0,0 @@
<?php
$file = 'C:\Users\Boss Man\Documents\GitHub\hub\resources\views\seller\products\edit11.blade.php';
$lines = file($file);
$stack = [];
foreach ($lines as $lineNum => $line) {
$lineNum++; // 1-indexed
// Check for @if (but not @endif, @elseif, etc.)
if (preg_match('/^\s*@if\(/', $line)) {
$stack[] = ['type' => 'if', 'line' => $lineNum];
echo "Line $lineNum: OPEN @if (stack depth: ".count($stack).")\n";
}
// Check for @elseif
elseif (preg_match('/^\s*@elseif\(/', $line)) {
echo "Line $lineNum: @elseif\n";
}
// Check for @else
elseif (preg_match('/^\s*@else\s*$/', $line)) {
echo "Line $lineNum: @else\n";
}
// Check for @endif
elseif (preg_match('/^\s*@endif\s*$/', $line)) {
if (empty($stack)) {
echo "ERROR Line $lineNum: @endif without matching @if!\n";
} else {
$opened = array_pop($stack);
echo "Line $lineNum: CLOSE @endif (opened at line {$opened['line']}, stack depth: ".count($stack).")\n";
}
}
}
if (! empty($stack)) {
echo "\nERROR: Unclosed @if directives:\n";
foreach ($stack as $item) {
echo " Line {$item['line']}: @if never closed\n";
}
} else {
echo "\nAll @if/@endif directives are balanced!\n";
}

View File

@@ -17,6 +17,7 @@
"owen-it/laravel-auditing": "^14.0",
"predis/predis": "*",
"rahulhaque/laravel-filepond": "*",
"simplesoftwareio/simple-qrcode": "^4.2",
"spatie/laravel-permission": "^6.18",
"stechstudio/filament-impersonate": "^4.0",
"tapp/filament-auditing": "^4.0"

174
composer.lock generated
View File

@@ -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": "6045e661b5737a9c8dd40aeeaf8c9897",
"content-hash": "06b0c49356f6da9011242a5f3c180c31",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -223,6 +223,60 @@
},
"time": "2025-11-05T19:08:10+00:00"
},
{
"name": "bacon/bacon-qr-code",
"version": "2.0.8",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phly/keep-a-changelog": "^2.1",
"phpunit/phpunit": "^7 | ^8 | ^9",
"spatie/phpunit-snapshot-assertions": "^4.2.9",
"squizlabs/php_codesniffer": "^3.4"
},
"suggest": {
"ext-imagick": "to generate QR code images"
},
"type": "library",
"autoload": {
"psr-4": {
"BaconQrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "BaconQrCode is a QR code generator for PHP.",
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8"
},
"time": "2022-12-07T17:46:57+00:00"
},
{
"name": "barryvdh/laravel-dompdf",
"version": "v3.1.1",
@@ -973,6 +1027,56 @@
],
"time": "2025-02-21T08:52:11+00:00"
},
{
"name": "dasprid/enum",
"version": "1.0.7",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"DASPRiD\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "PHP 7.1 enum implementation",
"keywords": [
"enum",
"map"
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
},
"time": "2025-09-16T12:23:56+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@@ -7019,6 +7123,74 @@
],
"time": "2022-12-17T21:53:22+00:00"
},
{
"name": "simplesoftwareio/simple-qrcode",
"version": "4.2.0",
"source": {
"type": "git",
"url": "https://github.com/SimpleSoftwareIO/simple-qrcode.git",
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SimpleSoftwareIO/simple-qrcode/zipball/916db7948ca6772d54bb617259c768c9cdc8d537",
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^2.0",
"ext-gd": "*",
"php": ">=7.2|^8.0"
},
"require-dev": {
"mockery/mockery": "~1",
"phpunit/phpunit": "~9"
},
"suggest": {
"ext-imagick": "Allows the generation of PNG QrCodes.",
"illuminate/support": "Allows for use within Laravel."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"QrCode": "SimpleSoftwareIO\\QrCode\\Facades\\QrCode"
},
"providers": [
"SimpleSoftwareIO\\QrCode\\QrCodeServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"SimpleSoftwareIO\\QrCode\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Simple Software LLC",
"email": "support@simplesoftware.io"
}
],
"description": "Simple QrCode is a QR code generator made for Laravel.",
"homepage": "https://www.simplesoftware.io/#/docs/simple-qrcode",
"keywords": [
"Simple",
"generator",
"laravel",
"qrcode",
"wrapper"
],
"support": {
"issues": "https://github.com/SimpleSoftwareIO/simple-qrcode/issues",
"source": "https://github.com/SimpleSoftwareIO/simple-qrcode/tree/4.2.0"
},
"time": "2021-02-08T20:43:55+00:00"
},
{
"name": "spatie/invade",
"version": "2.1.0",

View File

@@ -0,0 +1,89 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('batches', function (Blueprint $table) {
// Add business scoping
$table->foreignId('business_id')
->after('id')
->constrained('businesses')
->onDelete('cascade');
// Add lab relationship (reverse - lab points to batch, not batch to lab)
$table->foreignId('lab_id')
->nullable()
->after('product_id')
->constrained('labs')
->onDelete('set null');
// Add parent batch for traceability
$table->foreignId('parent_batch_id')
->nullable()
->after('lab_id')
->constrained('batches')
->onDelete('set null');
// Add batch type enum
$table->enum('batch_type', ['intake', 'production', 'finished'])
->default('finished')
->after('batch_number');
// Add dates
$table->date('intake_date')->nullable()->after('harvest_date');
$table->date('test_date')->nullable()->after('expiration_date');
// Add warehouse fields
$table->string('warehouse_location')->nullable()->after('test_date');
$table->string('container_type')->nullable()->after('warehouse_location');
// Add quarantine details
$table->text('quarantine_reason')->nullable()->after('is_quarantined');
// Add release status
$table->boolean('is_released_for_sale')->default(false)->after('quarantine_reason');
// Add QR code path
$table->string('qr_code_path')->nullable()->after('notes');
// Add indexes
$table->index('business_id');
$table->index('lab_id');
$table->index('parent_batch_id');
$table->index('batch_type');
$table->index('warehouse_location');
});
}
public function down(): void
{
Schema::table('batches', function (Blueprint $table) {
$table->dropForeign(['business_id']);
$table->dropForeign(['lab_id']);
$table->dropForeign(['parent_batch_id']);
$table->dropIndex(['business_id']);
$table->dropIndex(['lab_id']);
$table->dropIndex(['parent_batch_id']);
$table->dropIndex(['batch_type']);
$table->dropIndex(['warehouse_location']);
$table->dropColumn([
'business_id',
'lab_id',
'parent_batch_id',
'batch_type',
'intake_date',
'test_date',
'warehouse_location',
'container_type',
'quarantine_reason',
'is_released_for_sale',
'qr_code_path',
]);
});
}
};

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('order_item_batch_allocations', function (Blueprint $table) {
$table->id();
// Relationships
$table->foreignId('order_item_id')->constrained('order_items')->onDelete('cascade');
$table->foreignId('batch_id')->constrained('batches')->onDelete('cascade');
// Allocation Details
$table->integer('quantity_allocated'); // How many units from this batch
$table->integer('quantity_picked')->default(0); // Actually picked by warehouse
// Picking Workflow
$table->boolean('is_picked')->default(false);
$table->timestamp('picked_at')->nullable();
$table->foreignId('picked_by_user_id')->nullable()->constrained('users')->onDelete('set null');
// Verification
$table->boolean('is_verified')->default(false); // QA verification
$table->timestamp('verified_at')->nullable();
$table->foreignId('verified_by_user_id')->nullable()->constrained('users')->onDelete('set null');
$table->text('notes')->nullable(); // Picker notes (e.g., "short 5 units")
$table->timestamps();
// Indexes
$table->index('order_item_id');
$table->index('batch_id');
$table->index('is_picked');
// Ensure no duplicate allocations
$table->unique(['order_item_id', 'batch_id']);
});
}
public function down(): void
{
Schema::dropIfExists('order_item_batch_allocations');
}
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Tracks which component batches were used to create finished product batches
* Enables full genealogy tracking (seed-to-sale traceability)
*/
public function up(): void
{
Schema::create('batch_components', function (Blueprint $table) {
$table->id();
// Relationships
$table->foreignId('finished_batch_id')->constrained('batches')->onDelete('cascade'); // The batch that was created
$table->foreignId('component_batch_id')->constrained('batches')->onDelete('cascade'); // The component batch used
// Usage Tracking
$table->decimal('quantity_used', 10, 3); // How much of component was consumed
$table->string('unit_of_measure')->default('units'); // units, grams, kg, etc.
// Optional: Link to product/component relationship
$table->foreignId('product_id')->nullable()->constrained('products')->onDelete('set null'); // The finished product
$table->foreignId('component_product_id')->nullable()->constrained('products')->onDelete('set null'); // The component used
$table->timestamps();
// Indexes
$table->index('finished_batch_id');
$table->index('component_batch_id');
$table->index('product_id');
// Prevent duplicate component usage records
$table->unique(['finished_batch_id', 'component_batch_id']);
});
}
public function down(): void
{
Schema::dropIfExists('batch_components');
}
};

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Stores multiple COA files per lab test
* Replaces the old comma-separated file storage with proper relational structure
*/
public function up(): void
{
Schema::create('lab_coa_files', function (Blueprint $table) {
$table->id();
// Relationships
$table->foreignId('lab_id')->constrained('labs')->onDelete('cascade');
// File Information
$table->string('file_name'); // Original filename
$table->string('file_path'); // Storage path: businesses/{uuid}/compliance/coas/{filename}
$table->string('file_type')->default('pdf'); // pdf, jpg, png
$table->integer('file_size')->nullable(); // Size in bytes
$table->text('description')->nullable(); // Optional description/label
// Metadata
$table->boolean('is_primary')->default(false); // Primary/featured COA
$table->integer('display_order')->default(0); // Sort order for display
$table->timestamps();
// Indexes
$table->index('lab_id');
$table->index('is_primary');
$table->index('display_order');
});
}
public function down(): void
{
Schema::dropIfExists('lab_coa_files');
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Add barcode field to products table for inventory scanning
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->string('barcode')->nullable()->after('sku');
$table->index('barcode');
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropIndex(['barcode']);
$table->dropColumn('barcode');
});
}
};

View File

@@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Work orders track production runs that create finished product batches
* Different from picking tickets (which fulfill customer orders)
*/
public function up(): void
{
Schema::create('work_orders', function (Blueprint $table) {
$table->id();
// Relationships
$table->foreignId('business_id')->constrained('businesses')->onDelete('cascade');
$table->foreignId('product_id')->constrained('products')->onDelete('cascade'); // What's being manufactured
$table->foreignId('finished_batch_id')->nullable()->constrained('batches')->onDelete('set null'); // The resulting batch
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('started_by_user_id')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('completed_by_user_id')->nullable()->constrained('users')->onDelete('set null');
// Work Order Information
$table->string('work_order_number')->unique(); // e.g., "WO-1234"
$table->enum('status', ['pending', 'in_progress', 'completed', 'cancelled'])->default('pending');
$table->integer('quantity_to_produce'); // Target quantity
$table->integer('quantity_produced')->default(0); // Actual quantity produced
// Dates
$table->date('scheduled_date')->nullable(); // When production is planned
$table->timestamp('started_at')->nullable(); // When production actually started
$table->timestamp('completed_at')->nullable(); // When production was completed
// Production Details
$table->text('notes')->nullable(); // Production notes, issues, etc.
$table->json('component_requirements')->nullable(); // BOM snapshot at time of creation
/*
* component_requirements structure:
* [
* {"product_id": 1, "product_name": "Cured Flower", "quantity_required": 1000, "unit": "grams"},
* {"product_id": 2, "product_name": "Pre-Roll Tubes", "quantity_required": 100, "unit": "units"}
* ]
*/
$table->timestamps();
$table->softDeletes();
// Indexes
$table->index('business_id');
$table->index('product_id');
$table->index('finished_batch_id');
$table->index('status');
$table->index('scheduled_date');
$table->index('work_order_number');
});
}
public function down(): void
{
Schema::dropIfExists('work_orders');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Add buyer approval stage to order workflow
* Buyer reviews batch allocations and COAs before shipment
*/
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->timestamp('awaiting_buyer_approval_at')->nullable()->after('ready_for_invoice_at');
$table->timestamp('buyer_approved_at')->nullable()->after('awaiting_buyer_approval_at');
$table->foreignId('buyer_approved_by')->nullable()->after('buyer_approved_at')->constrained('users')->onDelete('set null');
$table->text('buyer_approval_notes')->nullable()->after('buyer_approved_by');
$table->index('awaiting_buyer_approval_at');
$table->index('buyer_approved_at');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropForeign(['buyer_approved_by']);
$table->dropIndex(['awaiting_buyer_approval_at']);
$table->dropIndex(['buyer_approved_at']);
$table->dropColumn([
'awaiting_buyer_approval_at',
'buyer_approved_at',
'buyer_approved_by',
'buyer_approval_notes',
]);
});
}
};

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('labs', function (Blueprint $table) {
// Old CRM compatibility fields
$table->string('test_id')->nullable()->after('sample_id');
$table->string('lot_number')->nullable()->after('batch_number');
$table->foreignId('brand_id')->nullable()->after('product_id')->constrained('brands')->onDelete('cascade');
// Additional cannabinoid fields from old system
$table->decimal('delta_9_percentage', 5, 2)->nullable()->after('cbdv_percentage');
$table->decimal('total_terps_percentage', 5, 2)->nullable()->after('total_cannabinoids');
// Add indexes
$table->index('brand_id');
$table->index('lot_number');
$table->index('test_id');
});
}
public function down(): void
{
Schema::table('labs', function (Blueprint $table) {
$table->dropForeign(['brand_id']);
$table->dropIndex(['brand_id']);
$table->dropIndex(['lot_number']);
$table->dropIndex(['test_id']);
$table->dropColumn([
'test_id',
'lot_number',
'brand_id',
'delta_9_percentage',
'total_terps_percentage',
]);
});
}
};

View File

@@ -0,0 +1,90 @@
<?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('batches', function (Blueprint $table) {
// Make lab_id nullable for backward compatibility
$table->foreignId('lab_id')->nullable()->change();
// Lab/Test identification fields
$table->string('test_id')->nullable()->after('batch_number');
$table->string('lot_number')->nullable()->after('test_id');
$table->string('lab_name')->nullable()->after('lot_number');
$table->string('lab_license_number')->nullable()->after('lab_name');
// Cannabinoid percentages
$table->decimal('thc_percentage', 5, 2)->nullable()->after('test_date');
$table->decimal('thca_percentage', 5, 2)->nullable()->after('thc_percentage');
$table->decimal('cbd_percentage', 5, 2)->nullable()->after('thca_percentage');
$table->decimal('cbda_percentage', 5, 2)->nullable()->after('cbd_percentage');
$table->decimal('cbg_percentage', 5, 2)->nullable()->after('cbda_percentage');
$table->decimal('cbn_percentage', 5, 2)->nullable()->after('cbg_percentage');
$table->decimal('thcv_percentage', 5, 2)->nullable()->after('cbn_percentage');
$table->decimal('cbdv_percentage', 5, 2)->nullable()->after('thcv_percentage');
$table->decimal('delta_9_percentage', 5, 2)->nullable()->after('cbdv_percentage');
// Calculated/total cannabinoid values
$table->decimal('total_thc', 5, 2)->nullable()->after('delta_9_percentage');
$table->decimal('total_cbd', 5, 2)->nullable()->after('total_thc');
$table->decimal('total_cannabinoids', 5, 2)->nullable()->after('total_cbd');
$table->decimal('total_terps_percentage', 5, 2)->nullable()->after('total_cannabinoids');
// Terpene profile (JSON array)
$table->json('terpenes')->nullable()->after('total_terps_percentage');
// Additional notes field (from Lab model)
// Note: batches table already has 'notes', so we'll skip adding it again
// Add indexes for common search fields
$table->index('test_id');
$table->index('lot_number');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('batches', function (Blueprint $table) {
// Drop indexes
$table->dropIndex(['test_id']);
$table->dropIndex(['lot_number']);
// Drop added columns
$table->dropColumn([
'test_id',
'lot_number',
'lab_name',
'lab_license_number',
'thc_percentage',
'thca_percentage',
'cbd_percentage',
'cbda_percentage',
'cbg_percentage',
'cbn_percentage',
'thcv_percentage',
'cbdv_percentage',
'delta_9_percentage',
'total_thc',
'total_cbd',
'total_cannabinoids',
'total_terps_percentage',
'terpenes',
]);
// Make lab_id non-nullable again (if it was before)
// Commented out to avoid breaking existing data
// $table->foreignId('lab_id')->nullable(false)->change();
});
}
};

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Stores multiple COA files per batch
* Matches the structure of lab_coa_files for consistency
*/
public function up(): void
{
Schema::create('batch_coa_files', function (Blueprint $table) {
$table->id();
// Relationships
$table->foreignId('batch_id')->constrained('batches')->onDelete('cascade');
// File Information
$table->string('file_name'); // Original filename
$table->string('file_path'); // Storage path: businesses/{uuid}/batches/{batch_id}/coas/{filename}
$table->string('file_type')->default('pdf'); // pdf, jpg, png
$table->integer('file_size')->nullable(); // Size in bytes
$table->text('description')->nullable(); // Optional description/label
// Metadata
$table->boolean('is_primary')->default(false); // Primary/featured COA
$table->integer('display_order')->default(0); // Sort order for display
$table->timestamps();
// Indexes
$table->index('batch_id');
$table->index('is_primary');
$table->index('display_order');
});
}
public function down(): void
{
Schema::dropIfExists('batch_coa_files');
}
};

View File

@@ -0,0 +1,31 @@
<?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('batches', function (Blueprint $table) {
// Add cannabinoid unit field (%, MG/ML, MG/G, MG/UNIT)
$table->string('cannabinoid_unit')->default('%')->after('business_id');
$table->index('cannabinoid_unit');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('batches', function (Blueprint $table) {
$table->dropIndex(['cannabinoid_unit']);
$table->dropColumn('cannabinoid_unit');
});
}
};

View File

@@ -3,6 +3,7 @@
namespace Database\Seeders;
use App\Models\Address;
use App\Models\Batch;
use App\Models\Business;
use App\Models\Contact;
use App\Models\Location;
@@ -829,6 +830,304 @@ class DevSeeder extends Seeder
],
]);
// ================================================================
// BATCHES WITH TEST RESULTS (COA DATA)
// ================================================================
// Batches for Product 1: Blue Dream (Flower) - Use % unit
$batch1a = Batch::updateOrCreate(
['batch_number' => 'B-250115-BD01'],
[
'product_id' => $product1->id,
'business_id' => $sellerBusiness->id,
'cannabinoid_unit' => '%',
'production_date' => now()->subDays(5),
'test_date' => now()->subDays(3),
'test_id' => 'LAB-2025-001',
'lot_number' => 'LOT-BD-2025-001',
'lab_name' => 'SC Labs',
'thc_percentage' => 22.5,
'thca_percentage' => 2.8,
'cbd_percentage' => 0.3,
'cbda_percentage' => 0.1,
'cbg_percentage' => 1.2,
'cbn_percentage' => 0.5,
'delta_9_percentage' => 22.5,
'total_terps_percentage' => 2.8,
'is_active' => true,
'is_tested' => true,
'is_released_for_sale' => true,
'notes' => 'Premium batch with excellent terpene profile',
]
);
$batch1b = Batch::updateOrCreate(
['batch_number' => 'B-241220-BD02'],
[
'product_id' => $product1->id,
'business_id' => $sellerBusiness->id,
'cannabinoid_unit' => '%',
'production_date' => now()->subDays(35),
'test_date' => now()->subDays(33),
'test_id' => 'LAB-2024-998',
'lot_number' => 'LOT-BD-2024-042',
'lab_name' => 'SC Labs',
'thc_percentage' => 21.8,
'thca_percentage' => 3.1,
'cbd_percentage' => 0.2,
'cbda_percentage' => 0.1,
'cbg_percentage' => 1.0,
'cbn_percentage' => 0.4,
'delta_9_percentage' => 21.8,
'total_terps_percentage' => 2.5,
'is_active' => true,
'is_tested' => true,
'is_released_for_sale' => true,
'notes' => 'Previous batch, still available',
]
);
// Batches for Product 2: Gelato (Flower) - Use % unit
$batch2 = Batch::updateOrCreate(
['batch_number' => 'B-250110-GEL01'],
[
'product_id' => $product2->id,
'business_id' => $sellerBusiness->id,
'cannabinoid_unit' => '%',
'production_date' => now()->subDays(10),
'test_date' => now()->subDays(8),
'test_id' => 'LAB-2025-002',
'lot_number' => 'LOT-GEL-2025-001',
'lab_name' => 'SC Labs',
'thc_percentage' => 24.2,
'thca_percentage' => 3.5,
'cbd_percentage' => 0.1,
'cbda_percentage' => 0.05,
'cbg_percentage' => 1.5,
'cbn_percentage' => 0.3,
'delta_9_percentage' => 24.2,
'total_terps_percentage' => 3.2,
'is_active' => true,
'is_tested' => true,
'is_released_for_sale' => true,
'notes' => 'High THC batch with sweet terpene profile',
]
);
// Batches for Product 3: Wedding Cake (Flower) - Use % unit
$batch3 = Batch::updateOrCreate(
['batch_number' => 'B-250105-WC01'],
[
'product_id' => $product3->id,
'business_id' => $sellerBusiness->id,
'cannabinoid_unit' => '%',
'production_date' => now()->subDays(15),
'test_date' => now()->subDays(13),
'test_id' => 'LAB-2025-003',
'lot_number' => 'LOT-WC-2025-001',
'lab_name' => 'SC Labs',
'thc_percentage' => 23.8,
'thca_percentage' => 2.9,
'cbd_percentage' => 0.2,
'cbda_percentage' => 0.08,
'cbg_percentage' => 1.1,
'cbn_percentage' => 0.6,
'delta_9_percentage' => 23.8,
'total_terps_percentage' => 2.9,
'is_active' => true,
'is_tested' => true,
'is_released_for_sale' => true,
'notes' => 'Premium Wedding Cake phenotype',
]
);
// Batches for Product 4: Live Resin (Concentrate) - Use MG/G unit
$batch4 = Batch::updateOrCreate(
['batch_number' => 'B-250112-LR01'],
[
'product_id' => $product4->id,
'business_id' => $sellerBusiness->id,
'cannabinoid_unit' => 'MG/G',
'production_date' => now()->subDays(8),
'test_date' => now()->subDays(6),
'test_id' => 'LAB-2025-004',
'lot_number' => 'LOT-LR-2025-001',
'lab_name' => 'CannaSafe Analytics',
'thc_percentage' => 782.5,
'thca_percentage' => 45.2,
'cbd_percentage' => 3.8,
'cbda_percentage' => 1.2,
'cbg_percentage' => 12.5,
'cbn_percentage' => 8.3,
'delta_9_percentage' => 782.5,
'total_terps_percentage' => 125.8,
'is_active' => true,
'is_tested' => true,
'is_released_for_sale' => true,
'notes' => 'Full-spectrum live resin with high terpene content',
]
);
// Batches for Product 5: Shatter (Concentrate) - Use MG/G unit
$batch5 = Batch::updateOrCreate(
['batch_number' => 'B-250108-SH01'],
[
'product_id' => $product5->id,
'business_id' => $sellerBusiness->id,
'cannabinoid_unit' => 'MG/G',
'production_date' => now()->subDays(12),
'test_date' => now()->subDays(10),
'test_id' => 'LAB-2025-005',
'lot_number' => 'LOT-SH-2025-001',
'lab_name' => 'CannaSafe Analytics',
'thc_percentage' => 895.3,
'thca_percentage' => 28.7,
'cbd_percentage' => 1.2,
'cbda_percentage' => 0.5,
'cbg_percentage' => 8.9,
'cbn_percentage' => 5.1,
'delta_9_percentage' => 895.3,
'total_terps_percentage' => 45.2,
'is_active' => true,
'is_tested' => true,
'is_released_for_sale' => true,
'notes' => 'High-potency Blue Dream shatter, crystal clear',
]
);
// Batches for Product 6: Mixed Fruit Gummies (Edible) - Use MG/UNIT
$batch6a = Batch::updateOrCreate(
['batch_number' => 'B-250118-GUM01'],
[
'product_id' => $product6->id,
'business_id' => $sellerBusiness->id,
'cannabinoid_unit' => 'MG/UNIT',
'production_date' => now()->subDays(3),
'test_date' => now()->subDays(1),
'test_id' => 'LAB-2025-006',
'lot_number' => 'LOT-GUM-2025-001',
'lab_name' => 'PharmLabs',
'thc_percentage' => 10.0,
'thca_percentage' => 0.0,
'cbd_percentage' => 0.0,
'cbda_percentage' => 0.0,
'cbg_percentage' => 0.0,
'cbn_percentage' => 0.5,
'delta_9_percentage' => 10.0,
'total_terps_percentage' => 0.0,
'is_active' => true,
'is_tested' => true,
'is_released_for_sale' => true,
'notes' => '10mg THC per gummy, 10 pieces per package',
]
);
$batch6b = Batch::updateOrCreate(
['batch_number' => 'B-250115-GUM02'],
[
'product_id' => $product6->id,
'business_id' => $sellerBusiness->id,
'cannabinoid_unit' => 'MG/UNIT',
'production_date' => now()->subDays(6),
'test_date' => now()->subDays(4),
'test_id' => 'LAB-2025-007',
'lot_number' => 'LOT-GUM-2025-002',
'lab_name' => 'PharmLabs',
'thc_percentage' => 9.8,
'thca_percentage' => 0.0,
'cbd_percentage' => 0.0,
'cbda_percentage' => 0.0,
'cbg_percentage' => 0.0,
'cbn_percentage' => 0.4,
'delta_9_percentage' => 9.8,
'total_terps_percentage' => 0.0,
'is_active' => true,
'is_tested' => true,
'is_released_for_sale' => true,
'notes' => 'Previous batch with consistent dosing',
]
);
// Batches for Product 7: CBD Tincture (Edible) - Use MG/ML
$batch7a = Batch::updateOrCreate(
['batch_number' => 'B-250120-TIN01'],
[
'product_id' => $product7->id,
'business_id' => $sellerBusiness->id,
'cannabinoid_unit' => 'MG/ML',
'production_date' => now()->subDays(2),
'test_date' => now()->subDay(),
'test_id' => 'LAB-2025-008',
'lot_number' => 'LOT-TIN-2025-001',
'lab_name' => 'PharmLabs',
'thc_percentage' => 1.0,
'thca_percentage' => 0.0,
'cbd_percentage' => 33.3,
'cbda_percentage' => 2.5,
'cbg_percentage' => 1.5,
'cbn_percentage' => 0.2,
'delta_9_percentage' => 1.0,
'total_terps_percentage' => 5.8,
'is_active' => true,
'is_tested' => true,
'is_released_for_sale' => true,
'notes' => 'CBD-dominant tincture, 1000mg CBD per 30ml bottle',
]
);
$batch7b = Batch::updateOrCreate(
['batch_number' => 'B-241215-TIN02'],
[
'product_id' => $product7->id,
'business_id' => $sellerBusiness->id,
'cannabinoid_unit' => 'MG/ML',
'production_date' => now()->subDays(40),
'test_date' => now()->subDays(38),
'test_id' => 'LAB-2024-995',
'lot_number' => 'LOT-TIN-2024-012',
'lab_name' => 'PharmLabs',
'thc_percentage' => 1.2,
'thca_percentage' => 0.0,
'cbd_percentage' => 32.8,
'cbda_percentage' => 2.3,
'cbg_percentage' => 1.3,
'cbn_percentage' => 0.3,
'delta_9_percentage' => 1.2,
'total_terps_percentage' => 5.5,
'is_active' => true,
'is_tested' => true,
'is_released_for_sale' => true,
'notes' => 'Previous batch, consistent formulation',
]
);
// Add one inactive batch to demonstrate the status
$batchInactive = Batch::updateOrCreate(
['batch_number' => 'B-241105-BD99'],
[
'product_id' => $product1->id,
'business_id' => $sellerBusiness->id,
'cannabinoid_unit' => '%',
'production_date' => now()->subDays(80),
'test_date' => now()->subDays(78),
'test_id' => 'LAB-2024-850',
'lot_number' => 'LOT-BD-2024-025',
'lab_name' => 'SC Labs',
'thc_percentage' => 20.5,
'thca_percentage' => 2.5,
'cbd_percentage' => 0.3,
'cbda_percentage' => 0.1,
'cbg_percentage' => 0.9,
'cbn_percentage' => 0.8,
'delta_9_percentage' => 20.5,
'total_terps_percentage' => 2.1,
'is_active' => false,
'is_tested' => true,
'is_released_for_sale' => false,
'notes' => 'Archived batch - sold out',
]
);
// ================================================================
// ORDERS & ORDER WORKFLOW
// ================================================================

View File

@@ -29,6 +29,8 @@ spec:
php artisan vendor:publish --tag=public --force
echo "Publishing Filament assets..."
php artisan filament:assets
echo "Creating storage symlink..."
php artisan storage:link
echo "Optimizing Laravel..."
php artisan optimize
echo "Optimizing Filament..."

View File

@@ -183,6 +183,12 @@
<a class="menu-item {{ request()->routeIs('seller.business.components.*') ? 'active' : '' }}" href="{{ route('seller.business.components.index', $sidebarBusiness->slug) }}">
<span class="grow">Components</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.labs.*') ? 'active' : '' }}" href="{{ route('seller.business.labs.index', $sidebarBusiness->slug) }}">
<span class="grow">Labs</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.batches.*') ? 'active' : '' }}" href="{{ route('seller.business.batches.index', $sidebarBusiness->slug) }}">
<span class="grow">Batches</span>
</a>
</div>
</div>
@else

View File

@@ -0,0 +1,307 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Picking Ticket - {{ $order->order_number }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
font-size: 11pt;
line-height: 1.4;
color: #333;
padding: 20px;
}
.header {
text-align: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 3px solid #2563eb;
}
.header h1 {
font-size: 24pt;
color: #1e40af;
margin-bottom: 5px;
}
.header .order-number {
font-size: 14pt;
font-weight: bold;
color: #64748b;
}
.info-section {
display: table;
width: 100%;
margin-bottom: 20px;
}
.info-column {
display: table-cell;
width: 50%;
vertical-align: top;
padding: 10px;
}
.info-column h3 {
font-size: 12pt;
color: #1e40af;
margin-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
padding-bottom: 4px;
}
.info-row {
margin-bottom: 4px;
font-size: 10pt;
}
.info-label {
font-weight: bold;
color: #64748b;
}
.picking-items {
margin-top: 20px;
}
.item-card {
border: 2px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
margin-bottom: 15px;
page-break-inside: avoid;
background: #f9fafb;
}
.item-header {
background: #2563eb;
color: white;
padding: 8px 12px;
margin: -12px -12px 12px -12px;
border-radius: 6px 6px 0 0;
font-weight: bold;
font-size: 11pt;
}
.allocation-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.allocation-table th {
background: #cbd5e1;
padding: 6px;
text-align: left;
font-size: 9pt;
font-weight: bold;
border: 1px solid #94a3b8;
}
.allocation-table td {
padding: 8px 6px;
border: 1px solid #e5e7eb;
font-size: 9pt;
}
.allocation-table tr:nth-child(even) {
background: white;
}
.checkbox {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid #64748b;
border-radius: 3px;
vertical-align: middle;
}
.batch-number {
font-family: 'Courier New', monospace;
font-weight: bold;
font-size: 10pt;
background: #fef3c7;
padding: 2px 6px;
border-radius: 3px;
}
.location {
font-weight: bold;
color: #dc2626;
font-size: 10pt;
}
.footer {
margin-top: 30px;
padding-top: 15px;
border-top: 2px solid #e5e7eb;
font-size: 9pt;
color: #64748b;
}
.signature-section {
display: table;
width: 100%;
margin-top: 30px;
}
.signature-box {
display: table-cell;
width: 50%;
padding: 10px;
}
.signature-line {
border-top: 2px solid #333;
margin-top: 40px;
padding-top: 5px;
font-size: 9pt;
}
.instructions {
background: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 10px;
margin-bottom: 20px;
font-size: 9pt;
}
.instructions strong {
color: #92400e;
}
</style>
</head>
<body>
<div class="header">
<h1>PICKING TICKET</h1>
<div class="order-number">Order #{{ $order->order_number }}</div>
<div style="font-size: 9pt; color: #64748b; margin-top: 5px;">
Generated: {{ $generatedAt->format('m/d/Y g:i A') }}
</div>
</div>
<div class="instructions">
<strong>INSTRUCTIONS:</strong> Verify batch numbers, check quantities, scan barcodes, and initial each item after picking. Report any discrepancies immediately.
</div>
<div class="info-section">
<div class="info-column">
<h3>Ship To (Buyer)</h3>
<div class="info-row"><span class="info-label">Business:</span> {{ $buyer->name }}</div>
@if($buyer->license_number)
<div class="info-row"><span class="info-label">License:</span> {{ $buyer->license_number }}</div>
@endif
@if($buyer->address)
<div class="info-row"><span class="info-label">Address:</span> {{ $buyer->address }}</div>
@endif
@if($buyer->phone)
<div class="info-row"><span class="info-label">Phone:</span> {{ $buyer->phone }}</div>
@endif
</div>
<div class="info-column">
<h3>Ship From (Seller)</h3>
<div class="info-row"><span class="info-label">Business:</span> {{ $seller->name }}</div>
@if($seller->license_number)
<div class="info-row"><span class="info-label">License:</span> {{ $seller->license_number }}</div>
@endif
<div class="info-row"><span class="info-label">Order Date:</span> {{ $order->created_at->format('m/d/Y') }}</div>
@if($order->notes)
<div class="info-row"><span class="info-label">Notes:</span> {{ Str::limit($order->notes, 80) }}</div>
@endif
</div>
</div>
<div class="picking-items">
<h3 style="font-size: 13pt; color: #1e40af; margin-bottom: 15px;">Items to Pick</h3>
@foreach($pickingItems as $index => $item)
<div class="item-card">
<div class="item-header">
{{ $index + 1 }}. {{ $item['product_name'] }} (SKU: {{ $item['product_sku'] }})
</div>
<div style="padding: 5px 0;">
<strong>Quantity Ordered:</strong> {{ $item['quantity_ordered'] }} units
</div>
<table class="allocation-table">
<thead>
<tr>
<th style="width: 5%;"></th>
<th style="width: 20%;">Batch Number</th>
<th style="width: 15%;">Quantity</th>
<th style="width: 25%;">Location</th>
<th style="width: 15%;">Container</th>
<th style="width: 15%;">Expires</th>
<th style="width: 5%;">Init.</th>
</tr>
</thead>
<tbody>
@foreach($item['allocations'] as $allocation)
<tr>
<td style="text-align: center;">
<span class="checkbox"></span>
</td>
<td>
<span class="batch-number">{{ $allocation['batch_number'] }}</span>
</td>
<td><strong>{{ $allocation['quantity'] }}</strong> units</td>
<td>
<span class="location">{{ $allocation['warehouse_location'] }}</span>
</td>
<td>{{ $allocation['container_type'] }}</td>
<td>
@if($allocation['expiration_date'])
{{ $allocation['expiration_date'] }}
@else
N/A
@endif
</td>
<td style="border-left: 2px solid #cbd5e1;"></td>
</tr>
@endforeach
<tr style="background: #f1f5f9; font-weight: bold;">
<td></td>
<td>TOTAL</td>
<td>{{ $item['total_allocated'] }} units</td>
<td colspan="4"></td>
</tr>
</tbody>
</table>
</div>
@endforeach
</div>
<div class="signature-section">
<div class="signature-box">
<div class="signature-line">
<strong>Picker Signature</strong><br>
Name: ___________________________<br>
Date/Time: ___________________________
</div>
</div>
<div class="signature-box">
<div class="signature-line">
<strong>QA Verification</strong><br>
Name: ___________________________<br>
Date/Time: ___________________________
</div>
</div>
</div>
<div class="footer">
<div><strong>Order Summary:</strong> {{ count($pickingItems) }} product(s), {{ array_sum(array_column($pickingItems, 'quantity_ordered')) }} total units</div>
<div style="margin-top: 5px;"><strong>Important:</strong> Scan or manually verify all batch numbers before shipping. Ensure proper temperature control during transport.</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Certificate of Analysis - Batch {{ $batch->batch_number }}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50">
<div class="min-h-screen py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="bg-white shadow-md rounded-lg overflow-hidden mb-6">
<div class="bg-gradient-to-r from-green-600 to-green-700 px-6 py-8 text-white">
<h1 class="text-3xl font-bold mb-2">Certificate of Analysis</h1>
<p class="text-green-100">Batch #{{ $batch->batch_number }}</p>
</div>
<div class="px-6 py-6">
<!-- Product Information -->
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Product Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-600">Product Name</p>
<p class="font-medium text-gray-900">{{ $product->name }}</p>
</div>
@if($business)
<div>
<p class="text-sm text-gray-600">Brand</p>
<p class="font-medium text-gray-900">{{ $business->name }}</p>
</div>
@endif
@if($batch->production_date)
<div>
<p class="text-sm text-gray-600">Production Date</p>
<p class="font-medium text-gray-900">{{ $batch->production_date->format('M d, Y') }}</p>
</div>
@endif
@if($lab->test_date)
<div>
<p class="text-sm text-gray-600">Test Date</p>
<p class="font-medium text-gray-900">{{ $lab->test_date->format('M d, Y') }}</p>
</div>
@endif
</div>
</div>
<!-- QR Code -->
@if($batch->qr_code_path)
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">QR Code</h2>
<div class="flex flex-col items-center justify-center p-6 bg-gray-50 rounded-lg border border-gray-200">
<img src="{{ Storage::url($batch->qr_code_path) }}"
alt="Batch QR Code"
class="w-48 h-48 border-2 border-gray-300 rounded-lg shadow-sm" />
<p class="mt-3 text-sm text-gray-600 text-center">
Scan this QR code to access this Certificate of Analysis
</p>
</div>
</div>
@endif
<!-- Lab Information -->
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Laboratory Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-600">Lab Name</p>
<p class="font-medium text-gray-900">{{ $lab->lab_name }}</p>
</div>
@if($lab->lab_license_number)
<div>
<p class="text-sm text-gray-600">License Number</p>
<p class="font-medium text-gray-900">{{ $lab->lab_license_number }}</p>
</div>
@endif
@if($lab->sample_id)
<div>
<p class="text-sm text-gray-600">Sample ID</p>
<p class="font-medium text-gray-900">{{ $lab->sample_id }}</p>
</div>
@endif
</div>
</div>
<!-- Cannabinoid Profile -->
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Cannabinoid Profile</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
@if($lab->total_thc)
<div class="bg-green-50 p-4 rounded-lg">
<p class="text-sm text-gray-600">Total THC</p>
<p class="text-2xl font-bold text-green-700">{{ number_format($lab->total_thc, 2) }}%</p>
</div>
@endif
@if($lab->total_cbd)
<div class="bg-blue-50 p-4 rounded-lg">
<p class="text-sm text-gray-600">Total CBD</p>
<p class="text-2xl font-bold text-blue-700">{{ number_format($lab->total_cbd, 2) }}%</p>
</div>
@endif
@if($lab->cbg_percentage)
<div class="bg-purple-50 p-4 rounded-lg">
<p class="text-sm text-gray-600">CBG</p>
<p class="text-2xl font-bold text-purple-700">{{ number_format($lab->cbg_percentage, 2) }}%</p>
</div>
@endif
@if($lab->total_cannabinoids)
<div class="bg-gray-50 p-4 rounded-lg">
<p class="text-sm text-gray-600">Total Cannabinoids</p>
<p class="text-2xl font-bold text-gray-700">{{ number_format($lab->total_cannabinoids, 2) }}%</p>
</div>
@endif
</div>
</div>
<!-- Compliance Testing -->
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Safety & Compliance Testing</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div class="flex items-center p-3 rounded-lg {{ $lab->pesticides_pass ? 'bg-green-50' : 'bg-red-50' }}">
<svg class="w-5 h-5 mr-2 {{ $lab->pesticides_pass ? 'text-green-600' : 'text-red-600' }}" fill="currentColor" viewBox="0 0 20 20">
@if($lab->pesticides_pass)
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
@else
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
@endif
</svg>
<span class="text-sm font-medium">Pesticides</span>
</div>
<div class="flex items-center p-3 rounded-lg {{ $lab->heavy_metals_pass ? 'bg-green-50' : 'bg-red-50' }}">
<svg class="w-5 h-5 mr-2 {{ $lab->heavy_metals_pass ? 'text-green-600' : 'text-red-600' }}" fill="currentColor" viewBox="0 0 20 20">
@if($lab->heavy_metals_pass)
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
@else
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
@endif
</svg>
<span class="text-sm font-medium">Heavy Metals</span>
</div>
<div class="flex items-center p-3 rounded-lg {{ $lab->microbials_pass ? 'bg-green-50' : 'bg-red-50' }}">
<svg class="w-5 h-5 mr-2 {{ $lab->microbials_pass ? 'text-green-600' : 'text-red-600' }}" fill="currentColor" viewBox="0 0 20 20">
@if($lab->microbials_pass)
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
@else
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
@endif
</svg>
<span class="text-sm font-medium">Microbials</span>
</div>
<div class="flex items-center p-3 rounded-lg {{ $lab->mycotoxins_pass ? 'bg-green-50' : 'bg-red-50' }}">
<svg class="w-5 h-5 mr-2 {{ $lab->mycotoxins_pass ? 'text-green-600' : 'text-red-600' }}" fill="currentColor" viewBox="0 0 20 20">
@if($lab->mycotoxins_pass)
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
@else
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
@endif
</svg>
<span class="text-sm font-medium">Mycotoxins</span>
</div>
<div class="flex items-center p-3 rounded-lg {{ $lab->residual_solvents_pass ? 'bg-green-50' : 'bg-red-50' }}">
<svg class="w-5 h-5 mr-2 {{ $lab->residual_solvents_pass ? 'text-green-600' : 'text-red-600' }}" fill="currentColor" viewBox="0 0 20 20">
@if($lab->residual_solvents_pass)
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
@else
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
@endif
</svg>
<span class="text-sm font-medium">Residual Solvents</span>
</div>
<div class="flex items-center p-3 rounded-lg {{ $lab->foreign_material_pass ? 'bg-green-50' : 'bg-red-50' }}">
<svg class="w-5 h-5 mr-2 {{ $lab->foreign_material_pass ? 'text-green-600' : 'text-red-600' }}" fill="currentColor" viewBox="0 0 20 20">
@if($lab->foreign_material_pass)
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
@else
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
@endif
</svg>
<span class="text-sm font-medium">Foreign Material</span>
</div>
</div>
<!-- Overall Compliance Badge -->
<div class="mt-4 p-4 rounded-lg {{ $lab->compliance_pass ? 'bg-green-100 border-2 border-green-500' : 'bg-red-100 border-2 border-red-500' }}">
<p class="text-center font-bold {{ $lab->compliance_pass ? 'text-green-800' : 'text-red-800' }}">
{{ $lab->compliance_pass ? '✓ COMPLIANT - Passed All Tests' : '✗ NON-COMPLIANT - Failed Safety Tests' }}
</p>
</div>
</div>
<!-- COA Files -->
@if($coaFiles->count() > 0)
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Certificate Documents</h2>
<div class="space-y-3">
@foreach($coaFiles as $coaFile)
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition">
<div class="flex items-center">
<svg class="w-8 h-8 text-red-600 mr-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"></path>
</svg>
<div>
<p class="font-medium text-gray-900">{{ $coaFile->file_name }}</p>
@if($coaFile->description)
<p class="text-sm text-gray-600">{{ $coaFile->description }}</p>
@endif
<p class="text-xs text-gray-500">{{ $coaFile->formatted_file_size }}</p>
</div>
@if($coaFile->is_primary)
<span class="ml-2 px-2 py-1 text-xs font-semibold text-green-800 bg-green-100 rounded">Primary</span>
@endif
</div>
<div class="flex space-x-2">
<a href="{{ route('public.coa.view', ['batchNumber' => $batch->batch_number, 'coaFileId' => $coaFile->id]) }}"
target="_blank"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition">
View
</a>
<a href="{{ route('public.coa.download', ['batchNumber' => $batch->batch_number, 'coaFileId' => $coaFile->id]) }}"
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition">
Download
</a>
</div>
</div>
@endforeach
</div>
</div>
@endif
</div>
</div>
<!-- Footer -->
<div class="text-center text-sm text-gray-600">
<p>This Certificate of Analysis is provided for informational purposes only.</p>
<p class="mt-1">For questions or concerns, please contact the laboratory listed above.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,320 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Header -->
<div class="mb-6">
<div class="flex items-center gap-2 text-sm breadcrumbs mb-2">
<ul>
<li><a href="{{ route('seller.business.batches.index', $business->slug) }}">Batches</a></li>
<li>Create Batch</li>
</ul>
</div>
<h1 class="text-3xl font-bold flex items-center gap-2">
<span class="icon-[lucide--package-2] size-8"></span>
Create Batch
</h1>
<p class="text-sm text-base-content/60 mt-1">Create a production batch with integrated test results</p>
</div>
<form method="POST" action="{{ route('seller.business.batches.store', $business->slug) }}" enctype="multipart/form-data" class="space-y-6 max-w-5xl">
@csrf
<!-- Batch Information -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-4">
<span class="icon-[lucide--package] size-5"></span>
Batch Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">SKU / Product <span class="text-error">*</span></span>
</label>
<div x-data="{
init() {
new Choices(this.$refs.productSelect, {
searchEnabled: true,
searchPlaceholderValue: 'Type to search products...',
shouldSort: false,
placeholder: true,
placeholderValue: 'Select a product...',
noResultsText: 'No products found',
noChoicesText: 'No products available',
allowHTML: false,
itemSelectText: 'Press to select',
})
}
}">
<select x-ref="productSelect" name="product_id" class="select select-bordered w-full @error('product_id') select-error @enderror" required>
<option value="">Select a product...</option>
@foreach($products as $product)
<option value="{{ $product->id }}" {{ old('product_id') == $product->id ? 'selected' : '' }}>
{{ $product->name }} ({{ $product->brand->name ?? 'No Brand' }})
</option>
@endforeach
</select>
</div>
@error('product_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Production Batch Number</span>
</label>
<input type="text" name="batch_number" class="input input-bordered @error('batch_number') input-error @enderror" value="{{ old('batch_number') }}" placeholder="Auto-generated if left blank" />
@error('batch_number')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
<label class="label">
<span class="label-text-alt text-base-content/60">Leave blank to auto-generate</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Batch Date</span>
</label>
<input type="date" name="production_date" class="input input-bordered @error('production_date') input-error @enderror" value="{{ old('production_date', now()->format('Y-m-d')) }}" />
@error('production_date')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Lot Number</span>
</label>
<input type="text" name="lot_number" class="input input-bordered @error('lot_number') input-error @enderror" value="{{ old('lot_number') }}" placeholder="e.g., LOT-2024-001" />
@error('lot_number')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Test ID</span>
</label>
<input type="text" name="test_id" class="input input-bordered @error('test_id') input-error @enderror" value="{{ old('test_id') }}" placeholder="e.g., LAB-2024-001" />
@error('test_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Test Date</span>
</label>
<input type="date" name="test_date" class="input input-bordered @error('test_date') input-error @enderror" value="{{ old('test_date') }}" />
@error('test_date')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-medium">Lab Name</span>
</label>
<input type="text" name="lab_name" class="input input-bordered @error('lab_name') input-error @enderror" value="{{ old('lab_name') }}" placeholder="e.g., SC Labs" />
@error('lab_name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
</div>
</div>
<!-- Test Results (Inline Cannabinoids - Leaflink Style) -->
<div class="card bg-base-100 shadow-sm" x-data="{
cannabinoidUnit: '{{ old('cannabinoid_unit', '%') }}',
get displayUnit() {
const units = {
'%': '%',
'MG/ML': 'mg/ml',
'MG/G': 'mg/g',
'MG/UNIT': 'mg/unit'
};
return units[this.cannabinoidUnit] || '%';
}
}">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-2">
<span class="icon-[lucide--flask-conical] size-5"></span>
Test Results
</h3>
<p class="text-sm text-base-content/60 mb-4">Enter cannabinoid values from the COA</p>
<!-- Cannabinoid Unit Selector -->
<div class="mb-6">
<label class="label">
<span class="label-text font-medium">Cannabinoid Unit <span class="text-error">*</span></span>
</label>
<select name="cannabinoid_unit" x-model="cannabinoidUnit" class="select select-bordered w-full max-w-xs @error('cannabinoid_unit') select-error @enderror" required>
<option value="%" {{ old('cannabinoid_unit', '%') == '%' ? 'selected' : '' }}>% (Percentage)</option>
<option value="MG/ML" {{ old('cannabinoid_unit') == 'MG/ML' ? 'selected' : '' }}>MG/ML (Milligrams per Milliliter)</option>
<option value="MG/G" {{ old('cannabinoid_unit') == 'MG/G' ? 'selected' : '' }}>MG/G (Milligrams per Gram)</option>
<option value="MG/UNIT" {{ old('cannabinoid_unit') == 'MG/UNIT' ? 'selected' : '' }}>MG/UNIT (Milligrams per Unit)</option>
</select>
@error('cannabinoid_unit')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
<label class="label">
<span class="label-text-alt text-base-content/60">Select the unit of measurement for cannabinoid values</span>
</label>
</div>
<div class="divider my-4">Cannabinoid Values</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">THC <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="thc_percentage" class="input input-bordered @error('thc_percentage') input-error @enderror" value="{{ old('thc_percentage') }}" placeholder="0.00" />
@error('thc_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">THCa <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="thca_percentage" class="input input-bordered @error('thca_percentage') input-error @enderror" value="{{ old('thca_percentage') }}" placeholder="0.00" />
@error('thca_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">CBD <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="cbd_percentage" class="input input-bordered @error('cbd_percentage') input-error @enderror" value="{{ old('cbd_percentage') }}" placeholder="0.00" />
@error('cbd_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">CBDa <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="cbda_percentage" class="input input-bordered @error('cbda_percentage') input-error @enderror" value="{{ old('cbda_percentage') }}" placeholder="0.00" />
@error('cbda_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">CBG <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="cbg_percentage" class="input input-bordered @error('cbg_percentage') input-error @enderror" value="{{ old('cbg_percentage') }}" placeholder="0.00" />
@error('cbg_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">CBN <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="cbn_percentage" class="input input-bordered @error('cbn_percentage') input-error @enderror" value="{{ old('cbn_percentage') }}" placeholder="0.00" />
@error('cbn_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Delta 9 <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="delta_9_percentage" class="input input-bordered @error('delta_9_percentage') input-error @enderror" value="{{ old('delta_9_percentage') }}" placeholder="0.00" />
@error('delta_9_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Total Terps <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="total_terps_percentage" class="input input-bordered @error('total_terps_percentage') input-error @enderror" value="{{ old('total_terps_percentage') }}" placeholder="0.00" />
@error('total_terps_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
<div class="mt-6">
<div class="alert alert-info shadow-sm">
<span class="icon-[lucide--info] size-5"></span>
<div>
<h4 class="font-semibold">Auto-Calculated Values</h4>
<p class="text-sm">Total THC, Total CBD, and Total Cannabinoids will be calculated automatically based on the values you enter above.</p>
</div>
</div>
</div>
</div>
</div>
<!-- COA Files -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-2">
<span class="icon-[lucide--file-text] size-5"></span>
Documents (COA Files)
</h3>
<p class="text-sm text-base-content/60 mb-4">Upload certificates of analysis - PDF, JPG, or PNG format (max 10MB each)</p>
<div class="form-control">
<input type="file" name="coa_files[]" multiple accept=".pdf,.jpg,.jpeg,.png" class="file-input file-input-bordered w-full @error('coa_files.*') file-input-error @enderror" />
@error('coa_files.*')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
<label class="label">
<span class="label-text-alt text-base-content/60">
<span class="icon-[lucide--info] size-3.5 inline"></span>
You can upload multiple files at once
</span>
</label>
</div>
</div>
</div>
<!-- Additional Notes -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-2">
<span class="icon-[lucide--sticky-note] size-5"></span>
Additional Notes
</h3>
<p class="text-sm text-base-content/60 mb-4">Optional - Add any additional information</p>
<div class="form-control">
<textarea name="notes" rows="4" class="textarea textarea-bordered @error('notes') textarea-error @enderror" placeholder="e.g., Retest results, special conditions, observations...">{{ old('notes') }}</textarea>
@error('notes')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
</div>
<!-- Actions -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex gap-3 justify-end">
<a href="{{ route('seller.business.batches.index', $business->slug) }}" class="btn btn-ghost">
<span class="icon-[lucide--x] size-4"></span>
Cancel
</a>
<button type="submit" class="btn btn-primary">
<span class="icon-[lucide--check] size-4"></span>
Create Batch
</button>
</div>
</div>
</div>
</form>
@endsection

View File

@@ -0,0 +1,543 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Header -->
<div class="mb-6">
<div class="flex items-center gap-2 text-sm breadcrumbs mb-2">
<ul>
<li><a href="{{ route('seller.business.batches.index', $business->slug) }}">Batches</a></li>
<li>Edit Batch</li>
</ul>
</div>
<h1 class="text-3xl font-bold flex items-center gap-2">
<span class="icon-[lucide--package-2] size-8"></span>
Edit Batch
</h1>
<p class="text-sm text-base-content/60 mt-1">Update batch information and test results</p>
</div>
<form method="POST" action="{{ route('seller.business.batches.update', [$business->slug, $batch->id]) }}" enctype="multipart/form-data" class="space-y-6 max-w-5xl">
@csrf
@method('PUT')
<!-- Batch Information -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-4">
<span class="icon-[lucide--package] size-5"></span>
Batch Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">SKU / Product <span class="text-error">*</span></span>
</label>
<div x-data="{
init() {
new Choices(this.$refs.productSelect, {
searchEnabled: true,
searchPlaceholderValue: 'Type to search products...',
shouldSort: false,
placeholder: true,
placeholderValue: 'Select a product...',
noResultsText: 'No products found',
noChoicesText: 'No products available',
allowHTML: false,
itemSelectText: 'Press to select',
})
}
}">
<select x-ref="productSelect" name="product_id" class="select select-bordered w-full @error('product_id') select-error @enderror" required>
<option value="">Select a product...</option>
@foreach($products as $product)
<option value="{{ $product->id }}" {{ old('product_id', $batch->product_id) == $product->id ? 'selected' : '' }}>
{{ $product->name }} ({{ $product->brand->name ?? 'No Brand' }})
</option>
@endforeach
</select>
</div>
@error('product_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Production Batch Number</span>
</label>
<input type="text" name="batch_number" class="input input-bordered @error('batch_number') input-error @enderror" value="{{ old('batch_number', $batch->batch_number) }}" placeholder="Auto-generated if left blank" />
@error('batch_number')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
<label class="label">
<span class="label-text-alt text-base-content/60">Leave blank to auto-generate</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Batch Date</span>
</label>
<input type="date" name="production_date" class="input input-bordered @error('production_date') input-error @enderror" value="{{ old('production_date', $batch->production_date?->format('Y-m-d')) }}" />
@error('production_date')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Lot Number</span>
</label>
<input type="text" name="lot_number" class="input input-bordered @error('lot_number') input-error @enderror" value="{{ old('lot_number', $batch->lot_number) }}" placeholder="e.g., LOT-2024-001" />
@error('lot_number')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Test ID</span>
</label>
<input type="text" name="test_id" class="input input-bordered @error('test_id') input-error @enderror" value="{{ old('test_id', $batch->test_id) }}" placeholder="e.g., LAB-2024-001" />
@error('test_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Test Date</span>
</label>
<input type="date" name="test_date" class="input input-bordered @error('test_date') input-error @enderror" value="{{ old('test_date', $batch->test_date?->format('Y-m-d')) }}" />
@error('test_date')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-medium">Lab Name</span>
</label>
<input type="text" name="lab_name" class="input input-bordered @error('lab_name') input-error @enderror" value="{{ old('lab_name', $batch->lab_name) }}" placeholder="e.g., SC Labs" />
@error('lab_name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
</div>
</div>
<!-- Test Results (Inline Cannabinoids - Leaflink Style) -->
<div class="card bg-base-100 shadow-sm" x-data="{
cannabinoidUnit: '{{ old('cannabinoid_unit', $batch->cannabinoid_unit ?? '%') }}',
get displayUnit() {
const units = {
'%': '%',
'MG/ML': 'mg/ml',
'MG/G': 'mg/g',
'MG/UNIT': 'mg/unit'
};
return units[this.cannabinoidUnit] || '%';
}
}">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-2">
<span class="icon-[lucide--flask-conical] size-5"></span>
Test Results
</h3>
<p class="text-sm text-base-content/60 mb-4">Enter cannabinoid values from the COA</p>
<!-- Cannabinoid Unit Selector -->
<div class="mb-6">
<label class="label">
<span class="label-text font-medium">Cannabinoid Unit <span class="text-error">*</span></span>
</label>
<select name="cannabinoid_unit" x-model="cannabinoidUnit" class="select select-bordered w-full max-w-xs @error('cannabinoid_unit') select-error @enderror" required>
<option value="%" {{ old('cannabinoid_unit', $batch->cannabinoid_unit ?? '%') == '%' ? 'selected' : '' }}>% (Percentage)</option>
<option value="MG/ML" {{ old('cannabinoid_unit', $batch->cannabinoid_unit) == 'MG/ML' ? 'selected' : '' }}>MG/ML (Milligrams per Milliliter)</option>
<option value="MG/G" {{ old('cannabinoid_unit', $batch->cannabinoid_unit) == 'MG/G' ? 'selected' : '' }}>MG/G (Milligrams per Gram)</option>
<option value="MG/UNIT" {{ old('cannabinoid_unit', $batch->cannabinoid_unit) == 'MG/UNIT' ? 'selected' : '' }}>MG/UNIT (Milligrams per Unit)</option>
</select>
@error('cannabinoid_unit')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
<label class="label">
<span class="label-text-alt text-base-content/60">Select the unit of measurement for cannabinoid values</span>
</label>
</div>
<div class="divider my-4">Cannabinoid Values</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">THC <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="thc_percentage" class="input input-bordered @error('thc_percentage') input-error @enderror" value="{{ old('thc_percentage', $batch->thc_percentage) }}" placeholder="0.00" />
@error('thc_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">THCa <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="thca_percentage" class="input input-bordered @error('thca_percentage') input-error @enderror" value="{{ old('thca_percentage', $batch->thca_percentage) }}" placeholder="0.00" />
@error('thca_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">CBD <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="cbd_percentage" class="input input-bordered @error('cbd_percentage') input-error @enderror" value="{{ old('cbd_percentage', $batch->cbd_percentage) }}" placeholder="0.00" />
@error('cbd_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">CBDa <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="cbda_percentage" class="input input-bordered @error('cbda_percentage') input-error @enderror" value="{{ old('cbda_percentage', $batch->cbda_percentage) }}" placeholder="0.00" />
@error('cbda_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">CBG <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="cbg_percentage" class="input input-bordered @error('cbg_percentage') input-error @enderror" value="{{ old('cbg_percentage', $batch->cbg_percentage) }}" placeholder="0.00" />
@error('cbg_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">CBN <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="cbn_percentage" class="input input-bordered @error('cbn_percentage') input-error @enderror" value="{{ old('cbn_percentage', $batch->cbn_percentage) }}" placeholder="0.00" />
@error('cbn_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Delta 9 <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="delta_9_percentage" class="input input-bordered @error('delta_9_percentage') input-error @enderror" value="{{ old('delta_9_percentage', $batch->delta_9_percentage) }}" placeholder="0.00" />
@error('delta_9_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Total Terps <span x-text="displayUnit"></span></span>
</label>
<input type="number" step="0.01" min="0" name="total_terps_percentage" class="input input-bordered @error('total_terps_percentage') input-error @enderror" value="{{ old('total_terps_percentage', $batch->total_terps_percentage) }}" placeholder="0.00" />
@error('total_terps_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
<div class="mt-6">
<div class="alert alert-info shadow-sm">
<span class="icon-[lucide--info] size-5"></span>
<div>
<h4 class="font-semibold">Auto-Calculated Values</h4>
<p class="text-sm">Total THC, Total CBD, and Total Cannabinoids will be calculated automatically based on the values you enter above.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Current COA Files -->
@if($batch->coaFiles->count() > 0)
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-2">
<span class="icon-[lucide--file-check] size-5"></span>
Current COA Files
</h3>
<p class="text-sm text-base-content/60 mb-4">Existing certificates of analysis</p>
<div class="space-y-2">
@foreach($batch->coaFiles as $coaFile)
<div class="flex items-center justify-between p-3 bg-base-200 rounded-box">
<div class="flex items-center gap-3">
<span class="icon-[lucide--file-text] size-5 text-primary"></span>
<div>
<div class="font-medium">{{ $coaFile->file_name }}</div>
<div class="text-xs text-base-content/60">
{{ $coaFile->getFormattedSize() }} Uploaded {{ $coaFile->created_at->diffForHumans() }}
@if($coaFile->is_primary)
<span class="badge badge-primary badge-xs ml-2">Primary</span>
@endif
</div>
</div>
</div>
<a href="{{ $coaFile->getUrl() }}" target="_blank" class="btn btn-ghost btn-sm">
<span class="icon-[lucide--external-link] size-4"></span>
View
</a>
</div>
@endforeach
</div>
</div>
</div>
@endif
<!-- Add New COA Files -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-2">
<span class="icon-[lucide--file-plus] size-5"></span>
Add New COA Files
</h3>
<p class="text-sm text-base-content/60 mb-4">Upload additional certificates of analysis - PDF, JPG, or PNG format (max 10MB each)</p>
<div class="form-control">
<input type="file" name="coa_files[]" multiple accept=".pdf,.jpg,.jpeg,.png" class="file-input file-input-bordered w-full @error('coa_files.*') file-input-error @enderror" />
@error('coa_files.*')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
<label class="label">
<span class="label-text-alt text-base-content/60">
<span class="icon-[lucide--info] size-3.5 inline"></span>
You can upload multiple files at once
</span>
</label>
</div>
</div>
</div>
<!-- Additional Notes -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-2">
<span class="icon-[lucide--sticky-note] size-5"></span>
Additional Notes
</h3>
<p class="text-sm text-base-content/60 mb-4">Optional - Add any additional information</p>
<div class="form-control">
<textarea name="notes" rows="4" class="textarea textarea-bordered @error('notes') textarea-error @enderror" placeholder="e.g., Retest results, special conditions, observations...">{{ old('notes', $batch->notes) }}</textarea>
@error('notes')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
</div>
<!-- Actions -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex gap-3 justify-end">
<a href="{{ route('seller.business.batches.index', $business->slug) }}" class="btn btn-ghost">
<span class="icon-[lucide--x] size-4"></span>
Cancel
</a>
<button type="submit" class="btn btn-primary">
<span class="icon-[lucide--check] size-4"></span>
Update Batch
</button>
</div>
</div>
</div>
</form>
<!-- QR Code Management (AJAX-powered, no page refresh) -->
<div class="card bg-base-100 shadow-sm" x-data="{
hasQrCode: {{ $batch->qr_code_path ? 'true' : 'false' }},
qrCodeUrl: '{{ $batch->qr_code_path ? Storage::url($batch->qr_code_path) : '' }}',
downloadUrl: '{{ $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : '' }}',
loading: false,
message: '',
messageType: '',
async generateQrCode() {
if (this.loading) return;
this.loading = true;
this.message = '';
try {
const response = await fetch('{{ route('seller.business.batches.qr-code.generate', [$business->slug, $batch->id]) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.success) {
this.hasQrCode = true;
this.qrCodeUrl = data.qr_code_url;
this.downloadUrl = data.download_url;
this.message = data.message;
this.messageType = 'success';
} else {
this.message = data.message;
this.messageType = 'error';
}
} catch (error) {
this.message = 'Failed to generate QR code. Please try again.';
this.messageType = 'error';
} finally {
this.loading = false;
}
},
async regenerateQrCode() {
if (!confirm('Are you sure you want to regenerate the QR code?')) return;
if (this.loading) return;
this.loading = true;
this.message = '';
try {
const response = await fetch('{{ route('seller.business.batches.qr-code.regenerate', [$business->slug, $batch->id]) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.success) {
this.qrCodeUrl = data.qr_code_url + '?t=' + Date.now(); // Cache bust
this.downloadUrl = data.download_url;
this.message = data.message;
this.messageType = 'success';
} else {
this.message = data.message;
this.messageType = 'error';
}
} catch (error) {
this.message = 'Failed to regenerate QR code. Please try again.';
this.messageType = 'error';
} finally {
this.loading = false;
}
},
async deleteQrCode() {
if (!confirm('Are you sure you want to delete the QR code?')) return;
if (this.loading) return;
this.loading = true;
this.message = '';
try {
const response = await fetch('{{ route('seller.business.batches.qr-code.delete', [$business->slug, $batch->id]) }}', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.success) {
this.hasQrCode = false;
this.qrCodeUrl = '';
this.downloadUrl = '';
this.message = data.message;
this.messageType = 'success';
} else {
this.message = data.message;
this.messageType = 'error';
}
} catch (error) {
this.message = 'Failed to delete QR code. Please try again.';
this.messageType = 'error';
} finally {
this.loading = false;
}
}
}">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-2">
<span class="icon-[lucide--qr-code] size-5"></span>
QR Code
</h3>
<p class="text-sm text-base-content/60 mb-4">Generate a QR code for easy access to this batch's COA</p>
<!-- Message Alert -->
<div x-show="message" x-cloak class="mb-4">
<div :class="messageType === 'success' ? 'alert alert-success' : 'alert alert-error'" class="shadow-sm">
<span class="icon-[lucide--check-circle] size-5" x-show="messageType === 'success'"></span>
<span class="icon-[lucide--alert-circle] size-5" x-show="messageType === 'error'"></span>
<span x-text="message"></span>
</div>
</div>
<!-- QR Code Display (when exists) -->
<div x-show="hasQrCode" x-cloak>
<div class="flex flex-col md:flex-row gap-4">
<div class="flex-shrink-0">
<img :src="qrCodeUrl"
alt="Batch QR Code"
class="w-48 h-48 border-2 border-base-300 rounded-box" />
</div>
<div class="flex-1 flex flex-col gap-3">
<div class="alert alert-success shadow-sm">
<span class="icon-[lucide--check-circle] size-5"></span>
<span>QR code generated successfully</span>
</div>
<div class="flex flex-wrap gap-2">
<a :href="downloadUrl"
class="btn btn-primary btn-sm">
<span class="icon-[lucide--download] size-4"></span>
Download QR Code
</a>
<button @click="regenerateQrCode()"
:disabled="loading"
class="btn btn-secondary btn-sm">
<span class="icon-[lucide--refresh-cw] size-4" :class="{ 'animate-spin': loading }"></span>
<span x-text="loading ? 'Regenerating...' : 'Regenerate'"></span>
</button>
<button @click="deleteQrCode()"
:disabled="loading"
class="btn btn-error btn-sm">
<span class="icon-[lucide--trash-2] size-4"></span>
<span x-text="loading ? 'Deleting...' : 'Delete'"></span>
</button>
</div>
</div>
</div>
</div>
<!-- Generate Button (when no QR code) -->
<div x-show="!hasQrCode" x-cloak>
<div class="flex flex-col gap-4">
<div class="alert alert-info shadow-sm">
<span class="icon-[lucide--info] size-5"></span>
<span>No QR code has been generated yet</span>
</div>
<div>
<button @click="generateQrCode()"
:disabled="loading"
class="btn btn-success">
<span class="icon-[lucide--qr-code] size-4" :class="{ 'animate-spin': loading }"></span>
<span x-text="loading ? 'Generating...' : 'Generate QR Code'"></span>
</button>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,234 @@
@extends('layouts.app-with-sidebar')
@section('content')
@use('Illuminate\Support\Facades\Storage')
<!-- Page Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold flex items-center gap-2">
<span class="icon-[lucide--package-2] size-8"></span>
Batch Management
</h1>
<p class="text-sm text-base-content/60 mt-1">Manage batches with integrated test results (COA data)</p>
</div>
<div class="flex gap-2">
<a href="{{ route('seller.business.batches.create', $business->slug) }}" class="btn btn-primary">
<span class="icon-[lucide--plus] size-4.5"></span>
Create Batch
</a>
</div>
</div>
<!-- Filters & Search -->
<div x-data="{ search: '{{ request('search') ?? '' }}' }">
<div class="mt-6 card bg-base-100 shadow-sm">
<div class="card-body p-4">
<div class="flex gap-3">
<div class="relative flex-1">
<input
type="text"
x-model="search"
placeholder="Filter batches (or use Search All button)..."
class="input input-bordered input-sm w-full pr-10"
autofocus
/>
<button
x-show="search"
@click="search = ''"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
>
<span class="icon-[lucide--x] size-4"></span>
</button>
</div>
<form method="GET" class="flex gap-2">
<input type="hidden" name="search" x-model="search" />
<button
type="submit"
class="btn btn-sm btn-outline"
:disabled="!search"
>
<span class="icon-[lucide--search] size-4"></span>
Search All Batches
</button>
@if(request('search'))
<a href="{{ route('seller.business.batches.index', $business->slug) }}" class="btn btn-sm btn-ghost">
<span class="icon-[lucide--x] size-4"></span>
</a>
@endif
</form>
</div>
@if(request('search'))
<div class="mt-3 alert alert-info">
<span class="icon-[lucide--info] size-4"></span>
<span>Showing results for: <strong>{{ request('search') }}</strong></span>
</div>
@endif
</div>
</div>
<!-- Active Batches Table -->
@if($batches->count() > 0)
<div class="mt-6 card bg-base-100 shadow" x-data="{ activeBatchesOpen: true }">
<div class="card-body p-0">
<!-- Active Batches Header -->
<button @click="activeBatchesOpen = !activeBatchesOpen" class="flex items-center justify-between p-4 hover:bg-base-200 transition-colors">
<h2 class="text-lg font-semibold flex items-center gap-2">
<span class="icon-[lucide--chevron-down] size-5 transition-transform" :class="activeBatchesOpen || 'rotate-[-90deg]'"></span>
Active Batches
<span class="badge badge-primary">{{ $batches->where('is_active', true)->count() }}</span>
</h2>
</button>
<div x-show="activeBatchesOpen" x-collapse>
@if($batches->where('is_active', true)->count() > 0)
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr class="bg-base-200">
<th class="font-mono text-xs">Batch #</th>
<th class="text-xs">Unit</th>
<th class="text-xs">THC</th>
<th class="text-xs">THCa</th>
<th class="text-xs">CBD</th>
<th class="text-xs">CBDa</th>
<th class="text-xs">CBG</th>
<th class="text-xs">CBN</th>
<th class="text-xs">Delta 8</th>
<th class="text-xs">Total THC</th>
<th class="text-xs">Total Cann.</th>
<th>SKU</th>
<th class="text-center">Doc</th>
<th>Batch Date</th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
@foreach($batches->where('is_active', true) as $batch)
<tr
x-data="{
batchNumber: '{{ $batch->batch_number }}',
testId: '{{ $batch->test_id ?? '' }}',
lotNumber: '{{ $batch->lot_number ?? '' }}',
sku: '{{ $batch->product->name ?? '' }}'
}"
x-show="!search ||
batchNumber.toLowerCase().includes(search.toLowerCase()) ||
testId.toLowerCase().includes(search.toLowerCase()) ||
lotNumber.toLowerCase().includes(search.toLowerCase()) ||
sku.toLowerCase().includes(search.toLowerCase())"
>
<td><span class="font-mono text-xs">{{ $batch->batch_number }}</span></td>
<td><span class="badge badge-sm badge-neutral">{{ $batch->cannabinoid_unit ?? '%' }}</span></td>
<td class="text-xs">{{ $batch->thc_percentage ?? '—' }}</td>
<td class="text-xs">{{ $batch->thca_percentage ?? '—' }}</td>
<td class="text-xs">{{ $batch->cbd_percentage ?? '—' }}</td>
<td class="text-xs">{{ $batch->cbda_percentage ?? '—' }}</td>
<td class="text-xs">{{ $batch->cbg_percentage ?? '—' }}</td>
<td class="text-xs">{{ $batch->cbn_percentage ?? '—' }}</td>
<td class="text-xs">{{ $batch->delta_9_percentage ?? '—' }}</td>
<td class="text-xs font-semibold">{{ $batch->total_thc ?? '—' }}</td>
<td class="text-xs font-semibold">{{ $batch->total_cannabinoids ?? '—' }}</td>
<td>
<div class="text-sm">{{ $batch->product->name ?? 'N/A' }}</div>
<div class="text-xs text-base-content/60">{{ $batch->product->brand->name ?? '' }}</div>
</td>
<td class="text-center">
@if($batch->hasCoaFiles())
<a href="{{ $batch->getCoaUrl() }}" target="_blank" class="btn btn-ghost btn-xs" title="View COA">
<span class="icon-[lucide--file-text] size-4 text-primary"></span>
</a>
@else
<span class="text-base-content/30"></span>
@endif
</td>
<td class="text-xs">{{ $batch->production_date ? $batch->production_date->format('M d, Y') : ($batch->test_date ? $batch->test_date->format('M d, Y') : '—') }}</td>
<td>
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-xs">
<span class="icon-[lucide--more-vertical] size-4"></span>
</button>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[100] w-52 p-2 shadow-lg border border-base-300">
@if($batch->hasCoaFiles())
<li>
<a href="{{ $batch->getCoaUrl() }}" target="_blank">
<span class="icon-[lucide--download] size-4"></span>
Download COA
</a>
</li>
@endif
<li>
<a href="{{ route('seller.business.batches.edit', [$business->slug, $batch->id]) }}">
<span class="icon-[lucide--pencil] size-4"></span>
Edit
</a>
</li>
<li>
<form action="{{ route('seller.business.batches.destroy', [$business->slug, $batch->id]) }}" method="POST" onsubmit="return confirm('Delete this batch?')" class="w-full">
@csrf
@method('DELETE')
<button type="submit" class="text-error w-full text-left">
<span class="icon-[lucide--trash-2] size-4"></span>
Delete
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="p-8 text-center text-base-content/60">
No active batches found
</div>
@endif
</div>
</div>
</div>
<!-- Inactive Batches Table -->
@if($batches->where('is_active', false)->count() > 0)
<div class="mt-6 card bg-base-100 shadow" x-data="{ inactiveBatchesOpen: false }">
<div class="card-body p-0">
<!-- Inactive Batches Header -->
<button @click="inactiveBatchesOpen = !inactiveBatchesOpen" class="flex items-center justify-between p-4 hover:bg-base-200 transition-colors">
<h2 class="text-lg font-semibold flex items-center gap-2">
<span class="icon-[lucide--chevron-down] size-5 transition-transform" :class="inactiveBatchesOpen || 'rotate-[-90deg]'"></span>
Inactive Batches
<span class="badge">{{ $batches->where('is_active', false)->count() }}</span>
</h2>
</button>
<div x-show="inactiveBatchesOpen" x-collapse>
<div class="p-8 text-center text-base-content/60">
{{ $batches->where('is_active', false)->count() }} inactive batches
</div>
</div>
</div>
</div>
@endif
<!-- Pagination -->
<div class="mt-6">
{{ $batches->links() }}
</div>
@else
<!-- Empty State -->
<div class="mt-6 card bg-base-100 shadow">
<div class="card-body text-center py-12">
<span class="icon-[lucide--package-2] size-16 text-base-content/20 mx-auto mb-4 block"></span>
<h3 class="text-lg font-semibold mb-2">No batches found</h3>
<p class="text-base-content/60 mb-4">Get started by creating your first batch with test results</p>
<a href="{{ route('seller.business.batches.create', $business->slug) }}" class="btn btn-primary">
<span class="icon-[lucide--plus] size-5"></span>
Create Batch
</a>
</div>
</div>
@endif
</div>
@endsection

View File

@@ -0,0 +1,250 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Header -->
<div class="mb-6">
<div class="flex items-center gap-2 text-sm breadcrumbs mb-2">
<ul>
<li><a href="{{ route('seller.business.labs.index', $business->slug) }}">Lab Tests</a></li>
<li>Add Lab Test</li>
</ul>
</div>
<h1 class="text-3xl font-bold flex items-center gap-2">
<span class="icon-[lucide--beaker] size-8"></span>
Add Lab Test
</h1>
<p class="text-sm text-base-content/60 mt-1">Record lab test results and upload certificates of analysis</p>
</div>
<form method="POST" action="{{ route('seller.business.labs.store', $business->slug) }}" enctype="multipart/form-data" class="space-y-6 max-w-5xl">
@csrf
<!-- Product & Test Information -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-4">
<span class="icon-[lucide--package] size-5"></span>
Product & Test Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">SKU / Product Name <span class="text-error">*</span></span>
</label>
<div x-data="{
init() {
new Choices(this.$refs.productSelect, {
searchEnabled: true,
searchPlaceholderValue: 'Type to search products...',
shouldSort: false,
placeholder: true,
placeholderValue: 'Select a product or SKU...',
noResultsText: 'No products found',
noChoicesText: 'No products available',
allowHTML: false,
itemSelectText: 'Press to select',
})
}
}">
<select x-ref="productSelect" name="product_id" class="select select-bordered w-full @error('product_id') select-error @enderror" required>
<option value="">Select a product or SKU...</option>
@foreach($products as $product)
<option value="{{ $product->id }}" {{ old('product_id') == $product->id ? 'selected' : '' }}>
{{ $product->name }} ({{ $product->brand->name ?? 'No Brand' }})
</option>
@endforeach
</select>
</div>
@error('product_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
<label class="label">
<span class="label-text-alt text-base-content/60">The brand will be automatically associated</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Test Date <span class="text-error">*</span></span>
</label>
<input type="date" name="test_date" class="input input-bordered @error('test_date') input-error @enderror" value="{{ old('test_date') }}" required />
@error('test_date')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Test ID</span>
</label>
<input type="text" name="test_id" class="input input-bordered @error('test_id') input-error @enderror" value="{{ old('test_id') }}" placeholder="e.g., LAB-2024-001" />
@error('test_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
<label class="label">
<span class="label-text-alt text-base-content/60">Optional - Lab's test identifier</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Lab Name</span>
</label>
<input type="text" name="lab_name" class="input input-bordered @error('lab_name') input-error @enderror" value="{{ old('lab_name') }}" placeholder="e.g., SC Labs" />
@error('lab_name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Batch Number</span>
</label>
<input type="text" name="batch_number" class="input input-bordered @error('batch_number') input-error @enderror" value="{{ old('batch_number') }}" placeholder="e.g., BATCH-2024-001" />
@error('batch_number')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Lot Number</span>
</label>
<input type="text" name="lot_number" class="input input-bordered @error('lot_number') input-error @enderror" value="{{ old('lot_number') }}" placeholder="e.g., LOT-2024-001" />
@error('lot_number')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
</div>
</div>
<!-- Cannabinoid Results -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-2">
<span class="icon-[lucide--flask-conical] size-5"></span>
Cannabinoid Results
</h3>
<p class="text-sm text-base-content/60 mb-4">Enter percentage values from the COA</p>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">THC %</span>
</label>
<input type="number" step="0.01" min="0" max="100" name="thc_percentage" class="input input-bordered @error('thc_percentage') input-error @enderror" value="{{ old('thc_percentage') }}" />
@error('thc_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">THCa %</span>
</label>
<input type="number" step="0.01" min="0" max="100" name="thca_percentage" class="input input-bordered @error('thca_percentage') input-error @enderror" value="{{ old('thca_percentage') }}" />
@error('thca_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">CBD %</span>
</label>
<input type="number" step="0.01" min="0" max="100" name="cbd_percentage" class="input input-bordered @error('cbd_percentage') input-error @enderror" value="{{ old('cbd_percentage') }}" />
@error('cbd_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">CBDa %</span>
</label>
<input type="number" step="0.01" min="0" max="100" name="cbda_percentage" class="input input-bordered @error('cbda_percentage') input-error @enderror" value="{{ old('cbda_percentage') }}" />
@error('cbda_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Delta-9 %</span>
</label>
<input type="number" step="0.01" min="0" max="100" name="delta_9_percentage" class="input input-bordered @error('delta_9_percentage') input-error @enderror" value="{{ old('delta_9_percentage') }}" />
@error('delta_9_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Total Terps %</span>
</label>
<input type="number" step="0.01" min="0" max="100" name="total_terps_percentage" class="input input-bordered @error('total_terps_percentage') input-error @enderror" value="{{ old('total_terps_percentage') }}" placeholder="0.00" />
@error('total_terps_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
</div>
</div>
<!-- COA Files -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-2">
<span class="icon-[lucide--file-text] size-5"></span>
Certificate of Analysis (COA)
</h3>
<p class="text-sm text-base-content/60 mb-4">Upload lab test certificates - PDF, JPG, or PNG format (max 10MB each)</p>
<div class="form-control">
<input type="file" name="coa_files[]" multiple accept=".pdf,.jpg,.jpeg,.png" class="file-input file-input-bordered w-full @error('coa_files.*') file-input-error @enderror" />
@error('coa_files.*')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
<label class="label">
<span class="label-text-alt text-base-content/60">
<span class="icon-[lucide--info] size-3.5 inline"></span>
You can upload multiple files at once
</span>
</label>
</div>
</div>
</div>
<!-- Notes (Optional) -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-2">
<span class="icon-[lucide--sticky-note] size-5"></span>
Additional Notes
</h3>
<p class="text-sm text-base-content/60 mb-4">Optional - Add any additional information or observations</p>
<div class="form-control">
<textarea name="notes" rows="4" class="textarea textarea-bordered @error('notes') textarea-error @enderror" placeholder="e.g., Retest results, special conditions, observations...">{{ old('notes') }}</textarea>
@error('notes')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
</div>
<!-- Actions -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex gap-3 justify-end">
<a href="{{ route('seller.business.labs.index', $business->slug) }}" class="btn btn-ghost">
<span class="icon-[lucide--x] size-4"></span>
Cancel
</a>
<button type="submit" class="btn btn-primary">
<span class="icon-[lucide--check] size-4"></span>
Create Lab Test
</button>
</div>
</div>
</div>
</form>
@endsection

View File

@@ -0,0 +1,279 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Header -->
<div class="mb-6">
<div class="flex items-center gap-2 text-sm breadcrumbs mb-2">
<ul>
<li><a href="{{ route('seller.business.labs.index', $business->slug) }}">Lab Tests</a></li>
<li>Edit Lab Test</li>
</ul>
</div>
<h1 class="text-3xl font-bold flex items-center gap-2">
<span class="icon-[lucide--beaker] size-8"></span>
Edit Lab Test
</h1>
<p class="text-sm text-base-content/60 mt-1">Update lab test results and certificates of analysis</p>
</div>
<form method="POST" action="{{ route('seller.business.labs.update', [$business->slug, $lab->id]) }}" enctype="multipart/form-data" class="space-y-6 max-w-5xl">
@csrf
@method('PUT')
<!-- Product & Test Information -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-4">
<span class="icon-[lucide--package] size-5"></span>
Product & Test Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">SKU / Product Name <span class="text-error">*</span></span>
</label>
<div x-data="{
init() {
new Choices(this.$refs.productSelect, {
searchEnabled: true,
searchPlaceholderValue: 'Type to search products...',
shouldSort: false,
placeholder: true,
placeholderValue: 'Select a product or SKU...',
noResultsText: 'No products found',
noChoicesText: 'No products available',
allowHTML: false,
itemSelectText: 'Press to select',
})
}
}">
<select x-ref="productSelect" name="product_id" class="select select-bordered w-full @error('product_id') select-error @enderror" required>
<option value="">Select a product or SKU...</option>
@foreach($products as $product)
<option value="{{ $product->id }}" {{ (old('product_id') ?? $lab->product_id) == $product->id ? 'selected' : '' }}>
{{ $product->name }} ({{ $product->brand->name ?? 'No Brand' }})
</option>
@endforeach
</select>
</div>
@error('product_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
<label class="label">
<span class="label-text-alt text-base-content/60">The brand will be automatically updated</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Test Date <span class="text-error">*</span></span>
</label>
<input type="date" name="test_date" class="input input-bordered @error('test_date') input-error @enderror" value="{{ old('test_date') ?? $lab->test_date }}" required />
@error('test_date')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Test ID</span>
</label>
<input type="text" name="test_id" class="input input-bordered @error('test_id') input-error @enderror" value="{{ old('test_id') ?? $lab->test_id }}" placeholder="e.g., LAB-2024-001" />
@error('test_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
<label class="label">
<span class="label-text-alt text-base-content/60">Optional - Lab's test identifier</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Lab Name</span>
</label>
<input type="text" name="lab_name" class="input input-bordered @error('lab_name') input-error @enderror" value="{{ old('lab_name') ?? $lab->lab_name }}" placeholder="e.g., SC Labs" />
@error('lab_name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Batch Number</span>
</label>
<input type="text" name="batch_number" class="input input-bordered @error('batch_number') input-error @enderror" value="{{ old('batch_number') ?? $lab->batch_number }}" placeholder="e.g., BATCH-2024-001" />
@error('batch_number')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Lot Number</span>
</label>
<input type="text" name="lot_number" class="input input-bordered @error('lot_number') input-error @enderror" value="{{ old('lot_number') ?? $lab->lot_number }}" placeholder="e.g., LOT-2024-001" />
@error('lot_number')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
</div>
</div>
<!-- Cannabinoid Results -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-2">
<span class="icon-[lucide--flask-conical] size-5"></span>
Cannabinoid Results
</h3>
<p class="text-sm text-base-content/60 mb-4">Update percentage values from the COA</p>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">THC %</span>
</label>
<input type="number" step="0.01" min="0" max="100" name="thc_percentage" class="input input-bordered @error('thc_percentage') input-error @enderror" value="{{ old('thc_percentage') ?? $lab->thc_percentage }}" />
@error('thc_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">THCa %</span>
</label>
<input type="number" step="0.01" min="0" max="100" name="thca_percentage" class="input input-bordered @error('thca_percentage') input-error @enderror" value="{{ old('thca_percentage') ?? $lab->thca_percentage }}" />
@error('thca_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">CBD %</span>
</label>
<input type="number" step="0.01" min="0" max="100" name="cbd_percentage" class="input input-bordered @error('cbd_percentage') input-error @enderror" value="{{ old('cbd_percentage') ?? $lab->cbd_percentage }}" />
@error('cbd_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">CBDa %</span>
</label>
<input type="number" step="0.01" min="0" max="100" name="cbda_percentage" class="input input-bordered @error('cbda_percentage') input-error @enderror" value="{{ old('cbda_percentage') ?? $lab->cbda_percentage }}" />
@error('cbda_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Delta-9 %</span>
</label>
<input type="number" step="0.01" min="0" max="100" name="delta_9_percentage" class="input input-bordered @error('delta_9_percentage') input-error @enderror" value="{{ old('delta_9_percentage') ?? $lab->delta_9_percentage }}" />
@error('delta_9_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Total Terps %</span>
</label>
<input type="number" step="0.01" min="0" max="100" name="total_terps_percentage" class="input input-bordered @error('total_terps_percentage') input-error @enderror" value="{{ old('total_terps_percentage') ?? $lab->total_terps_percentage }}" placeholder="0.00" />
@error('total_terps_percentage')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
</div>
</div>
<!-- Existing COA Files -->
@if($lab->coaFiles->count() > 0)
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-4">
<span class="icon-[lucide--files] size-5"></span>
Current COA Files
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
@foreach($lab->coaFiles as $coaFile)
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-box">
<span class="icon-[lucide--file-text] size-6 text-primary"></span>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{{ $coaFile->file_name }}</div>
@if($coaFile->is_primary)
<span class="badge badge-primary badge-xs mt-1">Primary</span>
@endif
</div>
<a href="{{ Storage::url($coaFile->file_path) }}" target="_blank" class="btn btn-ghost btn-sm btn-square" title="View file">
<span class="icon-[lucide--external-link] size-4"></span>
</a>
</div>
@endforeach
</div>
</div>
</div>
@endif
<!-- Add New COA Files -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-2">
<span class="icon-[lucide--file-plus] size-5"></span>
Add New COA Files
</h3>
<p class="text-sm text-base-content/60 mb-4">Upload additional certificates - PDF, JPG, or PNG format (max 10MB each)</p>
<div class="form-control">
<input type="file" name="coa_files[]" multiple accept=".pdf,.jpg,.jpeg,.png" class="file-input file-input-bordered w-full @error('coa_files.*') file-input-error @enderror" />
@error('coa_files.*')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
<label class="label">
<span class="label-text-alt text-base-content/60">
<span class="icon-[lucide--info] size-3.5 inline"></span>
New files will be added to existing ones
</span>
</label>
</div>
</div>
</div>
<!-- Notes -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="text-lg font-semibold flex items-center gap-2 mb-2">
<span class="icon-[lucide--sticky-note] size-5"></span>
Additional Notes
</h3>
<p class="text-sm text-base-content/60 mb-4">Optional - Add any additional information or observations</p>
<div class="form-control">
<textarea name="notes" rows="4" class="textarea textarea-bordered @error('notes') textarea-error @enderror" placeholder="e.g., Retest results, special conditions, observations...">{{ old('notes') ?? $lab->notes }}</textarea>
@error('notes')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
</div>
<!-- Actions -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex gap-3 justify-end">
<a href="{{ route('seller.business.labs.index', $business->slug) }}" class="btn btn-ghost">
<span class="icon-[lucide--x] size-4"></span>
Cancel
</a>
<button type="submit" class="btn btn-primary">
<span class="icon-[lucide--check] size-4"></span>
Update Lab Test
</button>
</div>
</div>
</div>
</form>
@endsection

View File

@@ -0,0 +1,175 @@
@extends('layouts.app-with-sidebar')
@section('content')
@use('Illuminate\Support\Facades\Storage')
<!-- Page Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold flex items-center gap-2">
<span class="icon-[lucide--beaker] size-8"></span>
Lab Tests
</h1>
<p class="text-sm text-base-content/60 mt-1">Manage lab tests and certificates of analysis (COAs)</p>
</div>
<div>
<a href="{{ route('seller.business.labs.create', $business->slug) }}" class="btn btn-primary">
<span class="icon-[lucide--plus] size-4.5"></span>
Add Lab Test
</a>
</div>
</div>
<!-- Filters & Lab Tests Table -->
<div x-data="{ search: '{{ request('search') ?? '' }}' }">
<!-- Filters -->
<div class="mt-6 card bg-base-100 shadow-sm">
<div class="card-body p-4">
<div class="flex gap-3">
<div class="relative flex-1">
<input
type="text"
x-model="search"
placeholder="Filter current page (or use Search All button)..."
class="input input-bordered input-sm w-full pr-10"
autofocus
/>
<button
x-show="search"
@click="search = ''"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
title="Clear search"
>
<span class="icon-[lucide--x] size-4"></span>
</button>
</div>
<form method="GET" class="flex gap-2" x-ref="searchForm">
<input type="hidden" name="search" x-model="search" />
<button
type="submit"
class="btn btn-sm btn-outline"
:disabled="!search"
title="Search across all lab tests in database"
>
<span class="icon-[lucide--search] size-4"></span>
Search All Labs
</button>
@if(request('search'))
<a href="{{ route('seller.business.labs.index', $business->slug) }}" class="btn btn-sm btn-ghost" title="Clear server search">
<span class="icon-[lucide--x] size-4"></span>
</a>
@endif
</form>
</div>
@if(request('search'))
<div class="mt-3 alert alert-info">
<span class="icon-[lucide--info] size-4"></span>
<span>Showing results for: <strong>{{ request('search') }}</strong></span>
</div>
@endif
</div>
</div>
<!-- Lab Tests Table -->
<div class="mt-6 card bg-base-100 shadow overflow-visible">
<div class="card-body p-0 overflow-visible">
@if($labs->count() > 0)
<div class="overflow-x-auto overflow-y-visible">
<table class="table table-zebra">
<thead>
<tr>
<th>Test ID</th>
<th>Batch</th>
<th>Lot</th>
<th>SKU</th>
<th>Brand</th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
@foreach($labs as $lab)
<tr
x-data="{
testId: '{{ $lab->test_id ?? '' }}',
batch: '{{ $lab->batch_number ?? '' }}',
lot: '{{ $lab->lot_number ?? '' }}',
sku: '{{ $lab->product->name ?? '' }}',
brand: '{{ $lab->product->brand->name ?? $lab->brand->name ?? '' }}'
}"
x-show="!search ||
testId.toLowerCase().includes(search.toLowerCase()) ||
batch.toLowerCase().includes(search.toLowerCase()) ||
lot.toLowerCase().includes(search.toLowerCase()) ||
sku.toLowerCase().includes(search.toLowerCase()) ||
brand.toLowerCase().includes(search.toLowerCase())"
>
<td>
<span class="font-mono text-sm">{{ $lab->test_id ?? '-' }}</span>
</td>
<td>
<span class="font-mono text-sm">{{ $lab->batch_number ?? '-' }}</span>
</td>
<td>
<span class="font-mono text-sm">{{ $lab->lot_number ?? '-' }}</span>
</td>
<td>
<div class="font-medium">{{ $lab->product->name ?? 'N/A' }}</div>
<div class="text-xs text-base-content/60">{{ $lab->test_date ? \Carbon\Carbon::parse($lab->test_date)->format('M d, Y') : '' }}</div>
</td>
<td>
<span class="text-sm">{{ $lab->product->brand->name ?? $lab->brand->name ?? '-' }}</span>
</td>
<td>
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-xs">
<span class="icon-[lucide--more-vertical] size-4"></span>
</button>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[100] w-52 p-2 shadow-lg border border-base-300 {{ $loop->last ? 'mb-1' : '' }}">
@if($lab->coaFiles->count() > 0)
<li>
<a href="{{ $lab->getPrimaryCoa() ? Storage::url($lab->getPrimaryCoa()->file_path) : Storage::url($lab->coaFiles->first()->file_path) }}" target="_blank">
<span class="icon-[lucide--download] size-4"></span>
Download COA
</a>
</li>
@endif
<li>
<a href="{{ route('seller.business.labs.edit', [$business->slug, $lab->id]) }}">
<span class="icon-[lucide--pencil] size-4"></span>
Edit
</a>
</li>
<li>
<form action="{{ route('seller.business.labs.destroy', [$business->slug, $lab->id]) }}" method="POST" onsubmit="return confirm('Delete this lab test?')" class="w-full">
@csrf
@method('DELETE')
<button type="submit" class="text-error w-full text-left">
<span class="icon-[lucide--trash-2] size-4"></span>
Delete
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="p-4 border-t border-base-300">{{ $labs->links() }}</div>
@else
<div class="text-center py-12">
<span class="icon-[lucide--beaker] size-16 text-base-content/20 mx-auto mb-4 block"></span>
<h3 class="text-lg font-semibold mb-2">No lab tests found</h3>
<p class="text-base-content/60 mb-4">Get started by adding your first lab test</p>
<a href="{{ route('seller.business.labs.create', $business->slug) }}" class="btn btn-primary">
<span class="icon-[lucide--plus] size-5"></span>
Add Lab Test
</a>
</div>
@endif
</div>
</div>
</div>
@endsection

View File

@@ -3,8 +3,8 @@
@section('content')
<div class="container mx-auto px-4 py-6">
{{-- Top Header Bar --}}
<div class="bg-white border border-gray-300 rounded-md shadow-sm mb-6">
<div class="px-6 py-4">
<div class="card bg-base-100 shadow mb-6">
<div class="card-body">
<div class="flex items-start justify-between gap-6">
{{-- Left: Product Image & Info --}}
<div class="flex items-start gap-4">
@@ -13,10 +13,10 @@
@if($product->images->where('is_primary', true)->first())
<img src="{{ asset('storage/' . $product->images->where('is_primary', true)->first()->path) }}"
alt="{{ $product->name }}"
class="w-16 h-16 object-cover rounded-md border border-gray-300">
class="w-16 h-16 object-cover rounded-md border border-base-300">
@else
<div class="w-16 h-16 bg-gray-100 rounded-md border border-gray-300 flex items-center justify-center">
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="w-16 h-16 bg-base-200 rounded-md border border-base-300 flex items-center justify-center">
<svg class="w-8 h-8 text-base-content/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
@@ -26,7 +26,7 @@
{{-- Product Details --}}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h1 class="text-xl font-bold text-gray-900">{{ $product->name }}</h1>
<h1 class="text-xl font-bold text-base-content">{{ $product->name }}</h1>
{{-- Status Badges --}}
<span id="activeBadge" style="display: {{ $product->is_active ? 'inline-flex' : 'none' }};" class="items-center px-2.5 py-0.5 rounded text-xs font-medium bg-success text-white">
Active
@@ -35,39 +35,30 @@
Featured
</span>
</div>
<div class="text-sm text-gray-600 space-y-0.5">
<div class="text-sm text-base-content/70 space-y-0.5">
<div><span class="font-medium">SKU:</span> <span class="font-mono">{{ $product->sku ?? 'N/A' }}</span> <span class="mx-2"></span> <span class="font-medium">Brand:</span> {{ $product->brand->name ?? 'N/A' }}</div>
<div><span class="font-medium">Last updated:</span> {{ $product->updated_at->format('M j, Y g:i A') }}</div>
</div>
</div>
</div>
{{-- Right: Action Buttons & Breadcrumb --}}
{{-- Right: Action Buttons --}}
<div class="flex flex-col gap-2 flex-shrink-0">
{{-- View on Marketplace Button (White with border) --}}
<a href="#" target="_blank" class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{{-- View on Marketplace Button --}}
<a href="#" target="_blank" class="btn btn-outline btn-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
View on Marketplace
</a>
{{-- Manage BOM Button (Blue solid) --}}
<a href="{{ route('seller.business.products.bom.index', [$business->slug, $product->id]) }}" class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md text-sm font-medium text-white bg-primary hover:bg-primary/90 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{{-- Manage BOM Button --}}
<a href="{{ route('seller.business.products.bom.index', [$business->slug, $product->id]) }}" class="btn btn-primary btn-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
</svg>
Manage BOM
</a>
{{-- Breadcrumb Navigation --}}
<nav class="flex text-xs text-gray-500 mt-1" aria-label="Breadcrumb">
<a href="{{ route('seller.business.dashboard', $business->slug) }}" class="hover:text-gray-700">Dashboard</a>
<span class="mx-2">&gt;</span>
<a href="{{ route('seller.business.products.index', $business->slug) }}" class="hover:text-gray-700">Products</a>
<span class="mx-2">&gt;</span>
<span class="text-gray-900 font-medium">Edit</span>
</nav>
</div>
</div>
</div>
@@ -104,7 +95,7 @@
{{-- LEFT SIDEBAR (1/4 width) --}}
<div class="space-y-6">
{{-- Product Images Card --}}
<div class="card bg-base-100 shadow-xl">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-sm">Product Images</h2>
<div class="space-y-4">
@@ -147,7 +138,7 @@
</div>
{{-- Quick Stats Card --}}
<div class="card bg-base-100 shadow-xl">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-sm">Quick Stats</h2>
<div class="space-y-2">
@@ -173,7 +164,7 @@
</div>
{{-- Audit Info Card --}}
<div class="card bg-base-100 shadow-xl">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-xs">Audit Info</h2>
<div class="space-y-1 text-xs text-base-content/70">
@@ -186,7 +177,7 @@
{{-- MAIN CONTENT WITH TABS (3/4 width) --}}
<div class="lg:col-span-3">
<div class="card bg-base-100 shadow-xl">
<div class="card bg-base-100 shadow">
<div class="card-body">
{{-- Tabs Navigation --}}
<div role="tablist" class="tabs tabs-bordered">

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,68 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Storage Test</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="p-8">
<div class="max-w-2xl mx-auto">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">File Storage Test</h2>
<div class="alert alert-info">
<span class="text-sm">Current Disk: <strong>{{ config('filesystems.default') }}</strong></span>
</div>
<form action="{{ route('storage.test') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="form-control">
<label class="label">
<span class="label-text">Upload Test File</span>
</label>
<input type="file" name="test_file" class="file-input file-input-bordered" required>
</div>
<div class="card-actions justify-end mt-4">
<button type="submit" class="btn btn-primary">Test Upload</button>
</div>
</form>
<div class="divider">Storage Info</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<tbody>
<tr>
<td class="font-semibold">Disk</td>
<td>{{ config('filesystems.default') }}</td>
</tr>
<tr>
<td class="font-semibold">Driver</td>
<td>{{ config('filesystems.disks.'.config('filesystems.default').'.driver') }}</td>
</tr>
@if(config('filesystems.default') === 's3')
<tr>
<td class="font-semibold">Endpoint</td>
<td>{{ config('filesystems.disks.s3.endpoint') }}</td>
</tr>
<tr>
<td class="font-semibold">Bucket</td>
<td>{{ config('filesystems.disks.s3.bucket') }}</td>
</tr>
<tr>
<td class="font-semibold">URL</td>
<td>{{ config('filesystems.disks.s3.url') }}</td>
</tr>
@endif
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -223,6 +223,40 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
Route::delete('/{component}', [\App\Http\Controllers\Seller\ComponentController::class, 'destroy'])->name('destroy');
});
// Lab Test Management (business-scoped)
Route::prefix('labs')->name('labs.')->group(function () {
Route::get('/', [\App\Http\Controllers\Seller\LabController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\Seller\LabController::class, 'create'])->name('create');
Route::post('/', [\App\Http\Controllers\Seller\LabController::class, 'store'])->name('store');
Route::get('/{lab}/edit', [\App\Http\Controllers\Seller\LabController::class, 'edit'])->name('edit');
Route::put('/{lab}', [\App\Http\Controllers\Seller\LabController::class, 'update'])->name('update');
Route::delete('/{lab}', [\App\Http\Controllers\Seller\LabController::class, 'destroy'])->name('destroy');
});
// Batch Management (business-scoped, Leaflink approach)
Route::prefix('batches')->name('batches.')->group(function () {
Route::get('/', [\App\Http\Controllers\Seller\BatchController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\Seller\BatchController::class, 'create'])->name('create');
Route::post('/', [\App\Http\Controllers\Seller\BatchController::class, 'store'])->name('store');
Route::get('/{batch}/edit', [\App\Http\Controllers\Seller\BatchController::class, 'edit'])->name('edit');
Route::put('/{batch}', [\App\Http\Controllers\Seller\BatchController::class, 'update'])->name('update');
Route::delete('/{batch}', [\App\Http\Controllers\Seller\BatchController::class, 'destroy'])->name('destroy');
// QR Code routes
Route::post('{batch}/qr-code/generate', [\App\Http\Controllers\Seller\BatchController::class, 'generateQrCode'])
->name('qr-code.generate');
Route::get('{batch}/qr-code/download', [\App\Http\Controllers\Seller\BatchController::class, 'downloadQrCode'])
->name('qr-code.download');
Route::post('{batch}/qr-code/regenerate', [\App\Http\Controllers\Seller\BatchController::class, 'regenerateQrCode'])
->name('qr-code.regenerate');
Route::delete('{batch}/qr-code', [\App\Http\Controllers\Seller\BatchController::class, 'deleteQrCode'])
->name('qr-code.delete');
// Bulk QR generation
Route::post('qr-codes/bulk-generate', [\App\Http\Controllers\Seller\BatchController::class, 'bulkGenerateQrCodes'])
->name('qr-codes.bulk-generate');
});
// Settings Management (business-scoped)
Route::prefix('settings')->name('settings.')->group(function () {
Route::get('/company-information', [\App\Http\Controllers\Seller\SettingsController::class, 'companyInformation'])->name('company-information');

View File

@@ -1,7 +1,6 @@
<?php
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\StorageTestController;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -200,8 +199,15 @@ Route::prefix('api')->group(function () {
->middleware('throttle:10,1'); // Rate limit: 10 requests per minute
});
// Storage Test Routes (Development/Testing Only)
Route::middleware(['auth'])->group(function () {
Route::get('/storage-test', [StorageTestController::class, 'form'])->name('storage.test.form');
Route::post('/storage-test', [StorageTestController::class, 'test'])->name('storage.test');
// Public COA (Certificate of Analysis) viewing routes
Route::prefix('coa')->name('public.coa.')->group(function () {
Route::get('/{batchNumber}', [\App\Http\Controllers\PublicCoaController::class, 'show'])
->name('show');
Route::get('/{batchNumber}/download/{coaFileId}', [\App\Http\Controllers\PublicCoaController::class, 'download'])
->name('download');
Route::get('/{batchNumber}/view/{coaFileId}', [\App\Http\Controllers\PublicCoaController::class, 'view'])
->name('view');
});
// Legacy COA route redirect (old system used /retail/labs/{batchNumber})
Route::get('/retail/labs/{batchNumber}', [\App\Http\Controllers\PublicCoaController::class, 'legacyShow']);

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
use App\Models\Batch;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Product;
use App\Models\User;
beforeEach(function () {
$this->artisan('db:seed', ['--class' => 'Database\\Seeders\\RoleSeeder']);
// Create seller user with business
$this->seller = User::factory()->create([
'user_type' => 'seller',
'email_verified_at' => now(),
'business_onboarding_completed' => true,
]);
$this->seller->assignRole('company-owner');
$this->business = Business::factory()->create([
'slug' => 'test-business',
]);
// Attach seller to business
$this->business->users()->attach($this->seller->id, [
'role' => 'owner',
'is_primary' => true,
]);
$this->brand = Brand::factory()->create([
'business_id' => $this->business->id,
]);
$this->product = Product::factory()->create([
'brand_id' => $this->brand->id,
]);
});
test('seller can create batch with percentage unit', function () {
$response = $this->actingAs($this->seller)
->post(route('seller.business.batches.store', $this->business->slug), [
'product_id' => $this->product->id,
'cannabinoid_unit' => '%',
'batch_number' => 'TEST-001',
'thc_percentage' => 22.5,
'thca_percentage' => 2.8,
'production_date' => now()->format('Y-m-d'),
]);
$response->assertRedirect(route('seller.business.batches.index', $this->business->slug));
$this->assertDatabaseHas('batches', [
'batch_number' => 'TEST-001',
'cannabinoid_unit' => '%',
'thc_percentage' => 22.5,
]);
});
test('seller can create batch with MG/ML unit', function () {
$response = $this->actingAs($this->seller)
->post(route('seller.business.batches.store', $this->business->slug), [
'product_id' => $this->product->id,
'cannabinoid_unit' => 'MG/ML',
'batch_number' => 'TEST-002',
'thc_percentage' => 33.3,
'cbd_percentage' => 1.5,
'production_date' => now()->format('Y-m-d'),
]);
$response->assertRedirect(route('seller.business.batches.index', $this->business->slug));
$this->assertDatabaseHas('batches', [
'batch_number' => 'TEST-002',
'cannabinoid_unit' => 'MG/ML',
'thc_percentage' => 33.3,
]);
});
test('seller can create batch with MG/G unit', function () {
$response = $this->actingAs($this->seller)
->post(route('seller.business.batches.store', $this->business->slug), [
'product_id' => $this->product->id,
'cannabinoid_unit' => 'MG/G',
'batch_number' => 'TEST-003',
'thc_percentage' => 782.5,
'production_date' => now()->format('Y-m-d'),
]);
$response->assertRedirect(route('seller.business.batches.index', $this->business->slug));
$this->assertDatabaseHas('batches', [
'batch_number' => 'TEST-003',
'cannabinoid_unit' => 'MG/G',
'thc_percentage' => 782.5,
]);
});
test('seller can create batch with MG/UNIT', function () {
$response = $this->actingAs($this->seller)
->post(route('seller.business.batches.store', $this->business->slug), [
'product_id' => $this->product->id,
'cannabinoid_unit' => 'MG/UNIT',
'batch_number' => 'TEST-004',
'thc_percentage' => 10.0,
'production_date' => now()->format('Y-m-d'),
]);
$response->assertRedirect(route('seller.business.batches.index', $this->business->slug));
$this->assertDatabaseHas('batches', [
'batch_number' => 'TEST-004',
'cannabinoid_unit' => 'MG/UNIT',
'thc_percentage' => 10.0,
]);
});
test('batch creation fails with invalid cannabinoid unit', function () {
$response = $this->actingAs($this->seller)
->post(route('seller.business.batches.store', $this->business->slug), [
'product_id' => $this->product->id,
'cannabinoid_unit' => 'INVALID_UNIT',
'batch_number' => 'TEST-005',
'thc_percentage' => 22.5,
'production_date' => now()->format('Y-m-d'),
]);
$response->assertSessionHasErrors('cannabinoid_unit');
$this->assertDatabaseMissing('batches', [
'batch_number' => 'TEST-005',
]);
});
test('batch creation requires cannabinoid unit', function () {
$response = $this->actingAs($this->seller)
->post(route('seller.business.batches.store', $this->business->slug), [
'product_id' => $this->product->id,
'batch_number' => 'TEST-006',
'thc_percentage' => 22.5,
'production_date' => now()->format('Y-m-d'),
]);
$response->assertSessionHasErrors('cannabinoid_unit');
$this->assertDatabaseMissing('batches', [
'batch_number' => 'TEST-006',
]);
});
test('seller can update batch cannabinoid unit', function () {
$batch = Batch::create([
'product_id' => $this->product->id,
'business_id' => $this->business->id,
'batch_number' => 'TEST-007',
'cannabinoid_unit' => '%',
'thc_percentage' => 22.5,
]);
$response = $this->actingAs($this->seller)
->put(route('seller.business.batches.update', [$this->business->slug, $batch->id]), [
'product_id' => $this->product->id,
'cannabinoid_unit' => 'MG/G',
'batch_number' => 'TEST-007',
'thc_percentage' => 782.5,
'production_date' => now()->format('Y-m-d'),
]);
$response->assertRedirect(route('seller.business.batches.index', $this->business->slug));
$this->assertDatabaseHas('batches', [
'id' => $batch->id,
'batch_number' => 'TEST-007',
'cannabinoid_unit' => 'MG/G',
'thc_percentage' => 782.5,
]);
});
test('batch update fails with invalid cannabinoid unit', function () {
$batch = Batch::create([
'product_id' => $this->product->id,
'business_id' => $this->business->id,
'batch_number' => 'TEST-008',
'cannabinoid_unit' => '%',
'thc_percentage' => 22.5,
]);
$response = $this->actingAs($this->seller)
->put(route('seller.business.batches.update', [$this->business->slug, $batch->id]), [
'product_id' => $this->product->id,
'cannabinoid_unit' => 'PPM', // Invalid unit
'batch_number' => 'TEST-008',
'thc_percentage' => 22.5,
'production_date' => now()->format('Y-m-d'),
]);
$response->assertSessionHasErrors('cannabinoid_unit');
// Verify original unit is unchanged
$this->assertDatabaseHas('batches', [
'id' => $batch->id,
'cannabinoid_unit' => '%',
]);
});
test('guest cannot create batch', function () {
$response = $this->post(route('seller.business.batches.store', $this->business->slug), [
'product_id' => $this->product->id,
'cannabinoid_unit' => '%',
'batch_number' => 'TEST-009',
'thc_percentage' => 22.5,
'production_date' => now()->format('Y-m-d'),
]);
$response->assertRedirect(route('login'));
});
test('batch index displays cannabinoid unit', function () {
Batch::create([
'product_id' => $this->product->id,
'business_id' => $this->business->id,
'batch_number' => 'TEST-010',
'cannabinoid_unit' => 'MG/ML',
'thc_percentage' => 33.3,
'is_active' => true,
]);
$response = $this->actingAs($this->seller)
->get(route('seller.business.batches.index', $this->business->slug));
$response->assertStatus(200)
->assertSee('TEST-010')
->assertSee('MG/ML');
});

View File

@@ -0,0 +1,537 @@
<?php
namespace Tests\Feature;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Product;
use App\Models\ProductImage;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ProductImageControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
}
protected function withCsrfToken(): static
{
return $this->withSession(['_token' => 'test-token'])
->withHeader('X-CSRF-TOKEN', 'test-token');
}
/**
* Test seller can upload valid product image
*/
public function test_seller_can_upload_valid_product_image(): void
{
// Create seller business with brand and product
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$brand = Brand::factory()->create(['business_id' => $business->id]);
$product = Product::factory()->create(['brand_id' => $brand->id]);
// Create valid test image (750x384 minimum)
$image = UploadedFile::fake()->image('product.jpg', 750, 384);
$this->actingAs($seller);
$response = $this->postJson(
route('seller.business.products.images.upload', [$business->slug, $product->id]),
['image' => $image]
);
$response->assertOk();
$response->assertJson(['success' => true]);
// Verify image was created in database
$this->assertDatabaseHas('product_images', [
'product_id' => $product->id,
'is_primary' => true, // First image should be primary
]);
// Verify file was stored
$productImage = ProductImage::where('product_id', $product->id)->first();
Storage::disk('local')->assertExists($productImage->path);
}
/**
* Test first uploaded image becomes primary
*/
public function test_first_image_becomes_primary(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$brand = Brand::factory()->create(['business_id' => $business->id]);
$product = Product::factory()->create(['brand_id' => $brand->id]);
$image = UploadedFile::fake()->image('product.jpg', 750, 384);
$this->actingAs($seller);
$response = $this->postJson(
route('seller.business.products.images.upload', [$business->slug, $product->id]),
['image' => $image]
);
$response->assertOk();
$productImage = ProductImage::where('product_id', $product->id)->first();
$this->assertTrue($productImage->is_primary);
}
/**
* Test image upload validates minimum dimensions
*/
public function test_upload_validates_minimum_dimensions(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$brand = Brand::factory()->create(['business_id' => $business->id]);
$product = Product::factory()->create(['brand_id' => $brand->id]);
// Image too small (below 750x384)
$image = UploadedFile::fake()->image('product.jpg', 500, 300);
$this->actingAs($seller);
$response = $this->postJson(
route('seller.business.products.images.upload', [$business->slug, $product->id]),
['image' => $image]
);
$response->assertStatus(422);
$response->assertJsonValidationErrors('image');
}
/**
* Test upload validates file type
*/
public function test_upload_validates_file_type(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$brand = Brand::factory()->create(['business_id' => $business->id]);
$product = Product::factory()->create(['brand_id' => $brand->id]);
// Invalid file type
$file = UploadedFile::fake()->create('document.pdf', 100);
$this->actingAs($seller);
$response = $this->postJson(
route('seller.business.products.images.upload', [$business->slug, $product->id]),
['image' => $file]
);
$response->assertStatus(422);
$response->assertJsonValidationErrors('image');
}
/**
* Test cannot upload more than 6 images per product
*/
public function test_cannot_upload_more_than_six_images(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$brand = Brand::factory()->create(['business_id' => $business->id]);
$product = Product::factory()->create(['brand_id' => $brand->id]);
// Create 6 existing images
for ($i = 0; $i < 6; $i++) {
ProductImage::create([
'product_id' => $product->id,
'path' => "products/test-{$i}.jpg",
'is_primary' => $i === 0,
'sort_order' => $i,
]);
}
$image = UploadedFile::fake()->image('product.jpg', 750, 384);
$this->actingAs($seller);
$response = $this->postJson(
route('seller.business.products.images.upload', [$business->slug, $product->id]),
['image' => $image]
);
$response->assertStatus(422);
$response->assertJson([
'success' => false,
'message' => 'Maximum of 6 images allowed per product',
]);
}
/**
* Test seller cannot upload image to another business's product (business_id isolation)
*/
public function test_seller_cannot_upload_image_to_other_business_product(): void
{
// Create two businesses
$businessA = Business::factory()->create(['business_type' => 'brand']);
$businessB = Business::factory()->create(['business_type' => 'brand']);
$sellerA = User::factory()->create(['user_type' => 'seller']);
$sellerA->businesses()->attach($businessA->id);
$brandB = Brand::factory()->create(['business_id' => $businessB->id]);
$productB = Product::factory()->create(['brand_id' => $brandB->id]);
$image = UploadedFile::fake()->image('product.jpg', 750, 384);
$this->actingAs($sellerA);
// Try to upload to businessB's product using businessA's slug
$response = $this->postJson(
route('seller.business.products.images.upload', [$businessA->slug, $productB->id]),
['image' => $image]
);
$response->assertNotFound(); // Product not found when scoped to businessA
// Verify no image was created
$this->assertDatabaseMissing('product_images', [
'product_id' => $productB->id,
]);
}
/**
* Test seller can delete their product's image
*/
public function test_seller_can_delete_product_image(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$brand = Brand::factory()->create(['business_id' => $business->id]);
$product = Product::factory()->create(['brand_id' => $brand->id]);
// Create test file
Storage::disk('local')->put('products/test.jpg', 'fake content');
$image = ProductImage::create([
'product_id' => $product->id,
'path' => 'products/test.jpg',
'is_primary' => true,
'sort_order' => 0,
]);
$this->actingAs($seller);
$response = $this->deleteJson(
route('seller.business.products.images.delete', [$business->slug, $product->id, $image->id])
);
$response->assertOk();
$response->assertJson(['success' => true]);
// Verify image was deleted from database
$this->assertDatabaseMissing('product_images', ['id' => $image->id]);
// Verify file was deleted from storage
Storage::disk('local')->assertMissing('products/test.jpg');
}
/**
* Test deleting primary image sets next image as primary
*/
public function test_deleting_primary_image_sets_next_as_primary(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$brand = Brand::factory()->create(['business_id' => $business->id]);
$product = Product::factory()->create(['brand_id' => $brand->id]);
// Create two images
Storage::disk('local')->put('products/test1.jpg', 'fake content 1');
Storage::disk('local')->put('products/test2.jpg', 'fake content 2');
$image1 = ProductImage::create([
'product_id' => $product->id,
'path' => 'products/test1.jpg',
'is_primary' => true,
'sort_order' => 0,
]);
$image2 = ProductImage::create([
'product_id' => $product->id,
'path' => 'products/test2.jpg',
'is_primary' => false,
'sort_order' => 1,
]);
$this->actingAs($seller);
// Delete primary image
$response = $this->deleteJson(
route('seller.business.products.images.delete', [$business->slug, $product->id, $image1->id])
);
$response->assertOk();
// Verify image2 is now primary
$this->assertTrue($image2->fresh()->is_primary);
}
/**
* Test seller cannot delete another business's product image
*/
public function test_seller_cannot_delete_other_business_product_image(): void
{
$businessA = Business::factory()->create(['business_type' => 'brand']);
$businessB = Business::factory()->create(['business_type' => 'brand']);
$sellerA = User::factory()->create(['user_type' => 'seller']);
$sellerA->businesses()->attach($businessA->id);
$brandB = Brand::factory()->create(['business_id' => $businessB->id]);
$productB = Product::factory()->create(['brand_id' => $brandB->id]);
Storage::disk('local')->put('products/test.jpg', 'fake content');
$imageB = ProductImage::create([
'product_id' => $productB->id,
'path' => 'products/test.jpg',
'is_primary' => true,
'sort_order' => 0,
]);
$this->actingAs($sellerA);
// Try to delete businessB's product image
$response = $this->deleteJson(
route('seller.business.products.images.delete', [$businessA->slug, $productB->id, $imageB->id])
);
$response->assertNotFound();
// Verify image was NOT deleted
$this->assertDatabaseHas('product_images', ['id' => $imageB->id]);
}
/**
* Test seller can reorder product images
*/
public function test_seller_can_reorder_product_images(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$brand = Brand::factory()->create(['business_id' => $business->id]);
$product = Product::factory()->create(['brand_id' => $brand->id]);
// Create three images
$image1 = ProductImage::create([
'product_id' => $product->id,
'path' => 'products/test1.jpg',
'is_primary' => true,
'sort_order' => 0,
]);
$image2 = ProductImage::create([
'product_id' => $product->id,
'path' => 'products/test2.jpg',
'is_primary' => false,
'sort_order' => 1,
]);
$image3 = ProductImage::create([
'product_id' => $product->id,
'path' => 'products/test3.jpg',
'is_primary' => false,
'sort_order' => 2,
]);
$this->actingAs($seller);
// Reorder: image3, image1, image2
$response = $this->postJson(
route('seller.business.products.images.reorder', [$business->slug, $product->id]),
['order' => [$image3->id, $image1->id, $image2->id]]
);
$response->assertOk();
$response->assertJson(['success' => true]);
// Verify new sort order
$this->assertEquals(0, $image3->fresh()->sort_order);
$this->assertEquals(1, $image1->fresh()->sort_order);
$this->assertEquals(2, $image2->fresh()->sort_order);
// Verify first image (image3) is now primary
$this->assertTrue($image3->fresh()->is_primary);
$this->assertFalse($image1->fresh()->is_primary);
$this->assertFalse($image2->fresh()->is_primary);
}
/**
* Test seller cannot reorder another business's product images
*/
public function test_seller_cannot_reorder_other_business_product_images(): void
{
$businessA = Business::factory()->create(['business_type' => 'brand']);
$businessB = Business::factory()->create(['business_type' => 'brand']);
$sellerA = User::factory()->create(['user_type' => 'seller']);
$sellerA->businesses()->attach($businessA->id);
$brandB = Brand::factory()->create(['business_id' => $businessB->id]);
$productB = Product::factory()->create(['brand_id' => $brandB->id]);
$image1 = ProductImage::create([
'product_id' => $productB->id,
'path' => 'products/test1.jpg',
'is_primary' => true,
'sort_order' => 0,
]);
$image2 = ProductImage::create([
'product_id' => $productB->id,
'path' => 'products/test2.jpg',
'is_primary' => false,
'sort_order' => 1,
]);
$this->actingAs($sellerA);
// Try to reorder businessB's product images
$response = $this->postJson(
route('seller.business.products.images.reorder', [$businessA->slug, $productB->id]),
['order' => [$image2->id, $image1->id]]
);
$response->assertNotFound();
// Verify order was NOT changed
$this->assertEquals(0, $image1->fresh()->sort_order);
$this->assertEquals(1, $image2->fresh()->sort_order);
}
/**
* Test seller can set image as primary
*/
public function test_seller_can_set_image_as_primary(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$brand = Brand::factory()->create(['business_id' => $business->id]);
$product = Product::factory()->create(['brand_id' => $brand->id]);
// Create two images
$image1 = ProductImage::create([
'product_id' => $product->id,
'path' => 'products/test1.jpg',
'is_primary' => true,
'sort_order' => 0,
]);
$image2 = ProductImage::create([
'product_id' => $product->id,
'path' => 'products/test2.jpg',
'is_primary' => false,
'sort_order' => 1,
]);
$this->actingAs($seller);
// Set image2 as primary
$response = $this->postJson(
route('seller.business.products.images.set-primary', [$business->slug, $product->id, $image2->id])
);
$response->assertOk();
$response->assertJson(['success' => true]);
// Verify image2 is now primary and image1 is not
$this->assertTrue($image2->fresh()->is_primary);
$this->assertFalse($image1->fresh()->is_primary);
}
/**
* Test seller cannot set primary on another business's product image
*/
public function test_seller_cannot_set_primary_on_other_business_product_image(): void
{
$businessA = Business::factory()->create(['business_type' => 'brand']);
$businessB = Business::factory()->create(['business_type' => 'brand']);
$sellerA = User::factory()->create(['user_type' => 'seller']);
$sellerA->businesses()->attach($businessA->id);
$brandB = Brand::factory()->create(['business_id' => $businessB->id]);
$productB = Product::factory()->create(['brand_id' => $brandB->id]);
$image = ProductImage::create([
'product_id' => $productB->id,
'path' => 'products/test.jpg',
'is_primary' => false,
'sort_order' => 0,
]);
$this->actingAs($sellerA);
// Try to set primary on businessB's product image
$response = $this->postJson(
route('seller.business.products.images.set-primary', [$businessA->slug, $productB->id, $image->id])
);
$response->assertNotFound();
// Verify is_primary was NOT changed
$this->assertFalse($image->fresh()->is_primary);
}
/**
* Test cannot set primary on image that doesn't belong to product
*/
public function test_cannot_set_primary_on_image_from_different_product(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$brand = Brand::factory()->create(['business_id' => $business->id]);
$product1 = Product::factory()->create(['brand_id' => $brand->id]);
$product2 = Product::factory()->create(['brand_id' => $brand->id]);
// Create image for product2
$image = ProductImage::create([
'product_id' => $product2->id,
'path' => 'products/test.jpg',
'is_primary' => false,
'sort_order' => 0,
]);
$this->actingAs($seller);
// Try to set it as primary for product1 (wrong product)
$response = $this->postJson(
route('seller.business.products.images.set-primary', [$business->slug, $product1->id, $image->id])
);
$response->assertStatus(404);
$response->assertJson([
'success' => false,
'message' => 'Image not found',
]);
}
}

View File

@@ -0,0 +1,324 @@
<?php
namespace Tests\Feature;
use App\Models\Business;
use App\Models\ProductLine;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProductLineControllerTest extends TestCase
{
use RefreshDatabase;
/**
* Test seller can create product line for their business
*/
public function test_seller_can_create_product_line(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$this->actingAs($seller);
$response = $this->post(
route('seller.business.product-lines.store', $business->slug),
['name' => 'Premium Line']
);
$response->assertRedirect();
$response->assertSessionHas('success', 'Product line created successfully.');
$this->assertDatabaseHas('product_lines', [
'business_id' => $business->id,
'name' => 'Premium Line',
]);
}
/**
* Test product line name is required
*/
public function test_product_line_name_is_required(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$this->actingAs($seller);
$response = $this->post(
route('seller.business.product-lines.store', $business->slug),
['name' => '']
);
$response->assertSessionHasErrors('name');
$this->assertDatabaseMissing('product_lines', [
'business_id' => $business->id,
]);
}
/**
* Test product line name must be unique per business
*/
public function test_product_line_name_must_be_unique_per_business(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
// Create existing product line
ProductLine::create([
'business_id' => $business->id,
'name' => 'Premium Line',
]);
$this->actingAs($seller);
$response = $this->post(
route('seller.business.product-lines.store', $business->slug),
['name' => 'Premium Line']
);
$response->assertSessionHasErrors('name');
}
/**
* Test product line name can be duplicated across different businesses
*/
public function test_product_line_name_can_be_duplicated_across_businesses(): void
{
$businessA = Business::factory()->create(['business_type' => 'brand']);
$businessB = Business::factory()->create(['business_type' => 'brand']);
$sellerA = User::factory()->create(['user_type' => 'seller']);
$sellerA->businesses()->attach($businessA->id);
$sellerB = User::factory()->create(['user_type' => 'seller']);
$sellerB->businesses()->attach($businessB->id);
// Create product line in business A
ProductLine::create([
'business_id' => $businessA->id,
'name' => 'Premium Line',
]);
// Create product line with same name in business B (should work)
$this->actingAs($sellerB);
$response = $this->post(
route('seller.business.product-lines.store', $businessB->slug),
['name' => 'Premium Line']
);
$response->assertRedirect();
$response->assertSessionHas('success');
// Verify both exist
$this->assertDatabaseHas('product_lines', [
'business_id' => $businessA->id,
'name' => 'Premium Line',
]);
$this->assertDatabaseHas('product_lines', [
'business_id' => $businessB->id,
'name' => 'Premium Line',
]);
}
/**
* Test seller can update their product line
*/
public function test_seller_can_update_product_line(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$productLine = ProductLine::create([
'business_id' => $business->id,
'name' => 'Premium Line',
]);
$this->actingAs($seller);
$response = $this->put(
route('seller.business.product-lines.update', [$business->slug, $productLine->id]),
['name' => 'Ultra Premium Line']
);
$response->assertRedirect();
$response->assertSessionHas('success', 'Product line updated successfully.');
$this->assertDatabaseHas('product_lines', [
'id' => $productLine->id,
'name' => 'Ultra Premium Line',
]);
$this->assertDatabaseMissing('product_lines', [
'id' => $productLine->id,
'name' => 'Premium Line',
]);
}
/**
* Test update validates name is required
*/
public function test_update_validates_name_is_required(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$productLine = ProductLine::create([
'business_id' => $business->id,
'name' => 'Premium Line',
]);
$this->actingAs($seller);
$response = $this->put(
route('seller.business.product-lines.update', [$business->slug, $productLine->id]),
['name' => '']
);
$response->assertSessionHasErrors('name');
// Verify name wasn't changed
$this->assertEquals('Premium Line', $productLine->fresh()->name);
}
/**
* Test update validates uniqueness per business
*/
public function test_update_validates_uniqueness_per_business(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$productLine1 = ProductLine::create([
'business_id' => $business->id,
'name' => 'Premium Line',
]);
$productLine2 = ProductLine::create([
'business_id' => $business->id,
'name' => 'Budget Line',
]);
// Try to rename productLine2 to match productLine1
$this->actingAs($seller);
$response = $this->put(
route('seller.business.product-lines.update', [$business->slug, $productLine2->id]),
['name' => 'Premium Line']
);
$response->assertSessionHasErrors('name');
// Verify name wasn't changed
$this->assertEquals('Budget Line', $productLine2->fresh()->name);
}
/**
* Test seller cannot update another business's product line
*/
public function test_seller_cannot_update_other_business_product_line(): void
{
$businessA = Business::factory()->create(['business_type' => 'brand']);
$businessB = Business::factory()->create(['business_type' => 'brand']);
$sellerA = User::factory()->create(['user_type' => 'seller']);
$sellerA->businesses()->attach($businessA->id);
$productLineB = ProductLine::create([
'business_id' => $businessB->id,
'name' => 'Premium Line',
]);
$this->actingAs($sellerA);
$response = $this->put(
route('seller.business.product-lines.update', [$businessA->slug, $productLineB->id]),
['name' => 'Hacked Name']
);
$response->assertNotFound();
// Verify name wasn't changed
$this->assertEquals('Premium Line', $productLineB->fresh()->name);
}
/**
* Test seller can delete their product line
*/
public function test_seller_can_delete_product_line(): void
{
$business = Business::factory()->create(['business_type' => 'brand']);
$seller = User::factory()->create(['user_type' => 'seller']);
$seller->businesses()->attach($business->id);
$productLine = ProductLine::create([
'business_id' => $business->id,
'name' => 'Premium Line',
]);
$this->actingAs($seller);
$response = $this->delete(
route('seller.business.product-lines.destroy', [$business->slug, $productLine->id])
);
$response->assertRedirect();
$response->assertSessionHas('success', 'Product line deleted successfully.');
$this->assertDatabaseMissing('product_lines', [
'id' => $productLine->id,
]);
}
/**
* Test seller cannot delete another business's product line
*/
public function test_seller_cannot_delete_other_business_product_line(): void
{
$businessA = Business::factory()->create(['business_type' => 'brand']);
$businessB = Business::factory()->create(['business_type' => 'brand']);
$sellerA = User::factory()->create(['user_type' => 'seller']);
$sellerA->businesses()->attach($businessA->id);
$productLineB = ProductLine::create([
'business_id' => $businessB->id,
'name' => 'Premium Line',
]);
$this->actingAs($sellerA);
$response = $this->delete(
route('seller.business.product-lines.destroy', [$businessA->slug, $productLineB->id])
);
$response->assertNotFound();
// Verify product line wasn't deleted
$this->assertDatabaseHas('product_lines', [
'id' => $productLineB->id,
'name' => 'Premium Line',
]);
}
/**
* Test seller from unauthorized business cannot access another business routes
*/
public function test_seller_cannot_access_unauthorized_business_routes(): void
{
$businessA = Business::factory()->create(['business_type' => 'brand']);
$businessB = Business::factory()->create(['business_type' => 'brand']);
$sellerA = User::factory()->create(['user_type' => 'seller']);
$sellerA->businesses()->attach($businessA->id);
$this->actingAs($sellerA);
// Try to access businessB routes
$response = $this->post(
route('seller.business.product-lines.store', $businessB->slug),
['name' => 'Test Line']
);
$response->assertForbidden();
}
}

View File

@@ -0,0 +1,234 @@
<?php
namespace Tests\Feature;
use App\Models\Batch;
use App\Models\Business;
use App\Models\Product;
use App\Models\User;
use App\Services\QrCodeService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class QrCodeGenerationTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Business $business;
protected Product $product;
protected function setUp(): void
{
parent::setUp();
// Create test user with business
$this->user = User::factory()->create();
$this->business = Business::factory()->create();
$this->user->businesses()->attach($this->business->id);
// Create test product
$this->product = Product::factory()->create([
'business_id' => $this->business->id,
]);
// Fake storage
Storage::fake('public');
}
public function test_can_generate_qr_code_for_batch(): void
{
$batch = Batch::factory()->create([
'business_id' => $this->business->id,
'product_id' => $this->product->id,
]);
$qrService = app(QrCodeService::class);
$result = $qrService->generateForBatch($batch);
$this->assertTrue($result['success']);
$this->assertNotNull($result['path']);
$this->assertNotNull($batch->fresh()->qr_code_path);
// Verify file was created
Storage::disk('public')->assertExists($batch->fresh()->qr_code_path);
}
public function test_can_download_qr_code(): void
{
$batch = Batch::factory()->create([
'business_id' => $this->business->id,
'product_id' => $this->product->id,
]);
// Generate QR code first
$qrService = app(QrCodeService::class);
$qrService->generateForBatch($batch);
// Attempt download
$response = $this->actingAs($this->user)
->get(route('seller.business.batches.qr-code.download', [
'business' => $this->business->slug,
'batch' => $batch->id,
]));
$response->assertOk();
$response->assertDownload();
}
public function test_can_regenerate_qr_code(): void
{
$batch = Batch::factory()->create([
'business_id' => $this->business->id,
'product_id' => $this->product->id,
]);
$qrService = app(QrCodeService::class);
// Generate initial QR code
$result1 = $qrService->generateForBatch($batch);
$firstPath = $result1['path'];
// Regenerate QR code
$result2 = $qrService->regenerate($batch);
$secondPath = $result2['path'];
$this->assertTrue($result2['success']);
$this->assertNotEquals($firstPath, $secondPath);
$this->assertNotNull($batch->fresh()->qr_code_path);
// Verify old file was deleted and new file exists
Storage::disk('public')->assertMissing($firstPath);
Storage::disk('public')->assertExists($secondPath);
}
public function test_can_delete_qr_code(): void
{
$batch = Batch::factory()->create([
'business_id' => $this->business->id,
'product_id' => $this->product->id,
]);
$qrService = app(QrCodeService::class);
// Generate QR code
$result = $qrService->generateForBatch($batch);
$qrPath = $result['path'];
// Delete QR code
$deleteResult = $qrService->delete($batch);
$this->assertTrue($deleteResult['success']);
$this->assertNull($batch->fresh()->qr_code_path);
// Verify file was deleted
Storage::disk('public')->assertMissing($qrPath);
}
public function test_can_bulk_generate_qr_codes(): void
{
$batches = Batch::factory()->count(5)->create([
'business_id' => $this->business->id,
'product_id' => $this->product->id,
]);
$batchIds = $batches->pluck('id')->toArray();
$qrService = app(QrCodeService::class);
$result = $qrService->bulkGenerate($batchIds);
$this->assertEquals(5, $result['successful']);
$this->assertEquals(0, $result['failed']);
// Verify all batches have QR codes
foreach ($batches as $batch) {
$this->assertNotNull($batch->fresh()->qr_code_path);
Storage::disk('public')->assertExists($batch->fresh()->qr_code_path);
}
}
public function test_controller_requires_authentication(): void
{
$batch = Batch::factory()->create([
'business_id' => $this->business->id,
'product_id' => $this->product->id,
]);
// Attempt without authentication
$response = $this->post(route('seller.business.batches.qr-code.generate', [
'business' => $this->business->slug,
'batch' => $batch->id,
]));
$response->assertRedirect('/login');
}
public function test_controller_verifies_business_ownership(): void
{
$otherBusiness = Business::factory()->create();
$batch = Batch::factory()->create([
'business_id' => $otherBusiness->id,
'product_id' => $this->product->id,
]);
// Attempt to generate QR for batch not owned by user's business
$response = $this->actingAs($this->user)
->post(route('seller.business.batches.qr-code.generate', [
'business' => $this->business->slug,
'batch' => $batch->id,
]));
$response->assertForbidden();
}
public function test_can_generate_qr_code_via_controller(): void
{
$batch = Batch::factory()->create([
'business_id' => $this->business->id,
'product_id' => $this->product->id,
]);
$response = $this->actingAs($this->user)
->post(route('seller.business.batches.qr-code.generate', [
'business' => $this->business->slug,
'batch' => $batch->id,
]));
$response->assertOk();
$response->assertJson(['success' => true]);
$this->assertNotNull($batch->fresh()->qr_code_path);
}
public function test_bulk_generate_validates_batch_ownership(): void
{
$ownBatches = Batch::factory()->count(3)->create([
'business_id' => $this->business->id,
'product_id' => $this->product->id,
]);
$otherBusiness = Business::factory()->create();
$otherBatch = Batch::factory()->create([
'business_id' => $otherBusiness->id,
'product_id' => $this->product->id,
]);
$batchIds = $ownBatches->pluck('id')->push($otherBatch->id)->toArray();
$response = $this->actingAs($this->user)
->post(route('seller.business.batches.qr-codes.bulk-generate', [
'business' => $this->business->slug,
]), [
'batch_ids' => $batchIds,
]);
$response->assertForbidden();
$response->assertJson([
'success' => false,
'message' => 'Some batches do not belong to this business',
]);
}
}

View File

@@ -0,0 +1,147 @@
<?php
use App\Models\Batch;
use App\Models\Business;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('batch cannabinoid_unit defaults to percentage', function () {
$business = Business::factory()->create();
$product = Product::factory()->create();
$batch = Batch::create([
'product_id' => $product->id,
'business_id' => $business->id,
'batch_number' => 'TEST-001',
]);
expect($batch->cannabinoid_unit)->toBe('%');
});
test('batch can be created with percentage unit', function () {
$business = Business::factory()->create();
$product = Product::factory()->create();
$batch = Batch::create([
'product_id' => $product->id,
'business_id' => $business->id,
'batch_number' => 'TEST-002',
'cannabinoid_unit' => '%',
'thc_percentage' => 22.5,
]);
expect($batch->cannabinoid_unit)->toBe('%')
->and((float) $batch->thc_percentage)->toBe(22.5);
});
test('batch can be created with MG/ML unit', function () {
$business = Business::factory()->create();
$product = Product::factory()->create();
$batch = Batch::create([
'product_id' => $product->id,
'business_id' => $business->id,
'batch_number' => 'TEST-003',
'cannabinoid_unit' => 'MG/ML',
'thc_percentage' => 33.3,
]);
expect($batch->cannabinoid_unit)->toBe('MG/ML')
->and((float) $batch->thc_percentage)->toBe(33.3);
});
test('batch can be created with MG/G unit', function () {
$business = Business::factory()->create();
$product = Product::factory()->create();
$batch = Batch::create([
'product_id' => $product->id,
'business_id' => $business->id,
'batch_number' => 'TEST-004',
'cannabinoid_unit' => 'MG/G',
'thc_percentage' => 782.5,
]);
expect($batch->cannabinoid_unit)->toBe('MG/G')
->and((float) $batch->thc_percentage)->toBe(782.5);
});
test('batch can be created with MG/UNIT', function () {
$business = Business::factory()->create();
$product = Product::factory()->create();
$batch = Batch::create([
'product_id' => $product->id,
'business_id' => $business->id,
'batch_number' => 'TEST-005',
'cannabinoid_unit' => 'MG/UNIT',
'thc_percentage' => 10.0,
]);
expect($batch->cannabinoid_unit)->toBe('MG/UNIT')
->and((float) $batch->thc_percentage)->toBe(10.0);
});
test('batch cannabinoid_unit can be updated', function () {
$business = Business::factory()->create();
$product = Product::factory()->create();
$batch = Batch::create([
'product_id' => $product->id,
'business_id' => $business->id,
'batch_number' => 'TEST-006',
'cannabinoid_unit' => '%',
'thc_percentage' => 22.5,
]);
expect($batch->cannabinoid_unit)->toBe('%');
$batch->update([
'cannabinoid_unit' => 'MG/G',
'thc_percentage' => 782.5,
]);
expect($batch->fresh()->cannabinoid_unit)->toBe('MG/G')
->and((float) $batch->fresh()->thc_percentage)->toBe(782.5);
});
test('batch auto-calculates total THC correctly', function () {
$business = Business::factory()->create();
$product = Product::factory()->create();
$batch = Batch::create([
'product_id' => $product->id,
'business_id' => $business->id,
'batch_number' => 'TEST-007',
'cannabinoid_unit' => '%',
'thc_percentage' => 22.5,
'thca_percentage' => 2.8,
]);
// Total THC = THC + (THCa * 0.877)
$expectedTotal = 22.5 + (2.8 * 0.877);
expect((float) $batch->total_thc)->toBe(round($expectedTotal, 2));
});
test('batch auto-calculates total CBD correctly', function () {
$business = Business::factory()->create();
$product = Product::factory()->create();
$batch = Batch::create([
'product_id' => $product->id,
'business_id' => $business->id,
'batch_number' => 'TEST-008',
'cannabinoid_unit' => '%',
'cbd_percentage' => 15.0,
'cbda_percentage' => 3.0,
]);
// Total CBD = CBD + (CBDa * 0.877)
$expectedTotal = 15.0 + (3.0 * 0.877);
expect((float) $batch->total_cbd)->toBe(round($expectedTotal, 2));
});

View File

@@ -10,4 +10,16 @@ export default defineConfig({
refresh: true,
}),
],
server: {
host: "0.0.0.0", // Listen on all interfaces (required for Docker/k8s)
port: 5173,
strictPort: false, // Allow fallback port if 5173 is taken
hmr: {
// K8s mode: use vite subdomain through ingress (port 80)
// Local mode: use localhost:5173
host: process.env.VITE_HMR_HOST || "localhost",
clientPort: process.env.VITE_HMR_HOST ? 80 : 5173,
protocol: "http",
},
},
});